diff --git a/.dev.vars.example b/.dev.vars.example deleted file mode 100644 index 78cc06d..0000000 --- a/.dev.vars.example +++ /dev/null @@ -1,4 +0,0 @@ -# Local development secrets loaded by `wrangler dev`. -# Copy to .dev.vars (gitignored) and fill in real values. -TELEGRAM_BOT_TOKEN= -TELEGRAM_WEBHOOK_SECRET= diff --git a/.env.deploy.example b/.env.deploy.example deleted file mode 100644 index 536f447..0000000 --- a/.env.deploy.example +++ /dev/null @@ -1,15 +0,0 @@ -# Post-deploy registration env, consumed by `scripts/register.js` via -# `node --env-file=.env.deploy`. Copy to .env.deploy (gitignored) and fill in. -# -# TELEGRAM_BOT_TOKEN + TELEGRAM_WEBHOOK_SECRET must match the values set via -# `wrangler secret put` so the Worker and Telegram agree on the same secret. -TELEGRAM_BOT_TOKEN= -TELEGRAM_WEBHOOK_SECRET= - -# Public URL of the deployed Worker (no trailing slash). Known after the first -# `wrangler deploy`. Example: https://miti99bot.your-subdomain.workers.dev -WORKER_URL= - -# Same MODULES value as wrangler.toml [vars]. Duplicated here so the register -# script can derive the public command list without parsing wrangler.toml. -MODULES=util,wordle,loldle,misc,trading,lolschedule,semantle,doantu,twentyq diff --git a/.github/workflows/scrape-loldle-data.yml b/.github/workflows/scrape-loldle-data.yml deleted file mode 100644 index 0b45699..0000000 --- a/.github/workflows/scrape-loldle-data.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: scrape-loldle-data - -# Rebuilds src/modules/loldle/champions.json every Monday 06:00 UTC by -# scraping loldle.net's JS bundle (sole source of truth for classic-mode -# fields). Opens a PR if the output changed. Manually triggerable from the -# Actions tab. -# -# Note: the bundled data ships with the Worker — the change only takes effect -# after `npm run deploy` is run on the updated main branch. - -on: - schedule: - - cron: "0 6 * * 1" - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -jobs: - scrape: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: npm - - - run: npm ci - - - name: Scrape loldle.net - run: npm run scrape:loldle-data - - - name: Open PR if data changed - uses: peter-evans/create-pull-request@v7 - with: - branch: data/loldle-weekly-refresh - delete-branch: true - commit-message: "data: weekly loldle.net champion refresh" - title: "data: weekly loldle.net champion refresh" - body: | - Automated weekly refresh of `src/modules/loldle/champions.json` - from loldle.net's JS bundle. - - Review the diff, merge, then run `npm run deploy` to ship. - add-paths: src/modules/loldle/champions.json diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 53e0608..0000000 --- a/.gitignore +++ /dev/null @@ -1,148 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.* -!.env.example -!.env.deploy.example - -# Cloudflare Wrangler local state + secrets -.dev.vars -!.dev.vars.example -.wrangler/ - -# Local Claude Code agent state (memory, session permissions). Never committed. -.claude/ - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# Sveltekit cache directory -.svelte-kit/ - -# vitepress build output -**/.vitepress/dist - -# vitepress cache directory -**/.vitepress/cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# Firebase cache directory -.firebase/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v3 -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions - -# Vite logs files -vite.config.js.timestamp-* -vite.config.ts.timestamp-* diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 56ccf4d..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,80 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Commands - -```bash -npm run dev # local dev server (wrangler dev) at http://localhost:8787 -npm run lint # biome check src tests scripts + eslint src -npm run format # biome format --write -npm test # vitest run (all tests) -npx vitest run tests/modules/trading/format.test.js # single test file -npx vitest run -t "formats with dot" # single test by name -npm run db:migrate # apply migrations to D1 (prod) -npm run db:migrate -- --local # apply to local dev D1 -npm run db:migrate -- --dry-run # preview without applying -npm run deploy # wrangler deploy + db:migrate + register webhook/commands -npm run register:dry # preview setWebhook + setMyCommands payloads without calling Telegram -``` - -## Architecture - -grammY Telegram bot on Cloudflare Workers. Modules are plug-n-play: each module is a folder under `src/modules/` that exports `{ name, commands[], init? }`. A single `MODULES` env var controls which modules are loaded. - -**Request flow:** `POST /webhook` → grammY validates secret header → `getBot(env)` (memoized per warm instance) → `installDispatcher` builds registry on first call → `bot.command(name, handler)` for every command → handler runs. - -**Key abstractions:** -- `src/modules/registry.js` — loads modules from static import map (`src/modules/index.js`), validates commands, detects name conflicts across all visibility levels, builds four maps (public/protected/private/all). Memoized via `getCurrentRegistry()`. -- `src/db/create-store.js` — wraps Cloudflare KV with auto-prefixed keys per module (`moduleName:key`). Modules never touch `env.KV` directly. -- `scripts/register.js` — post-deploy script that imports the same registry to derive public commands, then calls Telegram `setWebhook` + `setMyCommands`. Uses `stub-kv.js` to satisfy KV binding without real IO. - -**Three command visibilities:** public (in Telegram `/` menu + `/help`), protected (in `/help` only), private (hidden easter eggs). All three are registered via `bot.command()` — visibility controls discoverability, not access. - -## Adding a Module - -1. Create `src/modules//index.js` with default export `{ name, commands, init? }` -2. Add one line to `src/modules/index.js` static import map -3. Add `` to `MODULES` in `wrangler.toml` `[vars]` -4. Full guide: `docs/adding-a-module.md` - -## Module Contract - -```js -{ - name: "mymod", // must match folder + import map key - init: async ({ db, sql, env }) => { ... }, // optional — db: KVStore, sql: SqlStore|null - commands: [{ - name: "mycmd", // ^[a-z0-9_]{1,32}$, no leading slash - visibility: "public", // "public" | "protected" | "private" - description: "Does a thing", // required for all visibilities - handler: async (ctx) => { ... }, // grammY context - }], - crons: [{ // optional scheduled jobs - schedule: "0 17 * * *", // cron expression - name: "daily-cleanup", // unique within module - handler: async (event, ctx) => { ... }, // receives { db, sql, env } - }], -} -``` - -- Command names must be globally unique across ALL modules and visibilities. Conflicts throw at load time. -- Cron schedules declared here MUST also be registered in `wrangler.toml` `[triggers] crons`. -- For D1 setup (migrations, table naming), see [`docs/using-d1.md`](docs/using-d1.md). -- For cron syntax and testing, see [`docs/using-cron.md`](docs/using-cron.md). - -## Testing - -Pure-logic unit tests only — no workerd, no Telegram fixtures. Tests use fakes from `tests/fakes/` (fake-kv-namespace, fake-bot, fake-modules) injected via parameters, not `vi.mock`. - -For modules that call `fetch` (like trading/prices), stub `global.fetch` with `vi.fn()` in tests. - -## Code Style - -Biome enforces: 2-space indent, double quotes, semicolons, trailing commas, 100-char line width, sorted imports. Run `npm run format` before committing. Keep files under 200 lines — split into focused submodules when approaching the limit. - -## Environment - -- Secrets (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`): set via `wrangler secret put`, mirrored in `.env.deploy` (gitignored) for register script -- `.dev.vars`: local dev secrets (gitignored), copy from `.dev.vars.example` -- Node >=20.6 required (for `--env-file` flag) diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 261eeb9..0000000 --- a/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/README.md b/README.md deleted file mode 100644 index 23e8657..0000000 --- a/README.md +++ /dev/null @@ -1,219 +0,0 @@ -# miti99bot - -[My Telegram bot](https://t.me/miti99bot) — a plug-n-play bot framework for Cloudflare Workers. - -Modules are added or removed via a single `MODULES` env var. Each module registers its own commands with three visibility levels (public / protected / private). Data lives in Cloudflare KV behind a thin `KVStore` interface, so swapping the backend later is a one-file change. - -## Why - -- **Drop-in modules.** Write a single file, list the folder name in `MODULES`, redeploy. No registration boilerplate, no manual command wiring. -- **Three visibility levels out of the box.** Public commands show in Telegram's `/` menu and `/help`; protected show only in `/help`; private are hidden slash-command easter eggs. One namespace, loud conflict detection. -- **Dual storage backends.** Modules talk to a small `KVStore` interface (Cloudflare KV for simple state) or `SqlStore` interface (D1 for relational data, scans, leaderboards). Swappable with one-file changes. -- **Scheduled jobs.** Modules declare cron-based cleanup, stats refresh, or maintenance tasks — registered via `wrangler.toml` and dispatched automatically. -- **Zero admin surface.** No in-Worker `/admin/*` routes, no admin secret. `setWebhook` + `setMyCommands` run at deploy time from a local node script. -- **Tested.** 200+ vitest unit tests cover registry, storage, dispatcher, cron validation, help renderer, validators, HTML escaping, and the trading / loldle / wordle modules. - -## How a request flows - -``` -Telegram sends update - │ - ▼ -POST /webhook ◄── grammY validates X-Telegram-Bot-Api-Secret-Token (401 on miss) - │ - ▼ -getBot(env) ──► first call only: installDispatcher(bot, env) - │ │ - │ ├── loadModules(env.MODULES.split(",")) - │ ├── per module: init({ db: createStore(name, env), env }) - │ ├── build publicCommands / protectedCommands / privateCommands - │ │ + unified allCommands map (conflict check) - │ └── for each entry: bot.command(name, handler) - ▼ -bot.handleUpdate(update) ──► grammY routes /cmd → registered handler - │ - ▼ -handler reads/writes via db.getJSON / db.putJSON (auto-prefixed as "module:key") - │ - ▼ -ctx.reply(...) → response back to Telegram -``` - -## Architecture snapshot - -``` -src/ -├── index.js # fetch + scheduled handlers: POST /webhook + cron triggers -├── bot.js # memoized grammY Bot, lazy dispatcher + registry install -├── types.js # JSDoc typedefs (central: Env, Module, Command, Cron, etc.) -├── db/ -│ ├── kv-store-interface.js # KVStore contract (JSDoc) -│ ├── cf-kv-store.js # Cloudflare KV implementation -│ ├── create-store.js # KV per-module prefixing factory -│ ├── sql-store-interface.js # SqlStore contract (JSDoc) -│ ├── cf-sql-store.js # Cloudflare D1 implementation -│ └── create-sql-store.js # D1 per-module prefixing factory -├── modules/ -│ ├── index.js # static import map — register new modules here -│ ├── registry.js # load, validate, build command + cron tables -│ ├── dispatcher.js # wires every command via bot.command() -│ ├── cron-dispatcher.js # dispatches cron handlers by schedule match -│ ├── validate-command.js # command contract validator -│ ├── validate-cron.js # cron contract validator -│ ├── util/ # /info, /help (fully implemented) -│ ├── trading/ # paper trading — VN stocks (D1 storage, daily cron) -│ │ └── migrations/ -│ │ └── 0001_trades.sql -│ ├── wordle/ # 5-letter guessing game (KV storage, 14k-word dict) -│ ├── loldle/ # classic-mode LoL champion guessing (KV storage) -│ ├── loldle-emoji/ # emoji-clue LoL champion guessing (KV storage) -│ ├── loldle-quote/ # lore-blurb LoL champion guessing (KV storage) -│ ├── loldle-ability/ # ability-icon LoL champion guessing (KV storage) -│ ├── loldle-splash/ # splash-art LoL champion guessing (KV storage) -│ └── misc/ # stub (KV storage) -└── util/ - └── escape-html.js - -scripts/ -├── register.js # post-deploy: setWebhook + setMyCommands -├── migrate.js # discover + apply D1 migrations -└── stub-kv.js # no-op KV binding for deploy-time registry build - -tests/ -└── fakes/ - ├── fake-kv-namespace.js - ├── fake-d1.js # in-memory SQL for testing - ├── fake-bot.js - └── fake-modules.js -``` - -## Command visibility - -| Level | In `/` menu | In `/help` | Callable | -|---|---|---|---| -| `public` | yes | yes | yes | -| `protected` | **no** | yes | yes | -| `private` | **no** | **no** | yes (hidden slash command — easter egg) | - -All three are slash commands. Private commands are just hidden from both surfaces. They're not access control — anyone who knows the name can invoke them. - -Command names must match `^[a-z0-9_]{1,32}$` (Telegram's slash-command limit). Conflict detection is unified across all visibility levels — two modules cannot register the same command name no matter the visibility. Registry build throws at load time. - -## Prereqs - -- Node.js ≥ 20.6 (for `node --env-file`) -- A Cloudflare account with Workers + KV -- A Telegram bot token from [@BotFather](https://t.me/BotFather) - -## Setup - -1. **Install dependencies** - ```bash - npm install - ``` - -2. **Create KV namespaces** (production + preview) - ```bash - npx wrangler kv namespace create miti99bot-kv - npx wrangler kv namespace create miti99bot-kv --preview - ``` - Paste the returned IDs into `wrangler.toml` under `[[kv_namespaces]]`, replacing both `REPLACE_ME` placeholders. - -3. **Set Worker runtime secrets** (stored in Cloudflare, used by the deployed Worker) - ```bash - npx wrangler secret put TELEGRAM_BOT_TOKEN - npx wrangler secret put TELEGRAM_WEBHOOK_SECRET - ``` - `TELEGRAM_WEBHOOK_SECRET` can be any high-entropy string — e.g. `openssl rand -hex 32`. It gates incoming webhook requests; grammY validates it on every update. - -4. **Create `.dev.vars`** for local development - ```bash - cp .dev.vars.example .dev.vars - # fill in the same TELEGRAM_BOT_TOKEN + TELEGRAM_WEBHOOK_SECRET values - ``` - Used by `wrangler dev`. Gitignored. - -5. **Create `.env.deploy`** for the post-deploy register script - ```bash - cp .env.deploy.example .env.deploy - # fill in: token, webhook secret, WORKER_URL (known after first deploy), MODULES - ``` - Gitignored. The `TELEGRAM_BOT_TOKEN` and `TELEGRAM_WEBHOOK_SECRET` values MUST match what you set via `wrangler secret put` — mismatch means every incoming webhook returns 401. - -## Local dev - -```bash -npm run dev # wrangler dev — runs the Worker at http://localhost:8787 -npm run lint # biome check + eslint -npm test # vitest -npm run db:migrate # apply D1 migrations (--local for local dev, --dry-run to preview) -``` - -The local `wrangler dev` server exposes `GET /` (health), `POST /webhook` (Telegram), and `/__scheduled?cron=...` (cron simulation). For end-to-end testing you'd ngrok/cloudflared the local port and point a test bot's `setWebhook` at it — but pure unit tests (`npm test`) cover the logic seams without Telegram. - -## Deploy - -Single command, idempotent: - -```bash -npm run deploy -``` - -That runs `wrangler deploy`, applies D1 migrations, then `scripts/register.js`, which calls Telegram's `setWebhook` + `setMyCommands` using values from `.env.deploy`. - -First-time deploy flow: - -1. Create D1 database: `npx wrangler d1 create miti99bot-db` and paste ID into `wrangler.toml`. -2. Run `wrangler deploy` once to learn the `*.workers.dev` URL printed at the end. -3. Paste it into `.env.deploy` as `WORKER_URL`. -4. Apply migrations: `npm run db:migrate`. -5. Preview the register payloads without calling Telegram: - ```bash - npm run register:dry - ``` -6. Run the real deploy: - ```bash - npm run deploy - ``` - -Subsequent deploys: just `npm run deploy`. - -## Adding a module - -See [`docs/adding-a-module.md`](docs/adding-a-module.md) for the full guide. - -TL;DR: - -1. Create `src/modules//index.js` with a default export `{ name, commands, init? }`. -2. Add a line to `src/modules/index.js` static map. -3. Add `` to `MODULES` in both `wrangler.toml` `[vars]` and `.env.deploy`. -4. `npm test` + `npm run deploy`. - -## Troubleshooting - -| Symptom | Cause | -|---|---| -| 401 on every webhook | `TELEGRAM_WEBHOOK_SECRET` differs between `wrangler secret` and `.env.deploy`. | -| `/help` is missing a module's section | Module has no public or protected commands — private-only modules are hidden. | -| Module loads but no commands respond | `MODULES` does not list the module. Check `wrangler.toml` AND `.env.deploy`. | -| `command conflict: /foo ...` at deploy | Two modules register the same command name. Rename one. | -| `npm run register` exits `missing env: X` | Add `X` to `.env.deploy`. | -| `--env-file` flag not recognized | Node < 20.6. Upgrade Node. | - -## Credits - -The `loldle` module family (classic, emoji, quote, ability, splash) is -inspired by [**loldle.net**](https://loldle.net/). Classic's champion -metadata and splash mode's skin pool are scraped from their JS bundle; -other modes derive or generate data from [Riot Data Dragon](https://ddragon.leagueoflegends.com/). -League of Legends, champion art, and ability icons are © Riot Games. - -## Further reading - -- [`docs/architecture.md`](docs/architecture.md) — deeper dive: cold-start, module lifecycle, KV + D1 storage, cron dispatch, deploy flow, design tradeoffs. -- [`docs/adding-a-module.md`](docs/adding-a-module.md) — step-by-step guide to authoring a new module (commands, KV storage, D1 + migrations, crons). -- [`docs/using-d1.md`](docs/using-d1.md) — when to use D1, writing migrations, SQL API reference, worked examples. -- [`docs/using-cron.md`](docs/using-cron.md) — scheduling syntax, handler signature, wrangler.toml registration, local testing, worked examples. -- [`docs/deployment-guide.md`](docs/deployment-guide.md) — D1 + KV setup, migration, secret rotation, rollback. -- `plans/260415-1010-d1-cron-infra/` — phased implementation plan for D1 + cron support (6 phases + reports). -- `plans/260411-0853-telegram-bot-plugin-framework/` — original plugin framework implementation plan (9 phases + reports). diff --git a/biome.json b/biome.json deleted file mode 100644 index d9fc040..0000000 --- a/biome.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json", - "organizeImports": { "enabled": true }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "style": { - "noNonNullAssertion": "off", - "useTemplate": "off" - }, - "suspicious": { - "noConsoleLog": "off" - } - } - }, - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 2, - "lineWidth": 100 - }, - "javascript": { - "formatter": { - "quoteStyle": "double", - "semicolons": "always", - "trailingCommas": "all" - } - }, - "files": { - "ignore": [ - "node_modules", - ".wrangler", - ".claude", - "dist", - "coverage", - "src/modules/loldle/champions.json", - "src/modules/wordle/words-data.js" - ] - } -} diff --git a/docs/adding-a-module.md b/docs/adding-a-module.md deleted file mode 100644 index 1a0fe72..0000000 --- a/docs/adding-a-module.md +++ /dev/null @@ -1,234 +0,0 @@ -# Adding a Module - -A module is a plugin that registers commands with the bot. The framework handles loading, routing, visibility, `/help` rendering, and per-module database namespacing. A new module is usually under 100 lines. - -## Three-step checklist - -### 1. Create the module folder - -``` -src/modules//index.js -``` - -Module name must match `^[a-z0-9_-]+$`. The folder name, the `name` field in the default export, and the key in the static import map MUST all be identical. - -### 2. Add it to the static import map - -Edit `src/modules/index.js`: - -```js -export const moduleRegistry = { - util: () => import("./util/index.js"), - wordle: () => import("./wordle/index.js"), - // ... existing entries ... - mynew: () => import("./mynew/index.js"), // add this line -}; -``` - -Static imports are required — wrangler's bundler strips unused code based on static analysis. Dynamic `import(variablePath)` would bundle everything. - -### 3. Enable the module via env - -Add the name to `MODULES` in two places: - -`wrangler.toml`: -```toml -[vars] -MODULES = "util,wordle,loldle,misc,mynew" -``` - -`.env.deploy` (so the register script sees it too): -``` -MODULES=util,wordle,loldle,misc,mynew -``` - -Modules NOT listed in `MODULES` are simply not loaded — no errors, no overhead. - -## Minimal module skeleton - -```js -/** @type {import("../registry.js").BotModule} */ -const mynewModule = { - name: "mynew", - commands: [ - { - name: "hello", - visibility: "public", - description: "Say hello", - handler: async (ctx) => { - await ctx.reply("hi!"); - }, - }, - ], -}; - -export default mynewModule; -``` - -## Using the database - -If your module needs storage, opt into it with an `init` hook: - -```js -/** @type {import("../../db/kv-store-interface.js").KVStore | null} */ -let db = null; - -const mynewModule = { - name: "mynew", - init: async ({ db: store }) => { - db = store; - }, - commands: [ - { - name: "count", - visibility: "public", - description: "Increment a counter", - handler: async (ctx) => { - const state = (await db.getJSON("counter")) ?? { n: 0 }; - state.n += 1; - await db.putJSON("counter", state); - await ctx.reply(`counter: ${state.n}`); - }, - }, - ], -}; - -export default mynewModule; -``` - -**Key namespacing is automatic.** The `db` you receive is a `KVStore` whose keys are all prefixed with `mynew:` before hitting Cloudflare KV. The raw key for `counter` is `mynew:counter`. Two modules cannot read each other's data unless they reconstruct the prefix by hand. - -### Available `KVStore` methods - -```js -await db.get("key") // → string | null -await db.put("key", "value", { expirationTtl: 60 }) // seconds -await db.delete("key") -await db.list({ prefix: "games:", limit: 50, cursor }) // paginated - -// JSON helpers (recommended — DRY for structured state) -await db.getJSON("key") // → parsed | null (swallows corrupt JSON) -await db.putJSON("key", { a: 1 }, { expirationTtl: 60 }) -``` - -See `src/db/kv-store-interface.js` for the full JSDoc contract. - -## Command contract - -Each command is: - -```js -{ - name: "hello", // ^[a-z0-9_]{1,32}$, no leading slash - visibility: "public", // "public" | "protected" | "private" - description: "Say hello", // required, ≤ 256 chars - handler: async (ctx) => { ... }, // grammY context -} -``` - -### Name rules - -- Lowercase letters, digits, underscore -- 1 to 32 characters -- No leading `/` -- Must be unique across ALL loaded modules, regardless of visibility - -### Visibility levels - -| Level | In `/` menu | In `/help` | When to use | -|---|---|---|---| -| `public` | yes | yes | Normal commands users should discover | -| `protected` | no | yes | Admin / debug tools you don't want in the menu but want discoverable via `/help` | -| `private` | no | no | Easter eggs, testing hooks — fully hidden | - -Private commands are still slash commands — users type `/mycmd`. They're simply absent from Telegram's `/` popup and from `/help` output. - -## Optional: D1 Storage - -If your module needs a SQL database for relational queries, scans, or append-only history, add an `init` hook that receives `sql`: - -```js -/** @type {import("../../db/sql-store-interface.js").SqlStore | null} */ -let sql = null; - -const myModule = { - name: "mymod", - init: async ({ db, sql: sqlStore, env }) => { - db = store; - sql = sqlStore; // null when env.DB is not configured - }, - commands: [ /* ... */ ], -}; -``` - -Create migration files in `src/modules//migrations/`: - -``` -src/modules/mymod/migrations/ -├── 0001_initial.sql -├── 0002_add_index.sql -└── ... -``` - -Run migrations at deploy time: - -```bash -npm run db:migrate # production -npm run db:migrate -- --local # local dev -npm run db:migrate -- --dry-run # preview -``` - -For full details on D1 usage, table naming, and the SQL API, see [`docs/using-d1.md`](./using-d1.md). - -## Optional: Scheduled Jobs - -If your module needs to run maintenance tasks (cleanup, stats refresh) on a schedule, add a `crons` array: - -```js -const myModule = { - name: "mymod", - init: async ({ db, sql, env }) => { /* ... */ }, - commands: [ /* ... */ ], - crons: [ - { - schedule: "0 2 * * *", // 2 AM UTC daily - name: "daily-cleanup", - handler: async (event, { db, sql, env }) => { - // handler receives same context as init - await sql.run("DELETE FROM mymod_old WHERE created < ?", oldTimestamp); - }, - }, - ], -}; -``` - -**Important:** Every cron schedule declared in a module MUST also be registered in `wrangler.toml`: - -```toml -[triggers] -crons = ["0 2 * * *"] # matches module declaration -``` - -For full details on cron syntax, local testing, and worked examples, see [`docs/using-cron.md`](./using-cron.md). - -## Testing your module - -Add a test in `tests/modules/.test.js` or extend an existing suite. The `tests/fakes/` directory provides `fake-kv-namespace.js`, `fake-bot.js`, `fake-d1.js`, and `fake-modules.js` for hermetic unit tests that don't touch Cloudflare or Telegram. - -Run: - -```bash -npm test -``` - -Also worth running before deploying: - -```bash -npm run register:dry -``` - -This prints the `setMyCommands` payload your module will push to Telegram — a fast way to verify the public command descriptions look right. - -## Full example - -See `src/modules/misc/index.js` — it's a minimal module that uses the DB (`putJSON` / `getJSON` via `/ping` + `/mstats`) and registers one command at each visibility level. Copy it as a starting point for your own module. See `src/modules/trading/` for a full example with D1 storage and scheduled crons. diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 018ddd1..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,412 +0,0 @@ -# Architecture - -A deeper look at how miti99bot is wired: what loads when, where data lives, how commands get from Telegram into a handler, and why the boring parts are boring on purpose. - -For setup and day-to-day commands, see the top-level [README](../README.md). -For authoring a new plugin module, see [`adding-a-module.md`](./adding-a-module.md). - -## 1. Design goals - -- **Plug-n-play modules.** A module = one folder + one line in a static import map + one name in `MODULES`. Adding or removing one must never require touching framework code. -- **YAGNI / KISS / DRY.** Small surface area. No speculative abstractions beyond the KV interface (which is explicitly required so storage can be swapped). -- **Fail loud at load, not at runtime.** Invalid commands, unknown modules, name conflicts, missing env — all throw during registry build so the first request never sees a half-configured bot. -- **Single source of truth.** `/help` renders the registry. The register script reads the registry. `setMyCommands` is derived from the registry. Modules define commands in exactly one place. -- **No admin HTTP surface.** One less attack surface, one less secret. Webhook + menu registration happen out-of-band via a post-deploy node script. - -## 2. Component overview - -``` -src/ -├── index.js ── fetch + scheduled handlers -├── bot.js ── memoized grammY Bot factory, lazy dispatcher install -├── types.js ── central JSDoc typedefs (Env, Module, Command, Cron, …) -├── db/ -│ ├── kv-store-interface.js ── KVStore contract (JSDoc) -│ ├── cf-kv-store.js ── Cloudflare KV adapter -│ ├── create-store.js ── KV per-module prefixing factory -│ ├── sql-store-interface.js ── SqlStore contract (JSDoc) -│ ├── cf-sql-store.js ── Cloudflare D1 adapter -│ └── create-sql-store.js ── D1 per-module prefixing factory -├── modules/ -│ ├── index.js ── static import map (add new modules here) -│ ├── registry.js ── loader + builder + conflict detection + memoization -│ ├── dispatcher.js ── bot.command() for every visibility -│ ├── cron-dispatcher.js ── routes scheduled events to matching module crons -│ ├── validate-command.js ── command contract validator -│ ├── validate-cron.js ── cron contract validator -│ ├── util/ ── /info + /help -│ ├── misc/ ── stub: /ping + /mstats -│ ├── trading/ ── paper trading: VN stocks (D1 + KV, daily cron) -│ ├── wordle/ ── 5-letter guessing game (KV) -│ ├── loldle/ ── classic-mode LoL champion guesser (KV) -│ ├── lolschedule/ ── LoL esports schedule + daily digest subscriptions (KV, cron) -│ ├── semantle/ ── English semantic word guess (KV, word2sim) -│ ├── doantu/ ── Vietnamese semantle (KV, phow2sim) -│ └── twentyq/ ── reverse-Akinator yes/no game (KV + Workers AI) -└── util/ - └── escape-html.js - -scripts/ -├── register.js ── post-deploy: setWebhook + setMyCommands -├── migrate.js ── apply D1 migrations -└── stub-kv.js ── no-op KV + AI bindings for deploy-time registry build -``` - -## 3. Cold-start and the bot factory - -The Cloudflare Worker runtime hands your `fetch(request, env, ctx)` function fresh on every **cold** start. Warm requests on the same instance reuse module-scope state. We exploit that to initialize the grammY Bot exactly once per warm instance: - -``` -first request ──► getBot(env) ──► new Bot(TOKEN) - └── installDispatcher(bot, env) - ├── buildRegistry(env) - │ ├── loadModules(env.MODULES) - │ ├── init() each module - │ └── flatten commands into 4 maps - └── for each: bot.command(name, handler) - ▼ - return bot (cached at module scope) - -later requests ──► getBot(env) returns cached bot -``` - -`getBot` uses both a resolved instance (`botInstance`) **and** an in-flight promise (`botInitPromise`) to handle the case where two concurrent requests race the first init. If init throws, the promise is cleared so the next request retries — a failed init should not permanently wedge the worker. - -Required env vars (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET`, `MODULES`) are checked upfront: a missing var surfaces as a 500 with a clear error message on the first request, rather than a confusing runtime error deep inside grammY. - -## 4. The module contract - -Every module is a single default export with this shape: - -```js -export default { - name: "wordle", // must match folder + import map key - init: async ({ db, sql, env }) => { ... }, // optional, called once at build time - commands: [ - { - name: "wordle", // ^[a-z0-9_]{1,32}$, no leading slash - visibility: "public", // "public" | "protected" | "private" - description: "Play wordle", // required, ≤256 chars - handler: async (ctx) => { ... }, // grammY context - }, - // ... - ], - crons: [ // optional scheduled jobs - { - schedule: "0 2 * * *", // cron expression - name: "cleanup", // unique within module - handler: async (event, ctx) => { ... }, // receives { db, sql, env } - }, - ], -}; -``` - -- The command name regex is **uniform** across all visibility levels. A private command is still a slash command (`/konami`) — it is simply absent from Telegram's `/` menu and from `/help` output. It is NOT a hidden text-match easter egg. -- `description` is required for **all** visibilities. Private descriptions never reach Telegram; they exist so the registry remains self-documenting for debugging. -- `init({ db, sql, env })` is the one place where a module should do setup work. The `db` parameter is a `KVStore` whose keys are automatically prefixed with `:`. The `sql` parameter is a `SqlStore` (or `null` if `env.DB` is not bound) — for relational data. `env` is the raw worker env (read-only by convention). -- `crons` is optional. Each entry declares a scheduled job; the schedule MUST also be registered in `wrangler.toml` `[triggers] crons`. - -Validation runs per-command at registry load, and cross-module conflict detection runs at the same step. Any violation throws — deployment fails loudly before any request is served. - -## 5. Module loading: why the static map - -Cloudflare Workers bundle statically via wrangler. A dynamic import from a variable path (`import(name)`) either fails at bundle time or forces the bundler to include every possible import target, defeating tree-shaking. So we have an explicit map: - -```js -// src/modules/index.js -export const moduleRegistry = { - util: () => import("./util/index.js"), - wordle: () => import("./wordle/index.js"), - loldle: () => import("./loldle/index.js"), - misc: () => import("./misc/index.js"), - trading: () => import("./trading/index.js"), - lolschedule: () => import("./lolschedule/index.js"), - semantle: () => import("./semantle/index.js"), - doantu: () => import("./doantu/index.js"), - twentyq: () => import("./twentyq/index.js"), -}; -``` - -At runtime, `loadModules(env)` parses `env.MODULES` (comma-separated), trims, dedupes, and calls only the loaders for the listed names. Modules NOT listed are never imported — wrangler tree-shakes them out of the bundle if they reference code that is otherwise unused. - -Adding a new module is a **two-line change**: create the folder, add one line to this map. Removing a module is a **zero-line change**: just drop the name from `MODULES`. - -## 6. The registry and unified conflict detection - -`buildRegistry(env)` produces four maps: - -- `publicCommands: Map` — source of truth for `/help` public section + `setMyCommands` payload -- `protectedCommands: Map` — source of truth for `/help` protected section -- `privateCommands: Map` — bookkeeping only (hidden from `/help` and `setMyCommands`) -- `allCommands: Map` — **unified** flat index used by the dispatcher and by conflict detection - -Conflict detection walks `allCommands` as commands are added. If two modules (in any visibility combination) both try to register `foo`, build throws: - -``` -command conflict: /foo registered by both "a" and "b" -``` - -This is stricter than a visibility-scoped key space. Rationale: a user typing `/foo` sees exactly one response, regardless of visibility. If the framework silently picks one or the other, the behavior becomes order-dependent and confusing. Throwing at load means the ambiguity must be resolved in code. - -The memoized registry is also exposed via `getCurrentRegistry()` so `/help` can read it at handler time without rebuilding. `resetRegistry()` exists for tests. - -## 7. The dispatcher - -Minimalism is the point: - -```js -export async function installDispatcher(bot, env) { - const reg = await buildRegistry(env); - for (const { cmd } of reg.allCommands.values()) { - bot.command(cmd.name, cmd.handler); - } - return reg; -} -``` - -Every command — public, protected, **and private** — is registered via `bot.command()`. grammY handles: - -- Slash prefix parsing -- Case sensitivity (Telegram commands are case-sensitive in practice) -- `/cmd@botname` suffix matching in group chats -- Argument capture via the grammY context - -There is no custom text-match middleware, no `bot.on("message:text", ...)` handler, no private-command-specific path. One routing path for all three visibilities. This is what reduced the original two-path design (slash + text-match) to one during the revision pass. - -## 8. Storage: KVStore and SqlStore - -Modules NEVER touch `env.KV` or `env.DB` directly. They receive prefixed stores from the module context. - -### KVStore (key-value, fast reads/writes) - -For simple state and blobs, use `db` (a `KVStore`): - -```js -// In a module's init: -init: async ({ db, env }) => { - moduleDb = db; // stash for handlers -}, - -// In a handler: -const state = await moduleDb.getJSON("game:42"); -await moduleDb.putJSON("game:42", { score: 100 }, { expirationTtl: 3600 }); -``` - -The interface (full JSDoc in `src/db/kv-store-interface.js`): - -```js -get(key) // → string | null -put(key, value, { expirationTtl? }) -delete(key) -list({ prefix?, limit?, cursor? }) // → { keys, cursor?, done } -getJSON(key) // → any | null (swallows corrupt JSON) -putJSON(key, value, { expirationTtl? }) -``` - -#### Prefix mechanics - -`createStore("wordle", env)` returns a wrapped store where every key is rewritten: - -``` -module calls: wrapper sends to CFKVStore: raw KV key: -───────────────────────── ───────────────────────────── ───────────── -put("games:42", v) ──► put("wordle:games:42", v) ──► wordle:games:42 -get("games:42") ──► get("wordle:games:42") ──► wordle:games:42 -list({prefix:"games:"})──► list({prefix:"wordle:games:"}) (then strips "wordle:" from returned keys) -``` - -Two stores for different modules cannot read each other's data unless they reconstruct prefixes by hand — a code-review boundary, not a cryptographic one. - -### SqlStore (relational, scans, append-only history) - -For complex queries, aggregates, or audit logs, use `sql` (a `SqlStore`): - -```js -// In a module's init: -init: async ({ sql }) => { - sqlStore = sql; // null if env.DB not bound -}, - -// In a handler or cron: -const trades = await sqlStore.all( - "SELECT * FROM trading_trades WHERE user_id = ? ORDER BY ts DESC LIMIT 10", - userId -); -``` - -The interface (full JSDoc in `src/db/sql-store-interface.js`): - -```js -run(query, ...binds) // INSERT/UPDATE/DELETE — returns { changes, last_row_id } -all(query, ...binds) // SELECT all rows → array of objects -first(query, ...binds) // SELECT first row → object | null -prepare(query, ...binds) // Prepared statement for batch operations -batch(statements) // Execute multiple statements in one round-trip -``` - -All tables must follow the naming convention `{moduleName}_{table}` (e.g., `trading_trades`). - -Tables are created via migrations in `src/modules//migrations/*.sql`. The migration runner (`scripts/migrate.js`) applies them on deploy and tracks them in `_migrations` table. - -### Swapping the backends - -To replace Cloudflare KV with a different store (e.g. Upstash Redis, Postgres): - -1. Create a new `src/db/-store.js` that implements the `KVStore` interface. -2. Change the one `new CFKVStore(env.KV)` line in `src/db/create-store.js` to construct your new adapter. -3. Update `wrangler.toml` bindings. - -That's the full change. No module code moves. - -To replace D1 with a different SQL backend: - -1. Create a new `src/db/-sql-store.js` that implements the `SqlStore` interface. -2. Change the one `new CFSqlStore(env.DB)` line in `src/db/create-sql-store.js` to construct your new adapter. -3. Update `wrangler.toml` bindings. - -## 9. HTTP and Scheduled Entry Points - -### Webhook (HTTP) - -```js -// src/index.js — simplified -export default { - async fetch(request, env) { - const { pathname } = new URL(request.url); - if (request.method === "GET" && pathname === "/") { - return new Response("miti99bot ok", { status: 200 }); - } - if (request.method === "POST" && pathname === "/webhook") { - const handler = await getWebhookHandler(env); - return handler(request); - } - return new Response("not found", { status: 404 }); - }, - - async scheduled(event, env, ctx) { - // Cloudflare cron trigger - const registry = await getRegistry(env); - dispatchScheduled(event, env, ctx, registry); - }, -}; -``` - -`getWebhookHandler` is memoized and constructs `webhookCallback(bot, "cloudflare-mod", { secretToken: env.TELEGRAM_WEBHOOK_SECRET })` once. grammY's `webhookCallback` validates the `X-Telegram-Bot-Api-Secret-Token` header on every request, so a missing or mismatched secret returns `401` before the update reaches any handler. - -### Scheduled (Cron) - -Cloudflare fires cron triggers specified in `wrangler.toml` `[triggers] crons`. The `scheduled(event, env, ctx)` handler receives: - -- `event.cron` — the schedule string (e.g., "0 17 * * *") -- `event.scheduledTime` — Unix timestamp (ms) when the trigger fired -- `ctx.waitUntil(promise)` — keeps the handler alive until promise resolves - -Flow: - -``` -Cloudflare cron trigger - │ - ▼ -scheduled(event, env, ctx) - │ - ├── getRegistry(env) — build registry (same as HTTP) - │ └── load + init all modules - │ - └── dispatchScheduled(event, env, ctx, registry) - │ - ├── filter registry.crons by event.cron match - │ - └── for each matching cron: - ├── createStore(moduleName, env) — KV store - ├── createSqlStore(moduleName, env) — D1 store - └── ctx.waitUntil(handler(event, { db, sql, env })) - └── wrapped in try/catch for isolation -``` - -Each handler fires independently. If one fails, others still run. - -## 10. Deploy flow and the register script - -Deploy is a single idempotent command: - -```bash -npm run deploy -# = wrangler deploy && node --env-file=.env.deploy scripts/register.js -``` - -``` -npm run deploy - │ - ├── wrangler deploy - │ └── uploads src/ + wrangler.toml vars to CF - │ - └── scripts/register.js - ├── reads .env.deploy into process.env (Node --env-file) - ├── imports buildRegistry from src/modules/registry.js - ├── calls buildRegistry({ MODULES, KV: stubKv }) to derive public cmds - │ └── stubKv satisfies the binding without real IO - ├── POST /bot/setWebhook { url, secret_token, allowed_updates } - └── POST /bot/setMyCommands { commands: [...public only] } -``` - -The register script imports the **same** module loader + registry the Worker uses. That means the set of public commands pushed to Telegram's `/` menu is always consistent with the set of public commands the Worker will actually respond to. No chance of drift. No duplicate command list maintained somewhere. - -`stubKv` is a no-op KV binding provided so `createStore` doesn't crash during the deploy-time build. Module `init` hooks are expected to tolerate missing state at deploy time — either by reading only (no writes), or by deferring writes until the first handler call. - -`--dry-run` prints both payloads with the webhook secret redacted, without calling Telegram. Use this to sanity-check what will be pushed before a real deploy. - -### Why the register step is not in the Worker - -A previous design sketched a `POST /admin/setup` route inside the Worker, gated by a third `ADMIN_SECRET`. It was scrapped because: - -- The Worker gains no capability from it — it can just as easily run from a node script. -- It adds a third secret to manage and rotate. -- It adds an attack surface (even a gated one) to a Worker whose only other route is the Telegram webhook. -- Running locally + idempotently means the exact same script works whether invoked by a human, CI, or a git hook. - -## 11. Security posture - -- `TELEGRAM_BOT_TOKEN` lives in two places: Cloudflare Workers secrets (`wrangler secret put`) for runtime, and `.env.deploy` (gitignored, local-only) for the register script. These two copies must match. -- `TELEGRAM_WEBHOOK_SECRET` is validated by grammY on every webhook request. Telegram echoes it via `X-Telegram-Bot-Api-Secret-Token` on every update; wrong or missing header → `401`. Rotate by updating both the CF secret and `.env.deploy`, then re-running `npm run deploy` (the register step re-calls `setWebhook` with the new value on the same run). -- `.dev.vars` and `.env.deploy` are in `.gitignore`; their `*.example` siblings are committed. -- Module authors get a prefixed store — they cannot accidentally read another module's keys, but the boundary is a code-review one. A motivated module could reconstruct prefixes by hand. This is fine for first-party modules; it is NOT a sandbox. -- Private commands provide **discoverability control**, not access control. Anyone who knows the name can invoke them. -- HTML injection in `/help` output is blocked by `escapeHtml` on module names and descriptions. - -## 12. Testing philosophy - -Pure-logic unit tests only. No `workerd` pool, no Telegram fixtures, no integration-level tooling. 200 tests run in ~2s. - -Test seams: - -- **`cf-kv-store.test.js`** — round-trips, `list()` pagination cursor, `expirationTtl` passthrough, `getJSON`/`putJSON` (including corrupt-JSON swallow), `undefined` value rejection. -- **`create-store.test.js`** — module-name validation, prefix mechanics, module-to-module isolation, JSON helpers through the prefix layer. -- **`validate-command.test.js`** — uniform regex, leading-slash rejection, description length cap, all visibilities. -- **`registry.test.js`** — module loading, trim/dedupe, unknown/missing/empty `MODULES`, unified-namespace conflict detection (same AND cross-visibility), `init` injection, `getCurrentRegistry`/`resetRegistry`. -- **`dispatcher.test.js`** — every visibility registered via `bot.command()`, dispatcher does NOT install any `bot.on()` middleware, handler identity preserved. -- **`help-command.test.js`** — module grouping, `(protected)` suffix, zero private-command leakage, HTML escaping of module names + descriptions, placeholder when no commands are visible. -- **`escape-html.test.js`** — the four HTML entities, non-double-escaping, non-string coercion. - -Each module adds its own tests under `tests/modules//`. See module READMEs for coverage details. - -Tests inject fakes (`fake-kv-namespace`, `fake-bot`, `fake-modules`) via parameter passing — no `vi.mock`, no path-resolution flakiness. - -## 13. Module-specific documentation - -Each module maintains its own `README.md` with commands, data model, and implementation details. See `src/modules//README.md` for module-specific docs. - -## 14. Non-goals (for now) - -- Real game logic in `misc` — it's a stub that exercises the DB. Real game modules (`wordle`, `loldle`, `trading`) are live; `misc` stays a framework sanity check. -- A sandbox between modules. Same-origin trust model: all modules are first-party code. -- Per-user rate limiting. Cloudflare's own rate limiting is available as a higher layer if needed. -- `nodejs_compat` flag. Not needed — grammY + this codebase use only Web APIs. -- A CI pipeline. Deploys are developer-driven in v1. -- Internationalization. The bot replies in English; add i18n per-module if a module needs it. - -## 15. Further reading - -- The phased implementation plan: `plans/260411-0853-telegram-bot-plugin-framework/` — 9 phase files with detailed rationale, risk assessments, and todo lists. -- Researcher reports: `plans/reports/researcher-260411-0853-*.md` — grammY on Cloudflare Workers, Cloudflare KV basics, wrangler config and secrets. -- grammY docs: -- Cloudflare Workers KV: diff --git a/docs/code-standards.md b/docs/code-standards.md deleted file mode 100644 index 7a18dcb..0000000 --- a/docs/code-standards.md +++ /dev/null @@ -1,112 +0,0 @@ -# Code Standards - -## Language & Runtime - -- **JavaScript** (ES modules, `"type": "module"` in package.json) -- **No TypeScript** — JSDoc typedefs for type contracts (see `kv-store-interface.js`, `registry.js`) -- **Cloudflare Workers runtime** — Web APIs only, no Node.js built-ins, no `nodejs_compat` -- **grammY** for Telegram bot framework - -## Formatting (Biome) - -Enforced by `npm run lint` / `npm run format`: - -- 2-space indent -- Double quotes -- Semicolons: always -- Trailing commas: all -- Line width: 100 characters -- Imports: auto-sorted by Biome - -Run `npm run format` before committing. - -## JSDoc & Type Definitions - -- **Central typedefs location:** `src/types.js` — all module-level typedefs live here (Env, Module, Command, Cron, ModuleContext, SqlStore, KVStore, Trade, Portfolio, etc.). -- **When to add JSDoc:** Required on exported functions, types, and public module interfaces. Optional on internal helpers (< 5 lines, obviously self-documenting). -- **Validation:** ESLint (`eslint src`) enforces valid JSDoc syntax. Run `npm run lint` to check. -- **No TypeScript:** JSDoc + `.js` files only. Full type info available to editor tooling without a build step. -- **Example:** - ```js - /** - * Validate a trade before insertion. - * - * @param {Trade} trade - * @returns {boolean} - */ - function isValidTrade(trade) { - return trade.qty > 0 && trade.priceVnd > 0; - } - ``` - -## File Organization - -- **Max 200 lines per code file.** Split into focused submodules when approaching the limit. -- Module code lives in `src/modules//` — one folder per module. -- Shared utilities in `src/util/`. -- DB layer in `src/db/`. -- Tests mirror source structure: `tests/modules//`, `tests/db/`, `tests/util/`. - -## Naming Conventions - -- **Files:** lowercase, hyphens for multi-word (`stats-handler.js`, `fake-kv-namespace.js`) -- **Directories:** lowercase, single word preferred (`trading/`, `util/`) -- **Functions/variables:** camelCase -- **Constants:** UPPER_SNAKE_CASE for frozen config objects (e.g. `CURRENCIES`) -- **Command names:** lowercase + digits + underscore, 1-32 chars, no leading slash - -## Module Conventions - -Every module default export must have: - -```js -export default { - name: "modname", // === folder name === import map key - commands: [...], // validated at load time - init: async ({ db, sql, env }) => { ... }, // optional - crons: [...], // optional scheduled jobs -}; -``` - -- Store module-level `db` and `sql` references in closure variables, set during `init` -- Never access `env.KV` or `env.DB` directly — always use the prefixed `db` (KV) or `sql` (D1) from `init` -- `sql` is `null` when `env.DB` is not bound — always guard with `if (!sql) return` -- Command handlers receive grammY `ctx` — use `ctx.match` for command arguments, `ctx.from.id` for user identity -- Reply with `ctx.reply(text)` — plain text or Telegram HTML -- Cron handlers receive `(event, { db, sql, env })` — same context as `init` - -## Error Handling - -- **Load-time failures** (bad module, command conflicts, missing env): throw immediately — fail loud at deploy, not at runtime. -- **Handler-level errors** (API failures, bad user input): catch and reply with user-friendly message. Never crash the handler — grammY logs unhandled rejections but the user sees nothing. -- **KV failures**: best-effort writes (wrap in try/catch), guard reads with `?.` and null coalescing. -- `getJSON` swallows corrupt JSON and returns null — modules must handle null gracefully. - -## Testing - -- **Framework:** Vitest -- **Style:** Pure-logic unit tests. No workerd, no Telegram integration, no network calls. -- **Fakes:** `tests/fakes/` provides `fake-kv-namespace.js`, `fake-bot.js`, `fake-modules.js`. Inject via parameters, not `vi.mock`. -- **External APIs:** Stub `global.fetch` with `vi.fn()` returning canned responses. -- **Coverage:** `npx vitest run --coverage` (v8 provider, text + HTML output). - -## Commit Messages - -Conventional commits: - -``` -feat: add paper trading module -fix: handle null price in sell handler -docs: update architecture for trading module -refactor: extract stats handler to separate file -test: add portfolio edge case tests -``` - -## Security - -- Secrets live in Cloudflare Workers secrets (runtime) and `.env.deploy` (local, gitignored). Never commit secrets. -- `.dev.vars` is gitignored — local dev only. -- grammY validates webhook secret on every update. No manual header parsing. -- Module KV prefixing is a code-review boundary, not a cryptographic one. -- Private commands are discoverability control, not access control. -- HTML output in `/help` uses `escapeHtml` to prevent injection. diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md deleted file mode 100644 index 3b0a072..0000000 --- a/docs/codebase-summary.md +++ /dev/null @@ -1,78 +0,0 @@ -# Codebase Summary - -## Overview - -Telegram bot on Cloudflare Workers with a plug-n-play module system. grammY handles Telegram API; modules register commands with three visibility levels. Data stored in Cloudflare KV (behind a prefixed `KVStore` interface) or D1 (behind `SqlStore` interface). - -## Tech Stack - -| Layer | Technology | -|-------|-----------| -| Runtime | Cloudflare Workers (V8 isolates) | -| Bot framework | grammY 1.x | -| Storage | Cloudflare KV + D1 | -| AI inference | Workers AI binding (`env.AI`) | -| Linter/Formatter | Biome | -| Tests | Vitest | -| Deploy | Wrangler CLI | - -## Active Modules - -| Module | Commands | Storage | Crons | Description | -|--------|----------|---------|-------|-------------| -| `util` | `/info`, `/help`, `/stickerid` (private) | — | — | Bot info, command help renderer, sticker file_id echo helper | -| `misc` | `/ping`, `/mstats`, `/fortytwo` | KV | — | Health check + DB demo stub | -| `trading` | `/trade_topup`, `/trade_buy`, `/trade_sell`, `/trade_convert`, `/trade_stats`, `/history` | D1 (trades) + KV (portfolio, symbol cache) | Daily 5PM trim | Paper trading — VN stocks with dynamic symbol resolution | -| `wordle` | `/wordle`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` | KV | — | 5-letter word guessing game. 14,855-word dict | -| `loldle` | `/loldle`, `/loldle_giveup`, `/loldle_stats` | KV | — | Classic-mode LoL champion guesser. Data synced from `tiennm99/loldle-data` | -| `lolschedule` | `/lolschedule_today`, `/lolschedule_week`, `/lolschedule_subscribe`, `/lolschedule_unsubscribe` | KV | Daily 01:00 UTC | LoL esports schedule + daily digest subscriptions | -| `semantle` | `/semantle`, `/semantle_giveup`, `/semantle_stats` | KV | — | English semantic word guessing via hosted word2sim service | -| `doantu` | `/doantu`, `/doantu_hint`, `/doantu_giveup`, `/doantu_stats` | KV | — | Vietnamese semantle via hosted phow2sim service | -| `twentyq` | `/twentyq`, `/twentyq_giveup`, `/twentyq_stats` | KV | — | Reverse-Akinator yes/no game. Workers AI (`@cf/google/gemma-4-26b-a4b-it`) generates round-start category+hint and judges each turn via one-line JSON | - -## Key Data Flows - -### Command Processing -``` -Telegram update → POST /webhook → grammY secret validation -→ getBot(env) → dispatcher routes /cmd → module handler -→ handler reads/writes KV via db.getJSON/putJSON (or D1 via sql.all/run) -→ ctx.reply() → response to Telegram -``` - -### Scheduled Job (Cron) -``` -Cloudflare timer fires (e.g., "0 17 * * *") -→ scheduled(event, env, ctx) handler -→ getRegistry(env) → load + init modules -→ dispatchScheduled(event, env, ctx, registry) -→ filter matching crons by event.cron -→ for each: handler reads/writes D1 via sql.all/run (or KV via db) -→ ctx.waitUntil(promise) keeps handler alive -``` - -### Deploy Pipeline -``` -npm run deploy -→ wrangler deploy (upload to CF, set env vars and bindings) -→ npm run db:migrate (apply any new migrations to D1) -→ scripts/register.js → buildRegistry with stub KV -→ POST setWebhook + POST setMyCommands to Telegram API -``` - -## External Dependencies - -| Dependency | Purpose | Version | -|-----------|---------|---------| -| `grammy` | Telegram Bot API framework | ^1.30.0 | -| `@biomejs/biome` | Linting + formatting (dev) | ^1.9.0 | -| `vitest` | Test runner (dev) | ^4.1.4 | -| `wrangler` | Cloudflare Workers CLI (dev) | ^4.84.0 | - -## Module Documentation - -Each module maintains its own `README.md` with commands, data model, and implementation details. See `src/modules//README.md`. - -## Tests - -`npm test` runs the full vitest suite (run in a few seconds — ~450 tests). Structure: one folder per module under `tests/modules//`, shared fakes under `tests/fakes/` (fake-kv-namespace, fake-d1, fake-bot, fake-modules, fake-ai). No workerd, no Telegram fixtures — pure-logic unit tests with injected fakes. diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md deleted file mode 100644 index 7358479..0000000 --- a/docs/deployment-guide.md +++ /dev/null @@ -1,160 +0,0 @@ -# Deployment Guide - -## Prerequisites - -- Node.js ≥ 20.6 -- Cloudflare account with Workers + KV + D1 enabled -- Telegram bot token from [@BotFather](https://t.me/BotFather) -- `wrangler` CLI authenticated: `npx wrangler login` - -## Environment Setup - -### 1. Cloudflare D1 Database (Optional but Recommended) - -If your modules need relational data or append-only history, set up a D1 database: - -```bash -npx wrangler d1 create miti99bot-db -``` - -Copy the database ID from the output, then add it to `wrangler.toml`: - -```toml -[[d1_databases]] -binding = "DB" -database_name = "miti99bot-db" -database_id = "" -``` - -After this, run migrations to set up tables: - -```bash -npm run db:migrate -``` - -The migration runner discovers all `src/modules/*/migrations/*.sql` files and applies them. - -### 2. Cloudflare KV Namespaces - -```bash -npx wrangler kv namespace create miti99bot-kv -npx wrangler kv namespace create miti99bot-kv --preview -``` - -Paste returned IDs into `wrangler.toml`: - -```toml -[[kv_namespaces]] -binding = "KV" -id = "" -preview_id = "" -``` - -### 3. Worker Secrets - -```bash -npx wrangler secret put TELEGRAM_BOT_TOKEN -npx wrangler secret put TELEGRAM_WEBHOOK_SECRET -``` - -`TELEGRAM_WEBHOOK_SECRET` — any high-entropy string (e.g. `openssl rand -hex 32`). grammY validates it on every webhook update via `X-Telegram-Bot-Api-Secret-Token`. - -### 4. Local Dev Config - -```bash -cp .dev.vars.example .dev.vars # for wrangler dev -cp .env.deploy.example .env.deploy # for register script -``` - -Both are gitignored. Fill in matching token + secret values. - -`.env.deploy` also needs: -- `WORKER_URL` — the `*.workers.dev` URL (known after first deploy) -- `MODULES` — comma-separated, must match `wrangler.toml` `[vars].MODULES` - -## Deploy - -### Cron Configuration (if using scheduled jobs) - -If any of your modules declare crons, they MUST also be registered in `wrangler.toml`: - -```toml -[triggers] -crons = ["0 17 * * *", "0 2 * * *"] # list all cron schedules used by modules -``` - -The schedule string must exactly match what modules declare. For details on cron expressions and examples, see [`docs/using-cron.md`](./using-cron.md). - -### First Time - -```bash -npx wrangler deploy # learn the *.workers.dev URL -# paste URL into .env.deploy as WORKER_URL -npm run db:migrate # apply any migrations to D1 -npm run register:dry # preview payloads -npm run deploy # deploy + register webhook + commands -``` - -### Subsequent Deploys - -```bash -npm run deploy -``` - -This runs `wrangler deploy`, `npm run db:migrate`, then `scripts/register.js` (setWebhook + setMyCommands). - -### What the Register Script Does - -`scripts/register.js` imports the same registry the Worker uses, builds it with a stub KV to derive public commands, then calls two Telegram APIs: - -1. `setWebhook` — points Telegram at `WORKER_URL/webhook` with the secret token -2. `setMyCommands` — pushes public command list to Telegram's `/` menu - -### Dry Run - -```bash -npm run register:dry -``` - -Prints both payloads (webhook secret redacted) without calling Telegram. - -## Secret Rotation - -1. Generate new secret: `openssl rand -hex 32` -2. Update Cloudflare: `npx wrangler secret put TELEGRAM_WEBHOOK_SECRET` -3. Update `.env.deploy` with same value -4. Redeploy: `npm run deploy` (register step re-calls setWebhook with new secret) - -Both values MUST match — mismatch causes 401 on every webhook. - -## Adding/Removing Modules - -When changing the active module list: - -1. Update `MODULES` in `wrangler.toml` `[vars]` -2. Update `MODULES` in `.env.deploy` -3. If adding: ensure module exists in `src/modules/index.js` import map -4. `npm run deploy` - -Removing a module from `MODULES` makes it inert — its KV data remains but nothing loads it. - -## Rollback - -Cloudflare Workers supports instant rollback via the dashboard or: - -```bash -npx wrangler rollback -``` - -To disable a specific module without rollback, remove its name from `MODULES` and redeploy. - -## Troubleshooting - -| Symptom | Cause | Fix | -|---------|-------|-----| -| 401 on all webhooks | Secret mismatch between CF secret and `.env.deploy` | Re-set both to same value, redeploy | -| Bot doesn't respond to commands | `MODULES` missing the module name | Add to both `wrangler.toml` and `.env.deploy` | -| `command conflict` at deploy | Two modules register same command name | Rename one command | -| `missing env: X` from register | `.env.deploy` incomplete | Add missing variable | -| `--env-file` not recognized | Node < 20.6 | Upgrade Node | -| `/help` missing a module | Module has no public or protected commands | Add at least one non-private command | diff --git a/docs/development-roadmap.md b/docs/development-roadmap.md deleted file mode 100644 index f64f54a..0000000 --- a/docs/development-roadmap.md +++ /dev/null @@ -1,63 +0,0 @@ -# Development Roadmap - -Forward-looking plan for upcoming features and milestones. This document -tracks what's **next**, not what's done — for completed work, see git log and -`plans/*/` directories. - -## Guiding Principles - -- Each item lists the *future* value, not the past state. -- Cross-module infra lands before per-module features that need it. -- Keep items small enough to ship in one PR when possible. - -## Modules - -### Wordle - -- **Daily mode.** Wire up `pickDaily(words, todayUtc())` so `/wordle` defaults - to the shared puzzle of the day (one target per UTC date for every player). - Give per-day stats a dedicated key so historical streaks aren't lost when - random mode and daily mode are mixed. `pickDaily` already exists in - `src/modules/wordle/daily.js` and has tests; the wiring in `handlers.js` is - the missing piece. - - Decide: should random-mode `/wordle_new` still work alongside daily, or - should the flow be "one puzzle per day, no reroll"? - -### Loldle - -- **Daily mode.** Same shape as wordle's daily-mode plan — use `pickDaily` - from `src/modules/loldle/daily.js` instead of `pickRandom`. - -### Trading - -- **Crypto support.** Add BTC / ETH price feed + asset category. Mirror the - stock flow: dynamic symbol resolution, cache in KV, reuse `portfolio.assets`. -- **Gold support.** SJC / PNJ spot price feed, treated as a single asset. -- **Currency exchange.** `/trade_convert VND USD 1000000` — already scaffolded - as "coming soon". Needs a forex buy/sell step that debits one currency and - credits another. -- **Leaderboard.** Cross-user ranking by realized P&L over a time window. - D1 query on `trading_trades` plus portfolio snapshots. - -## Infrastructure - -- **Shared `picker` util.** Extract the duplicated `todayUtc` / `pickDaily` / - `pickRandom` / djb2 hash from `loldle/daily.js` and `wordle/daily.js` into a - single `src/util/picker.js`. Consolidate tests. -- **Handler-level tests** for wordle + loldle mirroring - `tests/modules/trading/handlers.test.js` (subject resolution, giveup flow, - stats rendering, finished-round branch). -- **Coverage reporting.** Add vitest `--coverage` config and wire a threshold - gate into `npm test`. -- **Staging environment.** Separate D1 database + KV namespace for a staging - Worker, so migrations can be validated before prod. - -## Unresolved Questions - -- Daily-mode scope: global puzzle (everyone gets the same word / champion) vs - per-chat daily (each group has its own seed)? -- Does daily mode count toward the existing `stats:` record, or live - in a separate `daily-stats::` namespace? -- Do we keep `/wordle_new` in daily mode, or hide it until the next UTC - rollover? (Loldle auto-starts a fresh round after solve/giveup, so the - question only applies to wordle now.) diff --git a/docs/using-cron.md b/docs/using-cron.md deleted file mode 100644 index 0dc9af6..0000000 --- a/docs/using-cron.md +++ /dev/null @@ -1,283 +0,0 @@ -# Using Cron (Scheduled Jobs) - -Cron allows modules to run scheduled tasks at fixed intervals. Use crons for cleanup (purge old data), maintenance (recompute stats), or periodic notifications. - -## Declaring Crons - -In your module's default export, add a `crons` array: - -```js -export default { - name: "mymod", - init: async ({ db, sql, env }) => { /* ... */ }, - commands: [ /* ... */ ], - crons: [ - { - schedule: "0 17 * * *", // 5 PM UTC daily - name: "cleanup", // human-readable identifier - handler: async (event, ctx) => { - // event.cron = "0 17 * * *" - // event.scheduledTime = timestamp (ms) - // ctx = { db, sql, env } (same as module init) - }, - }, - ], -}; -``` - -**Handler signature:** - -```js -async (event, { db, sql, env }) => { - // event.cron — the schedule string that fired - // event.scheduledTime — Unix timestamp (ms) - // db — namespaced KV store (same as init) - // sql — namespaced D1 store (same as init), null if not bound - // env — raw worker environment -} -``` - -## Cron Expression Syntax - -Standard 5-field cron format (minute, hour, day-of-month, month, day-of-week): - -``` -minute hour day-of-month month day-of-week - 0-59 0-23 1-31 1-12 0-6 (0=Sunday) - -"0 17 * * *" — 5 PM UTC daily -"*/5 * * * *" — every 5 minutes -"0 0 1 * *" — midnight on the 1st of each month -"0 9 * * 1" — 9 AM UTC every Monday -"30 2 * * *" — 2:30 AM UTC daily -``` - -See Cloudflare's [cron expression docs](https://developers.cloudflare.com/workers/configuration/cron-triggers/) for full syntax. - -## Registering in wrangler.toml - -**Important:** Crons declared in the module MUST also be listed in `wrangler.toml`. This is because Cloudflare needs to know what schedules to fire at deploy time. - -Edit `wrangler.toml`: - -```toml -[triggers] -crons = ["0 17 * * *", "0 0 * * *", "*/5 * * * *"] -``` - -Both the module contract and the `[triggers] crons` array must list the same schedules. If a schedule is in the module but not in `wrangler.toml`, Cloudflare won't fire it. If it's in `wrangler.toml` but not in any module, the worker won't know what to do with it. - -**Multiple modules can share a schedule** — all matching handlers will fire (fan-out). Each module must declare its own `crons` entry; the registry validates them at load time. - -## Handler Details - -### Error Isolation - -If one handler fails, other handlers still run. Each handler is wrapped in try/catch: - -```js -// In cron-dispatcher.js -for (const entry of matching) { - ctx.waitUntil( - (async () => { - try { - await entry.handler(event, handlerCtx); - } catch (err) { - console.error(`[cron] handler failed:`, err); - } - })(), - ); -} -``` - -Errors are logged to the Workers console but don't crash the dispatch loop. - -### Execution Time Limits - -Cloudflare cron tasks have a **15-minute wall-clock limit**. Operations exceeding this timeout are killed. For large data operations: - -- Batch in chunks (e.g., delete 1000 rows at a time, looping) -- Use pagination to avoid loading entire datasets into memory -- Monitor execution time and add logging - -### Context Availability - -Cron handlers run in the same Worker runtime as HTTP handlers, so they have access to: - -- `db` — the module's namespaced KV store (read/write) -- `sql` — the module's namespaced D1 store (read/write), or null if not configured -- `env` — all Worker environment bindings (secrets, etc.) - -### Return Value - -Handlers should return `Promise`. The runtime ignores return values. - -## Local Testing - -Use the local `wrangler dev` server to simulate cron triggers: - -```bash -npm run dev -``` - -In another terminal, send a simulated cron request: - -```bash -# Trigger the 5 PM daily cron -curl "http://localhost:8787/__scheduled?cron=0+17+*+*+*" - -# URL-encode the cron string (spaces → +) -``` - -The Worker responds with `200` and logs handler output to the dev server console. - -### Simulating Multiple Crons - -If you have several crons with different schedules, test each by passing the exact schedule string: - -```bash -curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*" # every 5 min -curl "http://localhost:8787/__scheduled?cron=0+0+1+*+*" # monthly -``` - -## Worked Example: Trade Retention - -The trading module uses a daily cron at `0 17 * * *` (5 PM UTC) to trim old trades: - -**Module declaration (src/modules/trading/index.js):** - -```js -crons: [ - { - schedule: "0 17 * * *", - name: "trim-trades", - handler: (event, ctx) => trimTradesHandler(event, ctx), - }, -], -``` - -**wrangler.toml:** - -```toml -[triggers] -crons = ["0 17 * * *"] -``` - -**Handler (src/modules/trading/retention.js):** - -```js -/** - * Delete trades older than 90 days. - */ -export async function trimTradesHandler(event, { sql }) { - if (!sql) return; // database not configured - - const ninetyDaysAgoMs = Date.now() - 90 * 24 * 60 * 60 * 1000; - - const result = await sql.run( - "DELETE FROM trading_trades WHERE ts < ?", - ninetyDaysAgoMs - ); - - console.log(`[cron] trim-trades: deleted ${result.changes} old trades`); -} -``` - -**wrangler.toml:** - -```toml -[triggers] -crons = ["0 17 * * *"] -``` - -At 5 PM UTC every day, Cloudflare fires the `0 17 * * *` cron. The Worker loads the registry, finds the trading module's handler, executes `trimTradesHandler`, and logs the number of deleted rows. - -## Worked Example: Stats Recalculation - -Imagine a leaderboard module that caches top-10 stats: - -```js -export default { - name: "leaderboard", - init: async ({ db, sql }) => { - // ... - }, - crons: [ - { - schedule: "0 12 * * *", // noon UTC daily - name: "refresh-stats", - handler: async (event, { sql, db }) => { - if (!sql) return; - - // Recompute aggregate stats from raw data - const topTen = await sql.all( - `SELECT user_id, SUM(score) as total_score - FROM leaderboard_plays - GROUP BY user_id - ORDER BY total_score DESC - LIMIT 10` - ); - - // Cache in KV for fast /leaderboard command response - await db.putJSON("cached_top_10", topTen); - console.log(`[cron] refresh-stats: updated top 10`); - }, - }, - ], -}; -``` - -Every day at noon, the leaderboard updates its cached stats without waiting for a user request. - -## Crons and Cold Starts - -Crons execute on a fresh Worker instance (potential cold start). Module `init` hooks run before the first handler, so cron handlers can safely assume initialization is complete. - -If `init` throws, the cron fires anyway but has `sql` and `db` in a half-initialized state. Handle this gracefully: - -```js -handler: async (event, { sql, db }) => { - if (!sql) { - console.warn("sql store not available, skipping"); - return; - } - // proceed with confidence -} -``` - -## Adding a New Cron - -1. **Declare in module:** - ```js - crons: [ - { schedule: "0 3 * * *", name: "my-cron", handler: myHandler } - ], - ``` - -2. **Add to wrangler.toml:** - ```toml - [triggers] - crons = ["0 3 * * *", "0 17 * * *"] # keep existing schedules - ``` - -3. **Deploy:** - ```bash - npm run deploy - ``` - -4. **Test locally:** - ```bash - npm run dev - # in another terminal: - curl "http://localhost:8787/__scheduled?cron=0+3+*+*+*" - ``` - -## Monitoring Crons - -Cron execution is logged to the Cloudflare Workers console. Check the tail: - -```bash -npx wrangler tail -``` - -Look for `[cron]` prefixed log lines to see which crons ran and what they did. diff --git a/docs/using-d1.md b/docs/using-d1.md deleted file mode 100644 index 9599a3e..0000000 --- a/docs/using-d1.md +++ /dev/null @@ -1,316 +0,0 @@ -# Using D1 (SQL Database) - -D1 is Cloudflare's serverless SQL database. Use it when your module needs to query structured data, perform scans, or maintain append-only history. For simple key → JSON blobs or per-user state, KV is lighter and faster. - -## When to Choose D1 vs KV - -| Use Case | D1 | KV | -|----------|----|----| -| Simple key → JSON state | — | ✓ | -| Per-user blob (config, stats) | — | ✓ | -| Relational queries (JOIN, GROUP BY) | ✓ | — | -| Scans (all users' records, filtered) | ✓ | — | -| Leaderboards, sorted aggregates | ✓ | — | -| Append-only history/audit log | ✓ | — | -| Exact row counts with WHERE | ✓ | — | - -The trading module uses D1 for `trading_trades` (append-only history). Each `/trade_buy` and `/trade_sell` writes a row; `/history` scans the last N rows per user. - -## Accessing SQL in a Module - -In your module's `init`, receive `sql` (alongside `db` for KV): - -```js -/** @type {import("../../db/sql-store-interface.js").SqlStore | null} */ -let sql = null; - -const myModule = { - name: "mymod", - init: async ({ db, sql: sqlStore, env }) => { - sql = sqlStore; // cache for handlers - }, - commands: [ - { - name: "myquery", - visibility: "public", - description: "Query the database", - handler: async (ctx) => { - if (!sql) { - await ctx.reply("Database not configured"); - return; - } - const rows = await sql.all("SELECT * FROM mymod_items LIMIT 10"); - await ctx.reply(`Found ${rows.length} rows`); - }, - }, - ], -}; -``` - -**Important:** `sql` is `null` when `env.DB` is not bound (e.g., in tests without a fake D1 setup). Always guard: - -```js -if (!sql) { - // handle gracefully — module still works, just without persistence -} -``` - -## Table Naming Convention - -All tables must follow the pattern `{moduleName}_{table}`: - -- `trading_trades` — trading module's trades table -- `mymod_items` — mymod's items table -- `mymod_leaderboard` — mymod's leaderboard table - -Enforce this by convention in code review. The `sql.tablePrefix` property is available for dynamic table names: - -```js -const tableName = `${sql.tablePrefix}items`; // = "mymod_items" -await sql.all(`SELECT * FROM ${tableName}`); -``` - -## Writing Migrations - -Migrations live in `src/modules//migrations/NNNN_descriptive.sql`. Files are sorted lexically and applied in order (one-way only; no down migrations). - -**Naming:** Use a 4-digit numeric prefix, then a descriptive name: - -``` -src/modules/trading/migrations/ -├── 0001_trades.sql # first migration -├── 0002_add_fees.sql # second migration (optional) -└── 0003_... -``` - -**Example migration:** - -```sql --- src/modules/mymod/migrations/0001_items.sql -CREATE TABLE mymod_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - name TEXT NOT NULL, - created_at INTEGER NOT NULL -); -CREATE INDEX idx_mymod_items_user ON mymod_items(user_id); -``` - -Key points: -- One-way only — migrations never roll back. -- Create indexes for columns you'll filter/sort by. -- Use `user_id` (snake_case in SQL), not `userId`. -- Reference other tables with full names: `other_module_items`. - -The migration runner (`scripts/migrate.js`) tracks applied migrations in a `_migrations` table and skips any that have already run. - -## SQL API Reference - -The `SqlStore` provides these methods. All accept parameterized bindings (? placeholders): - -### `run(query, ...binds)` -Execute INSERT / UPDATE / DELETE / CREATE. Returns `{ changes, last_row_id }`. - -```js -const result = await sql.run( - "INSERT INTO mymod_items (user_id, name) VALUES (?, ?)", - userId, - "Widget" -); -console.log(result.last_row_id); // newly inserted row ID -``` - -### `all(query, ...binds)` -Execute SELECT, return all rows as plain objects. - -```js -const items = await sql.all( - "SELECT * FROM mymod_items WHERE user_id = ?", - userId -); -// items = [{ id: 1, user_id: 123, name: "Widget", created_at: 1234567 }, ...] -``` - -### `first(query, ...binds)` -Execute SELECT, return first row or `null` if no match. - -```js -const item = await sql.first( - "SELECT * FROM mymod_items WHERE id = ?", - itemId -); -if (!item) { - // not found -} -``` - -### `prepare(query, ...binds)` -Advanced: return a D1 prepared statement for use with `.batch()`. - -```js -const stmt = sql.prepare("INSERT INTO mymod_items (user_id, name) VALUES (?, ?)"); -const batch = [ - stmt.bind(userId1, "Item1"), - stmt.bind(userId2, "Item2"), -]; -await sql.batch(batch); -``` - -### `batch(statements)` -Execute multiple prepared statements in a single round-trip. - -```js -const stmt = sql.prepare("INSERT INTO mymod_items (user_id, name) VALUES (?, ?)"); -const results = await sql.batch([ - stmt.bind(userId1, "Item1"), - stmt.bind(userId2, "Item2"), - stmt.bind(userId3, "Item3"), -]); -``` - -## Running Migrations - -### Production - -```bash -npm run db:migrate -``` - -This walks `src/modules/*/migrations/*.sql` (sorted), checks which have already run (tracked in `_migrations` table), and applies only new ones via `wrangler d1 execute --remote`. - -### Local Dev - -```bash -npm run db:migrate -- --local -``` - -Applies migrations to your local D1 binding in `.dev.vars`. - -### Preview (Dry Run) - -```bash -npm run db:migrate -- --dry-run -``` - -Prints the migration plan without executing anything. Useful before a production deploy. - -## Testing with Fake D1 - -For hermetic unit tests without a real D1 binding, use `tests/fakes/fake-d1.js`. It's a minimal in-memory SQL implementation that covers common patterns: - -```js -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { FakeD1 } from "../../fakes/fake-d1.js"; - -describe("trading trades", () => { - let sql; - - beforeEach(async () => { - const fakeDb = new FakeD1(); - // setup - await fakeDb.run( - "CREATE TABLE trading_trades (id INTEGER PRIMARY KEY, user_id INTEGER, qty INTEGER)" - ); - sql = fakeDb; - }); - - it("inserts and retrieves trades", async () => { - await sql.run( - "INSERT INTO trading_trades (user_id, qty) VALUES (?, ?)", - 123, - 10 - ); - const rows = await sql.all( - "SELECT qty FROM trading_trades WHERE user_id = ?", - 123 - ); - expect(rows).toEqual([{ qty: 10 }]); - }); -}); -``` - -Note: `FakeD1` supports a subset of SQL features needed for current modules. Extend it in `tests/fakes/fake-d1.js` if you need additional syntax (CTEs, window functions, etc.). - -## First-Time D1 Setup - -If your deployment environment doesn't have a D1 database yet: - -```bash -npx wrangler d1 create miti99bot-db -``` - -Copy the database ID from the output, then add it to `wrangler.toml`: - -```toml -[[d1_databases]] -binding = "DB" -database_name = "miti99bot-db" -database_id = "" -``` - -Then run migrations: - -```bash -npm run db:migrate -``` - -The `_migrations` table is created automatically. After that, new migrations apply on every deploy. - -## Worked Example: Simple Counter - -**Migration:** - -```sql --- src/modules/counter/migrations/0001_counters.sql -CREATE TABLE counter_state ( - id INTEGER PRIMARY KEY CHECK (id = 1), - count INTEGER NOT NULL DEFAULT 0 -); -INSERT INTO counter_state (count) VALUES (0); -``` - -**Module:** - -```js -import { createCounterHandler } from "./handler.js"; - -/** @type {import("../../db/sql-store-interface.js").SqlStore | null} */ -let sql = null; - -export default { - name: "counter", - init: async ({ sql: sqlStore }) => { - sql = sqlStore; - }, - commands: [ - { - name: "count", - visibility: "public", - description: "Increment global counter", - handler: (ctx) => createCounterHandler(sql)(ctx), - }, - ], -}; -``` - -**Handler:** - -```js -export function createCounterHandler(sql) { - return async (ctx) => { - if (!sql) { - await ctx.reply("Database not configured"); - return; - } - - // increment - await sql.run("UPDATE counter_state SET count = count + 1 WHERE id = 1"); - - // read current - const row = await sql.first("SELECT count FROM counter_state WHERE id = 1"); - await ctx.reply(`Counter: ${row.count}`); - }; -} -``` - -Run `/count` multiple times and watch the counter increment. The count persists across restarts because it's stored in D1. diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index c911968..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,57 +0,0 @@ -import jsdoc from "eslint-plugin-jsdoc"; - -// Cloudflare Workers globals + custom codebase types that no-undefined-types -// must treat as defined (no tsc/jsconfig — plain ESM JS). -const DEFINED_TYPES = [ - // Cloudflare Workers runtime globals - "KVNamespace", - "D1Database", - "D1PreparedStatement", - // Web globals not always in eslint's scope - "Request", - "Response", - "URL", - // KV interface types (src/db/kv-store-interface.js) - "KVStore", - "KVStorePutOptions", - "KVStoreListOptions", - "KVStoreListResult", - // SQL interface types (src/db/sql-store-interface.js) - "SqlStore", - "SqlRunResult", - // Central typedefs (src/types.js) - "Env", - "Module", - "Command", - "Cron", - "ModuleContext", - "Trade", - "Portfolio", - "PortfolioMeta", - // Module sub-types (registry, validate-*) - "BotModule", - "RegistryEntry", - "Registry", - "ModuleCommand", - "ModuleCron", - "CronEntry", - // Trading sub-types - "ResolvedSymbol", -]; - -export default [ - { - files: ["src/**/*.js"], - plugins: { jsdoc }, - rules: { - "jsdoc/check-alignment": "warn", - "jsdoc/check-param-names": "error", - "jsdoc/check-tag-names": "error", - "jsdoc/check-types": "error", - "jsdoc/no-undefined-types": ["error", { definedTypes: DEFINED_TYPES }], - "jsdoc/require-param-type": "error", - "jsdoc/require-returns-type": "error", - "jsdoc/valid-types": "error", - }, - }, -]; diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 653b51a..0000000 --- a/package-lock.json +++ /dev/null @@ -1,4013 +0,0 @@ -{ - "name": "miti99bot", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "miti99bot", - "version": "0.1.0", - "dependencies": { - "grammy": "^1.30.0" - }, - "devDependencies": { - "@biomejs/biome": "^1.9.0", - "eslint": "^10.2.0", - "eslint-plugin-jsdoc": "^62.9.0", - "vitest": "^4.1.4", - "wrangler": "^4.84.0" - }, - "engines": { - "node": ">=20.6" - } - }, - "node_modules/@biomejs/biome": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", - "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", - "dev": true, - "hasInstallScript": true, - "license": "MIT OR Apache-2.0", - "bin": { - "biome": "bin/biome" - }, - "engines": { - "node": ">=14.21.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/biome" - }, - "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "1.9.4", - "@biomejs/cli-darwin-x64": "1.9.4", - "@biomejs/cli-linux-arm64": "1.9.4", - "@biomejs/cli-linux-arm64-musl": "1.9.4", - "@biomejs/cli-linux-x64": "1.9.4", - "@biomejs/cli-linux-x64-musl": "1.9.4", - "@biomejs/cli-win32-arm64": "1.9.4", - "@biomejs/cli-win32-x64": "1.9.4" - } - }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", - "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-darwin-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", - "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", - "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", - "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", - "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", - "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", - "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", - "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", - "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", - "dev": true, - "license": "MIT OR Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@cloudflare/unenv-preset": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.0.tgz", - "integrity": "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==", - "dev": true, - "license": "MIT OR Apache-2.0", - "peerDependencies": { - "unenv": "2.0.0-rc.24", - "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" - }, - "peerDependenciesMeta": { - "workerd": { - "optional": true - } - } - }, - "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260420.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260420.1.tgz", - "integrity": "sha512-Y6HtAY+pS5INiD9HyO1JvvujZO24mD3eqRwPZlLXBkcT+wW8bTOve/8mVKErEzEtZ5LkuT3tJqG9py8TxQEBgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260420.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260420.1.tgz", - "integrity": "sha512-7aiRtZTc5S4aKcL6uIx+B3tCzb/bULjQmE67/03k0HtaDNzP20GnYmYpFCqleFqsdmIb4Tx8PkKPmsXI3AJLvQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260420.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260420.1.tgz", - "integrity": "sha512-J/DW149FPmug1wSM32zBF7My14xg+inIYwzS4bSAxyXR6tBiTxbhgFWQQz99nt08ZMstdKHRD6f6C/KQaleQcA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260420.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260420.1.tgz", - "integrity": "sha512-a5I147McRM/L4YHu9EwOsoAyIExZndPRQoLx/33dbw/yUEnO825gvn5QZkCGXBVL2JwsPAyowB0Xliqrj+71Sw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260420.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260420.1.tgz", - "integrity": "sha512-ZrHqlHbJNU8P24EAOBaZ6B44G9P+po2z0DBwbAr8965aWR+vohy3cfmgE9uzNPAQfKNmvq7fmc4VwsRpERkg0w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@es-joy/jsdoccomment": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.86.0.tgz", - "integrity": "sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.8", - "@typescript-eslint/types": "^8.58.0", - "comment-parser": "1.4.6", - "esquery": "^1.7.0", - "jsdoc-type-pratt-parser": "~7.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@es-joy/resolve.exports": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@es-joy/resolve.exports/-/resolve.exports-1.2.0.tgz", - "integrity": "sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", - "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", - "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@grammyjs/types": { - "version": "3.26.0", - "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.26.0.tgz", - "integrity": "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A==", - "license": "MIT" - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.126.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz", - "integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@poppinss/colors": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", - "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^4.1.5" - } - }, - "node_modules/@poppinss/dumper": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", - "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@sindresorhus/is": "^7.0.2", - "supports-color": "^10.0.0" - } - }, - "node_modules/@poppinss/exception": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", - "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", - "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", - "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", - "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", - "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", - "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", - "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", - "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", - "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", - "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.9.2", - "@emnapi/runtime": "1.9.2", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", - "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz", - "integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz", - "integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sindresorhus/base62": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/base62/-/base62-1.0.0.tgz", - "integrity": "sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@speed-highlight/core": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", - "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/types": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", - "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", - "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", - "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", - "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", - "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.4", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", - "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.4", - "@vitest/utils": "4.1.4", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", - "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", - "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.4", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/are-docs-informative": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", - "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/blake3-wasm": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", - "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/comment-parser": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.6.tgz", - "integrity": "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/error-stack-parser-es": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", - "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", - "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.4", - "@eslint/config-helpers": "^0.5.4", - "@eslint/core": "^1.2.0", - "@eslint/plugin-kit": "^0.7.0", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-jsdoc": { - "version": "62.9.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.9.0.tgz", - "integrity": "sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@es-joy/jsdoccomment": "~0.86.0", - "@es-joy/resolve.exports": "1.2.0", - "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.6", - "debug": "^4.4.3", - "escape-string-regexp": "^4.0.0", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "html-entities": "^2.6.0", - "object-deep-merge": "^2.0.0", - "parse-imports-exports": "^0.2.4", - "semver": "^7.7.4", - "spdx-expression-parse": "^4.0.0", - "to-valid-identifier": "^1.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/grammy": { - "version": "1.42.0", - "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.42.0.tgz", - "integrity": "sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g==", - "license": "MIT", - "dependencies": { - "@grammyjs/types": "3.26.0", - "abort-controller": "^3.0.0", - "debug": "^4.4.3", - "node-fetch": "^2.7.0" - }, - "engines": { - "node": "^12.20.0 || >=14.13.1" - } - }, - "node_modules/html-entities": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", - "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/mdevils" - }, - { - "type": "patreon", - "url": "https://patreon.com/mdevils" - } - ], - "license": "MIT" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jsdoc-type-pratt-parser": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.2.0.tgz", - "integrity": "sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/miniflare": { - "version": "4.20260420.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260420.0.tgz", - "integrity": "sha512-w8s3eh2W7EEsFh2uGdddZLkbTwiPI8MCSMXKtuLSA9btW8xmQsVVSkrFuLXFyTKcX0QkstS5dhcWjQPQRJ2WKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "0.8.1", - "sharp": "^0.34.5", - "undici": "7.24.8", - "workerd": "1.20260420.1", - "ws": "8.18.0", - "youch": "4.1.0-beta.10" - }, - "bin": { - "miniflare": "bootstrap.js" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/object-deep-merge": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.0.tgz", - "integrity": "sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==", - "dev": true, - "license": "MIT" - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse-imports-exports": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", - "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse-statements": "1.0.11" - } - }, - "node_modules/parse-statements": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", - "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/reserved-identifiers": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", - "integrity": "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rolldown": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", - "integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.126.0", - "@rolldown/pluginutils": "1.0.0-rc.16" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.16", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", - "@rolldown/binding-darwin-x64": "1.0.0-rc.16", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", - "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", - "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/to-valid-identifier": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/to-valid-identifier/-/to-valid-identifier-1.0.0.tgz", - "integrity": "sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/base62": "^1.0.0", - "reserved-identifiers": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/undici": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", - "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/unenv": { - "version": "2.0.0-rc.24", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", - "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "pathe": "^2.0.3" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/vite": { - "version": "8.0.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", - "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.16", - "tinyglobby": "^0.2.16" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", - "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.4", - "@vitest/mocker": "4.1.4", - "@vitest/pretty-format": "4.1.4", - "@vitest/runner": "4.1.4", - "@vitest/snapshot": "4.1.4", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.4", - "@vitest/browser-preview": "4.1.4", - "@vitest/browser-webdriverio": "4.1.4", - "@vitest/coverage-istanbul": "4.1.4", - "@vitest/coverage-v8": "4.1.4", - "@vitest/ui": "4.1.4", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/workerd": { - "version": "1.20260420.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260420.1.tgz", - "integrity": "sha512-1AOJgng169u4fiFrEd5WjrAGpdwd3A4ZJtP8PMvf+RF9NUKy+mdwrKdz4qPZ6Tt/Bya99vsLn6UX33fjAEVoaA==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "bin": { - "workerd": "bin/workerd" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260420.1", - "@cloudflare/workerd-darwin-arm64": "1.20260420.1", - "@cloudflare/workerd-linux-64": "1.20260420.1", - "@cloudflare/workerd-linux-arm64": "1.20260420.1", - "@cloudflare/workerd-windows-64": "1.20260420.1" - } - }, - "node_modules/wrangler": { - "version": "4.84.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.84.0.tgz", - "integrity": "sha512-lYScYXeHZ385rDzbTF7QfP4FWu2vQuD7uDQRUjDZuutyq5fZVCR6ZxLLsySbqFiFjvKsF5RoxVPeJtI78blz4w==", - "dev": true, - "license": "MIT OR Apache-2.0", - "dependencies": { - "@cloudflare/kv-asset-handler": "0.4.2", - "@cloudflare/unenv-preset": "2.16.0", - "blake3-wasm": "2.1.5", - "esbuild": "0.27.3", - "miniflare": "4.20260420.0", - "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.24", - "workerd": "1.20260420.1" - }, - "bin": { - "wrangler": "bin/wrangler.js", - "wrangler2": "bin/wrangler.js" - }, - "engines": { - "node": ">=20.3.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - }, - "peerDependencies": { - "@cloudflare/workers-types": "^4.20260420.1" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } - } - }, - "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/youch": { - "version": "4.1.0-beta.10", - "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", - "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@poppinss/dumper": "^0.6.4", - "@speed-highlight/core": "^1.2.7", - "cookie": "^1.0.2", - "youch-core": "^0.3.3" - } - }, - "node_modules/youch-core": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", - "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/exception": "^1.2.2", - "error-stack-parser-es": "^1.0.5" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 9232658..0000000 --- a/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "miti99bot", - "version": "0.1.0", - "description": "Telegram bot with plug-n-play module system, deployed to Cloudflare Workers.", - "private": true, - "type": "module", - "engines": { - "node": ">=20.6" - }, - "scripts": { - "dev": "wrangler dev", - "build": "npm run build:wordle-data && npm run build:semantle-words", - "build:wordle-data": "node scripts/build-wordle-data.js", - "build:semantle-words": "node scripts/build-semantle-words.js", - "scrape:loldle-data": "node scripts/scrape-loldle-data.js", - "fetch:ddragon-data": "node scripts/fetch-ddragon-data.js", - "deploy": "npm run build && wrangler deploy && npm run db:migrate && npm run register", - "db:migrate": "node scripts/migrate.js", - "register": "node --env-file-if-exists=.env.deploy scripts/register.js", - "register:dry": "node --env-file-if-exists=.env.deploy scripts/register.js --dry-run", - "lint": "biome check . && eslint src", - "format": "biome format --write .", - "test": "vitest run" - }, - "dependencies": { - "grammy": "^1.30.0" - }, - "devDependencies": { - "@biomejs/biome": "^1.9.0", - "eslint": "^10.2.0", - "eslint-plugin-jsdoc": "^62.9.0", - "vitest": "^4.1.4", - "wrangler": "^4.84.0" - } -} diff --git a/scripts/build-semantle-words.js b/scripts/build-semantle-words.js deleted file mode 100644 index 135a2eb..0000000 --- a/scripts/build-semantle-words.js +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env node -/** - * @file build-semantle-words — fetches the google-10000-english (no-swears) - * word list and writes it verbatim (lowercased + deduped, no filtering) to - * src/modules/semantle/words-data.js. - * - * The list is sorted by Google Ngram frequency. ConceptNet's verify-and- - * fallback in api-client.js handles weird picks (`a`, `dvd`, etc.) by - * rejecting them at round-start if they have no concept edges, so there's - * no need to pre-filter here. - * - * Source: https://github.com/first20hours/google-10000-english - * Credits: Josh Kaufman (first20hours) — list derived from Peter Norvig's - * Google Ngram analysis. - * - * Usage: - * node scripts/build-semantle-words.js - */ - -import { writeFileSync } from "node:fs"; -import { resolve } from "node:path"; - -const SOURCE_URL = - "https://raw.githubusercontent.com/first20hours/google-10000-english/master/google-10000-english-no-swears.txt"; - -const root = resolve(import.meta.dirname, ".."); -const dst = resolve(root, "src/modules/semantle/words-data.js"); - -const res = await fetch(SOURCE_URL); -if (!res.ok) throw new Error(`fetch failed: ${res.status} ${res.statusText}`); -const text = await res.text(); - -// Normalize only: trim whitespace, lowercase, drop blanks, dedupe. -// Preserve original frequency order — source is ranked by Google Ngram. -const words = Array.from( - new Set( - text - .split(/\r?\n/) - .map((w) => w.trim().toLowerCase()) - .filter((w) => w.length > 0), - ), -); - -if (words.length === 0) throw new Error("no words parsed from source"); - -const body = words.map((w) => ` "${w}",`).join("\n"); -const out = [ - "// Auto-generated from https://github.com/first20hours/google-10000-english", - "// Credits: Josh Kaufman (first20hours) — common English words by Google Ngram frequency.", - "// Normalized (lowercased, trimmed, deduped) but otherwise unfiltered.", - "// Regenerate with: node scripts/build-semantle-words.js", - "export default [", - body, - "];", - "", -].join("\n"); - -writeFileSync(dst, out); -console.log(`wrote ${dst} (${words.length} words)`); diff --git a/scripts/build-wordle-data.js b/scripts/build-wordle-data.js deleted file mode 100644 index 6d87e83..0000000 --- a/scripts/build-wordle-data.js +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node -/** - * @file build-wordle-data — fetches the 5-letter wordle word list from the - * dracos gist and writes it as an ES module export to - * src/modules/wordle/words-data.js. - * - * Source: https://gist.github.com/dracos/dd0668f281e685bad51479e5acaadb93 - * Credits: Anna Eilering (dracos) — combined Wordle dictionary of allowed guesses. - * - * Usage: - * node scripts/build-wordle-data.js - */ - -import { writeFileSync } from "node:fs"; -import { resolve } from "node:path"; - -const GIST_URL = "https://gist.githubusercontent.com/dracos/dd0668f281e685bad51479e5acaadb93/raw/"; - -const root = resolve(import.meta.dirname, ".."); -const dst = resolve(root, "src/modules/wordle/words-data.js"); - -const res = await fetch(GIST_URL); -if (!res.ok) throw new Error(`fetch failed: ${res.status} ${res.statusText}`); -const text = await res.text(); - -const words = [ - ...new Set( - text - .split(/\r?\n/) - .map((w) => w.trim().toLowerCase()) - .filter((w) => /^[a-z]{5}$/.test(w)), - ), -].sort(); - -if (words.length === 0) throw new Error("no words parsed from gist"); - -const body = words.map((w) => ` "${w}",`).join("\n"); -const out = [ - "// Auto-generated from https://gist.github.com/dracos/dd0668f281e685bad51479e5acaadb93", - "// Credits: Anna Eilering (dracos) — combined Wordle dictionary of allowed 5-letter guesses.", - "// Regenerate with: node scripts/build-wordle-data.js", - "export default [", - body, - "];", - "", -].join("\n"); - -writeFileSync(dst, out); -console.log(`wrote ${dst} (${words.length} words)`); diff --git a/scripts/fetch-ddragon-data.js b/scripts/fetch-ddragon-data.js deleted file mode 100644 index f8abc31..0000000 --- a/scripts/fetch-ddragon-data.js +++ /dev/null @@ -1,425 +0,0 @@ -#!/usr/bin/env node -/** - * @file Builds emoji + quote pools for loldle-emoji and loldle-quote modules. - * - * Data sources: - * - classic's src/modules/loldle/champions.json (already scraped) — feeds - * the algorithmic emoji derivation (species/region/resource mapping). - * - Data Dragon latest patch — fetched once for champion `title` + `lore` - * blurb, used to seed quote text. - * - * Why algorithmic emoji instead of scrape: - * loldle.net's JS bundle contains zero emoji code points — emoji sequences - * are stored encrypted (daily rotation only) in cache.loldle.net. Scraping - * a full pool from them is not feasible. We derive a loldle-style - * 3-emoji sequence from champion metadata (gender/species/region/resource/ - * position). The mapping is handcrafted but deterministic. - * - * Why lore-blurb for quote: - * Voice-line transcripts are not in a public official feed. Champion - * `title` (e.g. "the Nine-Tailed Fox") + first sentence of `lore` gives - * recognizable per-champion text for all 165+ champions. - * - * Usage: node scripts/fetch-ddragon-data.js - */ - -import { mkdirSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import championsData from "../src/modules/loldle/champions.json" with { type: "json" }; - -// ───── emoji mapping tables ───── -// Each category yields one emoji. A champion's sequence picks the strongest -// signal from species → region → position + resource/range. Short (3 emojis) -// by design — loldle.net typically shows 3. - -const SPECIES_EMOJI = { - Yordle: "🧚", - Darkin: "⚔️", - Demon: "😈", - Dragon: "🐉", - Cat: "🐱", - Dog: "🐕", - "Void-Being": "👁️", - Void: "👁️", - Undead: "💀", - Spirit: "👻", - Celestial: "🌟", - Aspect: "🌟", - God: "⚜️", - "God-Warrior": "⚜️", - Yeti: "❄️", - Troll: "🧌", - Minotaur: "🐂", - Cyborg: "🤖", - Golem: "🗿", - Plant: "🌱", - Rat: "🐀", - Revenant: "👻", - Iceborn: "🥶", - Vastayan: "🦊", - Brackern: "🦂", - "Magically Altered": "✨", - Magicborn: "🔮", - "Chemically Altered": "🧪", - Baccai: "💀", - Spiritualist: "👻", - Human: "🧝", - Unknown: "❓", -}; - -const REGION_EMOJI = { - Demacia: "🛡️", - Noxus: "🗡️", - Shurima: "🏜️", - Freljord: "❄️", - Ionia: "🏯", - Piltover: "⚙️", - Zaun: "🧪", - "Bandle City": "🏡", - Bilgewater: "⚓", - "Shadow Isles": "🌫️", - Targon: "⛰️", - Ixtal: "🌿", - Void: "👾", - Icathia: "👾", - Camavor: "⚜️", - Runeterra: "🌍", -}; - -const RESOURCE_EMOJI = { - Mana: "🔮", - Manaless: "💪", - Energy: "⚡", - Fury: "💢", - Rage: "😡", - Ferocity: "🔥", - Bloodthirst: "🩸", - Flow: "💧", - Heat: "🔥", - Courage: "🦁", - Grit: "🪨", - Shield: "🛡️", - "Health costs": "❤️‍🩹", -}; - -const POSITION_EMOJI = { - Top: "⛰️", - Middle: "✨", - Jungle: "🌲", - Bottom: "🏹", - Support: "💕", -}; - -function pickEmoji(table, keys, fallback = "") { - for (const k of keys) if (table[k]) return table[k]; - return fallback; -} - -function deriveEmoji(champ) { - const species = pickEmoji(SPECIES_EMOJI, champ.species ?? [], ""); - const region = pickEmoji(REGION_EMOJI, champ.regions ?? [], "🌍"); - const resource = RESOURCE_EMOJI[champ.resource] ?? ""; - const position = pickEmoji(POSITION_EMOJI, champ.positions ?? [], ""); - - // always 3 emojis — drop the weakest signals if we overflow - const parts = [species, region, resource || position].filter(Boolean); - if (parts.length < 3) parts.push(position || "🎮"); - return parts.slice(0, 3).join(" "); -} - -// ───── DDragon champion name mapping ───── - -function ddragonKey(championName) { - // DDragon uses PascalCase, but only capitalizes the first letter of the - // first word — e.g. "Kai'Sa" → "Kaisa", "Cho'Gath" → "Chogath". - // Multi-word names keep internal capitalization: "Miss Fortune" → "MissFortune". - const overrides = { - Wukong: "MonkeyKing", - "Nunu & Willump": "Nunu", - "Renata Glasc": "Renata", - "Dr. Mundo": "DrMundo", - }; - if (overrides[championName]) return overrides[championName]; - // Strip apostrophes/periods within a word (fold "Kai'Sa" → "KaiSa" → "Kaisa"). - // If the name has no internal spaces but has an apostrophe/period, lowercase - // everything after the first letter: "Kai'Sa" → "K" + "aisa". - const stripped = championName.replace(/['.]/g, ""); - if (!stripped.includes(" ") && !stripped.includes("&")) { - return stripped[0].toUpperCase() + stripped.slice(1).toLowerCase(); - } - return stripped.replace(/[\s&]/g, "").replace(/^(.)/, (c) => c.toUpperCase()); -} - -async function fetchJson(url) { - const r = await fetch(url); - if (!r.ok) throw new Error(`fetch ${url}: ${r.status} ${r.statusText}`); - return r.json(); -} - -function firstSentence(text) { - if (!text) return ""; - const clean = text - .replace(/<[^>]+>/g, " ") - .replace(/\s+/g, " ") - .trim(); - const m = clean.match(/^[^.!?]+[.!?]/); - return (m ? m[0] : clean).trim(); -} - -async function buildQuotes() { - const versions = await fetchJson("https://ddragon.leagueoflegends.com/api/versions.json"); - const v = versions[0]; - console.log(` DDragon version: ${v}`); - const summary = await fetchJson( - `https://ddragon.leagueoflegends.com/cdn/${v}/data/en_US/champion.json`, - ); - const summaryByKey = summary.data; - // Secondary lookup: normalize(ddragonKey) → actual key. Handles DDragon's - // inconsistent casing ("Kaisa" vs "KSante" vs "KogMaw") by folding to - // lowercase-alphanumeric for comparison. - const normMap = new Map(); - for (const k of Object.keys(summaryByKey)) { - normMap.set(k.toLowerCase().replace(/[^a-z0-9]/g, ""), k); - } - - const out = []; - const missing = []; - for (const champ of championsData) { - const attempted = ddragonKey(champ.championName); - let entry = summaryByKey[attempted]; - if (!entry) { - const norm = attempted.toLowerCase().replace(/[^a-z0-9]/g, ""); - const realKey = normMap.get(norm); - if (realKey) entry = summaryByKey[realKey]; - } - if (!entry) { - missing.push(`${champ.championName} → ${attempted}`); - continue; - } - // Strip the champion's own name from the quote so it isn't a giveaway. - // Handle first names too ("Ahri" in "Ahri is a fox-like vastaya"). - const nameParts = new Set( - [champ.championName, ...champ.championName.split(/[\s'.]+/)].filter( - (s) => s && s.length >= 3, - ), - ); - const nameRx = new RegExp(`\\b(${[...nameParts].join("|")})\\b`, "gi"); - const sentence = firstSentence(entry.blurb).replace(nameRx, "___"); - out.push({ - championName: champ.championName, - // Title already includes "the" prefix: "the Nine-Tailed Fox — …" - quote: `${entry.title} — ${sentence}`, - }); - } - if (missing.length) { - console.warn(` WARN: ${missing.length} champions missing from DDragon:`); - for (const m of missing) console.warn(` ${m}`); - } - return out; -} - -// ───── shared DDragon per-champion cache ───── -// Ability and splash builders both need per-champion details. One fetch -// cycle populates a shared cache to avoid 340 redundant HTTP requests. - -async function mapWithConcurrency(items, limit, fn) { - const out = new Array(items.length); - let next = 0; - const workers = Array.from({ length: limit }, async () => { - while (true) { - const i = next++; - if (i >= items.length) return; - out[i] = await fn(items[i], i); - } - }); - await Promise.all(workers); - return out; -} - -async function fetchDdragonDetails() { - const versions = await fetchJson("https://ddragon.leagueoflegends.com/api/versions.json"); - const v = versions[0]; - console.log(` DDragon version: ${v}`); - const summary = await fetchJson( - `https://ddragon.leagueoflegends.com/cdn/${v}/data/en_US/champion.json`, - ); - const summaryByKey = summary.data; - const normMap = new Map(); - for (const k of Object.keys(summaryByKey)) { - normMap.set(k.toLowerCase().replace(/[^a-z0-9]/g, ""), k); - } - function resolveKey(championName) { - const attempted = ddragonKey(championName); - if (summaryByKey[attempted]) return attempted; - const norm = attempted.toLowerCase().replace(/[^a-z0-9]/g, ""); - return normMap.get(norm) ?? null; - } - // Fetch per-champion in parallel with a polite concurrency cap. ~172 - // requests to a CDN — finishes in ~15-25s. - const tasks = championsData.map((c) => ({ champ: c, key: resolveKey(c.championName) })); - console.log(` fetching ${tasks.length} champion details (concurrency 10)…`); - const details = await mapWithConcurrency(tasks, 10, async ({ champ, key }) => { - if (!key) return { champ, key: null, data: null }; - const full = await fetchJson( - `https://ddragon.leagueoflegends.com/cdn/${v}/data/en_US/champion/${key}.json`, - ); - return { champ, key, data: full.data[key] }; - }); - return { version: v, details }; -} - -// ───── loldle.net splash-pool scrape ───── - -async function scrapeLoldleSplashPool() { - const pageRes = await fetch("https://loldle.net/splash"); - const page = await pageRes.text(); - const bundleMatch = page.match(/js\/index\.[^"]+\.js/); - if (!bundleMatch) throw new Error("loldle.net: could not find index.js bundle"); - const bundleRes = await fetch(`https://loldle.net/${bundleMatch[0]}`); - const bundle = await bundleRes.text(); - // `Ad=[{championName:"…",splashes:[{name:"…",translations:[…]}…]}…]` — - // the bundle's splash pool used by loldle.net's guess validator. We pull - // names only; translations and champion order can be ignored. - const championStart = bundle.indexOf('Ad=[{championName:"Aatrox"'); - if (championStart < 0) { - throw new Error("loldle.net: splash pool variable `Ad` not found — bundle format drifted"); - } - // Each champion block has nested translations arrays, so a naïve - // non-greedy `[…]` match terminates too early. Instead, slice the - // splash-pool region and split on `championName:"` — each segment is - // one champion's block; skin names inside are `{name:"…",translations:`. - const endMarker = bundle.indexOf("];", championStart); - if (endMarker < 0) throw new Error("loldle.net: could not find end of splash pool"); - const region = bundle.slice(championStart + 3, endMarker); // skip `Ad=` prefix - const championRx = /championName:"([^"]+)"/g; - const pool = new Map(); - const championMatches = [...region.matchAll(championRx)]; - for (let i = 0; i < championMatches.length; i++) { - const name = championMatches[i][1]; - const start = championMatches[i].index; - const end = i + 1 < championMatches.length ? championMatches[i + 1].index : region.length; - const slice = region.slice(start, end); - const skinNames = [...slice.matchAll(/\{name:"([^"]+)",translations:/g)].map((x) => x[1]); - pool.set(name, skinNames); - } - if (pool.size === 0) { - throw new Error("loldle.net: parsed zero splash entries — regex drifted"); - } - console.log(` scraped ${pool.size} champions from loldle.net splash pool`); - return pool; -} - -// ───── abilities.json builder ───── - -function abilityIconUrl(version, imageFull, isPassive) { - const segment = isPassive ? "passive" : "spell"; - return `https://ddragon.leagueoflegends.com/cdn/${version}/img/${segment}/${imageFull}`; -} - -function buildAbilities(version, details) { - const out = []; - const missing = []; - const SLOTS = ["Q", "W", "E", "R"]; - for (const { champ, key, data } of details) { - if (!data) { - missing.push(champ.championName); - continue; - } - const abilities = [ - { - slot: "P", - name: data.passive.name, - icon: abilityIconUrl(version, data.passive.image.full, true), - }, - ]; - data.spells.slice(0, 4).forEach((s, i) => { - abilities.push({ - slot: SLOTS[i], - name: s.name, - icon: abilityIconUrl(version, s.image.full, false), - }); - }); - out.push({ championName: champ.championName, key, abilities }); - } - if (missing.length) console.warn(` WARN: abilities missing for ${missing.join(", ")}`); - return out.sort((a, b) => a.championName.localeCompare(b.championName)); -} - -// ───── splashes.json builder ───── - -function splashUrl(championKey, skinNum) { - // DDragon splash URLs have NO version segment. Stable across patches. - return `https://ddragon.leagueoflegends.com/cdn/img/champion/splash/${championKey}_${skinNum}.jpg`; -} - -function buildSplashes(details, loldlePool) { - const out = []; - const missing = []; - for (const { champ, key, data } of details) { - if (!data) { - missing.push(champ.championName); - continue; - } - // Loldle's pool defines WHICH skins are in play (names). DDragon gives - // URL num + "default" skin normalization. Normalize both to match. - const allowedNames = loldlePool.get(champ.championName); - const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, ""); - const allowedSet = allowedNames ? new Set(allowedNames.map(norm)) : null; - const skins = []; - for (const s of data.skins) { - // DDragon labels base skin "default" — loldle.net calls it "Default". - const displayName = s.name === "default" ? "Default" : s.name; - if (allowedSet && !allowedSet.has(norm(displayName))) continue; - skins.push({ - id: Number(s.num), - name: displayName, - url: splashUrl(key, s.num), - }); - } - if (skins.length === 0) { - missing.push(`${champ.championName} (no skins matched pool)`); - continue; - } - out.push({ championName: champ.championName, skins }); - } - if (missing.length) console.warn(` WARN: splashes missing for ${missing.join(", ")}`); - return out.sort((a, b) => a.championName.localeCompare(b.championName)); -} - -// ───── main ───── - -const root = resolve(import.meta.dirname, ".."); - -console.log("deriving emoji sequences from champion metadata…"); -const emojis = championsData - .map((c) => ({ championName: c.championName, emojis: deriveEmoji(c) })) - .sort((a, b) => a.championName.localeCompare(b.championName)); -const emojisPath = resolve(root, "src/modules/loldle-emoji/emojis.json"); -mkdirSync(resolve(emojisPath, ".."), { recursive: true }); -writeFileSync(emojisPath, `${JSON.stringify(emojis, null, 4)}\n`); -console.log(`wrote ${emojisPath} (${emojis.length} champions)`); - -console.log("fetching DDragon for quote text…"); -const quotes = (await buildQuotes()).sort((a, b) => a.championName.localeCompare(b.championName)); -const quotesPath = resolve(root, "src/modules/loldle-quote/quotes.json"); -mkdirSync(resolve(quotesPath, ".."), { recursive: true }); -writeFileSync(quotesPath, `${JSON.stringify(quotes, null, 4)}\n`); -console.log(`wrote ${quotesPath} (${quotes.length} champions)`); - -console.log("fetching DDragon champion details (spells, passives, skins)…"); -const { version: ddragonVersion, details } = await fetchDdragonDetails(); - -const abilities = buildAbilities(ddragonVersion, details); -const abilitiesPath = resolve(root, "src/modules/loldle-ability/abilities.json"); -mkdirSync(resolve(abilitiesPath, ".."), { recursive: true }); -writeFileSync(abilitiesPath, `${JSON.stringify(abilities, null, 4)}\n`); -console.log(`wrote ${abilitiesPath} (${abilities.length} champions)`); - -console.log("scraping loldle.net splash pool…"); -const loldleSplashPool = await scrapeLoldleSplashPool(); - -const splashes = buildSplashes(details, loldleSplashPool); -const splashesPath = resolve(root, "src/modules/loldle-splash/splashes.json"); -mkdirSync(resolve(splashesPath, ".."), { recursive: true }); -writeFileSync(splashesPath, `${JSON.stringify(splashes, null, 4)}\n`); -console.log(`wrote ${splashesPath} (${splashes.length} champions)`); diff --git a/scripts/migrate.js b/scripts/migrate.js deleted file mode 100644 index 4a111a5..0000000 --- a/scripts/migrate.js +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env node -/** - * @file migrate — custom D1 migration runner for per-module SQL files. - * - * Discovers all SQL files under `src/modules//migrations/`, sorts them - * deterministically (by `{moduleName}/{filename}`), then applies each NEW - * migration via `wrangler d1 execute miti99bot-db --remote --file=`. - * - * Applied migrations are tracked in a `_migrations(name TEXT PRIMARY KEY, - * applied_at INTEGER)` table in the D1 database itself. - * - * Flags: - * --dry-run Print the migration plan without executing anything. - * --local Apply against local dev D1 (omits --remote flag). - * - * Usage: - * node scripts/migrate.js - * node scripts/migrate.js --dry-run - * node scripts/migrate.js --local - */ - -import { execSync } from "node:child_process"; -import { existsSync, readdirSync } from "node:fs"; -import { basename, join, resolve } from "node:path"; - -const DB_NAME = "miti99bot-db"; -const PROJECT_ROOT = resolve(import.meta.dirname, ".."); -const MODULES_DIR = join(PROJECT_ROOT, "src", "modules"); - -const dryRun = process.argv.includes("--dry-run"); -const local = process.argv.includes("--local"); -const remoteFlag = local ? "" : "--remote"; - -/** - * Run a wrangler d1 execute command and return stdout. - * - * @param {string} sql — inline SQL string (used for bootstrap queries) - * @param {string} [file] — path to a .sql file (mutually exclusive with sql) - * @returns {string} - */ -function wranglerExecute(sql, file) { - const target = file ? `--file="${file}"` : `--command="${sql.replace(/"/g, '\\"')}"`; - const cmd = `npx wrangler d1 execute ${DB_NAME} ${remoteFlag} ${target} --json`; - try { - return execSync(cmd, { stdio: ["ignore", "pipe", "pipe"] }).toString(); - } catch (err) { - const stderr = err.stderr?.toString() ?? ""; - const stdout = err.stdout?.toString() ?? ""; - throw new Error(`wrangler error:\n${stderr || stdout}`); - } -} - -/** - * Ensure the _migrations table exists. - */ -function bootstrapMigrationsTable() { - const sql = - "CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL);"; - wranglerExecute(sql); -} - -/** - * Fetch already-applied migration names from D1. - * - * @returns {Set} - */ -function getAppliedMigrations() { - const out = wranglerExecute("SELECT name FROM _migrations;"); - /** @type {any[]} */ - let parsed = []; - try { - const json = JSON.parse(out); - // wrangler --json wraps results in an array of result objects - parsed = Array.isArray(json) ? (json[0]?.results ?? []) : []; - } catch { - // If the table is freshly created it may return empty JSON — treat as empty. - } - return new Set(parsed.map((r) => r.name)); -} - -/** - * Record a migration as applied. - * - * @param {string} name - */ -function recordMigration(name) { - const sql = `INSERT INTO _migrations (name, applied_at) VALUES ('${name}', ${Date.now()});`; - wranglerExecute(sql); -} - -/** - * Discover all migration files as { name, absPath } sorted deterministically. - * name = "{moduleName}/{filename}" — used as the unique migration key. - * - * @returns {Array<{name: string, absPath: string}>} - */ -function discoverMigrations() { - if (!existsSync(MODULES_DIR)) return []; - - /** @type {Array<{name: string, absPath: string}>} */ - const found = []; - - for (const entry of readdirSync(MODULES_DIR, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const migrationsDir = join(MODULES_DIR, entry.name, "migrations"); - if (!existsSync(migrationsDir)) continue; - - for (const file of readdirSync(migrationsDir).sort()) { - if (!file.endsWith(".sql")) continue; - found.push({ - name: `${entry.name}/${file}`, - absPath: join(migrationsDir, file), - }); - } - } - - // Sort by the composite name so ordering is deterministic across modules. - found.sort((a, b) => a.name.localeCompare(b.name)); - return found; -} - -async function main() { - const all = discoverMigrations(); - - if (all.length === 0) { - console.log("No migration files found — nothing to do."); - return; - } - - if (dryRun) { - console.log(`DRY RUN — would apply up to ${all.length} migration(s):`); - for (const m of all) console.log(` ${m.name}`); - return; - } - - bootstrapMigrationsTable(); - const applied = getAppliedMigrations(); - - const pending = all.filter((m) => !applied.has(m.name)); - - if (pending.length === 0) { - console.log(`All ${all.length} migration(s) already applied.`); - return; - } - - console.log(`Applying ${pending.length} pending migration(s)...`); - - for (const migration of pending) { - console.log(` → ${migration.name}`); - wranglerExecute(null, migration.absPath); - recordMigration(migration.name); - console.log(" ✓ applied"); - } - - console.log("Done."); -} - -main().catch((err) => { - console.error(err.message ?? err); - process.exit(1); -}); diff --git a/scripts/register.js b/scripts/register.js deleted file mode 100644 index 75f2ac2..0000000 --- a/scripts/register.js +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env node -/** - * @file register — post-deploy Telegram registration. - * - * Runs after `wrangler deploy` (chained via `npm run deploy`). Reads env from - * `.env.deploy` (via `node --env-file`), imports the same module registry - * code the Worker uses, derives the public-command list, and calls Telegram's - * HTTP API to (1) register the webhook with a secret token and (2) publish - * setMyCommands. - * - * Idempotent — safe to re-run. Supports `--dry-run` to preview payloads - * without calling Telegram. - * - * Required env (in .env.deploy): - * TELEGRAM_BOT_TOKEN — bot token from BotFather - * TELEGRAM_WEBHOOK_SECRET — must match the value `wrangler secret put` set for the Worker - * WORKER_URL — https://.workers.dev (no trailing slash) - * MODULES — same comma-separated list as wrangler.toml [vars] - */ - -import { buildRegistry, resetRegistry } from "../src/modules/registry.js"; -import { stubAi, stubKv } from "./stub-kv.js"; - -const TELEGRAM_API = "https://api.telegram.org"; - -/** - * @param {string} name - * @returns {string} - */ -function requireEnv(name) { - const v = process.env[name]; - if (!v || v.trim().length === 0) { - console.error(`missing env: ${name}`); - process.exit(1); - } - return v.trim(); -} - -/** - * @param {string} token - * @param {string} method - * @param {unknown} body - */ -async function callTelegram(token, method, body) { - const res = await fetch(`${TELEGRAM_API}/bot${token}/${method}`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - /** @type {any} */ - let json = {}; - try { - json = await res.json(); - } catch { - // Telegram always returns JSON on documented endpoints; only leave `json` - // empty if the transport itself died. - } - if (!res.ok || json.ok === false) { - console.error(`${method} failed:`, res.status, json); - process.exit(1); - } - return json; -} - -async function main() { - const token = requireEnv("TELEGRAM_BOT_TOKEN"); - const secret = requireEnv("TELEGRAM_WEBHOOK_SECRET"); - const workerUrl = requireEnv("WORKER_URL").replace(/\/$/, ""); - const modules = requireEnv("MODULES"); - const dryRun = process.argv.includes("--dry-run"); - - // Build the registry against the same code the Worker uses. Stub KV - // satisfies the binding so createStore() does not throw. - resetRegistry(); - const reg = await buildRegistry({ MODULES: modules, KV: stubKv, AI: stubAi }); - - const commands = [...reg.publicCommands.values()].map(({ cmd }) => ({ - command: cmd.name, - description: cmd.description, - })); - - const webhookBody = { - url: `${workerUrl}/webhook`, - secret_token: secret, - allowed_updates: ["message", "edited_message", "callback_query"], - drop_pending_updates: false, - }; - const commandsBody = { commands }; - - if (dryRun) { - console.log("DRY RUN — not calling Telegram"); - // Redact the secret in the printed payload. - console.log("setWebhook:", { ...webhookBody, secret_token: "" }); - console.log("setMyCommands:", commandsBody); - return; - } - - await callTelegram(token, "setWebhook", webhookBody); - await callTelegram(token, "setMyCommands", commandsBody); - - console.log(`ok — webhook: ${webhookBody.url}`); - console.log(`ok — ${commands.length} public commands registered:`); - for (const c of commands) console.log(` /${c.command} — ${c.description}`); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/scripts/scrape-loldle-data.js b/scripts/scrape-loldle-data.js deleted file mode 100644 index 27e375a..0000000 --- a/scripts/scrape-loldle-data.js +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env node -/** - * @file Rebuilds src/modules/loldle/champions.json from loldle.net's JS - * bundle. The bundle embeds the full champion array in plaintext. Records - * have two shapes (older champions carry _id/championId, newer ones don't); - * both shapes share the gameplay fields: championName, gender, positions, - * species, resource, range_type, regions, release_date — the only fields - * the bot consumes, so we keep just those. - * - * The bot imports the resulting JSON directly via `with { type: "json" }`. - * - * Usage: node scripts/scrape-loldle-data.js - * Schedule: weekly via .github/workflows/scrape-loldle-data.yml - */ - -import { writeFileSync } from "node:fs"; -import { resolve } from "node:path"; - -const LOLDLE_CLASSIC = "https://loldle.net/classic"; - -// _id and championId are only present on older records — make them optional -// and discard them (they're not used downstream). -const CHAMPION_RECORD_RX = - /\{(?:_id:"[a-f0-9]+",championId:"[^"]+",)?championName:"([^"]+)",gender:"([^"]+)",positions:\[([^\]]+)\],species:\[([^\]]+)\],resource:"([^"]+)",range_type:\[([^\]]+)\],regions:\[([^\]]+)\],release_date:"(\d{4}-\d{2}-\d{2})"\}/g; - -async function fetchText(url) { - const res = await fetch(url); - if (!res.ok) throw new Error(`fetch ${url}: ${res.status} ${res.statusText}`); - return res.text(); -} - -function parseJsArrayStrings(inner) { - return [...inner.matchAll(/"([^"]+)"/g)].map((m) => m[1]); -} - -async function scrapeLoldle() { - const html = await fetchText(LOLDLE_CLASSIC); - const scriptMatch = html.match(/ a.championName.localeCompare(b.championName)); - return records; -} - -const root = resolve(import.meta.dirname, ".."); -const jsonPath = resolve(root, "src/modules/loldle/champions.json"); - -console.log("scraping loldle.net…"); -const records = await scrapeLoldle(); -console.log(` parsed ${records.length} champions`); - -writeFileSync(jsonPath, `${JSON.stringify(records, null, 4)}\n`); -console.log(`wrote ${jsonPath}`); diff --git a/scripts/stub-kv.js b/scripts/stub-kv.js deleted file mode 100644 index 4d2822b..0000000 --- a/scripts/stub-kv.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @file stub-kv — no-op binding stubs used by scripts/register.js. - * - * The register script imports buildRegistry() to derive the public command - * list at deploy time. Module init hooks in this codebase dereference - * runtime bindings like `env.KV` (via createStore) and `env.AI` (semantle). - * These stubs satisfy the shape without doing any real IO. All init hooks - * are assumed read-only (or tolerant of missing state) at registration time. - * If a future module writes inside init(), update the matching stub to - * swallow writes safely. - */ - -/** @type {KVNamespace} */ -export const stubKv = { - async get() { - return null; - }, - async put() { - // no-op - }, - async delete() { - // no-op - }, - async list() { - return { - keys: [], - list_complete: true, - cursor: undefined, - }; - }, - // getWithMetadata is part of the KVNamespace type but unused by CFKVStore - // — provide a stub so duck-typing doesn't trip. - async getWithMetadata() { - return { value: null, metadata: null }; - }, -}; - -/** - * Workers AI binding stub. Semantle's init() wires `createClient(env.AI)` - * which only type-checks `.run` — no inference ever fires during register. - */ -export const stubAi = { - async run() { - return { data: [] }; - }, -}; diff --git a/src/bot.js b/src/bot.js deleted file mode 100644 index d8538a6..0000000 --- a/src/bot.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @file bot — lazy, memoized grammY Bot factory. - * - * The Bot instance is constructed on the first request (env is not available - * at module-load time in CF Workers) and reused on subsequent warm requests. - * Dispatcher middleware is installed exactly once, as part of that same - * lazy init, so the registry is built before any update is routed. - */ - -import { Bot } from "grammy"; -import { installDispatcher } from "./modules/dispatcher.js"; -import { getCurrentRegistry } from "./modules/registry.js"; - -/** @type {Bot | null} */ -let botInstance = null; -/** @type {Promise | null} */ -let botInitPromise = null; - -/** - * Returns the memoized registry, building it (and the bot) if needed. - * Shares the same instance used by the fetch handler so scheduled() and - * fetch() operate on identical registry state within a warm instance. - * - * @param {any} env - * @returns {Promise} - */ -export async function getRegistry(env) { - // If the bot is already initialised the registry was built as a side effect. - if (botInstance) return getCurrentRegistry(); - // Otherwise bootstrap via getBot (which calls buildRegistry internally). - await getBot(env); - return getCurrentRegistry(); -} - -/** - * Fail fast if any required env var is missing — better a 500 on first webhook - * than a confusing runtime error inside grammY. - * - * @param {any} env - */ -function requireEnv(env) { - const required = ["TELEGRAM_BOT_TOKEN", "TELEGRAM_WEBHOOK_SECRET", "MODULES"]; - const missing = required.filter((key) => !env?.[key]); - if (missing.length > 0) { - throw new Error(`missing required env vars: ${missing.join(", ")}`); - } -} - -/** - * @param {any} env - * @returns {Promise} - */ -export async function getBot(env) { - if (botInstance) return botInstance; - if (botInitPromise) return botInitPromise; - - requireEnv(env); - - botInitPromise = (async () => { - const bot = new Bot(env.TELEGRAM_BOT_TOKEN); - await installDispatcher(bot, env); - botInstance = bot; - return bot; - })(); - - try { - return await botInitPromise; - } catch (err) { - // Clear the failed promise so the next request retries init. - botInitPromise = null; - throw err; - } -} diff --git a/src/db/cf-kv-store.js b/src/db/cf-kv-store.js deleted file mode 100644 index 38c44b9..0000000 --- a/src/db/cf-kv-store.js +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @file CFKVStore — Cloudflare Workers KV implementation of the KVStore interface. - * - * Wraps a `KVNamespace` binding. normalizes the list() response shape so the - * rest of the codebase never sees CF-specific fields like `list_complete`. - * - * @see ./kv-store-interface.js for the interface contract. - */ - -/** - * @typedef {import("./kv-store-interface.js").KVStore} KVStore - * @typedef {import("./kv-store-interface.js").KVStorePutOptions} KVStorePutOptions - * @typedef {import("./kv-store-interface.js").KVStoreListOptions} KVStoreListOptions - * @typedef {import("./kv-store-interface.js").KVStoreListResult} KVStoreListResult - */ - -/** - * @implements {KVStore} - */ -export class CFKVStore { - /** - * @param {KVNamespace} kvNamespace — bound via wrangler.toml [[kv_namespaces]]. - */ - constructor(kvNamespace) { - if (!kvNamespace) throw new Error("CFKVStore: kvNamespace is required"); - this.kv = kvNamespace; - } - - /** - * @param {string} key - * @returns {Promise} - */ - async get(key) { - return this.kv.get(key, { type: "text" }); - } - - /** - * @param {string} key - * @param {string} value - * @param {KVStorePutOptions} [opts] - * @returns {Promise} - */ - async put(key, value, opts) { - // CF KV rejects `{ expirationTtl: undefined }` on some wrangler versions, - // so only pass the options object when actually needed. - if (opts?.expirationTtl) { - await this.kv.put(key, value, { expirationTtl: opts.expirationTtl }); - return; - } - await this.kv.put(key, value); - } - - /** - * @param {string} key - * @returns {Promise} - */ - async delete(key) { - await this.kv.delete(key); - } - - /** - * @param {KVStoreListOptions} [opts] - * @returns {Promise} - */ - async list(opts = {}) { - const result = await this.kv.list({ - prefix: opts.prefix, - limit: opts.limit, - cursor: opts.cursor, - }); - return { - keys: result.keys.map((k) => k.name), - cursor: result.cursor, - done: result.list_complete === true, - }; - } - - /** - * @param {string} key - * @returns {Promise} - */ - async getJSON(key) { - const raw = await this.get(key); - if (raw == null) return null; - try { - return JSON.parse(raw); - } catch (err) { - // Corrupt record — do not crash a handler. Log, return null, move on. - console.warn("getJSON: parse failed", { key, err: String(err) }); - return null; - } - } - - /** - * @param {string} key - * @param {any} value - * @param {KVStorePutOptions} [opts] - * @returns {Promise} - */ - async putJSON(key, value, opts) { - if (value === undefined) { - throw new Error(`putJSON: value for key "${key}" is undefined`); - } - // JSON.stringify throws on cycles — let it propagate. - const serialized = JSON.stringify(value); - await this.put(key, serialized, opts); - } -} diff --git a/src/db/cf-sql-store.js b/src/db/cf-sql-store.js deleted file mode 100644 index ad381ea..0000000 --- a/src/db/cf-sql-store.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @file cf-sql-store — thin wrapper around a Cloudflare D1 database binding. - * - * Exposes `prepare`, `run`, `all`, `first`, and `batch` using the D1 - * prepared-statement API. This is the production implementation of SqlStore. - * Tests use `fake-d1.js` instead. - */ - -/** - * @typedef {import("./sql-store-interface.js").SqlStore} SqlStore - * @typedef {import("./sql-store-interface.js").SqlRunResult} SqlRunResult - */ - -export class CFSqlStore { - /** @param {D1Database} db */ - constructor(db) { - this._db = db; - } - - /** - * Returns a bound D1PreparedStatement for advanced use (e.g. batch()). - * - * @param {string} query - * @param {...any} binds - * @returns {D1PreparedStatement} - */ - prepare(query, ...binds) { - const stmt = this._db.prepare(query); - return binds.length > 0 ? stmt.bind(...binds) : stmt; - } - - /** - * Execute a write statement (INSERT/UPDATE/DELETE/CREATE). - * - * @param {string} query - * @param {...any} binds - * @returns {Promise} - */ - async run(query, ...binds) { - const result = await this.prepare(query, ...binds).run(); - return { - changes: result.meta?.changes ?? 0, - last_row_id: result.meta?.last_row_id ?? 0, - }; - } - - /** - * Execute a SELECT and return all matching rows. - * - * @param {string} query - * @param {...any} binds - * @returns {Promise} - */ - async all(query, ...binds) { - const result = await this.prepare(query, ...binds).all(); - return result.results ?? []; - } - - /** - * Execute a SELECT and return the first row, or null. - * - * @param {string} query - * @param {...any} binds - * @returns {Promise} - */ - async first(query, ...binds) { - return this.prepare(query, ...binds).first() ?? null; - } - - /** - * Execute multiple prepared statements in a single round-trip. - * - * @param {D1PreparedStatement[]} statements - * @returns {Promise} - */ - async batch(statements) { - const results = await this._db.batch(statements); - return results.map((r) => r.results ?? []); - } -} diff --git a/src/db/create-sql-store.js b/src/db/create-sql-store.js deleted file mode 100644 index a37f5d5..0000000 --- a/src/db/create-sql-store.js +++ /dev/null @@ -1,64 +0,0 @@ -/** - * @file create-sql-store — factory returning a namespaced SqlStore for a module. - * - * Table naming is by convention: `{moduleName}_{table}`. Authors write the - * full prefixed name directly in SQL (e.g. `trading_trades`). `tablePrefix` - * is exposed for authors who want to interpolate the prefix dynamically. - * - * Returns null when `env.DB` is absent so modules that don't use D1 have - * zero overhead — the registry passes `sql: null` and modules check for it. - */ - -import { CFSqlStore } from "./cf-sql-store.js"; - -/** - * @typedef {import("./sql-store-interface.js").SqlStore} SqlStore - */ - -const MODULE_NAME_RE = /^[a-z0-9_-]+$/; - -/** - * @param {string} moduleName — must match `[a-z0-9_-]+`. - * @param {{ DB?: D1Database }} env — worker env (or test double). - * @returns {SqlStore | null} null when env.DB is not bound. - */ -export function createSqlStore(moduleName, env) { - if (!moduleName || typeof moduleName !== "string") { - throw new Error("createSqlStore: moduleName is required"); - } - if (!MODULE_NAME_RE.test(moduleName)) { - throw new Error( - `createSqlStore: invalid moduleName "${moduleName}" — must match ${MODULE_NAME_RE}`, - ); - } - - // D1 is optional — workers without a DB binding still work fine. - if (!env?.DB) return null; - - const base = new CFSqlStore(env.DB); - const tablePrefix = `${moduleName}_`; - - return { - tablePrefix, - - prepare(query, ...binds) { - return base.prepare(query, ...binds); - }, - - async run(query, ...binds) { - return base.run(query, ...binds); - }, - - async all(query, ...binds) { - return base.all(query, ...binds); - }, - - async first(query, ...binds) { - return base.first(query, ...binds); - }, - - async batch(statements) { - return base.batch(statements); - }, - }; -} diff --git a/src/db/create-store.js b/src/db/create-store.js deleted file mode 100644 index bae8952..0000000 --- a/src/db/create-store.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @file createStore — factory that returns a namespaced KVStore for a module. - * - * Every module gets its own prefixed view: module `wordle` calling `put("k", v)` - * writes raw key `wordle:k`. list() automatically constrains to the module's - * namespace AND strips the prefix from returned keys so the module sees its - * own flat key-space. modules CANNOT escape their namespace without - * reconstructing prefixes manually — a code-review boundary, not a hard one. - */ - -import { CFKVStore } from "./cf-kv-store.js"; - -/** - * @typedef {import("./kv-store-interface.js").KVStore} KVStore - * @typedef {import("./kv-store-interface.js").KVStorePutOptions} KVStorePutOptions - * @typedef {import("./kv-store-interface.js").KVStoreListOptions} KVStoreListOptions - * @typedef {import("./kv-store-interface.js").KVStoreListResult} KVStoreListResult - */ - -const MODULE_NAME_RE = /^[a-z0-9_-]+$/; - -/** - * @param {string} moduleName — must match `[a-z0-9_-]+`. Used verbatim as the key prefix. - * @param {{ KV: KVNamespace }} env — worker env (or test double) with a `KV` binding. - * @returns {KVStore} - */ -export function createStore(moduleName, env) { - if (!moduleName || typeof moduleName !== "string") { - throw new Error("createStore: moduleName is required"); - } - if (!MODULE_NAME_RE.test(moduleName)) { - throw new Error( - `createStore: invalid moduleName "${moduleName}" — must match ${MODULE_NAME_RE}`, - ); - } - if (!env?.KV) { - throw new Error("createStore: env.KV binding is missing"); - } - - const base = new CFKVStore(env.KV); - const prefix = `${moduleName}:`; - - return { - async get(key) { - return base.get(prefix + key); - }, - - async put(key, value, opts) { - return base.put(prefix + key, value, opts); - }, - - async delete(key) { - return base.delete(prefix + key); - }, - - async list(opts = {}) { - const fullPrefix = prefix + (opts.prefix ?? ""); - const result = await base.list({ - prefix: fullPrefix, - limit: opts.limit, - cursor: opts.cursor, - }); - // Strip the module namespace from returned keys so the caller sees its own flat space. - return { - keys: result.keys.map((k) => (k.startsWith(prefix) ? k.slice(prefix.length) : k)), - cursor: result.cursor, - done: result.done, - }; - }, - - async getJSON(key) { - return base.getJSON(prefix + key); - }, - - async putJSON(key, value, opts) { - return base.putJSON(prefix + key, value, opts); - }, - }; -} diff --git a/src/db/kv-store-interface.js b/src/db/kv-store-interface.js deleted file mode 100644 index 78bd364..0000000 --- a/src/db/kv-store-interface.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @file KVStore interface — JSDoc typedefs only, no runtime code. - * - * This is the contract every storage backend must satisfy. modules receive - * a prefixed `KVStore` (via {@link module:db/create-store}) and must NEVER - * touch the underlying binding. to swap Cloudflare KV for a different - * backend (D1, Upstash Redis, ...) in the future, implement this interface - * in a new file and change the one import in create-store.js — no module - * code changes required. - */ - -/** - * @typedef {object} KVStorePutOptions - * @property {number} [expirationTtl] seconds — value auto-deletes after this many seconds. - */ - -/** - * @typedef {object} KVStoreListOptions - * @property {string} [prefix] additional prefix (appended AFTER the module namespace). - * @property {number} [limit] - * @property {string} [cursor] pagination cursor from a previous list() call. - */ - -/** - * @typedef {object} KVStoreListResult - * @property {string[]} keys — module namespace already stripped. - * @property {string} [cursor] — present if more pages available. - * @property {boolean} done — true when list_complete. - */ - -/** - * @typedef {object} KVStore - * @property {(key: string) => Promise} get - * @property {(key: string, value: string, opts?: KVStorePutOptions) => Promise} put - * @property {(key: string) => Promise} delete - * @property {(opts?: KVStoreListOptions) => Promise} list - * @property {(key: string) => Promise} getJSON - * returns null on missing key OR malformed JSON (logs a warning — does not throw). - * @property {(key: string, value: any, opts?: KVStorePutOptions) => Promise} putJSON - * throws if value is undefined or contains a cycle. - */ - -// JSDoc-only module. No runtime exports. -export {}; diff --git a/src/db/sql-store-interface.js b/src/db/sql-store-interface.js deleted file mode 100644 index 70da3d0..0000000 --- a/src/db/sql-store-interface.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @file SqlStore interface — JSDoc typedefs only, no runtime code. - * - * This is the contract every SQL storage backend must satisfy. Modules - * receive a prefixed `SqlStore` (via {@link module:db/create-sql-store}) and - * must NEVER touch the underlying `env.DB` binding directly. - * - * Table naming convention: `{moduleName}_{table}` (e.g. `trading_trades`). - * Enforced by convention — `tablePrefix` is exposed so authors can interpolate - * it when building dynamic table names, but most authors hard-code the full - * prefixed table name directly in their SQL. - */ - -/** - * Raw D1 run result. - * - * @typedef {object} SqlRunResult - * @property {number} changes — rows affected by INSERT/UPDATE/DELETE. - * @property {number} last_row_id — rowid of the last inserted row (0 if none). - */ - -/** - * @typedef {object} SqlStore - * @property {string} tablePrefix - * Convenience prefix `"${moduleName}_"`. Authors may interpolate this when - * constructing dynamic table names. - * @property {(query: string, ...binds: any[]) => Promise} run - * Execute a write statement (INSERT/UPDATE/DELETE/CREATE). Returns metadata. - * @property {(query: string, ...binds: any[]) => Promise} all - * Execute a SELECT and return all matching rows as plain objects. - * @property {(query: string, ...binds: any[]) => Promise} first - * Execute a SELECT and return the first row, or null if no rows match. - * @property {(query: string, ...binds: any[]) => D1PreparedStatement} prepare - * Expose the underlying prepared statement for advanced use (e.g. batch()). - * @property {(statements: D1PreparedStatement[]) => Promise} batch - * Execute multiple prepared statements in a single round-trip. - */ - -// JSDoc-only module. No runtime exports. -export {}; diff --git a/src/index.js b/src/index.js deleted file mode 100644 index fa92ea4..0000000 --- a/src/index.js +++ /dev/null @@ -1,106 +0,0 @@ -/** - * @file fetch entry point for the Cloudflare Worker. - * - * Routes: - * GET / — "miti99bot ok" health check (unauthenticated). - * POST /webhook — Telegram webhook. grammY validates the - * X-Telegram-Bot-Api-Secret-Token header against - * env.TELEGRAM_WEBHOOK_SECRET and replies 401 on mismatch. - * * — 404. - * - * There is NO admin HTTP surface. `setWebhook` + `setMyCommands` run at - * deploy time from `scripts/register.js`, not from the Worker. - */ - -import { webhookCallback } from "grammy"; -import { getBot, getRegistry } from "./bot.js"; -import { dispatchScheduled } from "./modules/cron-dispatcher.js"; - -/** @type {ReturnType | null} */ -let cachedWebhookHandler = null; - -/** - * @param {any} env - */ -async function getWebhookHandler(env) { - if (cachedWebhookHandler) return cachedWebhookHandler; - const bot = await getBot(env); - cachedWebhookHandler = webhookCallback(bot, "cloudflare-mod", { - secretToken: env.TELEGRAM_WEBHOOK_SECRET, - // Default is 10s — too short for LLM calls (Gemma 4 cold-start can exceed). - // Workers wall-clock limit is 30s; leave 5s headroom. - timeoutMilliseconds: 25000, - }); - return cachedWebhookHandler; -} - -export default { - /** - * Cloudflare Cron Trigger handler. - * Dispatches the scheduled event to all module cron handlers whose - * schedule matches event.cron. - * - * @param {any} event — ScheduledEvent ({ cron: string, scheduledTime: number }) - * @param {any} env - * @param {{ waitUntil: (p: Promise) => void }} ctx - */ - async scheduled(event, env, ctx) { - try { - const registry = await getRegistry(env); - dispatchScheduled(event, env, ctx, registry); - } catch (err) { - console.error("scheduled handler failed", err); - } - }, - - /** - * @param {Request} request - * @param {any} env - * @param {any} _ctx - */ - async fetch(request, env, _ctx) { - const start = Date.now(); - const { pathname } = new URL(request.url); - const method = request.method; - - const response = await route(request, env, pathname); - - // Structured request log for Workers Observability dashboard. - console.log( - JSON.stringify({ - msg: "req", - method, - path: pathname, - status: response.status, - ms: Date.now() - start, - }), - ); - return response; - }, -}; - -/** - * @param {Request} request - * @param {any} env - * @param {string} pathname - */ -async function route(request, env, pathname) { - if (request.method === "GET" && pathname === "/") { - return new Response("miti99bot ok", { - status: 200, - headers: { "content-type": "text/plain" }, - }); - } - - if (request.method === "POST" && pathname === "/webhook") { - try { - const handler = await getWebhookHandler(env); - return await handler(request); - } catch (err) { - console.error("webhook handler failed", err); - return new Response("internal error", { status: 500 }); - } - } - - return new Response("not found", { status: 404 }); -} diff --git a/src/modules/cron-dispatcher.js b/src/modules/cron-dispatcher.js deleted file mode 100644 index 01cc13c..0000000 --- a/src/modules/cron-dispatcher.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * @file cron-dispatcher — dispatches a Cloudflare scheduled event to all - * matching module cron handlers. - * - * Design: - * - Iterates registry.crons, filters by event.cron === entry.schedule. - * - Wraps each handler invocation in try/catch so one failure cannot block - * others (equivalent to Promise.allSettled fan-out via ctx.waitUntil). - * - ctx.waitUntil is fire-and-forget from Workers' perspective; we wrap in - * an async IIFE so errors are caught and logged rather than silently lost. - */ - -import { createSqlStore } from "../db/create-sql-store.js"; -import { createStore } from "../db/create-store.js"; - -/** - * @param {any} event — Cloudflare ScheduledEvent (has .cron string). - * @param {any} env - * @param {{ waitUntil: (p: Promise) => void }} ctx - * @param {import("./registry.js").Registry} registry - */ -export function dispatchScheduled(event, env, ctx, registry) { - const matching = registry.crons.filter((entry) => entry.schedule === event.cron); - - for (const entry of matching) { - const handlerCtx = { - db: createStore(entry.module.name, env), - sql: createSqlStore(entry.module.name, env), - env, - }; - - ctx.waitUntil( - (async () => { - try { - await entry.handler(event, handlerCtx); - } catch (err) { - console.error( - `[cron] handler "${entry.name}" (module "${entry.module.name}", schedule "${entry.schedule}") failed:`, - err, - ); - } - })(), - ); - } -} diff --git a/src/modules/dispatcher.js b/src/modules/dispatcher.js deleted file mode 100644 index 2af0eb5..0000000 --- a/src/modules/dispatcher.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @file dispatcher — wires every command (public, protected, AND private) - * into the grammY Bot via `bot.command()`. - * - * Visibility is pure metadata at this layer: the dispatcher does NOT care - * whether a command is hidden from the menu or from /help. All three paths - * share a single bot.command() registration. Visibility only affects: - * 1. What scripts/register.js pushes to Telegram's setMyCommands (public only). - * 2. What phase-05's /help renderer shows (public + protected). - */ - -import { buildRegistry } from "./registry.js"; - -/** - * Build the registry (if not already built) and register every command with grammY. - * - * @param {import("grammy").Bot} bot - * @param {any} env - * @returns {Promise} - */ -export async function installDispatcher(bot, env) { - const reg = await buildRegistry(env); - - for (const { cmd } of reg.allCommands.values()) { - // grammY's bot.command() matches /cmd and /cmd@botname, case-sensitively, - // which naturally satisfies the "exact, case-sensitive" rule for private commands. - bot.command(cmd.name, cmd.handler); - } - - return reg; -} diff --git a/src/modules/doantu/README.md b/src/modules/doantu/README.md deleted file mode 100644 index 4f053b5..0000000 --- a/src/modules/doantu/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# Doantu Module - -Vietnamese "đoán từ" (guess-the-word) — same core mechanic as `semantle`, -but targets + similarity come from a Vietnamese-tuned embedding service. -Unlimited guesses per round; solve on exact match (case-insensitive, -diacritic-sensitive). - -**Visibility: `public`** — commands appear in both `/help` and Telegram's -native `/` autocomplete menu. - -## Commands - -| Command | Visibility | Description | -|---------|-----------|-------------| -| `/doantu` | public | Show current board or submit a word guess | -| `/doantu_hint` | public | Reveal 3 related words (not the answer) as a nudge | -| `/doantu_giveup` | public | Reveal the answer and end the round (next `/doantu` starts a fresh one) | -| `/doantu_stats` | public | Show per-subject stats | - -Submit with `/doantu ` (e.g. `/doantu con chó`). Multi-syllable words -with single spaces between them are accepted. `cá` and `ca` are different -targets. Out-of-vocabulary words don't count toward the guess tally. -Repeating a prior guess replies with a `🔁 already guessed` notice and is -ignored (no cost, no stat inflation). - -## Data source - -Target words + similarity scores come from our self-hosted **phow2sim** -instance (default: `https://phow2sim.sg.miti99.com`). Wraps two endpoints: - -- `GET /random` — pick a secret Vietnamese word at round start. Targets - are filtered to the top-frequency band (`min_rank=100`, `max_rank=1000`) - so rounds stay guessable for casual players. -- `GET /similarity?a=…&b=…` — cosine similarity + canonical forms + - `in_vocab_a` / `in_vocab_b` flags. -- `GET /neighbors?word=…&topn=100` — top-N nearest words (used by - `/doantu_hint`). Results are filtered locally to skip capitalized - foreign place names that leak in from the corpus, then 3 are sampled - from the "warm but not hot" tail (skip the top 20%). - -Override the base URL for local dev via `PHOW2SIM_API_URL`. - -## Architecture - -- `api-client.js` — thin `fetch` wrapper around `/random` and - `/similarity`. 5 s timeout; `UpstreamError` carries HTTP status + body - snippet on failure. -- `state.js` — KV persistence for game + stats. Same shape as semantle. -- `lookup.js` — guess normalization + shape validation. Accepts Unicode - letters + combining marks + single internal spaces. -- `format.js` — warmth-percent + emoji-bucket formatters. Linear - `round(raw * 100)` — phow2sim (word2vec) cosines already span a - game-friendly range (unrelated ~0.0, synonyms 0.6+), so no sigmoid - remap. Contrast with semantle/format.js which needs a sigmoid to - spread bge-m3's narrow transformer cone. -- `render.js` — Telegram HTML `
` monospace board with a 🇻🇳 header.
-- `handlers.js` — subject resolution + the three command entry points.
-  Fast-path dedup (exact text OR prior canonical) skips wasted API calls
-  on repeat guesses; post-API dedup catches different inputs that
-  canonicalize to the same token.
-
-Near-clone of the semantle sibling — kept separate per the repo's
-one-module-per-game convention rather than factoring out a shared base.
-Diff your changes against `../semantle/` when fixing bugs that apply to
-both.
-
-## Storage
-
-KV namespace prefix: `doantu:`
-
-| Key | Value |
-|-----|-------|
-| `game:` | `{ target, startedAt, solved, guesses[] }` — active round (TTL 7 days). |
-| `stats:` | `{ played, solved, totalGuesses, bestGuessCount, lastResultAt }` |
-
-Each `guesses[]` entry is `{ word, canonical, similarity }`.
-
-## Config
-
-| Env var | Default | Purpose |
-|---------|---------|---------|
-| `PHOW2SIM_API_URL` | `https://phow2sim.sg.miti99.com` | Base URL for the phow2sim service. |
-
-## Credits
-
-- Similarity backend: self-hosted `phow2sim` (Vietnamese word2vec/PhoBERT-style).
-- Game concept: [Semantle](https://semantle.com/) by David Turner.
diff --git a/src/modules/doantu/api-client.js b/src/modules/doantu/api-client.js
deleted file mode 100644
index 9e994b9..0000000
--- a/src/modules/doantu/api-client.js
+++ /dev/null
@@ -1,105 +0,0 @@
-/**
- * @file phow2sim HTTP API client for the doantu module (Vietnamese semantle).
- *
- * Wraps two endpoints:
- *   GET /random      → pick a secret Vietnamese word at round start
- *   GET /similarity  → cosine similarity between target and guess per turn
- *
- * Stateless. No caching layer — phow2sim is cheap enough, and caching per-pair
- * scores in KV would pollute the namespace without measurable gain.
- */
-
-const DEFAULT_TIMEOUT_MS = 5000;
-const USER_AGENT = "miti99bot/doantu";
-
-export class UpstreamError extends Error {
-  /** @param {string} message @param {{status?: number, body?: string, cause?: unknown}} [meta] */
-  constructor(message, meta = {}) {
-    super(message);
-    this.name = "UpstreamError";
-    this.status = meta.status;
-    this.body = meta.body;
-    if (meta.cause !== undefined) this.cause = meta.cause;
-  }
-}
-
-function buildUrl(base, path, params) {
-  const normalized = String(base).replace(/\/+$/, "");
-  const url = new URL(`${normalized}${path}`);
-  for (const [k, v] of Object.entries(params)) {
-    if (v === undefined || v === null) continue;
-    url.searchParams.set(k, String(v));
-  }
-  return url.toString();
-}
-
-async function fetchJson(url, timeoutMs) {
-  const controller = new AbortController();
-  const timer = setTimeout(() => controller.abort(), timeoutMs);
-  let res;
-  try {
-    res = await fetch(url, {
-      headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
-      signal: controller.signal,
-    });
-  } catch (err) {
-    clearTimeout(timer);
-    throw new UpstreamError("phow2sim fetch failed", { cause: err });
-  }
-  clearTimeout(timer);
-  const text = await res.text();
-  if (!res.ok) {
-    throw new UpstreamError(`phow2sim HTTP ${res.status}`, {
-      status: res.status,
-      body: text.slice(0, 500),
-    });
-  }
-  try {
-    return JSON.parse(text);
-  } catch (err) {
-    throw new UpstreamError("phow2sim non-JSON response", { cause: err });
-  }
-}
-
-/**
- * @param {string} apiBase — e.g. "https://phow2sim.sg.miti99.com"
- * @param {{ timeoutMs?: number }} [opts]
- */
-export function createClient(apiBase, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
-  return {
-    /**
-     * Pick a random vocab word matching filters.
-     * @param {Record} [filters]
-     * @returns {Promise<{ word: string, rank?: number }>}
-     */
-    randomWord(filters = {}) {
-      return fetchJson(buildUrl(apiBase, "/random", filters), timeoutMs);
-    },
-    /**
-     * Cosine similarity between two words.
-     * @param {string} a
-     * @param {string} b
-     * @returns {Promise<{
-     *   a: string, b: string,
-     *   canonical_a: string|null, canonical_b: string|null,
-     *   in_vocab_a: boolean, in_vocab_b: boolean,
-     *   similarity: number|null
-     * }>}
-     */
-    similarity(a, b) {
-      return fetchJson(buildUrl(apiBase, "/similarity", { a, b }), timeoutMs);
-    },
-    /**
-     * Top-N nearest neighbors by cosine similarity.
-     * @param {string} word
-     * @param {number} [topn]
-     * @returns {Promise<{
-     *   word: string, canonical: string|null, in_vocab: boolean,
-     *   neighbors: { word: string, similarity: number }[]
-     * }>}
-     */
-    neighbors(word, topn = 100) {
-      return fetchJson(buildUrl(apiBase, "/neighbors", { word, topn }), timeoutMs);
-    },
-  };
-}
diff --git a/src/modules/doantu/format.js b/src/modules/doantu/format.js
deleted file mode 100644
index fd8e534..0000000
--- a/src/modules/doantu/format.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * @file Display formatting helpers for similarity scores.
- *
- * phow2sim (PhoW2V word2vec) cosines already span a wide, game-friendly
- * range — unrelated pairs near 0.0, synonyms 0.6+ — so we map linearly:
- * `round(raw * 100)`, clamped to [0, 100]. Negative cosines (antonyms) clamp
- * to 0. No sigmoid, no magic constants.
- */
-
-/** @param {number} rawCosine */
-export function calibrate(rawCosine) {
-  return Math.max(0, Math.min(100, rawCosine * 100));
-}
-
-/** @param {number} score — calibrated score in [0, 100] */
-export function formatWarmth(score) {
-  const pct = Math.round(score);
-  return pct >= 100 ? "100" : String(pct).padStart(2, "0");
-}
-
-/** @param {number} score */
-export function warmthEmoji(score) {
-  if (score >= 90) return "🎯";
-  if (score >= 70) return "🔥";
-  if (score >= 40) return "🌡️";
-  if (score >= 15) return "😐";
-  return "🥶";
-}
diff --git a/src/modules/doantu/handlers.js b/src/modules/doantu/handlers.js
deleted file mode 100644
index b60cedc..0000000
--- a/src/modules/doantu/handlers.js
+++ /dev/null
@@ -1,248 +0,0 @@
-/**
- * @file Command handlers for the doantu module (Vietnamese semantle).
- *
- * Subject resolution mirrors loldle/semantle:
- *   private chat           → user id (per-user game)
- *   group/supergroup chat  → chat id (shared game)
- *
- * Commands:
- *   /doantu              → show the board (or lazy-start a round)
- *   /doantu        → submit a guess
- *   /doantu_giveup       → reveal target (next /doantu auto-starts fresh)
- *   /doantu_stats        → show per-subject stats
- */
-
-import { escapeHtml } from "../../util/escape-html.js";
-import { UpstreamError } from "./api-client.js";
-import { isValidShape, normalize } from "./lookup.js";
-import { renderBoard, renderGuess } from "./render.js";
-import { clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js";
-
-const UPSTREAM_FAIL = "⚠️ Upstream hiccup — try again in a few seconds.";
-// Keep targets inside the top-frequency band so the game stays guessable.
-// phow2sim ranks by corpus frequency; lower rank = more common.
-const RANDOM_FILTERS = { min_rank: 100, max_rank: 1000 };
-
-function getSubject(ctx) {
-  const type = ctx.chat?.type;
-  if (type === "group" || type === "supergroup") return ctx.chat.id;
-  return ctx.from?.id ?? null;
-}
-
-function argAfterCommand(text) {
-  if (!text) return "";
-  const idx = text.indexOf(" ");
-  return idx === -1 ? "" : text.slice(idx + 1).trim();
-}
-
-function logFail(stage, err) {
-  console.log(
-    JSON.stringify({
-      msg: "doantu_upstream_fail",
-      stage,
-      err: err instanceof UpstreamError ? { status: err.status, body: err.body } : String(err),
-    }),
-  );
-}
-
-async function startFreshGame(db, client, subject) {
-  const picked = await client.randomWord(RANDOM_FILTERS);
-  const target = String(picked?.word ?? "").toLowerCase();
-  if (!target) throw new UpstreamError("empty target from randomWord");
-  const fresh = { target, startedAt: null, solved: false, guesses: [] };
-  await saveGame(db, subject, fresh);
-  return fresh;
-}
-
-async function getOrInitGame(db, client, subject) {
-  const existing = await loadGame(db, subject);
-  if (existing && !existing.solved) return existing;
-  return startFreshGame(db, client, subject);
-}
-
-export async function handleDoantu(ctx, { db, client }) {
-  const subject = getSubject(ctx);
-  if (subject == null) return ctx.reply("Cannot identify chat.");
-  const arg = argAfterCommand(ctx.message?.text ?? "");
-  let game;
-  try {
-    game = await getOrInitGame(db, client, subject);
-  } catch (err) {
-    logFail("random", err);
-    return ctx.reply(UPSTREAM_FAIL);
-  }
-  if (!arg) return ctx.reply(renderBoard(game.guesses), { parse_mode: "HTML" });
-  return submitGuess(ctx, { db, client }, subject, game, arg);
-}
-
-async function submitGuess(ctx, { db, client }, subject, game, arg) {
-  const guess = normalize(arg);
-  if (!isValidShape(guess)) {
-    return ctx.reply("Please provide a Vietnamese word (letters + optional single spaces).");
-  }
-  // Fast-path dedup: exact text OR prior canonical. Avoids wasted API calls.
-  if (game.guesses.some((g) => g.word === guess || g.canonical === guess)) {
-    return ctx.reply(
-      `🔁 ${escapeHtml(guess)} was already guessed this round — try another word.`,
-      { parse_mode: "HTML" },
-    );
-  }
-  let res;
-  try {
-    res = await client.similarity(game.target, guess);
-  } catch (err) {
-    logFail("similarity", err);
-    return ctx.reply(UPSTREAM_FAIL);
-  }
-  // Target OOV means the round was seeded before phow2sim owned the vocab
-  // (pre-refactor Worker, or an old rank-40k+ pick). Reset the round instead
-  // of letting the player type forever against a ghost target.
-  if (!res?.in_vocab_a) {
-    logFail("target_oov", new UpstreamError(`target ${game.target} not in phow2sim vocab`));
-    await clearGame(db, subject);
-    return ctx.reply(
-      "⚠️ This round's target is no longer valid (upstream vocabulary changed). " +
-        "Send /doantu again to start a fresh round.",
-      { parse_mode: "HTML" },
-    );
-  }
-  if (!res.in_vocab_b || res.similarity == null) {
-    return ctx.reply(`🤔 ${escapeHtml(guess)} isn't in the vocabulary.`, {
-      parse_mode: "HTML",
-    });
-  }
-
-  const entry = {
-    word: guess,
-    canonical: String(res.canonical_b ?? guess).toLowerCase(),
-    similarity: Number(res.similarity),
-  };
-  if (game.guesses.some((g) => g.canonical === entry.canonical)) {
-    return ctx.reply(
-      `🔁 ${escapeHtml(entry.canonical)} was already guessed this round — try another word.`,
-      { parse_mode: "HTML" },
-    );
-  }
-  game.guesses.push(entry);
-  if (game.startedAt === null) game.startedAt = Date.now();
-
-  if (entry.canonical === game.target) {
-    game.solved = true;
-    const count = game.guesses.length;
-    await recordResult(db, subject, { solved: true, guessCount: count });
-    await clearGame(db, subject);
-    const board = renderBoard(game.guesses, entry.canonical);
-    return ctx.reply(`${board}\n✅ Solved in ${count} guess${count === 1 ? "" : "es"}!`, {
-      parse_mode: "HTML",
-    });
-  }
-
-  await saveGame(db, subject, game);
-  const body = `${renderGuess(entry)}\n${renderBoard(game.guesses, entry.canonical)}`;
-  return ctx.reply(body, { parse_mode: "HTML" });
-}
-
-// Corpus leaks foreign place names (mixed-case Latin — "al-Qantara",
-// "Nam_Afrin") into neighbors. Keep only lowercase tokens that look
-// Vietnamese: either a diacritic (non-ASCII) or a compound marker (`_`).
-const PLAYABLE_WORD = /^[\p{Ll}\p{M}_]+$/u;
-
-/** @param {string} word — true if it has a diacritic or underscore compound. */
-function looksVietnamese(word) {
-  if (word.includes("_")) return true;
-  for (let i = 0; i < word.length; i++) {
-    if (word.charCodeAt(i) > 0x7f) return true;
-  }
-  return false;
-}
-
-function pickHintWords(target, neighbors, alreadyGuessed, count) {
-  const guessedSet = new Set(alreadyGuessed);
-  const playable = neighbors.filter(
-    (n) =>
-      PLAYABLE_WORD.test(n.word) &&
-      looksVietnamese(n.word) &&
-      !n.word.includes(target) &&
-      !target.includes(n.word) &&
-      !guessedSet.has(n.word),
-  );
-  // "Warm but not hot" — skip the top 20% so a hint doesn't hand the answer away.
-  const skip = Math.min(Math.floor(playable.length * 0.2), 20);
-  const pool = playable.slice(skip);
-  if (pool.length === 0) return [];
-  // Fisher-Yates-ish sample without replacement.
-  const picks = [];
-  const used = new Set();
-  const want = Math.min(count, pool.length);
-  while (picks.length < want) {
-    const i = Math.floor(Math.random() * pool.length);
-    if (used.has(i)) continue;
-    used.add(i);
-    picks.push(pool[i]);
-  }
-  return picks;
-}
-
-export async function handleHint(ctx, { db, client }) {
-  const subject = getSubject(ctx);
-  if (subject == null) return ctx.reply("Cannot identify chat.");
-  const game = await loadGame(db, subject);
-  if (!game || game.solved) {
-    return ctx.reply("No active round. Send /doantu to start one.", {
-      parse_mode: "HTML",
-    });
-  }
-  let res;
-  try {
-    res = await client.neighbors(game.target, 100);
-  } catch (err) {
-    logFail("neighbors", err);
-    return ctx.reply(UPSTREAM_FAIL);
-  }
-  const alreadyGuessed = game.guesses.map((g) => g.canonical);
-  const picks = pickHintWords(game.target, res?.neighbors ?? [], alreadyGuessed, 3);
-  if (picks.length === 0) {
-    return ctx.reply("🤷 No usable hints available for this round.");
-  }
-  const list = picks.map((p) => `• ${escapeHtml(p.word)}`).join("\n");
-  return ctx.reply(`💡 Hints — related words (not the answer):\n${list}`, {
-    parse_mode: "HTML",
-  });
-}
-
-export async function handleGiveup(ctx, { db }) {
-  const subject = getSubject(ctx);
-  if (subject == null) return ctx.reply("Cannot identify chat.");
-  const game = await loadGame(db, subject);
-  if (!game) {
-    return ctx.reply("No active round. Send /doantu to start one.", {
-      parse_mode: "HTML",
-    });
-  }
-  await recordResult(db, subject, { solved: false, guessCount: game.guesses.length });
-  await clearGame(db, subject);
-  return ctx.reply(
-    `🏳️ The target was ${escapeHtml(game.target)}. Send /doantu for a new round.`,
-    { parse_mode: "HTML" },
-  );
-}
-
-export async function handleStats(ctx, { db }) {
-  const subject = getSubject(ctx);
-  if (subject == null) return ctx.reply("Cannot identify chat.");
-  const s = await loadStats(db, subject);
-  if (s.played === 0) return ctx.reply("No doantu games played yet.");
-  const solveRate = Math.round((s.solved / s.played) * 100);
-  const avgPerRound = s.played > 0 ? Math.round(s.totalGuesses / s.played) : "—";
-  return ctx.reply(
-    [
-      "🇻🇳 Đoán từ stats",
-      `Played: ${s.played}`,
-      `Solved: ${s.solved} (${solveRate}%)`,
-      `Total guesses: ${s.totalGuesses}`,
-      `Fewest to solve: ${s.bestGuessCount ?? "—"}`,
-      `Avg per round: ${avgPerRound}`,
-    ].join("\n"),
-    { parse_mode: "HTML" },
-  );
-}
diff --git a/src/modules/doantu/index.js b/src/modules/doantu/index.js
deleted file mode 100644
index 568ac41..0000000
--- a/src/modules/doantu/index.js
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * @file Doantu module — Vietnamese semantle.
- *
- * Target words + cosine similarity come from our own hosted phow2sim instance
- * (default: https://phow2sim.sg.miti99.com). Override via env var
- * `PHOW2SIM_API_URL` for local dev or self-hosting.
- */
-
-import { createClient } from "./api-client.js";
-import { handleDoantu, handleGiveup, handleHint, handleStats } from "./handlers.js";
-
-const DEFAULT_API_URL = "https://phow2sim.sg.miti99.com";
-
-/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
-let db = null;
-/** @type {ReturnType | null} */
-let client = null;
-
-/** @type {import("../registry.js").BotModule} */
-const doantuModule = {
-  name: "doantu",
-  init: async ({ db: store, env }) => {
-    db = store;
-    const base = env?.PHOW2SIM_API_URL || DEFAULT_API_URL;
-    client = createClient(base);
-  },
-  commands: [
-    {
-      name: "doantu",
-      visibility: "public",
-      description: "Đoán từ — Vietnamese semantic word guessing (unlimited tries)",
-      handler: (ctx) => handleDoantu(ctx, { db, client }),
-    },
-    {
-      name: "doantu_hint",
-      visibility: "public",
-      description: "Reveal 3 related words (not the answer) to nudge your guessing",
-      handler: (ctx) => handleHint(ctx, { db, client }),
-    },
-    {
-      name: "doantu_giveup",
-      visibility: "public",
-      description: "Reveal the current doantu answer (auto-starts a fresh round)",
-      handler: (ctx) => handleGiveup(ctx, { db, client }),
-    },
-    {
-      name: "doantu_stats",
-      visibility: "public",
-      description: "Show your doantu stats",
-      handler: (ctx) => handleStats(ctx, { db, client }),
-    },
-  ],
-};
-
-export default doantuModule;
diff --git a/src/modules/doantu/lookup.js b/src/modules/doantu/lookup.js
deleted file mode 100644
index f60a479..0000000
--- a/src/modules/doantu/lookup.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @file Guess normalization + shape validation (Vietnamese).
- *
- * Allows Unicode letters (including diacritics via combining marks) plus
- * single spaces between syllables for compound words (`con chó`,
- * `máy bay`). Rejects digits, punctuation, and underscores so the board
- * stays clean.
- */
-
-/** @param {string} raw */
-export function normalize(raw) {
-  if (typeof raw !== "string") return "";
-  return raw.trim().replace(/\s+/g, " ").toLowerCase();
-}
-
-/** @param {string} word — must already be normalized */
-export function isValidShape(word) {
-  if (!word) return false;
-  if (word.length > 64) return false;
-  return /^[\p{L}\p{M}]+(?: [\p{L}\p{M}]+)*$/u.test(word);
-}
diff --git a/src/modules/doantu/render.js b/src/modules/doantu/render.js
deleted file mode 100644
index dee7b39..0000000
--- a/src/modules/doantu/render.js
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * @file Render a doantu board as a Telegram HTML monospace block.
- * Mirrors semantle/render.js; header text is the only visible change.
- */
-
-import { escapeHtml } from "../../util/escape-html.js";
-import { calibrate, formatWarmth, warmthEmoji } from "./format.js";
-
-const MAX_ROWS = 15;
-const LATEST_MARKER = "➡️";
-const PLAIN_MARKER = "  ";
-
-/** @typedef {import("./state.js").DoantuGuess} DoantuGuess */
-
-/**
- * @param {DoantuGuess[]} guesses
- * @param {string|null} [latestCanonical]
- */
-export function renderBoard(guesses, latestCanonical = null) {
-  const count = guesses.length;
-  const header = `🇻🇳 Đoán từ — ${count} guess${count === 1 ? "" : "es"}`;
-  if (count === 0) {
-    return `${header}\n🆕 Round ready — reply with /doantu <word>.`;
-  }
-
-  const sorted = [...guesses].sort((a, b) => b.similarity - a.similarity).slice(0, MAX_ROWS);
-  const wordWidth = Math.min(20, Math.max(...sorted.map((g) => g.canonical.length)));
-  const rows = sorted.map((g, i) => {
-    const score = Math.round(calibrate(g.similarity));
-    const marker = g.canonical === latestCanonical ? LATEST_MARKER : PLAIN_MARKER;
-    const rank = String(i + 1).padStart(2);
-    const warmth = formatWarmth(score).padStart(3);
-    const word = escapeHtml(g.canonical.padEnd(wordWidth));
-    return `${marker} ${rank}  ${warmth}  ${word} ${warmthEmoji(score)}`;
-  });
-
-  const hidden = count - sorted.length;
-  const footer = hidden > 0 ? `\n…${hidden} older guess${hidden === 1 ? "" : "es"} hidden.` : "";
-  return `${header}\n
${rows.join("\n")}
${footer}`; -} - -/** @param {DoantuGuess} guess */ -export function renderGuess(guess) { - const score = Math.round(calibrate(guess.similarity)); - return `${escapeHtml(guess.canonical)} → ${formatWarmth(score)} ${warmthEmoji(score)}`; -} diff --git a/src/modules/doantu/state.js b/src/modules/doantu/state.js deleted file mode 100644 index 0cffe42..0000000 --- a/src/modules/doantu/state.js +++ /dev/null @@ -1,94 +0,0 @@ -/** - * @file Game + stats persistence in KV, keyed by "subject" - * (user id in DMs, chat id in groups). Target stored lowercased; - * guesses are append-only until solve, giveup, or fresh-start. - * - * Key layout (inside the module-prefixed store): - * game: -> { target, startedAt, solved, guesses[] } - * stats: -> { played, solved, totalGuesses, bestGuessCount, lastResultAt } - */ - -const GAME_TTL_SECONDS = 60 * 60 * 24 * 7; - -const gameKey = (subject) => `game:${subject}`; -const statsKey = (subject) => `stats:${subject}`; - -/** - * @typedef {object} DoantuGuess - * @property {string} word — raw user input (normalized) - * @property {string} canonical — canonical form, lowercased - * @property {number} similarity - */ - -/** - * @typedef {object} DoantuGameState - * @property {string} target - * @property {number|null} startedAt - * @property {boolean} solved - * @property {DoantuGuess[]} guesses - */ - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - * @returns {Promise} - */ -export async function loadGame(db, subject) { - return db.getJSON(gameKey(subject)); -} - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - * @param {DoantuGameState} state - */ -export async function saveGame(db, subject, state) { - await db.putJSON(gameKey(subject), state, { expirationTtl: GAME_TTL_SECONDS }); -} - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - */ -export async function clearGame(db, subject) { - await db.delete(gameKey(subject)); -} - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - */ -export async function loadStats(db, subject) { - return ( - (await db.getJSON(statsKey(subject))) ?? { - played: 0, - solved: 0, - totalGuesses: 0, - bestGuessCount: null, - lastResultAt: null, - } - ); -} - -/** - * Record a finished round. `guessCount` counts scored guesses only — - * OOV rejections never reach the state and don't increment. - * - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - * @param {{ solved: boolean, guessCount: number }} outcome - */ -export async function recordResult(db, subject, { solved, guessCount }) { - const s = await loadStats(db, subject); - s.played += 1; - s.totalGuesses += guessCount; - if (solved) { - s.solved += 1; - if (s.bestGuessCount === null || guessCount < s.bestGuessCount) { - s.bestGuessCount = guessCount; - } - } - s.lastResultAt = Date.now(); - await db.putJSON(statsKey(subject), s); - return s; -} diff --git a/src/modules/index.js b/src/modules/index.js deleted file mode 100644 index d3a1b5f..0000000 --- a/src/modules/index.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @file moduleRegistry — static import map of every buildable module. - * - * wrangler bundles statically — dynamic `import(variablePath)` defeats - * tree-shaking and can fail at bundle time. So we enumerate every module here - * as a lazy loader, and loadModules filters the list at runtime - * against `env.MODULES` (comma-separated). Adding a new module is a two-step - * edit: create the folder, then add one line here. - */ - -export const moduleRegistry = { - util: () => import("./util/index.js"), - wordle: () => import("./wordle/index.js"), - loldle: () => import("./loldle/index.js"), - "loldle-emoji": () => import("./loldle-emoji/index.js"), - "loldle-quote": () => import("./loldle-quote/index.js"), - "loldle-ability": () => import("./loldle-ability/index.js"), - "loldle-splash": () => import("./loldle-splash/index.js"), - misc: () => import("./misc/index.js"), - trading: () => import("./trading/index.js"), - lolschedule: () => import("./lolschedule/index.js"), - semantle: () => import("./semantle/index.js"), - doantu: () => import("./doantu/index.js"), - twentyq: () => import("./twentyq/index.js"), -}; diff --git a/src/modules/loldle-ability/README.md b/src/modules/loldle-ability/README.md deleted file mode 100644 index c3d6b02..0000000 --- a/src/modules/loldle-ability/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# loldle-ability - -Guess the champion from a single ability icon (passive, Q, W, E, or R). -Binary right/wrong. 5 guesses per round. - -## Commands - -| Command | Visibility | Description | -|---|---|---| -| `/loldle_ability` | public | Show current ability icon / submit a guess with `/loldle_ability ` | -| `/loldle_ability_giveup` | public | Reveal the answer, record loss | -| `/loldle_ability_stats` | public | Show per-subject play stats | - -## Storage - -KV prefix: `loldle-ability:` -- `game:` — `{target, slot, guesses, startedAt}`. `slot` persists so - the same icon shows across every guess in a round. -- `stats:` — `{played, wins, streak, bestStreak}` - -## Data source - -`abilities.json` is generated by `npm run fetch:ddragon-data` directly from -[Riot Data Dragon](https://ddragon.leagueoflegends.com/) — the same CDN -that [loldle.net](https://loldle.net/) uses at runtime. Version is baked -into each icon URL, so re-run after patch bumps to refresh. - -Credits: -- Game concept inspired by [loldle.net](https://loldle.net/)'s ability mode. -- Icons © Riot Games, served from their public Data Dragon CDN. - -## Design notes - -- **No progressive crop** — full icon from turn 1. Shorter guess budget (5) - balances difficulty vs. loldle.net's unlimited-guess progressive-reveal. -- **No slot-ID bonus** — the mode ends on correct champion guess. Slot is - named in the reveal, not asked. diff --git a/src/modules/loldle-ability/abilities.json b/src/modules/loldle-ability/abilities.json deleted file mode 100644 index 44de7f9..0000000 --- a/src/modules/loldle-ability/abilities.json +++ /dev/null @@ -1,5334 +0,0 @@ -[ - { - "championName": "Aatrox", - "key": "Aatrox", - "abilities": [ - { - "slot": "P", - "name": "Deathbringer Stance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Aatrox_Passive.png" - }, - { - "slot": "Q", - "name": "The Darkin Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AatroxQ.png" - }, - { - "slot": "W", - "name": "Infernal Chains", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AatroxW.png" - }, - { - "slot": "E", - "name": "Umbral Dash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AatroxE.png" - }, - { - "slot": "R", - "name": "World Ender", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AatroxR.png" - } - ] - }, - { - "championName": "Ahri", - "key": "Ahri", - "abilities": [ - { - "slot": "P", - "name": "Essence Theft", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Ahri_SoulEater2.png" - }, - { - "slot": "Q", - "name": "Orb of Deception", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AhriQ.png" - }, - { - "slot": "W", - "name": "Fox-Fire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AhriW.png" - }, - { - "slot": "E", - "name": "Charm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AhriE.png" - }, - { - "slot": "R", - "name": "Spirit Rush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AhriR.png" - } - ] - }, - { - "championName": "Akali", - "key": "Akali", - "abilities": [ - { - "slot": "P", - "name": "Assassin's Mark", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Akali_P.png" - }, - { - "slot": "Q", - "name": "Five Point Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AkaliQ.png" - }, - { - "slot": "W", - "name": "Twilight Shroud", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AkaliW.png" - }, - { - "slot": "E", - "name": "Shuriken Flip", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AkaliE.png" - }, - { - "slot": "R", - "name": "Perfect Execution", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AkaliR.png" - } - ] - }, - { - "championName": "Akshan", - "key": "Akshan", - "abilities": [ - { - "slot": "P", - "name": "Dirty Fighting", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/akshan_p.png" - }, - { - "slot": "Q", - "name": "Avengerang", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AkshanQ.png" - }, - { - "slot": "W", - "name": "Going Rogue", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AkshanW.png" - }, - { - "slot": "E", - "name": "Heroic Swing", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AkshanE.png" - }, - { - "slot": "R", - "name": "Comeuppance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AkshanR.png" - } - ] - }, - { - "championName": "Alistar", - "key": "Alistar", - "abilities": [ - { - "slot": "P", - "name": "Triumphant Roar", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Alistar_E.png" - }, - { - "slot": "Q", - "name": "Pulverize", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Pulverize.png" - }, - { - "slot": "W", - "name": "Headbutt", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Headbutt.png" - }, - { - "slot": "E", - "name": "Trample", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AlistarE.png" - }, - { - "slot": "R", - "name": "Unbreakable Will", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FerociousHowl.png" - } - ] - }, - { - "championName": "Ambessa", - "key": "Ambessa", - "abilities": [ - { - "slot": "P", - "name": "Drakehound's Step", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Icon_Ambessa_Passive.Domina.png" - }, - { - "slot": "Q", - "name": "Cunning Sweep / Sundering Slam", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AmbessaQ.png" - }, - { - "slot": "W", - "name": "Repudiation", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AmbessaW.png" - }, - { - "slot": "E", - "name": "Lacerate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AmbessaE.png" - }, - { - "slot": "R", - "name": "Public Execution", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AmbessaR.png" - } - ] - }, - { - "championName": "Amumu", - "key": "Amumu", - "abilities": [ - { - "slot": "P", - "name": "Cursed Touch", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Amumu_Passive.png" - }, - { - "slot": "Q", - "name": "Bandage Toss", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BandageToss.png" - }, - { - "slot": "W", - "name": "Despair", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AuraofDespair.png" - }, - { - "slot": "E", - "name": "Tantrum", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Tantrum.png" - }, - { - "slot": "R", - "name": "Curse of the Sad Mummy", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CurseoftheSadMummy.png" - } - ] - }, - { - "championName": "Anivia", - "key": "Anivia", - "abilities": [ - { - "slot": "P", - "name": "Rebirth", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Anivia_P.png" - }, - { - "slot": "Q", - "name": "Flash Frost", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FlashFrost.png" - }, - { - "slot": "W", - "name": "Crystallize", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Crystallize.png" - }, - { - "slot": "E", - "name": "Frostbite", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Frostbite.png" - }, - { - "slot": "R", - "name": "Glacial Storm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GlacialStorm.png" - } - ] - }, - { - "championName": "Annie", - "key": "Annie", - "abilities": [ - { - "slot": "P", - "name": "Pyromania", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Annie_Passive.png" - }, - { - "slot": "Q", - "name": "Disintegrate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AnnieQ.png" - }, - { - "slot": "W", - "name": "Incinerate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AnnieW.png" - }, - { - "slot": "E", - "name": "Molten Shield", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AnnieE.png" - }, - { - "slot": "R", - "name": "Summon: Tibbers", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AnnieR.png" - } - ] - }, - { - "championName": "Aphelios", - "key": "Aphelios", - "abilities": [ - { - "slot": "P", - "name": "The Hitman and the Seer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ApheliosP.png" - }, - { - "slot": "Q", - "name": "Weapon Abilites", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ApheliosQ_ClientTooltipWrapper.png" - }, - { - "slot": "W", - "name": "Phase", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ApheliosW.png" - }, - { - "slot": "E", - "name": "Weapon Queue System", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ApheliosE_ClientTooltipWrapper.png" - }, - { - "slot": "R", - "name": "Moonlight Vigil", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ApheliosR.png" - } - ] - }, - { - "championName": "Ashe", - "key": "Ashe", - "abilities": [ - { - "slot": "P", - "name": "Frost Shot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Ashe_P.png" - }, - { - "slot": "Q", - "name": "Ranger's Focus", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AsheQ.png" - }, - { - "slot": "W", - "name": "Volley", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Volley.png" - }, - { - "slot": "E", - "name": "Hawkshot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AsheSpiritOfTheHawk.png" - }, - { - "slot": "R", - "name": "Enchanted Crystal Arrow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EnchantedCrystalArrow.png" - } - ] - }, - { - "championName": "Aurelion Sol", - "key": "AurelionSol", - "abilities": [ - { - "slot": "P", - "name": "Cosmic Creator", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/AurelionSolP.png" - }, - { - "slot": "Q", - "name": "Breath of Light", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AurelionSolQ.png" - }, - { - "slot": "W", - "name": "Astral Flight", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AurelionSolW.png" - }, - { - "slot": "E", - "name": "Singularity", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AurelionSolE.png" - }, - { - "slot": "R", - "name": "Falling Star / The Skies Descend", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AurelionSolR.png" - } - ] - }, - { - "championName": "Aurora", - "key": "Aurora", - "abilities": [ - { - "slot": "P", - "name": "Spirit Abjuration", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/AuroraPassive.Aurora.png" - }, - { - "slot": "Q", - "name": "Twofold Hex", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AuroraQ.png" - }, - { - "slot": "W", - "name": "Across the Veil", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AuroraW.png" - }, - { - "slot": "E", - "name": "The Weirding", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AuroraE.png" - }, - { - "slot": "R", - "name": "Between Worlds", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AuroraR.png" - } - ] - }, - { - "championName": "Azir", - "key": "Azir", - "abilities": [ - { - "slot": "P", - "name": "Shurima's Legacy", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Azir_Passive.png" - }, - { - "slot": "Q", - "name": "Conquering Sands", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AzirQWrapper.png" - }, - { - "slot": "W", - "name": "Arise!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AzirW.png" - }, - { - "slot": "E", - "name": "Shifting Sands", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AzirEWrapper.png" - }, - { - "slot": "R", - "name": "Emperor's Divide", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AzirR.png" - } - ] - }, - { - "championName": "Bard", - "key": "Bard", - "abilities": [ - { - "slot": "P", - "name": "Traveler's Call", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Bard_Passive.png" - }, - { - "slot": "Q", - "name": "Cosmic Binding", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BardQ.png" - }, - { - "slot": "W", - "name": "Caretaker's Shrine", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BardW.png" - }, - { - "slot": "E", - "name": "Magical Journey", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BardE.png" - }, - { - "slot": "R", - "name": "Tempered Fate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BardR.png" - } - ] - }, - { - "championName": "Bel'Veth", - "key": "Belveth", - "abilities": [ - { - "slot": "P", - "name": "Death in Lavender ", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Belveth_Passive.png" - }, - { - "slot": "Q", - "name": "Void Surge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BelvethQ.png" - }, - { - "slot": "W", - "name": "Above and Below", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BelvethW.png" - }, - { - "slot": "E", - "name": "Royal Maelstrom", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BelvethE.png" - }, - { - "slot": "R", - "name": "Endless Banquet", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BelvethR.png" - } - ] - }, - { - "championName": "Blitzcrank", - "key": "Blitzcrank", - "abilities": [ - { - "slot": "P", - "name": "Mana Barrier", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Blitzcrank_ManaBarrier.png" - }, - { - "slot": "Q", - "name": "Rocket Grab", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RocketGrab.png" - }, - { - "slot": "W", - "name": "Overdrive", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Overdrive.png" - }, - { - "slot": "E", - "name": "Power Fist", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PowerFist.png" - }, - { - "slot": "R", - "name": "Static Field", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/StaticField.png" - } - ] - }, - { - "championName": "Brand", - "key": "Brand", - "abilities": [ - { - "slot": "P", - "name": "Blaze", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/BrandP.png" - }, - { - "slot": "Q", - "name": "Sear", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BrandQ.png" - }, - { - "slot": "W", - "name": "Pillar of Flame", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BrandW.png" - }, - { - "slot": "E", - "name": "Conflagration", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BrandE.png" - }, - { - "slot": "R", - "name": "Pyroclasm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BrandR.png" - } - ] - }, - { - "championName": "Braum", - "key": "Braum", - "abilities": [ - { - "slot": "P", - "name": "Concussive Blows", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Braum_Passive.png" - }, - { - "slot": "Q", - "name": "Winter's Bite", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BraumQ.png" - }, - { - "slot": "W", - "name": "Stand Behind Me", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BraumW.png" - }, - { - "slot": "E", - "name": "Unbreakable", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BraumE.png" - }, - { - "slot": "R", - "name": "Glacial Fissure", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BraumRWrapper.png" - } - ] - }, - { - "championName": "Briar", - "key": "Briar", - "abilities": [ - { - "slot": "P", - "name": "Crimson Curse", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/BriarP.png" - }, - { - "slot": "Q", - "name": "Head Rush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BriarQ.png" - }, - { - "slot": "W", - "name": "Blood Frenzy / Snack Attack", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BriarW.png" - }, - { - "slot": "E", - "name": "Chilling Scream", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BriarE.png" - }, - { - "slot": "R", - "name": "Certain Death", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BriarR.png" - } - ] - }, - { - "championName": "Caitlyn", - "key": "Caitlyn", - "abilities": [ - { - "slot": "P", - "name": "Headshot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Caitlyn_Headshot.png" - }, - { - "slot": "Q", - "name": "Piltover Peacemaker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CaitlynQ.png" - }, - { - "slot": "W", - "name": "Yordle Snap Trap", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CaitlynW.png" - }, - { - "slot": "E", - "name": "90 Caliber Net", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CaitlynE.png" - }, - { - "slot": "R", - "name": "Ace in the Hole", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CaitlynR.png" - } - ] - }, - { - "championName": "Camille", - "key": "Camille", - "abilities": [ - { - "slot": "P", - "name": "Adaptive Defenses", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Camille_Passive.png" - }, - { - "slot": "Q", - "name": "Precision Protocol", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CamilleQ.png" - }, - { - "slot": "W", - "name": "Tactical Sweep", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CamilleW.png" - }, - { - "slot": "E", - "name": "Hookshot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CamilleE.png" - }, - { - "slot": "R", - "name": "The Hextech Ultimatum", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CamilleR.png" - } - ] - }, - { - "championName": "Cassiopeia", - "key": "Cassiopeia", - "abilities": [ - { - "slot": "P", - "name": "Serpentine Grace", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Cassiopeia_Passive.png" - }, - { - "slot": "Q", - "name": "Noxious Blast", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CassiopeiaQ.png" - }, - { - "slot": "W", - "name": "Miasma", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CassiopeiaW.png" - }, - { - "slot": "E", - "name": "Twin Fang", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CassiopeiaE.png" - }, - { - "slot": "R", - "name": "Petrifying Gaze", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CassiopeiaR.png" - } - ] - }, - { - "championName": "Cho'Gath", - "key": "Chogath", - "abilities": [ - { - "slot": "P", - "name": "Carnivore", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/GreenTerror_TailSpike.png" - }, - { - "slot": "Q", - "name": "Rupture", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Rupture.png" - }, - { - "slot": "W", - "name": "Feral Scream", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FeralScream.png" - }, - { - "slot": "E", - "name": "Vorpal Spikes", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VorpalSpikes.png" - }, - { - "slot": "R", - "name": "Feast", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Feast.png" - } - ] - }, - { - "championName": "Corki", - "key": "Corki", - "abilities": [ - { - "slot": "P", - "name": "Hextech Munitions", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Corki_RapidReload.png" - }, - { - "slot": "Q", - "name": "Phosphorus Bomb", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PhosphorusBomb.png" - }, - { - "slot": "W", - "name": "Valkyrie", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CarpetBomb.png" - }, - { - "slot": "E", - "name": "Gatling Gun", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GGun.png" - }, - { - "slot": "R", - "name": "Missile Barrage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MissileBarrage.png" - } - ] - }, - { - "championName": "Darius", - "key": "Darius", - "abilities": [ - { - "slot": "P", - "name": "Hemorrhage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Darius_Icon_Hemorrhage.png" - }, - { - "slot": "Q", - "name": "Decimate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DariusCleave.png" - }, - { - "slot": "W", - "name": "Crippling Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DariusNoxianTacticsONH.png" - }, - { - "slot": "E", - "name": "Apprehend", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DariusAxeGrabCone.png" - }, - { - "slot": "R", - "name": "Noxian Guillotine", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DariusExecute.png" - } - ] - }, - { - "championName": "Diana", - "key": "Diana", - "abilities": [ - { - "slot": "P", - "name": "Moonsilver Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Diana_Passive_LunarBlade.png" - }, - { - "slot": "Q", - "name": "Crescent Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DianaQ.png" - }, - { - "slot": "W", - "name": "Pale Cascade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DianaOrbs.png" - }, - { - "slot": "E", - "name": "Lunar Rush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DianaTeleport.png" - }, - { - "slot": "R", - "name": "Moonfall", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DianaR.png" - } - ] - }, - { - "championName": "Dr. Mundo", - "key": "DrMundo", - "abilities": [ - { - "slot": "P", - "name": "Goes Where He Pleases", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/DrMundo_P.png" - }, - { - "slot": "Q", - "name": "Infected Bonesaw", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DrMundoQ.png" - }, - { - "slot": "W", - "name": "Heart Zapper", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DrMundoW.png" - }, - { - "slot": "E", - "name": "Blunt Force Trauma", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DrMundoE.png" - }, - { - "slot": "R", - "name": "Maximum Dosage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DrMundoR.png" - } - ] - }, - { - "championName": "Draven", - "key": "Draven", - "abilities": [ - { - "slot": "P", - "name": "League of Draven", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Draven_passive.png" - }, - { - "slot": "Q", - "name": "Spinning Axe", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DravenSpinning.png" - }, - { - "slot": "W", - "name": "Blood Rush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DravenFury.png" - }, - { - "slot": "E", - "name": "Stand Aside", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DravenDoubleShot.png" - }, - { - "slot": "R", - "name": "Whirling Death", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DravenRCast.png" - } - ] - }, - { - "championName": "Ekko", - "key": "Ekko", - "abilities": [ - { - "slot": "P", - "name": "Z-Drive Resonance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Ekko_P.png" - }, - { - "slot": "Q", - "name": "Timewinder", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EkkoQ.png" - }, - { - "slot": "W", - "name": "Parallel Convergence", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EkkoW.png" - }, - { - "slot": "E", - "name": "Phase Dive", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EkkoE.png" - }, - { - "slot": "R", - "name": "Chronobreak", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EkkoR.png" - } - ] - }, - { - "championName": "Elise", - "key": "Elise", - "abilities": [ - { - "slot": "P", - "name": "Spider Queen", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ElisePassive.png" - }, - { - "slot": "Q", - "name": "Neurotoxin / Venomous Bite", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EliseHumanQ.png" - }, - { - "slot": "W", - "name": "Volatile Spiderling / Skittering Frenzy", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EliseHumanW.png" - }, - { - "slot": "E", - "name": "Cocoon / Rappel", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EliseHumanE.png" - }, - { - "slot": "R", - "name": "Spider Form", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EliseR.png" - } - ] - }, - { - "championName": "Evelynn", - "key": "Evelynn", - "abilities": [ - { - "slot": "P", - "name": "Demon Shade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Evelynn_Passive.png" - }, - { - "slot": "Q", - "name": "Hate Spike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EvelynnQ.png" - }, - { - "slot": "W", - "name": "Allure", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EvelynnW.png" - }, - { - "slot": "E", - "name": "Whiplash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EvelynnE.png" - }, - { - "slot": "R", - "name": "Last Caress", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EvelynnR.png" - } - ] - }, - { - "championName": "Ezreal", - "key": "Ezreal", - "abilities": [ - { - "slot": "P", - "name": "Rising Spell Force", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Ezreal_RisingSpellForce.png" - }, - { - "slot": "Q", - "name": "Mystic Shot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EzrealQ.png" - }, - { - "slot": "W", - "name": "Essence Flux", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EzrealW.png" - }, - { - "slot": "E", - "name": "Arcane Shift", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EzrealE.png" - }, - { - "slot": "R", - "name": "Trueshot Barrage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EzrealR.png" - } - ] - }, - { - "championName": "Fiddlesticks", - "key": "Fiddlesticks", - "abilities": [ - { - "slot": "P", - "name": "A Harmless Scarecrow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/FiddlesticksP.png" - }, - { - "slot": "Q", - "name": "Terrify", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FiddleSticksQ.png" - }, - { - "slot": "W", - "name": "Bountiful Harvest", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FiddleSticksW.png" - }, - { - "slot": "E", - "name": "Reap", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FiddleSticksE.png" - }, - { - "slot": "R", - "name": "Crowstorm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FiddleSticksR.png" - } - ] - }, - { - "championName": "Fiora", - "key": "Fiora", - "abilities": [ - { - "slot": "P", - "name": "Duelist's Dance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Fiora_P.png" - }, - { - "slot": "Q", - "name": "Lunge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FioraQ.png" - }, - { - "slot": "W", - "name": "Riposte", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FioraW.png" - }, - { - "slot": "E", - "name": "Bladework", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FioraE.png" - }, - { - "slot": "R", - "name": "Grand Challenge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FioraR.png" - } - ] - }, - { - "championName": "Fizz", - "key": "Fizz", - "abilities": [ - { - "slot": "P", - "name": "Nimble Fighter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Fizz_P.png" - }, - { - "slot": "Q", - "name": "Urchin Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FizzQ.png" - }, - { - "slot": "W", - "name": "Seastone Trident", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FizzW.png" - }, - { - "slot": "E", - "name": "Playful / Trickster", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FizzE.png" - }, - { - "slot": "R", - "name": "Chum the Waters", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FizzR.png" - } - ] - }, - { - "championName": "Galio", - "key": "Galio", - "abilities": [ - { - "slot": "P", - "name": "Colossal Smash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Galio_Passive.png" - }, - { - "slot": "Q", - "name": "Winds of War", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GalioQ.png" - }, - { - "slot": "W", - "name": "Shield of Durand", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GalioW.png" - }, - { - "slot": "E", - "name": "Justice Punch", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GalioE.png" - }, - { - "slot": "R", - "name": "Hero's Entrance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GalioR.png" - } - ] - }, - { - "championName": "Gangplank", - "key": "Gangplank", - "abilities": [ - { - "slot": "P", - "name": "Trial by Fire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Gangplank_Passive.png" - }, - { - "slot": "Q", - "name": "Parrrley", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GangplankQWrapper.png" - }, - { - "slot": "W", - "name": "Remove Scurvy", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GangplankW.png" - }, - { - "slot": "E", - "name": "Powder Keg", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GangplankE.png" - }, - { - "slot": "R", - "name": "Cannon Barrage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GangplankR.png" - } - ] - }, - { - "championName": "Garen", - "key": "Garen", - "abilities": [ - { - "slot": "P", - "name": "Perseverance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Garen_Passive.png" - }, - { - "slot": "Q", - "name": "Decisive Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GarenQ.png" - }, - { - "slot": "W", - "name": "Courage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GarenW.png" - }, - { - "slot": "E", - "name": "Judgment", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GarenE.png" - }, - { - "slot": "R", - "name": "Demacian Justice", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GarenR.png" - } - ] - }, - { - "championName": "Gnar", - "key": "Gnar", - "abilities": [ - { - "slot": "P", - "name": "Rage Gene", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Gnar_Passive.png" - }, - { - "slot": "Q", - "name": "Boomerang Throw / Boulder Toss", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GnarQ.png" - }, - { - "slot": "W", - "name": "Hyper / Wallop", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GnarW.png" - }, - { - "slot": "E", - "name": "Hop / Crunch", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GnarE.png" - }, - { - "slot": "R", - "name": "GNAR!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GnarR.png" - } - ] - }, - { - "championName": "Gragas", - "key": "Gragas", - "abilities": [ - { - "slot": "P", - "name": "Happy Hour", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/GragasPassiveHeal.png" - }, - { - "slot": "Q", - "name": "Barrel Roll", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GragasQ.png" - }, - { - "slot": "W", - "name": "Drunken Rage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GragasW.png" - }, - { - "slot": "E", - "name": "Body Slam", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GragasE.png" - }, - { - "slot": "R", - "name": "Explosive Cask", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GragasR.png" - } - ] - }, - { - "championName": "Graves", - "key": "Graves", - "abilities": [ - { - "slot": "P", - "name": "New Destiny", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/GravesTrueGrit.png" - }, - { - "slot": "Q", - "name": "End of the Line", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GravesQLineSpell.png" - }, - { - "slot": "W", - "name": "Smoke Screen", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GravesSmokeGrenade.png" - }, - { - "slot": "E", - "name": "Quickdraw", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GravesMove.png" - }, - { - "slot": "R", - "name": "Collateral Damage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GravesChargeShot.png" - } - ] - }, - { - "championName": "Gwen", - "key": "Gwen", - "abilities": [ - { - "slot": "P", - "name": "A Thousand Cuts", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Gwen_Passive.png" - }, - { - "slot": "Q", - "name": "Snip Snip!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GwenQ.png" - }, - { - "slot": "W", - "name": "Hallowed Mist", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GwenW.png" - }, - { - "slot": "E", - "name": "Skip 'n Slash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GwenE.png" - }, - { - "slot": "R", - "name": "Needlework", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GwenR.png" - } - ] - }, - { - "championName": "Hecarim", - "key": "Hecarim", - "abilities": [ - { - "slot": "P", - "name": "Warpath", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Hecarim_Passive.png" - }, - { - "slot": "Q", - "name": "Rampage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HecarimRapidSlash.png" - }, - { - "slot": "W", - "name": "Spirit of Dread", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HecarimW.png" - }, - { - "slot": "E", - "name": "Devastating Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HecarimRamp.png" - }, - { - "slot": "R", - "name": "Onslaught of Shadows", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HecarimUlt.png" - } - ] - }, - { - "championName": "Heimerdinger", - "key": "Heimerdinger", - "abilities": [ - { - "slot": "P", - "name": "Hextech Affinity", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Heimerdinger_Passive.png" - }, - { - "slot": "Q", - "name": "H-28 G Evolution Turret", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HeimerdingerQ.png" - }, - { - "slot": "W", - "name": "Hextech Micro-Rockets", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HeimerdingerW.png" - }, - { - "slot": "E", - "name": "CH-2 Electron Storm Grenade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HeimerdingerE.png" - }, - { - "slot": "R", - "name": "UPGRADE!!!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HeimerdingerR.png" - } - ] - }, - { - "championName": "Hwei", - "key": "Hwei", - "abilities": [ - { - "slot": "P", - "name": "Signature of the Visionary", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/HweiPassive.png" - }, - { - "slot": "Q", - "name": "Subject: Disaster", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HweiQ.png" - }, - { - "slot": "W", - "name": "Subject: Serenity", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HweiW.png" - }, - { - "slot": "E", - "name": "Subject: Torment", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HweiE.png" - }, - { - "slot": "R", - "name": "Spiraling Despair", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HweiR.png" - } - ] - }, - { - "championName": "Illaoi", - "key": "Illaoi", - "abilities": [ - { - "slot": "P", - "name": "Prophet of an Elder God", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Illaoi_P.png" - }, - { - "slot": "Q", - "name": "Tentacle Smash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IllaoiQ.png" - }, - { - "slot": "W", - "name": "Harsh Lesson", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IllaoiW.png" - }, - { - "slot": "E", - "name": "Test of Spirit", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IllaoiE.png" - }, - { - "slot": "R", - "name": "Leap of Faith", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IllaoiR.png" - } - ] - }, - { - "championName": "Irelia", - "key": "Irelia", - "abilities": [ - { - "slot": "P", - "name": "Ionian Fervor", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Irelia_Passive.png" - }, - { - "slot": "Q", - "name": "Bladesurge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IreliaQ.png" - }, - { - "slot": "W", - "name": "Defiant Dance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IreliaW.png" - }, - { - "slot": "E", - "name": "Flawless Duet", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IreliaE.png" - }, - { - "slot": "R", - "name": "Vanguard's Edge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IreliaR.png" - } - ] - }, - { - "championName": "Ivern", - "key": "Ivern", - "abilities": [ - { - "slot": "P", - "name": "Friend of the Forest", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/IvernP.png" - }, - { - "slot": "Q", - "name": "Rootcaller", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IvernQ.png" - }, - { - "slot": "W", - "name": "Brushmaker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IvernW.png" - }, - { - "slot": "E", - "name": "Triggerseed", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IvernE.png" - }, - { - "slot": "R", - "name": "Daisy!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IvernR.png" - } - ] - }, - { - "championName": "Janna", - "key": "Janna", - "abilities": [ - { - "slot": "P", - "name": "Tailwind", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/JannaP.png" - }, - { - "slot": "Q", - "name": "Howling Gale", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HowlingGale.png" - }, - { - "slot": "W", - "name": "Zephyr", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SowTheWind.png" - }, - { - "slot": "E", - "name": "Eye Of The Storm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EyeOfTheStorm.png" - }, - { - "slot": "R", - "name": "Monsoon", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ReapTheWhirlwind.png" - } - ] - }, - { - "championName": "Jarvan IV", - "key": "JarvanIV", - "abilities": [ - { - "slot": "P", - "name": "Martial Cadence", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/JarvanIVP.png" - }, - { - "slot": "Q", - "name": "Dragon Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JarvanIVDragonStrike.png" - }, - { - "slot": "W", - "name": "Golden Aegis", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JarvanIVGoldenAegis.png" - }, - { - "slot": "E", - "name": "Demacian Standard", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JarvanIVDemacianStandard.png" - }, - { - "slot": "R", - "name": "Cataclysm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JarvanIVCataclysm.png" - } - ] - }, - { - "championName": "Jax", - "key": "Jax", - "abilities": [ - { - "slot": "P", - "name": "Relentless Assault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Armsmaster_MasterOfArms.png" - }, - { - "slot": "Q", - "name": "Leap Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JaxQ.png" - }, - { - "slot": "W", - "name": "Empower", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JaxW.png" - }, - { - "slot": "E", - "name": "Counter Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JaxE.png" - }, - { - "slot": "R", - "name": "Grandmaster-at-Arms", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JaxR.png" - } - ] - }, - { - "championName": "Jayce", - "key": "Jayce", - "abilities": [ - { - "slot": "P", - "name": "Hextech Capacitor", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Jayce_Passive.png" - }, - { - "slot": "Q", - "name": "To the Skies! / Shock Blast", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JayceToTheSkies.png" - }, - { - "slot": "W", - "name": "Lightning Field / Hyper Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JayceStaticField.png" - }, - { - "slot": "E", - "name": "Thundering Blow / Acceleration Gate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JayceThunderingBlow.png" - }, - { - "slot": "R", - "name": "Mercury Cannon / Mercury Hammer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JayceStanceHtG.png" - } - ] - }, - { - "championName": "Jhin", - "key": "Jhin", - "abilities": [ - { - "slot": "P", - "name": "Whisper", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Jhin_P.png" - }, - { - "slot": "Q", - "name": "Dancing Grenade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JhinQ.png" - }, - { - "slot": "W", - "name": "Deadly Flourish", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JhinW.png" - }, - { - "slot": "E", - "name": "Captive Audience", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JhinE.png" - }, - { - "slot": "R", - "name": "Curtain Call", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JhinR.png" - } - ] - }, - { - "championName": "Jinx", - "key": "Jinx", - "abilities": [ - { - "slot": "P", - "name": "Get Excited!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Jinx_Passive.png" - }, - { - "slot": "Q", - "name": "Switcheroo!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JinxQ.png" - }, - { - "slot": "W", - "name": "Zap!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JinxW.png" - }, - { - "slot": "E", - "name": "Flame Chompers!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JinxE.png" - }, - { - "slot": "R", - "name": "Super Mega Death Rocket!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JinxR.png" - } - ] - }, - { - "championName": "K'Sante", - "key": "KSante", - "abilities": [ - { - "slot": "P", - "name": "Dauntless Instinct", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Icons_KSante_P.png" - }, - { - "slot": "Q", - "name": "Ntofo Strikes", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KSanteQ.png" - }, - { - "slot": "W", - "name": "Path Maker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KSanteW.png" - }, - { - "slot": "E", - "name": "Footwork", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KSanteE.png" - }, - { - "slot": "R", - "name": "All Out", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KSanteR.png" - } - ] - }, - { - "championName": "Kai'Sa", - "key": "Kaisa", - "abilities": [ - { - "slot": "P", - "name": "Second Skin", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Kaisa_Passive.png" - }, - { - "slot": "Q", - "name": "Icathian Rain", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KaisaQ.png" - }, - { - "slot": "W", - "name": "Void Seeker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KaisaW.png" - }, - { - "slot": "E", - "name": "Supercharge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KaisaE.png" - }, - { - "slot": "R", - "name": "Killer Instinct", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KaisaR.png" - } - ] - }, - { - "championName": "Kalista", - "key": "Kalista", - "abilities": [ - { - "slot": "P", - "name": "Martial Poise", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Kalista_Passive.png" - }, - { - "slot": "Q", - "name": "Pierce", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KalistaMysticShot.png" - }, - { - "slot": "W", - "name": "Sentinel", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KalistaW.png" - }, - { - "slot": "E", - "name": "Rend", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KalistaExpungeWrapper.png" - }, - { - "slot": "R", - "name": "Fate's Call", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KalistaRx.png" - } - ] - }, - { - "championName": "Karma", - "key": "Karma", - "abilities": [ - { - "slot": "P", - "name": "Gathering Fire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Karma_Passive.png" - }, - { - "slot": "Q", - "name": "Inner Flame", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KarmaQ.png" - }, - { - "slot": "W", - "name": "Focused Resolve", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KarmaSpiritBind.png" - }, - { - "slot": "E", - "name": "Inspire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KarmaSolKimShield.png" - }, - { - "slot": "R", - "name": "Mantra", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KarmaMantra.png" - } - ] - }, - { - "championName": "Karthus", - "key": "Karthus", - "abilities": [ - { - "slot": "P", - "name": "Death Defied", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Karthus_Passive.png" - }, - { - "slot": "Q", - "name": "Lay Waste", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KarthusLayWasteA1.png" - }, - { - "slot": "W", - "name": "Wall of Pain", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KarthusWallOfPain.png" - }, - { - "slot": "E", - "name": "Defile", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KarthusDefile.png" - }, - { - "slot": "R", - "name": "Requiem", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KarthusFallenOne.png" - } - ] - }, - { - "championName": "Kassadin", - "key": "Kassadin", - "abilities": [ - { - "slot": "P", - "name": "Void Stone", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Kassadin_Passive.png" - }, - { - "slot": "Q", - "name": "Null Sphere", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NullLance.png" - }, - { - "slot": "W", - "name": "Nether Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NetherBlade.png" - }, - { - "slot": "E", - "name": "Force Pulse", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ForcePulse.png" - }, - { - "slot": "R", - "name": "Riftwalk", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RiftWalk.png" - } - ] - }, - { - "championName": "Katarina", - "key": "Katarina", - "abilities": [ - { - "slot": "P", - "name": "Voracity", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Katarina_Passive.png" - }, - { - "slot": "Q", - "name": "Bouncing Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KatarinaQ.png" - }, - { - "slot": "W", - "name": "Preparation", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KatarinaW.png" - }, - { - "slot": "E", - "name": "Shunpo", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KatarinaEWrapper.png" - }, - { - "slot": "R", - "name": "Death Lotus", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KatarinaR.png" - } - ] - }, - { - "championName": "Kayle", - "key": "Kayle", - "abilities": [ - { - "slot": "P", - "name": "Divine Ascent", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Kayle_P.png" - }, - { - "slot": "Q", - "name": "Radiant Blast", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KayleQ.png" - }, - { - "slot": "W", - "name": "Celestial Blessing", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KayleW.png" - }, - { - "slot": "E", - "name": "Starfire Spellblade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KayleE.png" - }, - { - "slot": "R", - "name": "Divine Judgment", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KayleR.png" - } - ] - }, - { - "championName": "Kayn", - "key": "Kayn", - "abilities": [ - { - "slot": "P", - "name": "The Darkin Scythe", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Kayn_Passive_Primary.png" - }, - { - "slot": "Q", - "name": "Reaping Slash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KaynQ.png" - }, - { - "slot": "W", - "name": "Blade's Reach", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KaynW.png" - }, - { - "slot": "E", - "name": "Shadow Step", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KaynE.png" - }, - { - "slot": "R", - "name": "Umbral Trespass", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KaynR.png" - } - ] - }, - { - "championName": "Kennen", - "key": "Kennen", - "abilities": [ - { - "slot": "P", - "name": "Mark of the Storm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Kennen_Passive.png" - }, - { - "slot": "Q", - "name": "Thundering Shuriken", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KennenShurikenHurlMissile1.png" - }, - { - "slot": "W", - "name": "Electrical Surge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KennenBringTheLight.png" - }, - { - "slot": "E", - "name": "Lightning Rush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KennenLightningRush.png" - }, - { - "slot": "R", - "name": "Slicing Maelstrom", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KennenShurikenStorm.png" - } - ] - }, - { - "championName": "Kha'Zix", - "key": "Khazix", - "abilities": [ - { - "slot": "P", - "name": "Unseen Threat", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Khazix_P.png" - }, - { - "slot": "Q", - "name": "Taste Their Fear", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KhazixQ.png" - }, - { - "slot": "W", - "name": "Void Spike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KhazixW.png" - }, - { - "slot": "E", - "name": "Leap", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KhazixE.png" - }, - { - "slot": "R", - "name": "Void Assault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KhazixR.png" - } - ] - }, - { - "championName": "Kindred", - "key": "Kindred", - "abilities": [ - { - "slot": "P", - "name": "Mark of the Kindred", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Kindred_Passive.png" - }, - { - "slot": "Q", - "name": "Dance of Arrows", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KindredQ.png" - }, - { - "slot": "W", - "name": "Wolf's Frenzy", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KindredW.png" - }, - { - "slot": "E", - "name": "Mounting Dread", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KindredEWrapper.png" - }, - { - "slot": "R", - "name": "Lamb's Respite", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KindredR.png" - } - ] - }, - { - "championName": "Kled", - "key": "Kled", - "abilities": [ - { - "slot": "P", - "name": "Skaarl, the Cowardly Lizard", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Kled_P.png" - }, - { - "slot": "Q", - "name": "Bear Trap on a Rope", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KledQ.png" - }, - { - "slot": "W", - "name": "Violent Tendencies", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KledW.png" - }, - { - "slot": "E", - "name": "Jousting", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KledE.png" - }, - { - "slot": "R", - "name": "Chaaaaaaaarge!!!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KledR.png" - } - ] - }, - { - "championName": "Kog'Maw", - "key": "KogMaw", - "abilities": [ - { - "slot": "P", - "name": "Icathian Surprise", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/KogMaw_IcathianSurprise.png" - }, - { - "slot": "Q", - "name": "Caustic Spittle", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KogMawQ.png" - }, - { - "slot": "W", - "name": "Bio-Arcane Barrage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KogMawBioArcaneBarrage.png" - }, - { - "slot": "E", - "name": "Void Ooze", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KogMawVoidOoze.png" - }, - { - "slot": "R", - "name": "Living Artillery", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KogMawLivingArtillery.png" - } - ] - }, - { - "championName": "LeBlanc", - "key": "Leblanc", - "abilities": [ - { - "slot": "P", - "name": "Mirror Image", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/LeblancP.Leblanc_Rework.png" - }, - { - "slot": "Q", - "name": "Sigil of Malice", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeblancQ.png" - }, - { - "slot": "W", - "name": "Distortion", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeblancW.png" - }, - { - "slot": "E", - "name": "Ethereal Chains", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeblancE.png" - }, - { - "slot": "R", - "name": "Mimic", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeblancR.png" - } - ] - }, - { - "championName": "Lee Sin", - "key": "LeeSin", - "abilities": [ - { - "slot": "P", - "name": "Flurry", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/LeeSinPassive.png" - }, - { - "slot": "Q", - "name": "Sonic Wave / Resonating Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeeSinQOne.png" - }, - { - "slot": "W", - "name": "Safeguard / Iron Will", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeeSinWOne.png" - }, - { - "slot": "E", - "name": "Tempest / Cripple", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeeSinEOne.png" - }, - { - "slot": "R", - "name": "Dragon's Rage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeeSinR.png" - } - ] - }, - { - "championName": "Leona", - "key": "Leona", - "abilities": [ - { - "slot": "P", - "name": "Sunlight", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/LeonaSunlight.png" - }, - { - "slot": "Q", - "name": "Shield of Daybreak", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeonaShieldOfDaybreak.png" - }, - { - "slot": "W", - "name": "Eclipse", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeonaSolarBarrier.png" - }, - { - "slot": "E", - "name": "Zenith Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeonaZenithBlade.png" - }, - { - "slot": "R", - "name": "Solar Flare", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeonaSolarFlare.png" - } - ] - }, - { - "championName": "Lillia", - "key": "Lillia", - "abilities": [ - { - "slot": "P", - "name": "Dream-Laden Bough", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Lillia_Icon_Passive.png" - }, - { - "slot": "Q", - "name": "Blooming Blows", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LilliaQ.png" - }, - { - "slot": "W", - "name": "Watch Out! Eep!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LilliaW.png" - }, - { - "slot": "E", - "name": "Swirlseed", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LilliaE.png" - }, - { - "slot": "R", - "name": "Lilting Lullaby", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LilliaR.png" - } - ] - }, - { - "championName": "Lissandra", - "key": "Lissandra", - "abilities": [ - { - "slot": "P", - "name": "Iceborn Subjugation", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Lissandra_Passive.png" - }, - { - "slot": "Q", - "name": "Ice Shard", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LissandraQ.png" - }, - { - "slot": "W", - "name": "Ring of Frost", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LissandraW.png" - }, - { - "slot": "E", - "name": "Glacial Path", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LissandraE.png" - }, - { - "slot": "R", - "name": "Frozen Tomb", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LissandraR.png" - } - ] - }, - { - "championName": "Lucian", - "key": "Lucian", - "abilities": [ - { - "slot": "P", - "name": "Lightslinger", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Lucian_Passive.png" - }, - { - "slot": "Q", - "name": "Piercing Light", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LucianQ.png" - }, - { - "slot": "W", - "name": "Ardent Blaze", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LucianW.png" - }, - { - "slot": "E", - "name": "Relentless Pursuit", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LucianE.png" - }, - { - "slot": "R", - "name": "The Culling", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LucianR.png" - } - ] - }, - { - "championName": "Lulu", - "key": "Lulu", - "abilities": [ - { - "slot": "P", - "name": "Pix, Faerie Companion", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Lulu_PixFaerieCompanion.png" - }, - { - "slot": "Q", - "name": "Glitterlance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LuluQ.png" - }, - { - "slot": "W", - "name": "Whimsy", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LuluW.png" - }, - { - "slot": "E", - "name": "Help, Pix!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LuluE.png" - }, - { - "slot": "R", - "name": "Wild Growth", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LuluR.png" - } - ] - }, - { - "championName": "Lux", - "key": "Lux", - "abilities": [ - { - "slot": "P", - "name": "Illumination", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/LuxIlluminatingFraulein.png" - }, - { - "slot": "Q", - "name": "Light Binding", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LuxLightBinding.png" - }, - { - "slot": "W", - "name": "Prismatic Barrier", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LuxPrismaticWave.png" - }, - { - "slot": "E", - "name": "Lucent Singularity", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LuxLightStrikeKugel.png" - }, - { - "slot": "R", - "name": "Final Spark", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LuxR.png" - } - ] - }, - { - "championName": "Malphite", - "key": "Malphite", - "abilities": [ - { - "slot": "P", - "name": "Granite Shield", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Malphite_GraniteShield.png" - }, - { - "slot": "Q", - "name": "Seismic Shard", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SeismicShard.png" - }, - { - "slot": "W", - "name": "Thunderclap", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Obduracy.png" - }, - { - "slot": "E", - "name": "Ground Slam", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Landslide.png" - }, - { - "slot": "R", - "name": "Unstoppable Force", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UFSlash.png" - } - ] - }, - { - "championName": "Malzahar", - "key": "Malzahar", - "abilities": [ - { - "slot": "P", - "name": "Void Shift", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Malzahar_Passive.png" - }, - { - "slot": "Q", - "name": "Call of the Void", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MalzaharQ.png" - }, - { - "slot": "W", - "name": "Void Swarm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MalzaharW.png" - }, - { - "slot": "E", - "name": "Malefic Visions", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MalzaharE.png" - }, - { - "slot": "R", - "name": "Nether Grasp", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MalzaharR.png" - } - ] - }, - { - "championName": "Maokai", - "key": "Maokai", - "abilities": [ - { - "slot": "P", - "name": "Sap Magic", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Maokai_Passive.png" - }, - { - "slot": "Q", - "name": "Bramble Smash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MaokaiQ.png" - }, - { - "slot": "W", - "name": "Twisted Advance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MaokaiW.png" - }, - { - "slot": "E", - "name": "Sapling Toss", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MaokaiE.png" - }, - { - "slot": "R", - "name": "Nature's Grasp", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MaokaiR.png" - } - ] - }, - { - "championName": "Master Yi", - "key": "MasterYi", - "abilities": [ - { - "slot": "P", - "name": "Double Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/MasterYi_Passive1.png" - }, - { - "slot": "Q", - "name": "Alpha Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AlphaStrike.png" - }, - { - "slot": "W", - "name": "Meditate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Meditate.png" - }, - { - "slot": "E", - "name": "Wuju Style", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/WujuStyle.png" - }, - { - "slot": "R", - "name": "Highlander", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Highlander.png" - } - ] - }, - { - "championName": "Mel", - "key": "Mel", - "abilities": [ - { - "slot": "P", - "name": "Searing Brilliance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Mel_Passive.png" - }, - { - "slot": "Q", - "name": "Radiant Volley", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MelQ.png" - }, - { - "slot": "W", - "name": "Rebuttal", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MelW.png" - }, - { - "slot": "E", - "name": "Solar Snare", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MelE.png" - }, - { - "slot": "R", - "name": "Golden Eclipse", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MelR.png" - } - ] - }, - { - "championName": "Milio", - "key": "Milio", - "abilities": [ - { - "slot": "P", - "name": "Fired Up!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Milio_P.png" - }, - { - "slot": "Q", - "name": "Ultra Mega Fire Kick", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MilioQ.png" - }, - { - "slot": "W", - "name": "Cozy Campfire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MilioW.png" - }, - { - "slot": "E", - "name": "Warm Hugs", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MilioE.png" - }, - { - "slot": "R", - "name": "Breath of Life", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MilioR.png" - } - ] - }, - { - "championName": "Miss Fortune", - "key": "MissFortune", - "abilities": [ - { - "slot": "P", - "name": "Love Tap", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/MissFortune_W.png" - }, - { - "slot": "Q", - "name": "Double Up", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MissFortuneRicochetShot.png" - }, - { - "slot": "W", - "name": "Strut", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MissFortuneViciousStrikes.png" - }, - { - "slot": "E", - "name": "Make It Rain", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MissFortuneScattershot.png" - }, - { - "slot": "R", - "name": "Bullet Time", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MissFortuneBulletTime.png" - } - ] - }, - { - "championName": "Mordekaiser", - "key": "Mordekaiser", - "abilities": [ - { - "slot": "P", - "name": "Darkness Rise", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/MordekaiserPassive.png" - }, - { - "slot": "Q", - "name": "Obliterate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MordekaiserQ.png" - }, - { - "slot": "W", - "name": "Indestructible", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MordekaiserW.png" - }, - { - "slot": "E", - "name": "Death's Grasp", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MordekaiserE.png" - }, - { - "slot": "R", - "name": "Realm of Death", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MordekaiserR.png" - } - ] - }, - { - "championName": "Morgana", - "key": "Morgana", - "abilities": [ - { - "slot": "P", - "name": "Soul Siphon", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/FallenAngel_Empathize.png" - }, - { - "slot": "Q", - "name": "Dark Binding", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MorganaQ.png" - }, - { - "slot": "W", - "name": "Tormented Shadow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MorganaW.png" - }, - { - "slot": "E", - "name": "Black Shield", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MorganaE.png" - }, - { - "slot": "R", - "name": "Soul Shackles", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MorganaR.png" - } - ] - }, - { - "championName": "Naafiri", - "key": "Naafiri", - "abilities": [ - { - "slot": "P", - "name": "We Are More", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Icons_Naafiri_P.png" - }, - { - "slot": "Q", - "name": "Darkin Daggers", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NaafiriQ.png" - }, - { - "slot": "W", - "name": "The Call of the Pack", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NaafiriR.png" - }, - { - "slot": "E", - "name": "Eviscerate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NaafiriE.png" - }, - { - "slot": "R", - "name": "Hounds' Pursuit", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NaafiriW.png" - } - ] - }, - { - "championName": "Nami", - "key": "Nami", - "abilities": [ - { - "slot": "P", - "name": "Surging Tides", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/NamiPassive.png" - }, - { - "slot": "Q", - "name": "Aqua Prison", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NamiQ.png" - }, - { - "slot": "W", - "name": "Ebb and Flow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NamiW.png" - }, - { - "slot": "E", - "name": "Tidecaller's Blessing", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NamiE.png" - }, - { - "slot": "R", - "name": "Tidal Wave", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NamiR.png" - } - ] - }, - { - "championName": "Nasus", - "key": "Nasus", - "abilities": [ - { - "slot": "P", - "name": "Soul Eater", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Nasus_Passive.png" - }, - { - "slot": "Q", - "name": "Siphoning Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NasusQ.png" - }, - { - "slot": "W", - "name": "Wither", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NasusW.png" - }, - { - "slot": "E", - "name": "Spirit Fire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NasusE.png" - }, - { - "slot": "R", - "name": "Fury of the Sands", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NasusR.png" - } - ] - }, - { - "championName": "Nautilus", - "key": "Nautilus", - "abilities": [ - { - "slot": "P", - "name": "Staggering Blow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Nautilus_StaggeringBlow.png" - }, - { - "slot": "Q", - "name": "Dredge Line", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NautilusAnchorDrag.png" - }, - { - "slot": "W", - "name": "Titan's Wrath", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NautilusPiercingGaze.png" - }, - { - "slot": "E", - "name": "Riptide", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NautilusSplashZone.png" - }, - { - "slot": "R", - "name": "Depth Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NautilusGrandLine.png" - } - ] - }, - { - "championName": "Neeko", - "key": "Neeko", - "abilities": [ - { - "slot": "P", - "name": "Inherent Glamour", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Neeko_P.png" - }, - { - "slot": "Q", - "name": "Blooming Burst", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NeekoQ.png" - }, - { - "slot": "W", - "name": "Shapesplitter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NeekoW.png" - }, - { - "slot": "E", - "name": "Tangle-Barbs", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NeekoE.png" - }, - { - "slot": "R", - "name": "Pop Blossom", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NeekoR.png" - } - ] - }, - { - "championName": "Nidalee", - "key": "Nidalee", - "abilities": [ - { - "slot": "P", - "name": "Prowl", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Nidalee_Passive.png" - }, - { - "slot": "Q", - "name": "Javelin Toss / Takedown", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JavelinToss.png" - }, - { - "slot": "W", - "name": "Bushwhack / Pounce", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Bushwhack.png" - }, - { - "slot": "E", - "name": "Primal Surge / Swipe", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PrimalSurge.png" - }, - { - "slot": "R", - "name": "Aspect Of The Cougar", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AspectOfTheCougar.png" - } - ] - }, - { - "championName": "Nilah", - "key": "Nilah", - "abilities": [ - { - "slot": "P", - "name": "Joy Unending", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/NIlahP.png" - }, - { - "slot": "Q", - "name": "Formless Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NilahQ.png" - }, - { - "slot": "W", - "name": "Jubilant Veil", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NilahW.png" - }, - { - "slot": "E", - "name": "Slipstream", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NilahE.png" - }, - { - "slot": "R", - "name": "Apotheosis", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NilahR.png" - } - ] - }, - { - "championName": "Nocturne", - "key": "Nocturne", - "abilities": [ - { - "slot": "P", - "name": "Umbra Blades", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Nocturne_UmbraBlades.png" - }, - { - "slot": "Q", - "name": "Duskbringer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NocturneDuskbringer.png" - }, - { - "slot": "W", - "name": "Shroud of Darkness", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NocturneShroudofDarkness.png" - }, - { - "slot": "E", - "name": "Unspeakable Horror", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NocturneUnspeakableHorror.png" - }, - { - "slot": "R", - "name": "Paranoia", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NocturneParanoia.png" - } - ] - }, - { - "championName": "Nunu & Willump", - "key": "Nunu", - "abilities": [ - { - "slot": "P", - "name": "Call of the Freljord", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/NunuPassive.png" - }, - { - "slot": "Q", - "name": "Consume", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NunuQ.png" - }, - { - "slot": "W", - "name": "Biggest Snowball Ever!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NunuW.png" - }, - { - "slot": "E", - "name": "Snowball Barrage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NunuE.png" - }, - { - "slot": "R", - "name": "Absolute Zero", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NunuR.png" - } - ] - }, - { - "championName": "Olaf", - "key": "Olaf", - "abilities": [ - { - "slot": "P", - "name": "Berserker Rage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Olaf_Passive.png" - }, - { - "slot": "Q", - "name": "Undertow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OlafAxeThrowCast.png" - }, - { - "slot": "W", - "name": "Tough It Out", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OlafFrenziedStrikes.png" - }, - { - "slot": "E", - "name": "Reckless Swing", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OlafRecklessStrike.png" - }, - { - "slot": "R", - "name": "Ragnarok", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OlafRagnarok.png" - } - ] - }, - { - "championName": "Orianna", - "key": "Orianna", - "abilities": [ - { - "slot": "P", - "name": "Clockwork Windup", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/OriannaPassive.png" - }, - { - "slot": "Q", - "name": "Command: Attack", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OrianaIzunaCommand.png" - }, - { - "slot": "W", - "name": "Command: Dissonance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OrianaDissonanceCommand.png" - }, - { - "slot": "E", - "name": "Command: Protect", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OrianaRedactCommand.png" - }, - { - "slot": "R", - "name": "Command: Shockwave", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OrianaDetonateCommand.png" - } - ] - }, - { - "championName": "Ornn", - "key": "Ornn", - "abilities": [ - { - "slot": "P", - "name": "Living Forge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/OrnnP.png" - }, - { - "slot": "Q", - "name": "Volcanic Rupture", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OrnnQ.png" - }, - { - "slot": "W", - "name": "Bellows Breath", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OrnnW.png" - }, - { - "slot": "E", - "name": "Searing Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OrnnE.png" - }, - { - "slot": "R", - "name": "Call of the Forge God", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OrnnR.png" - } - ] - }, - { - "championName": "Pantheon", - "key": "Pantheon", - "abilities": [ - { - "slot": "P", - "name": "Mortal Will", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Pantheon_Passive.png" - }, - { - "slot": "Q", - "name": "Comet Spear", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PantheonQ.png" - }, - { - "slot": "W", - "name": "Shield Vault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PantheonW.png" - }, - { - "slot": "E", - "name": "Aegis Assault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PantheonE.png" - }, - { - "slot": "R", - "name": "Grand Starfall", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PantheonR.png" - } - ] - }, - { - "championName": "Poppy", - "key": "Poppy", - "abilities": [ - { - "slot": "P", - "name": "Iron Ambassador", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Poppy_Passive.png" - }, - { - "slot": "Q", - "name": "Hammer Shock", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PoppyQ.png" - }, - { - "slot": "W", - "name": "Steadfast Presence", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PoppyW.png" - }, - { - "slot": "E", - "name": "Heroic Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PoppyE.png" - }, - { - "slot": "R", - "name": "Keeper's Verdict", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PoppyR.png" - } - ] - }, - { - "championName": "Pyke", - "key": "Pyke", - "abilities": [ - { - "slot": "P", - "name": "Gift of the Drowned Ones", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/PykePassive.png" - }, - { - "slot": "Q", - "name": "Bone Skewer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PykeQ.png" - }, - { - "slot": "W", - "name": "Ghostwater Dive", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PykeW.png" - }, - { - "slot": "E", - "name": "Phantom Undertow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PykeE.png" - }, - { - "slot": "R", - "name": "Death From Below", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PykeR.png" - } - ] - }, - { - "championName": "Qiyana", - "key": "Qiyana", - "abilities": [ - { - "slot": "P", - "name": "Royal Privilege", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Qiyana_Passive.png" - }, - { - "slot": "Q", - "name": "Elemental Wrath / Edge of Ixtal", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/QiyanaQ.png" - }, - { - "slot": "W", - "name": "Terrashape", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/QiyanaW.png" - }, - { - "slot": "E", - "name": "Audacity", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/QiyanaE.png" - }, - { - "slot": "R", - "name": "Supreme Display of Talent", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/QiyanaR.png" - } - ] - }, - { - "championName": "Quinn", - "key": "Quinn", - "abilities": [ - { - "slot": "P", - "name": "Harrier", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Quinn_Passive.png" - }, - { - "slot": "Q", - "name": "Blinding Assault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/QuinnQ.png" - }, - { - "slot": "W", - "name": "Heightened Senses", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/QuinnW.png" - }, - { - "slot": "E", - "name": "Vault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/QuinnE.png" - }, - { - "slot": "R", - "name": "Behind Enemy Lines", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/QuinnR.png" - } - ] - }, - { - "championName": "Rakan", - "key": "Rakan", - "abilities": [ - { - "slot": "P", - "name": "Fey Feathers", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Rakan_P.png" - }, - { - "slot": "Q", - "name": "Gleaming Quill", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RakanQ.png" - }, - { - "slot": "W", - "name": "Grand Entrance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RakanW.png" - }, - { - "slot": "E", - "name": "Battle Dance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RakanE.png" - }, - { - "slot": "R", - "name": "The Quickness", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RakanR.png" - } - ] - }, - { - "championName": "Rammus", - "key": "Rammus", - "abilities": [ - { - "slot": "P", - "name": "Spiked Shell", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Armordillo_ScavengeArmor.png" - }, - { - "slot": "Q", - "name": "Powerball", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PowerBall.png" - }, - { - "slot": "W", - "name": "Defensive Ball Curl", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DefensiveBallCurl.png" - }, - { - "slot": "E", - "name": "Frenzying Taunt", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PuncturingTaunt.png" - }, - { - "slot": "R", - "name": "Soaring Slam", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Tremors2.png" - } - ] - }, - { - "championName": "Rek'Sai", - "key": "RekSai", - "abilities": [ - { - "slot": "P", - "name": "Fury of the Xer'Sai", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/RekSai_Passive.png" - }, - { - "slot": "Q", - "name": "Queen's Wrath / Prey Seeker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RekSaiQ.png" - }, - { - "slot": "W", - "name": "Burrow / Un-burrow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RekSaiW.png" - }, - { - "slot": "E", - "name": "Furious Bite / Tunnel", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RekSaiE.png" - }, - { - "slot": "R", - "name": "Void Rush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RekSaiR.png" - } - ] - }, - { - "championName": "Rell", - "key": "Rell", - "abilities": [ - { - "slot": "P", - "name": "Break the Mold", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/RellP.png" - }, - { - "slot": "Q", - "name": "Shattering Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RellQ.png" - }, - { - "slot": "W", - "name": "Ferromancy: Crash Down", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RellW_Dismount.png" - }, - { - "slot": "E", - "name": "Full Tilt", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RellE.png" - }, - { - "slot": "R", - "name": "Magnet Storm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RellR.png" - } - ] - }, - { - "championName": "Renata Glasc", - "key": "Renata", - "abilities": [ - { - "slot": "P", - "name": "Leverage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Renata_P.png" - }, - { - "slot": "Q", - "name": "Handshake", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RenataQ.png" - }, - { - "slot": "W", - "name": "Bailout", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RenataW.png" - }, - { - "slot": "E", - "name": "Loyalty Program", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RenataE.png" - }, - { - "slot": "R", - "name": "Hostile Takeover", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RenataR.png" - } - ] - }, - { - "championName": "Renekton", - "key": "Renekton", - "abilities": [ - { - "slot": "P", - "name": "Reign of Anger", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Renekton_Passive.png" - }, - { - "slot": "Q", - "name": "Cull the Meek", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RenektonCleave.png" - }, - { - "slot": "W", - "name": "Ruthless Predator", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RenektonPreExecute.png" - }, - { - "slot": "E", - "name": "Slice and Dice", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RenektonSliceAndDice.png" - }, - { - "slot": "R", - "name": "Dominus", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RenektonReignOfTheTyrant.png" - } - ] - }, - { - "championName": "Rengar", - "key": "Rengar", - "abilities": [ - { - "slot": "P", - "name": "Unseen Predator", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Rengar_P.png" - }, - { - "slot": "Q", - "name": "Savagery", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RengarQ.png" - }, - { - "slot": "W", - "name": "Battle Roar", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RengarW.png" - }, - { - "slot": "E", - "name": "Bola Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RengarE.png" - }, - { - "slot": "R", - "name": "Thrill of the Hunt", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RengarR.png" - } - ] - }, - { - "championName": "Riven", - "key": "Riven", - "abilities": [ - { - "slot": "P", - "name": "Runic Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/RivenRunicBlades.png" - }, - { - "slot": "Q", - "name": "Broken Wings", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RivenTriCleave.png" - }, - { - "slot": "W", - "name": "Ki Burst", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RivenMartyr.png" - }, - { - "slot": "E", - "name": "Valor", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RivenFeint.png" - }, - { - "slot": "R", - "name": "Blade of the Exile", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RivenFengShuiEngine.png" - } - ] - }, - { - "championName": "Rumble", - "key": "Rumble", - "abilities": [ - { - "slot": "P", - "name": "Junkyard Titan", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Rumble_JunkyardTitan1.png" - }, - { - "slot": "Q", - "name": "Flamespitter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RumbleFlameThrower.png" - }, - { - "slot": "W", - "name": "Scrap Shield", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RumbleShield.png" - }, - { - "slot": "E", - "name": "Electro Harpoon", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RumbleGrenade.png" - }, - { - "slot": "R", - "name": "The Equalizer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RumbleCarpetBomb.png" - } - ] - }, - { - "championName": "Ryze", - "key": "Ryze", - "abilities": [ - { - "slot": "P", - "name": "Arcane Mastery", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Ryze_P.png" - }, - { - "slot": "Q", - "name": "Overload", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RyzeQWrapper.png" - }, - { - "slot": "W", - "name": "Rune Prison", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RyzeW.png" - }, - { - "slot": "E", - "name": "Spell Flux", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RyzeE.png" - }, - { - "slot": "R", - "name": "Realm Warp", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RyzeR.png" - } - ] - }, - { - "championName": "Samira", - "key": "Samira", - "abilities": [ - { - "slot": "P", - "name": "Daredevil Impulse", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/SamiraP.png" - }, - { - "slot": "Q", - "name": "Flair", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SamiraQ.png" - }, - { - "slot": "W", - "name": "Blade Whirl", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SamiraW.png" - }, - { - "slot": "E", - "name": "Wild Rush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SamiraE.png" - }, - { - "slot": "R", - "name": "Inferno Trigger", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SamiraR.png" - } - ] - }, - { - "championName": "Sejuani", - "key": "Sejuani", - "abilities": [ - { - "slot": "P", - "name": "Fury of the North", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Sejuani_passive.png" - }, - { - "slot": "Q", - "name": "Arctic Assault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SejuaniQ.png" - }, - { - "slot": "W", - "name": "Winter's Wrath", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SejuaniW.png" - }, - { - "slot": "E", - "name": "Permafrost", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SejuaniE.png" - }, - { - "slot": "R", - "name": "Glacial Prison", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SejuaniR.png" - } - ] - }, - { - "championName": "Senna", - "key": "Senna", - "abilities": [ - { - "slot": "P", - "name": "Absolution", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Senna_Passive.png" - }, - { - "slot": "Q", - "name": "Piercing Darkness", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SennaQ.png" - }, - { - "slot": "W", - "name": "Last Embrace", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SennaW.png" - }, - { - "slot": "E", - "name": "Curse of the Black Mist", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SennaE.png" - }, - { - "slot": "R", - "name": "Dawning Shadow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SennaR.png" - } - ] - }, - { - "championName": "Seraphine", - "key": "Seraphine", - "abilities": [ - { - "slot": "P", - "name": "Stage Presence", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Seraphine_Passive.png" - }, - { - "slot": "Q", - "name": "High Note", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SeraphineQ.png" - }, - { - "slot": "W", - "name": "Surround Sound", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SeraphineW.png" - }, - { - "slot": "E", - "name": "Beat Drop", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SeraphineE.png" - }, - { - "slot": "R", - "name": "Encore", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SeraphineR.png" - } - ] - }, - { - "championName": "Sett", - "key": "Sett", - "abilities": [ - { - "slot": "P", - "name": "Pit Grit", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Sett_P.png" - }, - { - "slot": "Q", - "name": "Knuckle Down", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SettQ.png" - }, - { - "slot": "W", - "name": "Haymaker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SettW.png" - }, - { - "slot": "E", - "name": "Facebreaker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SettE.png" - }, - { - "slot": "R", - "name": "The Show Stopper", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SettR.png" - } - ] - }, - { - "championName": "Shaco", - "key": "Shaco", - "abilities": [ - { - "slot": "P", - "name": "Backstab", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Jester_CarefulStrikes.png" - }, - { - "slot": "Q", - "name": "Deceive", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Deceive.png" - }, - { - "slot": "W", - "name": "Jack In The Box", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JackInTheBox.png" - }, - { - "slot": "E", - "name": "Two-Shiv Poison", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TwoShivPoison.png" - }, - { - "slot": "R", - "name": "Hallucinate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HallucinateFull.png" - } - ] - }, - { - "championName": "Shen", - "key": "Shen", - "abilities": [ - { - "slot": "P", - "name": "Ki Barrier", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Shen_Passive.png" - }, - { - "slot": "Q", - "name": "Twilight Assault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ShenQ.png" - }, - { - "slot": "W", - "name": "Spirit's Refuge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ShenW.png" - }, - { - "slot": "E", - "name": "Shadow Dash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ShenE.png" - }, - { - "slot": "R", - "name": "Stand United", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ShenR.png" - } - ] - }, - { - "championName": "Shyvana", - "key": "Shyvana", - "abilities": [ - { - "slot": "P", - "name": "Scalemail", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Shyvana_Passive.Shyvana_Rework.png" - }, - { - "slot": "Q", - "name": "Emberstrike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ShyvanaQ.png" - }, - { - "slot": "W", - "name": "Inferno Aegis", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ShyvanaW.png" - }, - { - "slot": "E", - "name": "Molten Burst", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ShyvanaE.png" - }, - { - "slot": "R", - "name": "Dragon's Descent", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ShyvanaR.png" - } - ] - }, - { - "championName": "Singed", - "key": "Singed", - "abilities": [ - { - "slot": "P", - "name": "Noxious Slipstream", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Singed_Passive.png" - }, - { - "slot": "Q", - "name": "Poison Trail", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PoisonTrail.png" - }, - { - "slot": "W", - "name": "Mega Adhesive", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MegaAdhesive.png" - }, - { - "slot": "E", - "name": "Fling", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Fling.png" - }, - { - "slot": "R", - "name": "Insanity Potion", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/InsanityPotion.png" - } - ] - }, - { - "championName": "Sion", - "key": "Sion", - "abilities": [ - { - "slot": "P", - "name": "Glory in Death", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Sion_Passive1.png" - }, - { - "slot": "Q", - "name": "Decimating Smash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SionQ.png" - }, - { - "slot": "W", - "name": "Soul Furnace", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SionW.png" - }, - { - "slot": "E", - "name": "Roar of the Slayer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SionE.png" - }, - { - "slot": "R", - "name": "Unstoppable Onslaught", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SionR.png" - } - ] - }, - { - "championName": "Sivir", - "key": "Sivir", - "abilities": [ - { - "slot": "P", - "name": "Fleet of Foot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Sivir_Passive.png" - }, - { - "slot": "Q", - "name": "Boomerang Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SivirQ.png" - }, - { - "slot": "W", - "name": "Ricochet", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SivirW.png" - }, - { - "slot": "E", - "name": "Spell Shield", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SivirE.png" - }, - { - "slot": "R", - "name": "On The Hunt", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SivirR.png" - } - ] - }, - { - "championName": "Skarner", - "key": "Skarner", - "abilities": [ - { - "slot": "P", - "name": "Threads of Vibration", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Skarner_Passive.png" - }, - { - "slot": "Q", - "name": "Shattered Earth / Upheaval", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SkarnerQ.png" - }, - { - "slot": "W", - "name": "Seismic Bastion", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SkarnerW.png" - }, - { - "slot": "E", - "name": "Ixtal's Impact", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SkarnerE.png" - }, - { - "slot": "R", - "name": "Impale", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SkarnerR.png" - } - ] - }, - { - "championName": "Smolder", - "key": "Smolder", - "abilities": [ - { - "slot": "P", - "name": "Dragon Practice", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Icons_Smolder_Passive.png" - }, - { - "slot": "Q", - "name": "Super Scorcher Breath", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SmolderQ.png" - }, - { - "slot": "W", - "name": "Achooo!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SmolderW.png" - }, - { - "slot": "E", - "name": "Flap, Flap, Flap", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SmolderE.png" - }, - { - "slot": "R", - "name": "MMOOOMMMM!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SmolderR.png" - } - ] - }, - { - "championName": "Sona", - "key": "Sona", - "abilities": [ - { - "slot": "P", - "name": "Power Chord", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Sona_Passive_Charged.png" - }, - { - "slot": "Q", - "name": "Hymn of Valor", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SonaQ.png" - }, - { - "slot": "W", - "name": "Aria of Perseverance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SonaW.png" - }, - { - "slot": "E", - "name": "Song of Celerity", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SonaE.png" - }, - { - "slot": "R", - "name": "Crescendo", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SonaR.png" - } - ] - }, - { - "championName": "Soraka", - "key": "Soraka", - "abilities": [ - { - "slot": "P", - "name": "Salvation", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Soraka_Passive.png" - }, - { - "slot": "Q", - "name": "Starcall", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SorakaQ.png" - }, - { - "slot": "W", - "name": "Astral Infusion", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SorakaW.png" - }, - { - "slot": "E", - "name": "Equinox", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SorakaE.png" - }, - { - "slot": "R", - "name": "Wish", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SorakaR.png" - } - ] - }, - { - "championName": "Swain", - "key": "Swain", - "abilities": [ - { - "slot": "P", - "name": "Ravenous Flock", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Swain_P.png" - }, - { - "slot": "Q", - "name": "Death's Hand", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SwainQ.png" - }, - { - "slot": "W", - "name": "Vision of Empire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SwainW.png" - }, - { - "slot": "E", - "name": "Nevermove", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SwainE.png" - }, - { - "slot": "R", - "name": "Demonic Ascension", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SwainR.png" - } - ] - }, - { - "championName": "Sylas", - "key": "Sylas", - "abilities": [ - { - "slot": "P", - "name": "Petricite Burst", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/SylasP.png" - }, - { - "slot": "Q", - "name": "Chain Lash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SylasQ.png" - }, - { - "slot": "W", - "name": "Kingslayer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SylasW.png" - }, - { - "slot": "E", - "name": "Abscond / Abduct", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SylasE.png" - }, - { - "slot": "R", - "name": "Hijack", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SylasR.png" - } - ] - }, - { - "championName": "Syndra", - "key": "Syndra", - "abilities": [ - { - "slot": "P", - "name": "Transcendent", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/SyndraPassive.png" - }, - { - "slot": "Q", - "name": "Dark Sphere", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SyndraQ.png" - }, - { - "slot": "W", - "name": "Force of Will", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SyndraW.png" - }, - { - "slot": "E", - "name": "Scatter the Weak", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SyndraE.png" - }, - { - "slot": "R", - "name": "Unleashed Power", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SyndraR.png" - } - ] - }, - { - "championName": "Tahm Kench", - "key": "TahmKench", - "abilities": [ - { - "slot": "P", - "name": "An Acquired Taste", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/TahmKenchP.png" - }, - { - "slot": "Q", - "name": "Tongue Lash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TahmKenchQ.png" - }, - { - "slot": "W", - "name": "Abyssal Dive", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TahmKenchW.png" - }, - { - "slot": "E", - "name": "Thick Skin", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TahmKenchE.png" - }, - { - "slot": "R", - "name": "Devour", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TahmKenchRWrapper.png" - } - ] - }, - { - "championName": "Taliyah", - "key": "Taliyah", - "abilities": [ - { - "slot": "P", - "name": "Rock Surfing", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Taliyah_Passive.png" - }, - { - "slot": "Q", - "name": "Threaded Volley", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TaliyahQ.png" - }, - { - "slot": "W", - "name": "Seismic Shove", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TaliyahWVC.png" - }, - { - "slot": "E", - "name": "Unraveled Earth", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TaliyahE.png" - }, - { - "slot": "R", - "name": "Weaver's Wall", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TaliyahR.png" - } - ] - }, - { - "championName": "Talon", - "key": "Talon", - "abilities": [ - { - "slot": "P", - "name": "Blade's End", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/TalonP.png" - }, - { - "slot": "Q", - "name": "Noxian Diplomacy", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TalonQ.png" - }, - { - "slot": "W", - "name": "Rake", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TalonW.png" - }, - { - "slot": "E", - "name": "Assassin's Path", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TalonE.png" - }, - { - "slot": "R", - "name": "Shadow Assault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TalonR.png" - } - ] - }, - { - "championName": "Taric", - "key": "Taric", - "abilities": [ - { - "slot": "P", - "name": "Bravado", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Taric_Passive.png" - }, - { - "slot": "Q", - "name": "Starlight's Touch", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TaricQ.png" - }, - { - "slot": "W", - "name": "Bastion", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TaricW.png" - }, - { - "slot": "E", - "name": "Dazzle", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TaricE.png" - }, - { - "slot": "R", - "name": "Cosmic Radiance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TaricR.png" - } - ] - }, - { - "championName": "Teemo", - "key": "Teemo", - "abilities": [ - { - "slot": "P", - "name": "Guerrilla Warfare", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/TeemoPassive.ASU_Teemo.png" - }, - { - "slot": "Q", - "name": "Blinding Dart", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TeemoQ.png" - }, - { - "slot": "W", - "name": "Move Quick", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TeemoW.png" - }, - { - "slot": "E", - "name": "Toxic Shot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TeemoE.png" - }, - { - "slot": "R", - "name": "Noxious Trap", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TeemoR.png" - } - ] - }, - { - "championName": "Thresh", - "key": "Thresh", - "abilities": [ - { - "slot": "P", - "name": "Damnation", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Thresh_Passive.png" - }, - { - "slot": "Q", - "name": "Death Sentence", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ThreshQ.png" - }, - { - "slot": "W", - "name": "Dark Passage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ThreshW.png" - }, - { - "slot": "E", - "name": "Flay", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ThreshE.png" - }, - { - "slot": "R", - "name": "The Box", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ThreshRPenta.png" - } - ] - }, - { - "championName": "Tristana", - "key": "Tristana", - "abilities": [ - { - "slot": "P", - "name": "Draw a Bead", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Tristana_Passive.png" - }, - { - "slot": "Q", - "name": "Rapid Fire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TristanaQ.png" - }, - { - "slot": "W", - "name": "Rocket Jump", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TristanaW.png" - }, - { - "slot": "E", - "name": "Explosive Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TristanaE.png" - }, - { - "slot": "R", - "name": "Buster Shot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TristanaR.png" - } - ] - }, - { - "championName": "Trundle", - "key": "Trundle", - "abilities": [ - { - "slot": "P", - "name": "King's Tribute", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Trundle_Passive.png" - }, - { - "slot": "Q", - "name": "Chomp", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TrundleTrollSmash.png" - }, - { - "slot": "W", - "name": "Frozen Domain", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/trundledesecrate.png" - }, - { - "slot": "E", - "name": "Pillar of Ice", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TrundleCircle.png" - }, - { - "slot": "R", - "name": "Subjugate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TrundlePain.png" - } - ] - }, - { - "championName": "Tryndamere", - "key": "Tryndamere", - "abilities": [ - { - "slot": "P", - "name": "Battle Fury", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Tryndamere_Passive.png" - }, - { - "slot": "Q", - "name": "Bloodlust", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TryndamereQ.png" - }, - { - "slot": "W", - "name": "Mocking Shout", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TryndamereW.png" - }, - { - "slot": "E", - "name": "Spinning Slash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TryndamereE.png" - }, - { - "slot": "R", - "name": "Undying Rage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UndyingRage.png" - } - ] - }, - { - "championName": "Twisted Fate", - "key": "TwistedFate", - "abilities": [ - { - "slot": "P", - "name": "Loaded Dice", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Cardmaster_SealFate.png" - }, - { - "slot": "Q", - "name": "Wild Cards", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/WildCards.png" - }, - { - "slot": "W", - "name": "Pick a Card", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PickACard.png" - }, - { - "slot": "E", - "name": "Stacked Deck", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CardmasterStack.png" - }, - { - "slot": "R", - "name": "Destiny", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Destiny.png" - } - ] - }, - { - "championName": "Twitch", - "key": "Twitch", - "abilities": [ - { - "slot": "P", - "name": "Deadly Venom", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Twitch_Passive.png" - }, - { - "slot": "Q", - "name": "Ambush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TwitchHideInShadows.png" - }, - { - "slot": "W", - "name": "Venom Cask", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TwitchVenomCask.png" - }, - { - "slot": "E", - "name": "Contaminate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TwitchExpunge.png" - }, - { - "slot": "R", - "name": "Spray and Pray", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TwitchFullAutomatic.png" - } - ] - }, - { - "championName": "Udyr", - "key": "Udyr", - "abilities": [ - { - "slot": "P", - "name": "Bridge Between", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Udyr_P.png" - }, - { - "slot": "Q", - "name": "Wilding Claw", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UdyrQ.png" - }, - { - "slot": "W", - "name": "Iron Mantle", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UdyrW.png" - }, - { - "slot": "E", - "name": "Blazing Stampede", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UdyrE.png" - }, - { - "slot": "R", - "name": "Wingborne Storm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UdyrR.png" - } - ] - }, - { - "championName": "Urgot", - "key": "Urgot", - "abilities": [ - { - "slot": "P", - "name": "Echoing Flames", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Urgot_Passive.png" - }, - { - "slot": "Q", - "name": "Corrosive Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UrgotQ.png" - }, - { - "slot": "W", - "name": "Purge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UrgotW.png" - }, - { - "slot": "E", - "name": "Disdain", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UrgotE.png" - }, - { - "slot": "R", - "name": "Fear Beyond Death", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UrgotR.png" - } - ] - }, - { - "championName": "Varus", - "key": "Varus", - "abilities": [ - { - "slot": "P", - "name": "Living Vengeance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/VarusPassive.png" - }, - { - "slot": "Q", - "name": "Piercing Arrow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VarusQ.png" - }, - { - "slot": "W", - "name": "Blighted Quiver", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VarusW.png" - }, - { - "slot": "E", - "name": "Hail of Arrows", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VarusE.png" - }, - { - "slot": "R", - "name": "Chain of Corruption", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VarusR.png" - } - ] - }, - { - "championName": "Vayne", - "key": "Vayne", - "abilities": [ - { - "slot": "P", - "name": "Night Hunter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Vayne_NightHunter.png" - }, - { - "slot": "Q", - "name": "Tumble", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VayneTumble.png" - }, - { - "slot": "W", - "name": "Silver Bolts", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VayneSilveredBolts.png" - }, - { - "slot": "E", - "name": "Condemn", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VayneCondemn.png" - }, - { - "slot": "R", - "name": "Final Hour", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VayneInquisition.png" - } - ] - }, - { - "championName": "Veigar", - "key": "Veigar", - "abilities": [ - { - "slot": "P", - "name": "Phenomenal Evil Power", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/VeigarEntropy.png" - }, - { - "slot": "Q", - "name": "Baleful Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VeigarBalefulStrike.png" - }, - { - "slot": "W", - "name": "Dark Matter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VeigarDarkMatter.png" - }, - { - "slot": "E", - "name": "Event Horizon", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VeigarEventHorizon.png" - }, - { - "slot": "R", - "name": "Primordial Burst", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VeigarR.png" - } - ] - }, - { - "championName": "Vel'Koz", - "key": "Velkoz", - "abilities": [ - { - "slot": "P", - "name": "Organic Deconstruction", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/VelKoz_Passive.png" - }, - { - "slot": "Q", - "name": "Plasma Fission", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VelkozQ.png" - }, - { - "slot": "W", - "name": "Void Rift", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VelkozW.png" - }, - { - "slot": "E", - "name": "Tectonic Disruption", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VelkozE.png" - }, - { - "slot": "R", - "name": "Life Form Disintegration Ray", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VelkozR.png" - } - ] - }, - { - "championName": "Vex", - "key": "Vex", - "abilities": [ - { - "slot": "P", - "name": "Doom 'n Gloom", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Icons_Vex_Passive.png" - }, - { - "slot": "Q", - "name": "Mistral Bolt", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VexQ.png" - }, - { - "slot": "W", - "name": "Personal Space", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VexW.png" - }, - { - "slot": "E", - "name": "Looming Darkness", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VexE.png" - }, - { - "slot": "R", - "name": "Shadow Surge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VexR.png" - } - ] - }, - { - "championName": "Vi", - "key": "Vi", - "abilities": [ - { - "slot": "P", - "name": "Blast Shield", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ViPassive.png" - }, - { - "slot": "Q", - "name": "Vault Breaker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViQ.png" - }, - { - "slot": "W", - "name": "Denting Blows", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViW.png" - }, - { - "slot": "E", - "name": "Relentless Force", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViE.png" - }, - { - "slot": "R", - "name": "Cease and Desist", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViR.png" - } - ] - }, - { - "championName": "Viego", - "key": "Viego", - "abilities": [ - { - "slot": "P", - "name": "Sovereign's Domination", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Viego_Passive.png" - }, - { - "slot": "Q", - "name": "Blade of the Ruined King", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViegoQ.png" - }, - { - "slot": "W", - "name": "Spectral Maw", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViegoW.png" - }, - { - "slot": "E", - "name": "Harrowed Path", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViegoE.png" - }, - { - "slot": "R", - "name": "Heartbreaker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViegoR.png" - } - ] - }, - { - "championName": "Viktor", - "key": "Viktor", - "abilities": [ - { - "slot": "P", - "name": "Glorious Evolution", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Viktor_Passive.ViktorVGU.png" - }, - { - "slot": "Q", - "name": "Siphon Power", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViktorQ.png" - }, - { - "slot": "W", - "name": "Gravity Field", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViktorW.png" - }, - { - "slot": "E", - "name": "Hextech Ray", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViktorE.png" - }, - { - "slot": "R", - "name": "Arcane Storm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViktorR.png" - } - ] - }, - { - "championName": "Vladimir", - "key": "Vladimir", - "abilities": [ - { - "slot": "P", - "name": "Crimson Pact", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/VladimirP.png" - }, - { - "slot": "Q", - "name": "Transfusion", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VladimirQ.png" - }, - { - "slot": "W", - "name": "Sanguine Pool", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VladimirSanguinePool.png" - }, - { - "slot": "E", - "name": "Tides of Blood", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VladimirE.png" - }, - { - "slot": "R", - "name": "Hemoplague", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VladimirHemoplague.png" - } - ] - }, - { - "championName": "Volibear", - "key": "Volibear", - "abilities": [ - { - "slot": "P", - "name": "The Relentless Storm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Volibear_Icon_P.png" - }, - { - "slot": "Q", - "name": "Thundering Smash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VolibearQ.png" - }, - { - "slot": "W", - "name": "Frenzied Maul", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VolibearW.png" - }, - { - "slot": "E", - "name": "Sky Splitter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VolibearE.png" - }, - { - "slot": "R", - "name": "Stormbringer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VolibearR.png" - } - ] - }, - { - "championName": "Warwick", - "key": "Warwick", - "abilities": [ - { - "slot": "P", - "name": "Eternal Hunger", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/WarwickP.png" - }, - { - "slot": "Q", - "name": "Jaws of the Beast", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/WarwickQ.png" - }, - { - "slot": "W", - "name": "Blood Hunt", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/WarwickW.png" - }, - { - "slot": "E", - "name": "Primal Howl", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/WarwickE.png" - }, - { - "slot": "R", - "name": "Infinite Duress", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/WarwickR.png" - } - ] - }, - { - "championName": "Wukong", - "key": "MonkeyKing", - "abilities": [ - { - "slot": "P", - "name": "Stone Skin", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/MonkeyKingStoneSkin.png" - }, - { - "slot": "Q", - "name": "Crushing Blow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MonkeyKingDoubleAttack.png" - }, - { - "slot": "W", - "name": "Warrior Trickster", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MonkeyKingDecoy.png" - }, - { - "slot": "E", - "name": "Nimbus Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MonkeyKingNimbus.png" - }, - { - "slot": "R", - "name": "Cyclone", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MonkeyKingSpinToWin.png" - } - ] - }, - { - "championName": "Xayah", - "key": "Xayah", - "abilities": [ - { - "slot": "P", - "name": "Clean Cuts", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/XayahPassive.png" - }, - { - "slot": "Q", - "name": "Double Daggers", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XayahQ.png" - }, - { - "slot": "W", - "name": "Deadly Plumage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XayahW.png" - }, - { - "slot": "E", - "name": "Bladecaller", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XayahE.png" - }, - { - "slot": "R", - "name": "Featherstorm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XayahR.png" - } - ] - }, - { - "championName": "Xerath", - "key": "Xerath", - "abilities": [ - { - "slot": "P", - "name": "Mana Surge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Xerath_Passive1.png" - }, - { - "slot": "Q", - "name": "Arcanopulse", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XerathArcanopulseChargeUp.png" - }, - { - "slot": "W", - "name": "Eye of Destruction", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XerathArcaneBarrage2.png" - }, - { - "slot": "E", - "name": "Shocking Orb", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XerathMageSpear.png" - }, - { - "slot": "R", - "name": "Rite of the Arcane", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XerathLocusOfPower2.png" - } - ] - }, - { - "championName": "Xin Zhao", - "key": "XinZhao", - "abilities": [ - { - "slot": "P", - "name": "Determination", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/XinZhaoReworkP.XinZhaoRework.png" - }, - { - "slot": "Q", - "name": "Three Talon Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XinZhaoQ.png" - }, - { - "slot": "W", - "name": "Wind Becomes Lightning", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XinZhaoW.png" - }, - { - "slot": "E", - "name": "Audacious Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XinZhaoE.png" - }, - { - "slot": "R", - "name": "Crescent Guard", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XinZhaoR.png" - } - ] - }, - { - "championName": "Yasuo", - "key": "Yasuo", - "abilities": [ - { - "slot": "P", - "name": "Way of the Wanderer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Yasuo_Passive.png" - }, - { - "slot": "Q", - "name": "Steel Tempest", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YasuoQ1Wrapper.png" - }, - { - "slot": "W", - "name": "Wind Wall", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YasuoW.png" - }, - { - "slot": "E", - "name": "Sweeping Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YasuoE.png" - }, - { - "slot": "R", - "name": "Last Breath", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YasuoR.png" - } - ] - }, - { - "championName": "Yone", - "key": "Yone", - "abilities": [ - { - "slot": "P", - "name": "Way of the Hunter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/YonePassive.png" - }, - { - "slot": "Q", - "name": "Mortal Steel", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YoneQ.png" - }, - { - "slot": "W", - "name": "Spirit Cleave", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YoneW.png" - }, - { - "slot": "E", - "name": "Soul Unbound", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YoneE.png" - }, - { - "slot": "R", - "name": "Fate Sealed", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YoneR.png" - } - ] - }, - { - "championName": "Yorick", - "key": "Yorick", - "abilities": [ - { - "slot": "P", - "name": "Shepherd of Souls", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Yorick_P.png" - }, - { - "slot": "Q", - "name": "Last Rites", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YorickQ.png" - }, - { - "slot": "W", - "name": "Dark Procession", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YorickW.png" - }, - { - "slot": "E", - "name": "Mourning Mist", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YorickE.png" - }, - { - "slot": "R", - "name": "Eulogy of the Isles", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YorickR.png" - } - ] - }, - { - "championName": "Yunara", - "key": "Yunara", - "abilities": [ - { - "slot": "P", - "name": "Vow of the First Lands", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Yunara_Passive.Yunara.png" - }, - { - "slot": "Q", - "name": "Cultivation of Spirit", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YunaraQ.png" - }, - { - "slot": "W", - "name": "Arc of Judgment | Arc of Ruin", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YunaraW.png" - }, - { - "slot": "E", - "name": "Kanmei's Steps | Untouchable Shadow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YunaraE.png" - }, - { - "slot": "R", - "name": "Transcend One's Self", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YunaraR.png" - } - ] - }, - { - "championName": "Yuumi", - "key": "Yuumi", - "abilities": [ - { - "slot": "P", - "name": "Feline Friendship", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/YuumiP2.png" - }, - { - "slot": "Q", - "name": "Prowling Projectile", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YuumiQ.png" - }, - { - "slot": "W", - "name": "You and Me!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YuumiW.png" - }, - { - "slot": "E", - "name": "Zoomies", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YuumiE.png" - }, - { - "slot": "R", - "name": "Final Chapter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YuumiR.png" - } - ] - }, - { - "championName": "Zaahen", - "key": "Zaahen", - "abilities": [ - { - "slot": "P", - "name": "Cultivation of War", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ZaahenP.Zaahen.png" - }, - { - "slot": "Q", - "name": "The Darkin Glaive", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZaahenQ.png" - }, - { - "slot": "W", - "name": "Dreaded Return", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZaahenW.png" - }, - { - "slot": "E", - "name": "Aureate Rush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZaahenE.png" - }, - { - "slot": "R", - "name": "Grim Deliverance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZaahenR.png" - } - ] - }, - { - "championName": "Zac", - "key": "Zac", - "abilities": [ - { - "slot": "P", - "name": "Cell Division", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ZacPassive.png" - }, - { - "slot": "Q", - "name": "Stretching Strikes", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZacQ.png" - }, - { - "slot": "W", - "name": "Unstable Matter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZacW.png" - }, - { - "slot": "E", - "name": "Elastic Slingshot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZacE.png" - }, - { - "slot": "R", - "name": "Let's Bounce!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZacR.png" - } - ] - }, - { - "championName": "Zed", - "key": "Zed", - "abilities": [ - { - "slot": "P", - "name": "Contempt for the Weak", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ZedP.png" - }, - { - "slot": "Q", - "name": "Razor Shuriken", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZedQ.png" - }, - { - "slot": "W", - "name": "Living Shadow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZedW.png" - }, - { - "slot": "E", - "name": "Shadow Slash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZedE.png" - }, - { - "slot": "R", - "name": "Death Mark", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZedR.png" - } - ] - }, - { - "championName": "Zeri", - "key": "Zeri", - "abilities": [ - { - "slot": "P", - "name": "Living Battery", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ZeriP.png" - }, - { - "slot": "Q", - "name": "Burst Fire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZeriQ.png" - }, - { - "slot": "W", - "name": "Ultrashock Laser", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZeriW.png" - }, - { - "slot": "E", - "name": "Spark Surge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZeriE.png" - }, - { - "slot": "R", - "name": "Lightning Crash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZeriR.png" - } - ] - }, - { - "championName": "Ziggs", - "key": "Ziggs", - "abilities": [ - { - "slot": "P", - "name": "Short Fuse", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ZiggsPassiveReady.png" - }, - { - "slot": "Q", - "name": "Bouncing Bomb", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZiggsQ.png" - }, - { - "slot": "W", - "name": "Satchel Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZiggsW.png" - }, - { - "slot": "E", - "name": "Hexplosive Minefield", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZiggsE.png" - }, - { - "slot": "R", - "name": "Mega Inferno Bomb", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZiggsR.png" - } - ] - }, - { - "championName": "Zilean", - "key": "Zilean", - "abilities": [ - { - "slot": "P", - "name": "Time In A Bottle", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Zilean_Passive.png" - }, - { - "slot": "Q", - "name": "Time Bomb", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZileanQ.png" - }, - { - "slot": "W", - "name": "Rewind", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZileanW.png" - }, - { - "slot": "E", - "name": "Time Warp", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TimeWarp.png" - }, - { - "slot": "R", - "name": "Chronoshift", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ChronoShift.png" - } - ] - }, - { - "championName": "Zoe", - "key": "Zoe", - "abilities": [ - { - "slot": "P", - "name": "More Sparkles!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Zoe_P.png" - }, - { - "slot": "Q", - "name": "Paddle Star!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZoeQ.png" - }, - { - "slot": "W", - "name": "Spell Thief", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZoeW.png" - }, - { - "slot": "E", - "name": "Sleepy Trouble Bubble", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZoeE.png" - }, - { - "slot": "R", - "name": "Portal Jump", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZoeR.png" - } - ] - }, - { - "championName": "Zyra", - "key": "Zyra", - "abilities": [ - { - "slot": "P", - "name": "Garden of Thorns", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ZyraP.png" - }, - { - "slot": "Q", - "name": "Deadly Spines", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZyraQ.png" - }, - { - "slot": "W", - "name": "Rampant Growth", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZyraW.png" - }, - { - "slot": "E", - "name": "Grasping Roots", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZyraE.png" - }, - { - "slot": "R", - "name": "Stranglethorns", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZyraR.png" - } - ] - } -] diff --git a/src/modules/loldle-ability/handlers.js b/src/modules/loldle-ability/handlers.js deleted file mode 100644 index 2cb3df0..0000000 --- a/src/modules/loldle-ability/handlers.js +++ /dev/null @@ -1,189 +0,0 @@ -/** - * @file loldle-ability command handlers. - * - * Binary right/wrong — 5 guesses. Subject resolution mirrors other loldle - * modes. Round state persists `{target, slot, guesses, startedAt}` so the - * SAME ability icon shows across all turns until the round ends. - */ - -import { escapeHtml } from "../../util/escape-html.js"; -import abilitiesData from "./abilities.json" with { type: "json" }; -import { findChampion } from "./lookup.js"; -import { - MAX_GUESSES_CAP, - clearGame, - getMaxGuesses, - loadGame, - loadStats, - recordResult, - saveGame, - setMaxGuesses, -} from "./state.js"; - -const POOL = abilitiesData.filter((c) => c.abilities && c.abilities.length > 0); - -const NEW_ROUND_HINT = - "🆕 Send /loldle_ability or /loldle_ability <champion> to start a new round."; - -function getSubject(ctx) { - const type = ctx.chat?.type; - if (type === "group" || type === "supergroup") return ctx.chat.id; - return ctx.from?.id ?? null; -} - -function argAfterCommand(text) { - if (!text) return ""; - const idx = text.indexOf(" "); - return idx === -1 ? "" : text.slice(idx + 1).trim(); -} - -function pickRandom() { - return POOL[Math.floor(Math.random() * POOL.length)]; -} - -function findByName(name) { - return POOL.find((c) => c.championName === name); -} - -function pickAbility(champ) { - return champ.abilities[Math.floor(Math.random() * champ.abilities.length)]; -} - -function getAbilityBySlot(champ, slot) { - return champ.abilities.find((a) => a.slot === slot); -} - -async function startFreshGame(db, subject) { - const target = pickRandom(); - const ability = pickAbility(target); - const fresh = { - target: target.championName, - slot: ability.slot, - guesses: [], - startedAt: null, - }; - await saveGame(db, subject, fresh); - return fresh; -} - -async function getOrInitGame(db, subject, maxGuesses) { - const existing = await loadGame(db, subject); - if (existing && existing.guesses.length < maxGuesses) return existing; - return startFreshGame(db, subject); -} - -function caption(guesses, maxGuesses) { - return `🔮 Guess the champion from this ability. ${guesses.length}/${maxGuesses} guesses so far.`; -} - -export async function handleAbility(ctx, db) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const arg = argAfterCommand(ctx.message?.text ?? ""); - - const maxGuesses = await getMaxGuesses(db, subject); - const game = await getOrInitGame(db, subject, maxGuesses); - const target = findByName(game.target); - const ability = target && getAbilityBySlot(target, game.slot); - if (!target || !ability) { - await clearGame(db, subject); - return ctx.reply(`Ability data was updated since this round started. ${NEW_ROUND_HINT}`, { - parse_mode: "HTML", - }); - } - - if (!arg) { - return ctx.replyWithPhoto(ability.icon, { caption: caption(game.guesses, maxGuesses) }); - } - - const guess = findChampion(POOL, arg); - if (!guess) return ctx.reply(`Champion not found: "${arg}".`); - - if (game.guesses.includes(guess.championName)) { - return ctx.reply( - `🔁 ${escapeHtml(guess.championName)} was already guessed this round — try another champion.`, - { parse_mode: "HTML" }, - ); - } - - if (game.startedAt == null) game.startedAt = Date.now(); - game.guesses.push(guess.championName); - const won = guess.championName === target.championName; - const answer = escapeHtml(target.championName); - const abilityLabel = `${escapeHtml(ability.name)} (${ability.slot})`; - - if (won) { - const s = await recordResult(db, subject, true); - await clearGame(db, subject); - return ctx.reply( - `🎉 Got it! That was ${answer} — ${abilityLabel}. Solved in ${game.guesses.length}/${maxGuesses}\n🔥 Streak: ${s.streak}\n${NEW_ROUND_HINT}`, - { parse_mode: "HTML" }, - ); - } - - if (game.guesses.length >= maxGuesses) { - await recordResult(db, subject, false); - await clearGame(db, subject); - return ctx.reply( - `❌ Out of guesses. Answer was ${answer} — ${abilityLabel}.\n${NEW_ROUND_HINT}`, - { parse_mode: "HTML" }, - ); - } - - await saveGame(db, subject, game); - return ctx.reply( - `❌ Not ${escapeHtml(guess.championName)}. Guess ${game.guesses.length}/${maxGuesses}.`, - { parse_mode: "HTML" }, - ); -} - -/** - * Hidden — set the per-subject MAX_GUESSES override (1..MAX_GUESSES_CAP). - * - * @param {import("grammy").Context} ctx - * @param {import("../../db/kv-store-interface.js").KVStore} db - */ -export async function handleSetMax(ctx, db) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const arg = argAfterCommand(ctx.message?.text ?? ""); - const n = Number.parseInt(arg, 10); - if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { - return ctx.reply(`Usage: /loldle_ability_setmax <1-${MAX_GUESSES_CAP}>`); - } - await setMaxGuesses(db, subject, n); - return ctx.reply(`✅ Loldle ability max guesses set to ${n} (applies to the next round).`); -} - -export async function handleGiveup(ctx, db) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const existing = await loadGame(db, subject); - if (!existing) { - return ctx.reply(`No active round. ${NEW_ROUND_HINT}`, { parse_mode: "HTML" }); - } - await recordResult(db, subject, false); - const target = findByName(existing.target); - const ability = target && getAbilityBySlot(target, existing.slot); - await clearGame(db, subject); - const abilityLabel = ability ? ` — ${escapeHtml(ability.name)} (${ability.slot})` : ""; - return ctx.reply( - `🏳️ Answer was ${escapeHtml(existing.target)}${abilityLabel}.\n${NEW_ROUND_HINT}`, - { parse_mode: "HTML" }, - ); -} - -export async function handleStats(ctx, db) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const s = await loadStats(db, subject); - const winRate = s.played ? Math.round((s.wins / s.played) * 100) : 0; - const scope = ctx.chat?.type === "private" ? "your" : "group"; - return ctx.reply( - `📊 Loldle Ability ${scope} stats\n` + - `Played: ${s.played}\n` + - `Wins: ${s.wins} (${winRate}%)\n` + - `Current streak: ${s.streak}\n` + - `Best streak: ${s.bestStreak}`, - ); -} diff --git a/src/modules/loldle-ability/index.js b/src/modules/loldle-ability/index.js deleted file mode 100644 index 4f8c96a..0000000 --- a/src/modules/loldle-ability/index.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * @file loldle-ability — guess the champion from a single ability icon. - * Pool seeded from Data Dragon (same source loldle.net uses at runtime). - */ - -import { handleAbility, handleGiveup, handleSetMax, handleStats } from "./handlers.js"; - -/** @type {import("../../db/kv-store-interface.js").KVStore | null} */ -let db = null; - -/** @type {import("../registry.js").BotModule} */ -const loldleAbilityModule = { - name: "loldle-ability", - init: async ({ db: store }) => { - db = store; - }, - commands: [ - { - name: "loldle_ability", - visibility: "public", - description: "Ability loldle — guess the champion from an ability icon", - handler: (ctx) => handleAbility(ctx, db), - }, - { - name: "loldle_ability_giveup", - visibility: "public", - description: "Reveal the current ability loldle answer", - handler: (ctx) => handleGiveup(ctx, db), - }, - { - name: "loldle_ability_stats", - visibility: "public", - description: "Show your ability loldle stats (wins, streak)", - handler: (ctx) => handleStats(ctx, db), - }, - { - name: "loldle_ability_setmax", - visibility: "private", - description: "Override ability loldle max guesses per round (1-10)", - handler: (ctx) => handleSetMax(ctx, db), - }, - ], -}; - -export default loldleAbilityModule; diff --git a/src/modules/loldle-ability/lookup.js b/src/modules/loldle-ability/lookup.js deleted file mode 100644 index 7b1ccd6..0000000 --- a/src/modules/loldle-ability/lookup.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @file Champion lookup over the ability pool — case/space/punctuation- - * insensitive with unique-prefix fallback. - */ - -import { normalize } from "../../util/normalize-name.js"; - -export function findChampion(pool, input) { - const q = normalize(input); - if (!q) return null; - - const exact = pool.find((c) => normalize(c.championName) === q); - if (exact) return exact; - - const prefix = pool.filter((c) => normalize(c.championName).startsWith(q)); - return prefix.length === 1 ? prefix[0] : null; -} diff --git a/src/modules/loldle-ability/state.js b/src/modules/loldle-ability/state.js deleted file mode 100644 index 4af3552..0000000 --- a/src/modules/loldle-ability/state.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * @file Game + stats persistence for loldle-ability. Adds `slot` field vs - * emoji/quote so the SAME ability icon persists across guesses in a round. - */ - -const MAX_GUESSES = 5; -const MAX_GUESSES_CAP = 10; -const GAME_TTL_SECONDS = 60 * 60 * 24 * 7; - -const gameKey = (subject) => `game:${subject}`; -const statsKey = (subject) => `stats:${subject}`; -const configKey = (subject) => `config:${subject}`; - -export { MAX_GUESSES, MAX_GUESSES_CAP }; - -export async function getMaxGuesses(db, subject) { - const cfg = await db.getJSON(configKey(subject)); - const n = cfg?.maxGuesses; - if (Number.isInteger(n) && n >= 1 && n <= MAX_GUESSES_CAP) return n; - return MAX_GUESSES; -} - -export async function setMaxGuesses(db, subject, n) { - if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { - throw new RangeError(`maxGuesses must be an integer in [1, ${MAX_GUESSES_CAP}]`); - } - await db.putJSON(configKey(subject), { maxGuesses: n }); -} - -export async function loadGame(db, subject) { - return db.getJSON(gameKey(subject)); -} - -export async function saveGame(db, subject, state) { - await db.putJSON(gameKey(subject), state, { expirationTtl: GAME_TTL_SECONDS }); -} - -export async function clearGame(db, subject) { - await db.delete(gameKey(subject)); -} - -export async function loadStats(db, subject) { - return ( - (await db.getJSON(statsKey(subject))) ?? { - played: 0, - wins: 0, - streak: 0, - bestStreak: 0, - } - ); -} - -export async function recordResult(db, subject, won) { - const s = await loadStats(db, subject); - s.played += 1; - if (won) { - s.wins += 1; - s.streak += 1; - if (s.streak > s.bestStreak) s.bestStreak = s.streak; - } else { - s.streak = 0; - } - await db.putJSON(statsKey(subject), s); - return s; -} diff --git a/src/modules/loldle-emoji/README.md b/src/modules/loldle-emoji/README.md deleted file mode 100644 index 0c6a479..0000000 --- a/src/modules/loldle-emoji/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# loldle-emoji - -Guess the champion from a short emoji sequence. Binary right/wrong. 5 guesses -per round. - -## Commands - -| Command | Visibility | Description | -|---|---|---| -| `/loldle_emoji` | public | Show current round / submit a guess with `/loldle_emoji ` | -| `/loldle_emoji_giveup` | public | Reveal the current answer, record loss | -| `/loldle_emoji_stats` | public | Show per-subject play stats | - -## Storage - -KV prefix: `loldle-emoji:` -- `game:` — active round state -- `stats:` — `{ played, wins, streak, bestStreak }` - -Subject = user id in DMs, chat id in groups (group shares one round). - -## Data source - -`emojis.json` is generated by `npm run fetch:ddragon-data` from classic's -champion metadata (species, regions, resource, position) via a handcrafted -emoji mapping table. Re-runs are idempotent. - -**Why not scrape from loldle.net?** Their JS bundle contains zero -emoji code points — emoji sequences live encrypted in a daily-rotating -cache, not a full pool. Deriving from metadata gives us all 170+ champions -deterministically with no brittle scrape. - -Credits: -- Game concept inspired by [loldle.net](https://loldle.net/)'s emoji mode. -- Emoji mappings are ours (not scraped from loldle.net); any similarity to - their per-champion sequences is coincidental. diff --git a/src/modules/loldle-emoji/emojis.json b/src/modules/loldle-emoji/emojis.json deleted file mode 100644 index ed7bc05..0000000 --- a/src/modules/loldle-emoji/emojis.json +++ /dev/null @@ -1,690 +0,0 @@ -[ - { - "championName": "Aatrox", - "emojis": "⚔️ 🌍 💪" - }, - { - "championName": "Ahri", - "emojis": "🦊 🏯 🔮" - }, - { - "championName": "Akali", - "emojis": "🧝 🏯 ⚡" - }, - { - "championName": "Akshan", - "emojis": "🧝 🏜️ 🔮" - }, - { - "championName": "Alistar", - "emojis": "🐂 🌍 🔮" - }, - { - "championName": "Ambessa", - "emojis": "🧝 ⚙️ ⚡" - }, - { - "championName": "Amumu", - "emojis": "💀 🏜️ 🔮" - }, - { - "championName": "Anivia", - "emojis": "👻 ❄️ 🔮" - }, - { - "championName": "Annie", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Aphelios", - "emojis": "🧝 ⛰️ 🔮" - }, - { - "championName": "Ashe", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Aurelion Sol", - "emojis": "🌟 🌍 🔮" - }, - { - "championName": "Aurora", - "emojis": "🦊 ❄️ 🔮" - }, - { - "championName": "Azir", - "emojis": "⚜️ 🏜️ 🔮" - }, - { - "championName": "Bard", - "emojis": "🌟 🌍 🔮" - }, - { - "championName": "Bel'Veth", - "emojis": "👁️ 👾 💪" - }, - { - "championName": "Blitzcrank", - "emojis": "🗿 🧪 🔮" - }, - { - "championName": "Brand", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Braum", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Briar", - "emojis": "🗿 🗡️ ❤️‍🩹" - }, - { - "championName": "Caitlyn", - "emojis": "🧝 ⚙️ 🔮" - }, - { - "championName": "Camille", - "emojis": "🧝 ⚙️ 🔮" - }, - { - "championName": "Cassiopeia", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Cho'Gath", - "emojis": "👁️ 👾 🔮" - }, - { - "championName": "Corki", - "emojis": "🧚 🏡 🔮" - }, - { - "championName": "Darius", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Diana", - "emojis": "🧝 ⛰️ 🔮" - }, - { - "championName": "Dr. Mundo", - "emojis": "🧝 🧪 ❤️‍🩹" - }, - { - "championName": "Draven", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Ekko", - "emojis": "🧝 🧪 🔮" - }, - { - "championName": "Elise", - "emojis": "🧝 🌫️ 🔮" - }, - { - "championName": "Evelynn", - "emojis": "😈 🌍 🔮" - }, - { - "championName": "Ezreal", - "emojis": "🧝 ⚙️ 🔮" - }, - { - "championName": "Fiddlesticks", - "emojis": "😈 🌍 🔮" - }, - { - "championName": "Fiora", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Fizz", - "emojis": "🧚 ⚓ 🔮" - }, - { - "championName": "Galio", - "emojis": "🗿 🛡️ 🔮" - }, - { - "championName": "Gangplank", - "emojis": "🧝 ⚓ 🔮" - }, - { - "championName": "Garen", - "emojis": "🧝 🛡️ 💪" - }, - { - "championName": "Gnar", - "emojis": "🧚 ❄️ 😡" - }, - { - "championName": "Gragas", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Graves", - "emojis": "🧝 ⚓ 🔮" - }, - { - "championName": "Gwen", - "emojis": "🧝 🌫️ 🔮" - }, - { - "championName": "Hecarim", - "emojis": "💀 🌫️ 🔮" - }, - { - "championName": "Heimerdinger", - "emojis": "🧚 ⚙️ 🔮" - }, - { - "championName": "Hwei", - "emojis": "🧝 🏯 🔮" - }, - { - "championName": "Illaoi", - "emojis": "🧝 ⚓ 🔮" - }, - { - "championName": "Irelia", - "emojis": "🧝 🏯 🔮" - }, - { - "championName": "Ivern", - "emojis": "🧝 🏯 🔮" - }, - { - "championName": "Janna", - "emojis": "👻 🧪 🔮" - }, - { - "championName": "Jarvan IV", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Jax", - "emojis": "❓ 🌍 🔮" - }, - { - "championName": "Jayce", - "emojis": "🧝 ⚙️ 🔮" - }, - { - "championName": "Jhin", - "emojis": "🧝 🏯 🔮" - }, - { - "championName": "Jinx", - "emojis": "🧝 🧪 🔮" - }, - { - "championName": "K'Sante", - "emojis": "🧝 🏜️ 🔮" - }, - { - "championName": "Kai'Sa", - "emojis": "🧝 🏜️ 🔮" - }, - { - "championName": "Kalista", - "emojis": "💀 🌫️ 🔮" - }, - { - "championName": "Karma", - "emojis": "🧝 🏯 🔮" - }, - { - "championName": "Karthus", - "emojis": "💀 🌫️ 🔮" - }, - { - "championName": "Kassadin", - "emojis": "🧝 🏜️ 🔮" - }, - { - "championName": "Katarina", - "emojis": "🧝 🗡️ 💪" - }, - { - "championName": "Kayle", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Kayn", - "emojis": "⚔️ 🏯 🔮" - }, - { - "championName": "Kennen", - "emojis": "🧚 🏯 ⚡" - }, - { - "championName": "Kha'Zix", - "emojis": "👁️ 👾 🔮" - }, - { - "championName": "Kindred", - "emojis": "👻 🌍 🔮" - }, - { - "championName": "Kled", - "emojis": "🧚 🗡️ 🦁" - }, - { - "championName": "Kog'Maw", - "emojis": "👁️ 👾 🔮" - }, - { - "championName": "LeBlanc", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Lee Sin", - "emojis": "🧝 🏯 ⚡" - }, - { - "championName": "Leona", - "emojis": "🧝 ⛰️ 🔮" - }, - { - "championName": "Lillia", - "emojis": "👻 🏯 🔮" - }, - { - "championName": "Lissandra", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Lucian", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Lulu", - "emojis": "🧚 🏡 🔮" - }, - { - "championName": "Lux", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Malphite", - "emojis": "🗿 🌿 🔮" - }, - { - "championName": "Malzahar", - "emojis": "🧝 🏜️ 🔮" - }, - { - "championName": "Maokai", - "emojis": "👻 🌫️ 🔮" - }, - { - "championName": "Master Yi", - "emojis": "🧝 🏯 🔮" - }, - { - "championName": "Mel", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Milio", - "emojis": "🧝 🌿 🔮" - }, - { - "championName": "Miss Fortune", - "emojis": "🧝 ⚓ 🔮" - }, - { - "championName": "Mordekaiser", - "emojis": "👻 🗡️ 🛡️" - }, - { - "championName": "Morgana", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Naafiri", - "emojis": "🐕 🏜️ 🔮" - }, - { - "championName": "Nami", - "emojis": "🦊 🌍 🔮" - }, - { - "championName": "Nasus", - "emojis": "⚜️ 🏜️ 🔮" - }, - { - "championName": "Nautilus", - "emojis": "👻 ⚓ 🔮" - }, - { - "championName": "Neeko", - "emojis": "🦊 🌿 🔮" - }, - { - "championName": "Nidalee", - "emojis": "🧝 🌿 🔮" - }, - { - "championName": "Nilah", - "emojis": "🧝 ⚓ 🔮" - }, - { - "championName": "Nocturne", - "emojis": "😈 🌍 🔮" - }, - { - "championName": "Nunu & Willump", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Olaf", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Orianna", - "emojis": "🗿 ⚙️ 🔮" - }, - { - "championName": "Ornn", - "emojis": "👻 ❄️ 🔮" - }, - { - "championName": "Pantheon", - "emojis": "🧝 ⛰️ 🔮" - }, - { - "championName": "Poppy", - "emojis": "🧚 🛡️ 🔮" - }, - { - "championName": "Pyke", - "emojis": "👻 ⚓ 🔮" - }, - { - "championName": "Qiyana", - "emojis": "🧝 🌿 🔮" - }, - { - "championName": "Quinn", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Rakan", - "emojis": "🦊 🏯 🔮" - }, - { - "championName": "Rammus", - "emojis": "❓ 🏜️ 🔮" - }, - { - "championName": "Rek'Sai", - "emojis": "👁️ 🏜️ 😡" - }, - { - "championName": "Rell", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Renata Glasc", - "emojis": "🧝 🧪 🔮" - }, - { - "championName": "Renekton", - "emojis": "⚜️ 🏜️ 💢" - }, - { - "championName": "Rengar", - "emojis": "🦊 🌿 🔥" - }, - { - "championName": "Riven", - "emojis": "🧝 🏯 💪" - }, - { - "championName": "Rumble", - "emojis": "🧚 🏡 🔥" - }, - { - "championName": "Ryze", - "emojis": "🧝 🌍 🔮" - }, - { - "championName": "Samira", - "emojis": "🧝 🏜️ 🔮" - }, - { - "championName": "Sejuani", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Senna", - "emojis": "🧝 🌫️ 🔮" - }, - { - "championName": "Seraphine", - "emojis": "🧝 ⚙️ 🔮" - }, - { - "championName": "Sett", - "emojis": "🧝 🏯 🪨" - }, - { - "championName": "Shaco", - "emojis": "👻 🌍 🔮" - }, - { - "championName": "Shen", - "emojis": "🧝 🏯 ⚡" - }, - { - "championName": "Shyvana", - "emojis": "🐉 🛡️ 💢" - }, - { - "championName": "Singed", - "emojis": "🧝 🧪 🔮" - }, - { - "championName": "Sion", - "emojis": "👻 🗡️ 🔮" - }, - { - "championName": "Sivir", - "emojis": "🧝 🏜️ 🔮" - }, - { - "championName": "Skarner", - "emojis": "🦂 🌿 🔮" - }, - { - "championName": "Smolder", - "emojis": "🐉 ⚜️ 🔮" - }, - { - "championName": "Sona", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Soraka", - "emojis": "🌟 ⛰️ 🔮" - }, - { - "championName": "Swain", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Sylas", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Syndra", - "emojis": "🧝 🏯 🔮" - }, - { - "championName": "Tahm Kench", - "emojis": "😈 ⚓ 🔮" - }, - { - "championName": "Taliyah", - "emojis": "🧝 🏜️ 🔮" - }, - { - "championName": "Talon", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Taric", - "emojis": "🧝 ⛰️ 🔮" - }, - { - "championName": "Teemo", - "emojis": "🧚 🏡 🔮" - }, - { - "championName": "Thresh", - "emojis": "💀 🌫️ 🔮" - }, - { - "championName": "Tristana", - "emojis": "🧚 🏡 🔮" - }, - { - "championName": "Trundle", - "emojis": "🧌 ❄️ 🔮" - }, - { - "championName": "Tryndamere", - "emojis": "🧝 ❄️ 💢" - }, - { - "championName": "Twisted Fate", - "emojis": "🧝 ⚓ 🔮" - }, - { - "championName": "Twitch", - "emojis": "🐀 🧪 🔮" - }, - { - "championName": "Udyr", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Urgot", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Varus", - "emojis": "⚔️ 🏯 🔮" - }, - { - "championName": "Vayne", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Veigar", - "emojis": "🧚 🌍 🔮" - }, - { - "championName": "Vel'Koz", - "emojis": "👁️ 👾 🔮" - }, - { - "championName": "Vex", - "emojis": "🧚 🌫️ 🔮" - }, - { - "championName": "Vi", - "emojis": "🧝 ⚙️ 🔮" - }, - { - "championName": "Viego", - "emojis": "💀 🌫️ 💪" - }, - { - "championName": "Viktor", - "emojis": "🧝 ⚙️ 🔮" - }, - { - "championName": "Vladimir", - "emojis": "🧝 🗡️ 🩸" - }, - { - "championName": "Volibear", - "emojis": "👻 ❄️ 🔮" - }, - { - "championName": "Warwick", - "emojis": "🧝 🧪 🔮" - }, - { - "championName": "Wukong", - "emojis": "🦊 🏯 🔮" - }, - { - "championName": "Xayah", - "emojis": "🦊 🏯 🔮" - }, - { - "championName": "Xerath", - "emojis": "💀 🏜️ 🔮" - }, - { - "championName": "Xin Zhao", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Yasuo", - "emojis": "🧝 🏯 💧" - }, - { - "championName": "Yone", - "emojis": "🧝 🏯 💪" - }, - { - "championName": "Yorick", - "emojis": "🧝 🌫️ 🔮" - }, - { - "championName": "Yunara", - "emojis": "🧝 🏯 🔮" - }, - { - "championName": "Yuumi", - "emojis": "🐱 🏡 🔮" - }, - { - "championName": "Zaahen", - "emojis": "⚔️ 🏜️ 🔮" - }, - { - "championName": "Zac", - "emojis": "🗿 🧪 ❤️‍🩹" - }, - { - "championName": "Zed", - "emojis": "🧝 🏯 ⚡" - }, - { - "championName": "Zeri", - "emojis": "🧝 🧪 🔮" - }, - { - "championName": "Ziggs", - "emojis": "🧚 🧪 🔮" - }, - { - "championName": "Zilean", - "emojis": "🧝 🌍 🔮" - }, - { - "championName": "Zoe", - "emojis": "🧝 ⛰️ 🔮" - }, - { - "championName": "Zyra", - "emojis": "🧝 🌿 🔮" - } -] diff --git a/src/modules/loldle-emoji/handlers.js b/src/modules/loldle-emoji/handlers.js deleted file mode 100644 index 25926de..0000000 --- a/src/modules/loldle-emoji/handlers.js +++ /dev/null @@ -1,174 +0,0 @@ -/** - * @file loldle-emoji command handlers. - * - * Subject resolution mirrors classic loldle: private chat → user id, - * group/supergroup → chat id (shared round). - * - * Round lifecycle: created lazily on first /loldle_emoji after the - * previous round ended. `startedAt` stamps on the first actual guess so - * viewing an empty board doesn't start the clock. - * - * Binary right/wrong — no attribute comparison. 5 guesses. - */ - -import { escapeHtml } from "../../util/escape-html.js"; -import emojisData from "./emojis.json" with { type: "json" }; -import { findChampion } from "./lookup.js"; -import { renderBoard } from "./render.js"; -import { - MAX_GUESSES_CAP, - clearGame, - getMaxGuesses, - loadGame, - loadStats, - recordResult, - saveGame, - setMaxGuesses, -} from "./state.js"; - -const POOL = emojisData.filter((c) => c.emojis && c.emojis.trim().length > 0); - -const NEW_ROUND_HINT = - "🆕 Send /loldle_emoji or /loldle_emoji <champion> to start a new round."; - -function getSubject(ctx) { - const type = ctx.chat?.type; - if (type === "group" || type === "supergroup") return ctx.chat.id; - return ctx.from?.id ?? null; -} - -function argAfterCommand(text) { - if (!text) return ""; - const idx = text.indexOf(" "); - return idx === -1 ? "" : text.slice(idx + 1).trim(); -} - -function pickRandom() { - return POOL[Math.floor(Math.random() * POOL.length)]; -} - -function findByName(name) { - return POOL.find((c) => c.championName === name); -} - -async function startFreshGame(db, subject) { - const target = pickRandom(); - const fresh = { target: target.championName, guesses: [], startedAt: null }; - await saveGame(db, subject, fresh); - return fresh; -} - -async function getOrInitGame(db, subject, maxGuesses) { - const existing = await loadGame(db, subject); - if (existing && existing.guesses.length < maxGuesses) return existing; - return startFreshGame(db, subject); -} - -export async function handleEmoji(ctx, db) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const arg = argAfterCommand(ctx.message?.text ?? ""); - - const maxGuesses = await getMaxGuesses(db, subject); - const game = await getOrInitGame(db, subject, maxGuesses); - const target = findByName(game.target); - if (!target) { - // Pool refreshed mid-round and the target is gone — start over. - await clearGame(db, subject); - return ctx.reply(`Emoji data was updated since this round started. ${NEW_ROUND_HINT}`, { - parse_mode: "HTML", - }); - } - - if (!arg) { - return ctx.reply(renderBoard(target.emojis, game.guesses, maxGuesses), { - parse_mode: "HTML", - }); - } - - const guess = findChampion(POOL, arg); - if (!guess) return ctx.reply(`Champion not found: "${arg}".`); - - if (game.guesses.includes(guess.championName)) { - return ctx.reply( - `🔁 ${escapeHtml(guess.championName)} was already guessed this round — try another champion.`, - { parse_mode: "HTML" }, - ); - } - - if (game.startedAt == null) game.startedAt = Date.now(); - game.guesses.push(guess.championName); - const won = guess.championName === target.championName; - const answer = escapeHtml(target.championName); - - if (won) { - const s = await recordResult(db, subject, true); - await clearGame(db, subject); - return ctx.reply( - `🎉 Got it! ${answer} — solved in ${game.guesses.length}/${maxGuesses}\n🔥 Streak: ${s.streak}\n${NEW_ROUND_HINT}`, - { parse_mode: "HTML" }, - ); - } - - if (game.guesses.length >= maxGuesses) { - await recordResult(db, subject, false); - await clearGame(db, subject); - return ctx.reply( - `${renderBoard(target.emojis, game.guesses, maxGuesses)}\n\n❌ Out of guesses. Answer was ${answer}.\n${NEW_ROUND_HINT}`, - { parse_mode: "HTML" }, - ); - } - - await saveGame(db, subject, game); - return ctx.reply( - `${renderBoard(target.emojis, game.guesses, maxGuesses)}\n\n❌ Not ${escapeHtml(guess.championName)}. Guess ${game.guesses.length}/${maxGuesses}.`, - { parse_mode: "HTML" }, - ); -} - -/** - * Hidden — set the per-subject MAX_GUESSES override (1..MAX_GUESSES_CAP). - * - * @param {import("grammy").Context} ctx - * @param {import("../../db/kv-store-interface.js").KVStore} db - */ -export async function handleSetMax(ctx, db) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const arg = argAfterCommand(ctx.message?.text ?? ""); - const n = Number.parseInt(arg, 10); - if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { - return ctx.reply(`Usage: /loldle_emoji_setmax <1-${MAX_GUESSES_CAP}>`); - } - await setMaxGuesses(db, subject, n); - return ctx.reply(`✅ Loldle emoji max guesses set to ${n} (applies to the next round).`); -} - -export async function handleGiveup(ctx, db) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const existing = await loadGame(db, subject); - if (!existing) { - return ctx.reply(`No active round. ${NEW_ROUND_HINT}`, { parse_mode: "HTML" }); - } - await recordResult(db, subject, false); - await clearGame(db, subject); - return ctx.reply(`🏳️ Answer was ${escapeHtml(existing.target)}.\n${NEW_ROUND_HINT}`, { - parse_mode: "HTML", - }); -} - -export async function handleStats(ctx, db) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const s = await loadStats(db, subject); - const winRate = s.played ? Math.round((s.wins / s.played) * 100) : 0; - const scope = ctx.chat?.type === "private" ? "your" : "group"; - return ctx.reply( - `📊 Loldle Emoji ${scope} stats\n` + - `Played: ${s.played}\n` + - `Wins: ${s.wins} (${winRate}%)\n` + - `Current streak: ${s.streak}\n` + - `Best streak: ${s.bestStreak}`, - ); -} diff --git a/src/modules/loldle-emoji/index.js b/src/modules/loldle-emoji/index.js deleted file mode 100644 index d45e335..0000000 --- a/src/modules/loldle-emoji/index.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @file loldle-emoji — guess the champion from a short emoji sequence. - * Emoji pool derived from classic champion metadata (see - * scripts/fetch-ddragon-data.js). - */ - -import { handleEmoji, handleGiveup, handleSetMax, handleStats } from "./handlers.js"; - -/** @type {import("../../db/kv-store-interface.js").KVStore | null} */ -let db = null; - -/** @type {import("../registry.js").BotModule} */ -const loldleEmojiModule = { - name: "loldle-emoji", - init: async ({ db: store }) => { - db = store; - }, - commands: [ - { - name: "loldle_emoji", - visibility: "public", - description: "Emoji loldle — guess the champion from emojis", - handler: (ctx) => handleEmoji(ctx, db), - }, - { - name: "loldle_emoji_giveup", - visibility: "public", - description: "Reveal the current emoji loldle answer", - handler: (ctx) => handleGiveup(ctx, db), - }, - { - name: "loldle_emoji_stats", - visibility: "public", - description: "Show your emoji loldle stats (wins, streak)", - handler: (ctx) => handleStats(ctx, db), - }, - { - name: "loldle_emoji_setmax", - visibility: "private", - description: "Override emoji loldle max guesses per round (1-10)", - handler: (ctx) => handleSetMax(ctx, db), - }, - ], -}; - -export default loldleEmojiModule; diff --git a/src/modules/loldle-emoji/lookup.js b/src/modules/loldle-emoji/lookup.js deleted file mode 100644 index 176d67f..0000000 --- a/src/modules/loldle-emoji/lookup.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @file Champion lookup over the emoji pool — case/space/punctuation- - * insensitive with unique-prefix fallback, matching classic's behaviour. - */ - -import { normalize } from "../../util/normalize-name.js"; - -export function findChampion(pool, input) { - const q = normalize(input); - if (!q) return null; - - const exact = pool.find((c) => normalize(c.championName) === q); - if (exact) return exact; - - const prefix = pool.filter((c) => normalize(c.championName).startsWith(q)); - return prefix.length === 1 ? prefix[0] : null; -} diff --git a/src/modules/loldle-emoji/render.js b/src/modules/loldle-emoji/render.js deleted file mode 100644 index b4c6d5a..0000000 --- a/src/modules/loldle-emoji/render.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @file Render the emoji board — clue line + list of wrong guesses. - */ - -import { escapeHtml } from "../../util/escape-html.js"; - -export function renderBoard(emojis, guesses, max) { - const clue = `🎭 ${emojis}`; - if (guesses.length === 0) { - return `${clue}\n\nNo guesses yet. Reply with /loldle_emoji <champion>.`; - } - const lines = guesses.map((name) => ` • ${escapeHtml(name)} ❌`).join("\n"); - return `${clue}\n\nGuesses (${guesses.length}/${max}):\n${lines}`; -} diff --git a/src/modules/loldle-emoji/state.js b/src/modules/loldle-emoji/state.js deleted file mode 100644 index 78756eb..0000000 --- a/src/modules/loldle-emoji/state.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @file Game + stats persistence for loldle-emoji. KV-backed, keyed by - * "subject" (user id in DMs, chat id in groups). - * - * Key layout (inside the module-prefixed store): - * game: -> { target, guesses, startedAt } - * stats: -> { played, wins, streak, bestStreak } - * - * Shape matches classic loldle intentionally — different module prefix - * keeps stats isolated per mode. - */ - -// Default round length. Admins can override per-subject via the hidden -// /loldle_emoji_setmax command (bounded by MAX_GUESSES_CAP). -const MAX_GUESSES = 5; -const MAX_GUESSES_CAP = 10; -const GAME_TTL_SECONDS = 60 * 60 * 24 * 7; - -const gameKey = (subject) => `game:${subject}`; -const statsKey = (subject) => `stats:${subject}`; -const configKey = (subject) => `config:${subject}`; - -export { MAX_GUESSES, MAX_GUESSES_CAP }; - -export async function getMaxGuesses(db, subject) { - const cfg = await db.getJSON(configKey(subject)); - const n = cfg?.maxGuesses; - if (Number.isInteger(n) && n >= 1 && n <= MAX_GUESSES_CAP) return n; - return MAX_GUESSES; -} - -export async function setMaxGuesses(db, subject, n) { - if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { - throw new RangeError(`maxGuesses must be an integer in [1, ${MAX_GUESSES_CAP}]`); - } - await db.putJSON(configKey(subject), { maxGuesses: n }); -} - -export async function loadGame(db, subject) { - return db.getJSON(gameKey(subject)); -} - -export async function saveGame(db, subject, state) { - await db.putJSON(gameKey(subject), state, { expirationTtl: GAME_TTL_SECONDS }); -} - -export async function clearGame(db, subject) { - await db.delete(gameKey(subject)); -} - -export async function loadStats(db, subject) { - return ( - (await db.getJSON(statsKey(subject))) ?? { - played: 0, - wins: 0, - streak: 0, - bestStreak: 0, - } - ); -} - -export async function recordResult(db, subject, won) { - const s = await loadStats(db, subject); - s.played += 1; - if (won) { - s.wins += 1; - s.streak += 1; - if (s.streak > s.bestStreak) s.bestStreak = s.streak; - } else { - s.streak = 0; - } - await db.putJSON(statsKey(subject), s); - return s; -} diff --git a/src/modules/loldle-quote/README.md b/src/modules/loldle-quote/README.md deleted file mode 100644 index ee35990..0000000 --- a/src/modules/loldle-quote/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# loldle-quote - -Guess the champion from a one-sentence lore blurb. Binary right/wrong. 6 -guesses per round. - -## Commands - -| Command | Visibility | Description | -|---|---|---| -| `/loldle_quote` | public | Show current round / submit a guess with `/loldle_quote ` | -| `/loldle_quote_giveup` | public | Reveal the current answer, record loss | -| `/loldle_quote_stats` | public | Show per-subject play stats | - -## Storage - -KV prefix: `loldle-quote:` -- `game:` — active round state -- `stats:` — `{ played, wins, streak, bestStreak }` - -Subject = user id in DMs, chat id in groups. - -## Data source - -`quotes.json` is generated by `npm run fetch:ddragon-data` from Data -Dragon's champion endpoint. Each entry is: - -``` -" — " -``` - -The champion's own name is redacted with `___` so it isn't a giveaway. - -Credits: -- Game concept inspired by [loldle.net](https://loldle.net/)'s quote mode. -- Lore text © Riot Games, served from their public Data Dragon CDN. - -## Design notes - -- **No audio hint** — serving MP3s from Workers would eat the bundle budget, - require R2, and likely run afoul of Riot's asset TOS. If users ask for - audio, revisit with a sticker/voice-link approach. -- **Lore blurb instead of voice-line text** — public feeds don't expose - per-champion voice-line transcripts. Lore blurbs are official, - per-champion, and stable across patches. -- **Quote ambiguity is by design.** Short lore sentences can fit multiple - champions ("For glory!"-style). That's what makes the mode hard. diff --git a/src/modules/loldle-quote/handlers.js b/src/modules/loldle-quote/handlers.js deleted file mode 100644 index 51447b9..0000000 --- a/src/modules/loldle-quote/handlers.js +++ /dev/null @@ -1,165 +0,0 @@ -/** - * @file loldle-quote command handlers. Binary right/wrong; 6 guesses. - * Subject resolution and round lifecycle mirror loldle-emoji. - */ - -import { escapeHtml } from "../../util/escape-html.js"; -import { findChampion } from "./lookup.js"; -import quotesData from "./quotes.json" with { type: "json" }; -import { renderBoard } from "./render.js"; -import { - MAX_GUESSES_CAP, - clearGame, - getMaxGuesses, - loadGame, - loadStats, - recordResult, - saveGame, - setMaxGuesses, -} from "./state.js"; - -const POOL = quotesData.filter((c) => c.quote && c.quote.trim().length > 0); - -const NEW_ROUND_HINT = - "🆕 Send /loldle_quote or /loldle_quote <champion> to start a new round."; - -function getSubject(ctx) { - const type = ctx.chat?.type; - if (type === "group" || type === "supergroup") return ctx.chat.id; - return ctx.from?.id ?? null; -} - -function argAfterCommand(text) { - if (!text) return ""; - const idx = text.indexOf(" "); - return idx === -1 ? "" : text.slice(idx + 1).trim(); -} - -function pickRandom() { - return POOL[Math.floor(Math.random() * POOL.length)]; -} - -function findByName(name) { - return POOL.find((c) => c.championName === name); -} - -async function startFreshGame(db, subject) { - const target = pickRandom(); - const fresh = { target: target.championName, guesses: [], startedAt: null }; - await saveGame(db, subject, fresh); - return fresh; -} - -async function getOrInitGame(db, subject, maxGuesses) { - const existing = await loadGame(db, subject); - if (existing && existing.guesses.length < maxGuesses) return existing; - return startFreshGame(db, subject); -} - -export async function handleQuote(ctx, db) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const arg = argAfterCommand(ctx.message?.text ?? ""); - - const maxGuesses = await getMaxGuesses(db, subject); - const game = await getOrInitGame(db, subject, maxGuesses); - const target = findByName(game.target); - if (!target) { - await clearGame(db, subject); - return ctx.reply(`Quote data was updated since this round started. ${NEW_ROUND_HINT}`, { - parse_mode: "HTML", - }); - } - - if (!arg) { - return ctx.reply(renderBoard(target.quote, game.guesses, maxGuesses), { - parse_mode: "HTML", - }); - } - - const guess = findChampion(POOL, arg); - if (!guess) return ctx.reply(`Champion not found: "${arg}".`); - - if (game.guesses.includes(guess.championName)) { - return ctx.reply( - `🔁 ${escapeHtml(guess.championName)} was already guessed this round — try another champion.`, - { parse_mode: "HTML" }, - ); - } - - if (game.startedAt == null) game.startedAt = Date.now(); - game.guesses.push(guess.championName); - const won = guess.championName === target.championName; - const answer = escapeHtml(target.championName); - - if (won) { - const s = await recordResult(db, subject, true); - await clearGame(db, subject); - return ctx.reply( - `🎉 Nailed it! ${answer} — solved in ${game.guesses.length}/${maxGuesses}\n🔥 Streak: ${s.streak}\n${NEW_ROUND_HINT}`, - { parse_mode: "HTML" }, - ); - } - - if (game.guesses.length >= maxGuesses) { - await recordResult(db, subject, false); - await clearGame(db, subject); - return ctx.reply( - `${renderBoard(target.quote, game.guesses, maxGuesses)}\n\n❌ Out of guesses. Answer: ${answer}.\n${NEW_ROUND_HINT}`, - { parse_mode: "HTML" }, - ); - } - - await saveGame(db, subject, game); - return ctx.reply( - `${renderBoard(target.quote, game.guesses, maxGuesses)}\n\n❌ Not ${escapeHtml(guess.championName)}. Guess ${game.guesses.length}/${maxGuesses}.`, - { parse_mode: "HTML" }, - ); -} - -/** - * Hidden — set the per-subject MAX_GUESSES override (1..MAX_GUESSES_CAP). - * - * @param {import("grammy").Context} ctx - * @param {import("../../db/kv-store-interface.js").KVStore} db - */ -export async function handleSetMax(ctx, db) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const arg = argAfterCommand(ctx.message?.text ?? ""); - const n = Number.parseInt(arg, 10); - if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { - return ctx.reply(`Usage: /loldle_quote_setmax <1-${MAX_GUESSES_CAP}>`); - } - await setMaxGuesses(db, subject, n); - return ctx.reply(`✅ Loldle quote max guesses set to ${n} (applies to the next round).`); -} - -export async function handleGiveup(ctx, db) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const existing = await loadGame(db, subject); - if (!existing) { - return ctx.reply(`No active round. ${NEW_ROUND_HINT}`, { parse_mode: "HTML" }); - } - await recordResult(db, subject, false); - await clearGame(db, subject); - return ctx.reply(`🏳️ Answer: ${escapeHtml(existing.target)}.\n${NEW_ROUND_HINT}`, { - parse_mode: "HTML", - }); -} - -export async function handleStats(ctx, db) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const s = await loadStats(db, subject); - const winRate = s.played ? Math.round((s.wins / s.played) * 100) : 0; - const scope = ctx.chat?.type === "private" ? "your" : "group"; - return ctx.reply( - `📊 Loldle Quote ${scope} stats\n` + - `Played: ${s.played}\n` + - `Wins: ${s.wins} (${winRate}%)\n` + - `Current streak: ${s.streak}\n` + - `Best streak: ${s.bestStreak}`, - ); -} diff --git a/src/modules/loldle-quote/index.js b/src/modules/loldle-quote/index.js deleted file mode 100644 index ed9f283..0000000 --- a/src/modules/loldle-quote/index.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @file loldle-quote — guess the champion from a one-sentence lore blurb. - * Text-only (no audio). Pool seeded from DDragon's champion title + lore - * (see scripts/fetch-ddragon-data.js). - */ - -import { handleGiveup, handleQuote, handleSetMax, handleStats } from "./handlers.js"; - -/** @type {import("../../db/kv-store-interface.js").KVStore | null} */ -let db = null; - -/** @type {import("../registry.js").BotModule} */ -const loldleQuoteModule = { - name: "loldle-quote", - init: async ({ db: store }) => { - db = store; - }, - commands: [ - { - name: "loldle_quote", - visibility: "public", - description: "Quote loldle — guess the champion from a lore blurb", - handler: (ctx) => handleQuote(ctx, db), - }, - { - name: "loldle_quote_giveup", - visibility: "public", - description: "Reveal the current quote loldle answer", - handler: (ctx) => handleGiveup(ctx, db), - }, - { - name: "loldle_quote_stats", - visibility: "public", - description: "Show your quote loldle stats (wins, streak)", - handler: (ctx) => handleStats(ctx, db), - }, - { - name: "loldle_quote_setmax", - visibility: "private", - description: "Override quote loldle max guesses per round (1-10)", - handler: (ctx) => handleSetMax(ctx, db), - }, - ], -}; - -export default loldleQuoteModule; diff --git a/src/modules/loldle-quote/lookup.js b/src/modules/loldle-quote/lookup.js deleted file mode 100644 index 4ac5e9b..0000000 --- a/src/modules/loldle-quote/lookup.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @file Champion lookup over the quote pool — case/space/punctuation- - * insensitive with unique-prefix fallback, matching classic's behaviour. - */ - -import { normalize } from "../../util/normalize-name.js"; - -export function findChampion(pool, input) { - const q = normalize(input); - if (!q) return null; - - const exact = pool.find((c) => normalize(c.championName) === q); - if (exact) return exact; - - const prefix = pool.filter((c) => normalize(c.championName).startsWith(q)); - return prefix.length === 1 ? prefix[0] : null; -} diff --git a/src/modules/loldle-quote/quotes.json b/src/modules/loldle-quote/quotes.json deleted file mode 100644 index f5aefdd..0000000 --- a/src/modules/loldle-quote/quotes.json +++ /dev/null @@ -1,690 +0,0 @@ -[ - { - "championName": "Aatrox", - "quote": "the Darkin Blade — Once honored defenders of Shurima against the Void, ___ and his brethren would eventually become an even greater threat to Runeterra, and were defeated only by cunning mortal sorcery." - }, - { - "championName": "Ahri", - "quote": "the Nine-Tailed Fox — Innately connected to the magic of the spirit realm, ___ is a fox-like vastaya who can manipulate her prey's emotions and consume their essence—receiving flashes of their memory and insight from each soul she consumes." - }, - { - "championName": "Akali", - "quote": "the Rogue Assassin — Abandoning the Kinkou Order and her title of the Fist of Shadow, ___ now strikes alone, ready to be the deadly weapon her people need." - }, - { - "championName": "Akshan", - "quote": "the Rogue Sentinel — Raising an eyebrow in the face of danger, ___ fights evil with dashing charisma, righteous vengeance, and a conspicuous lack of shirts." - }, - { - "championName": "Alistar", - "quote": "the Minotaur — Always a mighty warrior with a fearsome reputation, ___ seeks revenge for the death of his clan at the hands of the Noxian empire." - }, - { - "championName": "Ambessa", - "quote": "Matriarch of War — All who know the name Medarda respect and fear the family's leader, ___." - }, - { - "championName": "Amumu", - "quote": "the Sad Mummy — Legend claims that ___ is a lonely and melancholy soul from ancient Shurima, roaming the world in search of a friend." - }, - { - "championName": "Anivia", - "quote": "the Cryophoenix — ___ is a benevolent winged spirit who endures endless cycles of life, death, and rebirth to protect the Freljord." - }, - { - "championName": "Annie", - "quote": "the Dark Child — Dangerous, yet disarmingly precocious, ___ is a child mage with immense pyromantic power." - }, - { - "championName": "Aphelios", - "quote": "the Weapon of the Faithful — Emerging from moonlight's shadow with weapons drawn, ___ kills the enemies of his faith in brooding silence—speaking only through the certainty of his aim, and the firing of each gun." - }, - { - "championName": "Ashe", - "quote": "the Frost Archer — Iceborn warmother of the Avarosan tribe, ___ commands the most populous horde in the north." - }, - { - "championName": "Aurelion Sol", - "quote": "The Star Forger — ___ once graced the vast emptiness of the cosmos with celestial wonders of his own devising." - }, - { - "championName": "Aurora", - "quote": "the Witch Between Worlds — From the moment she was born, ___ navigated life with a unique ability to move between the spirit and material realms." - }, - { - "championName": "Azir", - "quote": "the Emperor of the Sands — ___ was a mortal emperor of Shurima in a far distant age, a proud man who stood at the cusp of immortality." - }, - { - "championName": "Bard", - "quote": "the Wandering Caretaker — A traveler from beyond the stars, ___ is an agent of serendipity who fights to maintain a balance where life can endure the indifference of chaos." - }, - { - "championName": "Bel'Veth", - "quote": "the Empress of the Void — A nightmarish empress created from the raw material of an entire devoured city, ___ is the end of Runeterra itself." - }, - { - "championName": "Blitzcrank", - "quote": "the Great Steam Golem — ___ is an enormous, near-indestructible automaton from Zaun, originally built to dispose of hazardous waste." - }, - { - "championName": "Brand", - "quote": "the Burning Vengeance — Once a tribesman of the icy Freljord named Kegan Rodhe, the creature known as ___ is a lesson in the temptation of greater power." - }, - { - "championName": "Braum", - "quote": "the Heart of the Freljord — Blessed with massive biceps and an even bigger heart, ___ is a beloved hero of the Freljord." - }, - { - "championName": "Briar", - "quote": "the Restrained Hunger — A failed experiment by the Black Rose, ___'s uncontrollable bloodlust required a special pillory to focus her frenzied mind." - }, - { - "championName": "Caitlyn", - "quote": "the Sheriff of Piltover — Renowned as its finest peacekeeper, ___ Kiramman is also Piltover's best shot at ridding the city of its elusive criminal elements." - }, - { - "championName": "Camille", - "quote": "the Steel Shadow — Weaponized to operate outside the boundaries of the law, ___ is the Principal Intelligencer of Clan Ferros—an elegant and elite agent who ensures the Piltover machine and its Zaunite underbelly runs smoothly." - }, - { - "championName": "Cassiopeia", - "quote": "the Serpent's Embrace — ___ is a deadly creature bent on manipulating others to her sinister will." - }, - { - "championName": "Cho'Gath", - "quote": "the Terror of the Void — From the moment ___ first emerged into the harsh light of Runeterra's sun, the beast was driven by the most pure and insatiable hunger." - }, - { - "championName": "Corki", - "quote": "the Daring Bombardier — The yordle pilot ___ loves two things above all others: flying, and his glamorous mustache." - }, - { - "championName": "Darius", - "quote": "the Hand of Noxus — There is no greater symbol of Noxian might than ___, the nation's most feared and battle-hardened commander." - }, - { - "championName": "Diana", - "quote": "Scorn of the Moon — Bearing her crescent moonblade, ___ fights as a warrior of the Lunari—a faith all but quashed in the lands around Mount Targon." - }, - { - "championName": "Dr. Mundo", - "quote": "the Madman of Zaun — Utterly mad, tragically homicidal, and horrifyingly purple, Dr." - }, - { - "championName": "Draven", - "quote": "the Glorious Executioner — In Noxus, warriors known as Reckoners face one another in arenas where blood is spilled and strength tested—but none has ever been as celebrated as ___." - }, - { - "championName": "Ekko", - "quote": "the Boy Who Shattered Time — A prodigy from the rough streets of Zaun, ___ is able to manipulate time to twist any situation to his advantage." - }, - { - "championName": "Elise", - "quote": "the Spider Queen — ___ is a deadly predator who dwells in a shuttered, lightless palace, deep within the oldest city of Noxus." - }, - { - "championName": "Evelynn", - "quote": "Agony's Embrace — Within the dark seams of Runeterra, the demon ___ searches for her next victim." - }, - { - "championName": "Ezreal", - "quote": "the Prodigal Explorer — A dashing adventurer, unknowingly gifted in the magical arts, ___ raids long-lost catacombs, tangles with ancient curses, and overcomes seemingly impossible odds with ease." - }, - { - "championName": "Fiddlesticks", - "quote": "the Ancient Fear — Something has awoken in Runeterra." - }, - { - "championName": "Fiora", - "quote": "the Grand Duelist — The most feared duelist in all Valoran, ___ is as renowned for her brusque manner and cunning mind as she is for the speed of her bluesteel rapier." - }, - { - "championName": "Fizz", - "quote": "the Tidal Trickster — ___ is an amphibious yordle, who dwells among the reefs surrounding Bilgewater." - }, - { - "championName": "Galio", - "quote": "the Colossus — Outside the gleaming city of Demacia, the stone colossus ___ keeps vigilant watch." - }, - { - "championName": "Gangplank", - "quote": "the Saltwater Scourge — As unpredictable as he is brutal, the dethroned reaver king ___ is feared far and wide." - }, - { - "championName": "Garen", - "quote": "The Might of Demacia — A proud and noble warrior, ___ fights as one of the Dauntless Vanguard." - }, - { - "championName": "Gnar", - "quote": "the Missing Link — ___ is a primeval yordle whose playful antics can erupt into a toddler's outrage in an instant, transforming him into a massive beast bent on destruction." - }, - { - "championName": "Gragas", - "quote": "the Rabble Rouser — Equal parts jolly and imposing, ___ is a massive, rowdy brewmaster who's always on the lookout for new ways to raise everyone's spirits." - }, - { - "championName": "Graves", - "quote": "the Outlaw — Malcolm ___ is a renowned mercenary, gambler, and thief—a wanted man in every city and empire he has visited." - }, - { - "championName": "Gwen", - "quote": "The Hallowed Seamstress — A former doll transformed and brought to life by magic, ___ wields the very tools that once created her." - }, - { - "championName": "Hecarim", - "quote": "the Shadow of War — ___ is a spectral fusion of man and beast, cursed to ride down the souls of the living for all eternity." - }, - { - "championName": "Heimerdinger", - "quote": "the Revered Inventor — The eccentric Professor Cecil B." - }, - { - "championName": "Hwei", - "quote": "the Visionary — ___ is a brooding painter who creates brilliant art in order to confront Ionia's criminals and comfort their victims." - }, - { - "championName": "Illaoi", - "quote": "the Kraken Priestess — ___'s powerful physique is dwarfed only by her indomitable faith." - }, - { - "championName": "Irelia", - "quote": "the Blade Dancer — The Noxian occupation of Ionia produced many heroes, none more unlikely than young ___ of Navori." - }, - { - "championName": "Ivern", - "quote": "the Green Father — ___ Bramblefoot, known to many as the Green Father, is a peculiar half man, half tree who roams Runeterra's forests, cultivating life everywhere he goes." - }, - { - "championName": "Janna", - "quote": "the Storm's Fury — Armed with the power of Runeterra's gales, ___ is a mysterious, elemental wind spirit who protects the dispossessed of Zaun." - }, - { - "championName": "Jarvan IV", - "quote": "the Exemplar of Demacia — Prince ___, scion of the Lightshield dynasty, is heir apparent to the throne of Demacia." - }, - { - "championName": "Jax", - "quote": "Grandmaster at Arms — Unmatched in both his skill with unique armaments and his biting sarcasm, ___ is the last known weapons master of Icathia." - }, - { - "championName": "Jayce", - "quote": "the Defender of Tomorrow — ___ Talis is a brilliant inventor who, along with his friend Viktor, made the first great discoveries in the field of hextech." - }, - { - "championName": "Jhin", - "quote": "the Virtuoso — ___ is a meticulous criminal psychopath who believes murder is art." - }, - { - "championName": "Jinx", - "quote": "the Loose Cannon — An unhinged and impulsive criminal from the undercity, ___ is haunted by the consequences of her past—but that doesn't stop her from bringing her own chaotic brand of pandemonium to Piltover and Zaun." - }, - { - "championName": "K'Sante", - "quote": "the Pride of Nazumah — Defiant and courageous, ___ battles colossal beasts and ruthless Ascended to protect his home of Nazumah, a coveted oasis amid the sands of Shurima." - }, - { - "championName": "Kai'Sa", - "quote": "Daughter of the Void — Claimed by the Void when she was only a child, ___ managed to survive through sheer tenacity and strength of will." - }, - { - "championName": "Kalista", - "quote": "the Spear of Vengeance — A specter of wrath and retribution, ___ is the undying spirit of vengeance, an armored nightmare summoned from the Shadow Isles to hunt deceivers and traitors." - }, - { - "championName": "Karma", - "quote": "the Enlightened One — No mortal exemplifies the spiritual traditions of Ionia more than ___." - }, - { - "championName": "Karthus", - "quote": "the Deathsinger — The harbinger of oblivion, ___ is an undying spirit whose haunting songs are a prelude to the horror of his nightmarish appearance." - }, - { - "championName": "Kassadin", - "quote": "the Void Walker — Cutting a burning swath through the darkest places of the world, ___ knows his days are numbered." - }, - { - "championName": "Katarina", - "quote": "the Sinister Blade — Decisive in judgment and lethal in combat, ___ is a Noxian assassin of the highest caliber." - }, - { - "championName": "Kayle", - "quote": "the Righteous — Born to a Targonian Aspect at the height of the Rune Wars, ___ honored her mother's legacy by fighting for justice on wings of divine flame." - }, - { - "championName": "Kayn", - "quote": "the Shadow Reaper — A peerless practitioner of lethal shadow magic, Shieda ___ battles to achieve his true destiny—to one day lead the Order of Shadow into a new era of Ionian supremacy." - }, - { - "championName": "Kennen", - "quote": "the Heart of the Tempest — More than just the lightning-quick enforcer of Ionian balance, ___ is the only yordle member of the Kinkou." - }, - { - "championName": "Kha'Zix", - "quote": "the Voidreaver — The Void grows, and the Void adapts—in none of its myriad spawn are these truths more apparent than ___." - }, - { - "championName": "Kindred", - "quote": "The Eternal Hunters — Separate, but never parted, ___ represents the twin essences of death." - }, - { - "championName": "Kled", - "quote": "the Cantankerous Cavalier — A warrior as fearless as he is ornery, the yordle ___ embodies the furious bravado of Noxus." - }, - { - "championName": "Kog'Maw", - "quote": "the Mouth of the Abyss — Belched forth from a rotting Void incursion deep in the wastelands of Icathia, ___ is an inquisitive yet putrid creature with a caustic, gaping mouth." - }, - { - "championName": "LeBlanc", - "quote": "the Deceiver — Mysterious even to other members of the Black Rose cabal, ___ is but one of many names for a pale woman who has manipulated people and events since the earliest days of Noxus." - }, - { - "championName": "Lee Sin", - "quote": "the Blind Monk — A master of Ionia's ancient martial arts, ___ is a principled fighter who channels the essence of the dragon spirit to face any challenge." - }, - { - "championName": "Leona", - "quote": "the Radiant Dawn — Imbued with the fire of the sun, ___ is a holy warrior of the Solari who defends Mount Targon with her Zenith Blade and the Shield of Daybreak." - }, - { - "championName": "Lillia", - "quote": "the Bashful Bloom — Intensely shy, the fae fawn ___ skittishly wanders Ionia's forests." - }, - { - "championName": "Lissandra", - "quote": "the Ice Witch — ___'s magic twists the pure power of ice into something dark and terrible." - }, - { - "championName": "Lucian", - "quote": "the Purifier — ___, a Sentinel of Light, is a grim hunter of wraiths and specters, pursuing them relentlessly and annihilating them with his twin relic pistols." - }, - { - "championName": "Lulu", - "quote": "the Fae Sorceress — The yordle mage ___ is known for conjuring dreamlike illusions and fanciful creatures as she roams Runeterra with her fairy companion Pix." - }, - { - "championName": "Lux", - "quote": "the Lady of Luminosity — Luxanna Crownguard hails from Demacia, an insular realm where magical abilities are viewed with fear and suspicion." - }, - { - "championName": "Malphite", - "quote": "Shard of the Monolith — A massive creature of living stone, ___ struggles to impose blessed order on a chaotic world." - }, - { - "championName": "Malzahar", - "quote": "the Prophet of the Void — A zealous seer dedicated to the unification of all life, ___ truly believes the newly emergent Void to be the path to Runeterra's salvation." - }, - { - "championName": "Maokai", - "quote": "the Twisted Treant — ___ is a rageful, towering treant who fights the unnatural horrors of the Shadow Isles." - }, - { - "championName": "Master Yi", - "quote": "the Wuju Bladesman — ___ has tempered his body and sharpened his mind, so that thought and action have become almost as one." - }, - { - "championName": "Mel", - "quote": "the Soul's Reflection — ___ Medarda is the presumed heir of the Medarda family, once one of the most powerful in Noxus." - }, - { - "championName": "Milio", - "quote": "The Gentle Flame — ___ is a warmhearted boy from Ixtal who has, despite his young age, mastered the fire axiom and discovered something new: soothing fire." - }, - { - "championName": "Miss Fortune", - "quote": "the Bounty Hunter — A Bilgewater captain famed for her looks but feared for her ruthlessness, Sarah ___ paints a stark figure among the hardened criminals of the port city." - }, - { - "championName": "Mordekaiser", - "quote": "the Iron Revenant — Twice slain and thrice born, ___ is a brutal warlord from a foregone epoch who uses his necromantic sorcery to bind souls into an eternity of servitude." - }, - { - "championName": "Morgana", - "quote": "the Fallen — Conflicted between her celestial and mortal natures, ___ bound her wings to embrace humanity, and inflicts her pain and bitterness upon the dishonest and the corrupt." - }, - { - "championName": "Naafiri", - "quote": "the Hound of a Hundred Bites — Across the sands of Shurima, a chorus of howls rings out." - }, - { - "championName": "Nami", - "quote": "the Tidecaller — A headstrong young vastaya of the seas, ___ was the first of the Marai tribe to leave the waves and venture onto dry land, when their ancient accord with the Targonians was broken." - }, - { - "championName": "Nasus", - "quote": "the Curator of the Sands — ___ is an imposing, jackal-headed Ascended being from ancient Shurima, a heroic figure regarded as a demigod by the people of the desert." - }, - { - "championName": "Nautilus", - "quote": "the Titan of the Depths — A lonely legend as old as the first piers sunk in Bilgewater, the armored goliath known as ___ roams the dark waters off the coast of the Blue Flame Isles." - }, - { - "championName": "Neeko", - "quote": "the Curious Chameleon — Hailing from a long lost tribe of vastaya, ___ can blend into any crowd by borrowing the appearances of others, even absorbing something of their emotional state to tell friend from foe in an instant." - }, - { - "championName": "Nidalee", - "quote": "the Bestial Huntress — Raised in the deepest jungle, ___ is a master tracker who can shapeshift into a ferocious cougar at will." - }, - { - "championName": "Nilah", - "quote": "the Joy Unbound — ___ is an ascetic warrior from a distant land, seeking the world's deadliest, most titanic opponents so that she might challenge and destroy them." - }, - { - "championName": "Nocturne", - "quote": "the Eternal Nightmare — A demonic amalgamation drawn from the nightmares that haunt every sentient mind, the thing known as ___ has become a primordial force of pure evil." - }, - { - "championName": "Nunu & Willump", - "quote": "the Boy and His Yeti — Once upon a time, there was a boy who wanted to prove he was a hero by slaying a fearsome monster—only to discover that the beast, a lonely and magical yeti, merely needed a friend." - }, - { - "championName": "Olaf", - "quote": "the Berserker — An unstoppable force of destruction, the axe-wielding ___ wants nothing but to die in glorious combat." - }, - { - "championName": "Orianna", - "quote": "the Lady of Clockwork — Once a curious girl of flesh and blood, ___ is now a technological marvel comprised entirely of clockwork." - }, - { - "championName": "Ornn", - "quote": "The Fire below the Mountain — ___ is the Freljordian spirit of forging and craftsmanship." - }, - { - "championName": "Pantheon", - "quote": "the Unbreakable Spear — Once an unwilling host to the Aspect of War, Atreus survived when the celestial power within him was slain, refusing to succumb to a blow that tore stars from the heavens." - }, - { - "championName": "Poppy", - "quote": "Keeper of the Hammer — Runeterra has no shortage of valiant champions, but few are as tenacious as ___." - }, - { - "championName": "Pyke", - "quote": "the Bloodharbor Ripper — A renowned harpooner from the slaughter docks of Bilgewater, ___ should have met his death in the belly of a gigantic jaull-fish… and yet, he returned." - }, - { - "championName": "Qiyana", - "quote": "Empress of the Elements — In the jungle city of Ixaocan, ___ plots her own ruthless path to the high seat of the Yun Tal." - }, - { - "championName": "Quinn", - "quote": "Demacia's Wings — ___ is an elite ranger-knight of Demacia, who undertakes dangerous missions deep in enemy territory." - }, - { - "championName": "Rakan", - "quote": "The Charmer — As mercurial as he is charming, ___ is an infamous vastayan troublemaker and the greatest battle-dancer in Lhotlan tribal history." - }, - { - "championName": "Rammus", - "quote": "the Armordillo — Idolized by many, dismissed by some, mystifying to all, the curious being ___ is an enigma." - }, - { - "championName": "Rek'Sai", - "quote": "the Void Burrower — An apex predator, ___ is a merciless Void-spawn that tunnels beneath the ground to ambush and devour unsuspecting prey." - }, - { - "championName": "Rell", - "quote": "the Iron Maiden — The product of brutal experimentation at the hands of the Black Rose, ___ is a defiant, living weapon determined to topple Noxus." - }, - { - "championName": "Renata Glasc", - "quote": "the Chem-Baroness — ___ rose from the ashes of her childhood home with nothing but her name and her parents' alchemical research." - }, - { - "championName": "Renekton", - "quote": "the Butcher of the Sands — ___ is a terrifying, rage-fueled Ascended being from the scorched deserts of Shurima." - }, - { - "championName": "Rengar", - "quote": "the Pridestalker — ___ is a ferocious vastayan trophy hunter who lives for the thrill of tracking down and killing dangerous creatures." - }, - { - "championName": "Riven", - "quote": "the Exile — Once a swordmaster in the warhosts of Noxus, ___ is an expatriate in a land she previously tried to conquer." - }, - { - "championName": "Rumble", - "quote": "the Mechanized Menace — ___ is a young inventor with a temper." - }, - { - "championName": "Ryze", - "quote": "the Rune Mage — Widely considered one of the most adept sorcerers on Runeterra, ___ is an ancient, hard-bitten archmage with an impossibly heavy burden to bear." - }, - { - "championName": "Samira", - "quote": "the Desert Rose — ___ stares death in the eye with unyielding confidence, seeking thrill wherever she goes." - }, - { - "championName": "Sejuani", - "quote": "Fury of the North — ___ is the brutal, unforgiving Iceborn warmother of the Winter's Claw, one of the most feared tribes of the Freljord." - }, - { - "championName": "Senna", - "quote": "the Redeemer — Cursed from childhood to be haunted by the supernatural Black Mist, ___ joined a sacred order known as the Sentinels of Light, and fiercely fought back—only to be killed, her soul imprisoned in a lantern by the cruel specter Thresh." - }, - { - "championName": "Seraphine", - "quote": "the Starry-Eyed Songstress — Born in Piltover to Zaunite parents, ___ can hear the souls of others—the world sings to her, and she sings back." - }, - { - "championName": "Sett", - "quote": "the Boss — A leader of Ionia's growing criminal underworld, ___ rose to prominence in the wake of the war with Noxus." - }, - { - "championName": "Shaco", - "quote": "the Demon Jester — Crafted long ago as a plaything for a lonely prince, the enchanted marionette ___ now delights in murder and mayhem." - }, - { - "championName": "Shen", - "quote": "the Eye of Twilight — Among the secretive, Ionian warriors known as the Kinkou, ___ serves as their leader, the Eye of Twilight." - }, - { - "championName": "Shyvana", - "quote": "the Half-Dragon — ___ is a fearsome half-dragon warrior." - }, - { - "championName": "Singed", - "quote": "the Mad Chemist — ___ is a brilliant alchemist of dubious morality, whose experiments would turn the stomach of even the most cutthroat criminal." - }, - { - "championName": "Sion", - "quote": "The Undead Juggernaut — A war hero from a bygone era, ___ was revered in Noxus for choking the life out of a Demacian king with his bare hands—but, denied oblivion, he was resurrected to serve his empire even in death." - }, - { - "championName": "Sivir", - "quote": "the Battle Mistress — ___ is a renowned fortune hunter and mercenary captain who plies her trade in the deserts of Shurima." - }, - { - "championName": "Skarner", - "quote": "the Primordial Sovereign — The ancient, colossal brackern ___ is revered in Ixtal as one of the founding members of its ruling caste, the Yun Tal." - }, - { - "championName": "Smolder", - "quote": "the Fiery Fledgling — Hidden amongst the craggy cliffs of the Noxian frontier, under the watchful eyes of his mother, a young dragon is learning what it means to be heir to the Camavoran imperial dragon lineage." - }, - { - "championName": "Sona", - "quote": "Maven of the Strings — ___ is Demacia's foremost virtuoso of the stringed etwahl, speaking only through her graceful chords and vibrant arias." - }, - { - "championName": "Soraka", - "quote": "the Starchild — A wanderer from the celestial dimensions beyond Mount Targon, ___ gave up her immortality to protect the mortal races from their own more violent instincts." - }, - { - "championName": "Swain", - "quote": "the Noxian Grand General — Jericho ___ is the visionary ruler of Noxus, an expansionist nation that reveres only strength." - }, - { - "championName": "Sylas", - "quote": "the Unshackled — Raised in one of Demacia's lesser quarters, ___ of Dregbourne has come to symbolize the darker side of the Great City." - }, - { - "championName": "Syndra", - "quote": "the Dark Sovereign — ___ is a fearsome Ionian mage with incredible power at her command." - }, - { - "championName": "Tahm Kench", - "quote": "The River King — Known by many names throughout history, the demon ___ travels the waterways of Runeterra, feeding his insatiable appetite with the misery of others." - }, - { - "championName": "Taliyah", - "quote": "the Stoneweaver — ___ is a nomadic mage from Shurima, torn between teenage wonder and adult responsibility." - }, - { - "championName": "Talon", - "quote": "the Blade's Shadow — ___ is the knife in the darkness, a merciless killer able to strike without warning and escape before any alarm is raised." - }, - { - "championName": "Taric", - "quote": "the Shield of Valoran — ___ is the Aspect of the Protector, wielding incredible power as Runeterra's guardian of life, love, and beauty." - }, - { - "championName": "Teemo", - "quote": "the Swift Scout — Undeterred by even the most dangerous and threatening of obstacles, ___ scouts the world with boundless enthusiasm and a cheerful spirit." - }, - { - "championName": "Thresh", - "quote": "the Chain Warden — Sadistic and cunning, ___ is an ambitious and restless specter of the Shadow Isles." - }, - { - "championName": "Tristana", - "quote": "the Yordle Gunner — While many other yordles channel their energy into discovery, invention, or just plain mischief-making, ___ was always inspired by the adventures of great warriors." - }, - { - "championName": "Trundle", - "quote": "the Troll King — ___ is a hulking and devious troll with a particularly vicious streak, and there is nothing he cannot bludgeon into submission—not even the Freljord itself." - }, - { - "championName": "Tryndamere", - "quote": "the Barbarian King — Fueled by unbridled fury and rage, ___ once carved his way through the Freljord, openly challenging the greatest warriors of the north to prepare himself for even darker days ahead." - }, - { - "championName": "Twisted Fate", - "quote": "the Card Master — ___ is an infamous cardsharp and swindler who has gambled and charmed his way across much of the known world, earning the enmity and admiration of the rich and foolish alike." - }, - { - "championName": "Twitch", - "quote": "the Plague Rat — A Zaunite plague rat by birth, but a connoisseur of filth by passion, ___ is not afraid to get his paws dirty." - }, - { - "championName": "Udyr", - "quote": "the Spirit Walker — The most powerful spirit walker alive, ___ communes with all the spirits of the Freljord, whether by empathically understanding their needs, or by channeling and transforming their ethereal energy into his own primal fighting style." - }, - { - "championName": "Urgot", - "quote": "the Dreadnought — Once a powerful Noxian headsman, ___ was betrayed by the empire for which he had killed so many." - }, - { - "championName": "Varus", - "quote": "the Arrow of Retribution — One of the ancient darkin, ___ was a deadly killer who loved to torment his foes, driving them almost to insanity before delivering the killing arrow." - }, - { - "championName": "Vayne", - "quote": "the Night Hunter — Shauna ___ is a deadly, remorseless Demacian monster hunter, who has dedicated her life to finding and destroying the demon that murdered her family." - }, - { - "championName": "Veigar", - "quote": "the Tiny Master of Evil — An enthusiastic master of dark sorcery, ___ has embraced powers that few mortals dare approach." - }, - { - "championName": "Vel'Koz", - "quote": "the Eye of the Void — It is unclear if ___ was the first Void-spawn to emerge on Runeterra, but there has certainly never been another to match his level of cruel, calculating sentience." - }, - { - "championName": "Vex", - "quote": "the Gloomist — In the black heart of the Shadow Isles, a lone yordle trudges through the spectral fog, content in its murky misery." - }, - { - "championName": "Vi", - "quote": "the Piltover Enforcer — ___Raised___ ___on___ ___the___ ___mean___ ___streets___ ___of___ ___Zaun___, ___Vi___ ___is___ ___a___ ___hotheaded___, ___impulsive___, ___and___ ___fearsome___ ___woman___ ___with___ ___very___ ___little___ ___respect___ ___for___ ___authority___." - }, - { - "championName": "Viego", - "quote": "The Ruined King — Once ruler of a long-lost kingdom, ___ perished over a thousand years ago when his attempt to bring his wife back from the dead triggered the magical catastrophe known as the Ruination." - }, - { - "championName": "Viktor", - "quote": "the Herald of the Arcane — The fully biomechanical evolution of his former self, ___ has embraced his Glorious Evolution and become something of a messiah to his followers." - }, - { - "championName": "Vladimir", - "quote": "the Crimson Reaper — A fiend with a thirst for mortal blood, ___ has influenced the affairs of Noxus since the empire's earliest days." - }, - { - "championName": "Volibear", - "quote": "the Relentless Storm — To those who still revere him, the ___ is the storm made manifest." - }, - { - "championName": "Warwick", - "quote": "the Uncaged Wrath of Zaun — ___ is a monster who hunts the gray alleys of Zaun." - }, - { - "championName": "Wukong", - "quote": "the Monkey King — ___ is a vastayan trickster who uses his strength, agility, and intelligence to confuse his opponents and gain the upper hand." - }, - { - "championName": "Xayah", - "quote": "the Rebel — Deadly and precise, ___ is a vastayan revolutionary waging a personal war to save her people." - }, - { - "championName": "Xerath", - "quote": "the Magus Ascendant — ___ is an Ascended Magus of ancient Shurima, a being of arcane energy writhing in the broken shards of a magical sarcophagus." - }, - { - "championName": "Xin Zhao", - "quote": "the Seneschal of Demacia — ___ is a resolute warrior loyal to the ruling Lightshield dynasty." - }, - { - "championName": "Yasuo", - "quote": "the Unforgiven — An Ionian of deep resolve, ___ is an agile swordsman who wields the air itself against his enemies." - }, - { - "championName": "Yone", - "quote": "the Unforgotten — In life, he was ___—half-brother of Yasuo, and renowned student of his village's sword school." - }, - { - "championName": "Yorick", - "quote": "Shepherd of Souls — The last survivor of a long-forgotten religious order, ___ is both blessed and cursed with power over the dead." - }, - { - "championName": "Yunara", - "quote": "the Unbroken Faith — Unwavering in her devotion to Ionia, ___ has spent centuries cloistered away in the spirit realm honing her skills with the Aion Er'na, a legendary Kinkou relic." - }, - { - "championName": "Yuumi", - "quote": "the Magical Cat — A magical cat from Bandle City, ___ was once the familiar of a yordle enchantress, Norra." - }, - { - "championName": "Zaahen", - "quote": "The Unsundered — A fallen god wielding both divine and profane power, ___ hunts his fellow Darkin while defying the corruption that threatens to consume him." - }, - { - "championName": "Zac", - "quote": "the Secret Weapon — ___ is the product of a toxic spill that ran through a chemtech seam and pooled in an isolated cavern deep in Zaun's Sump." - }, - { - "championName": "Zed", - "quote": "the Master of Shadows — Utterly ruthless and without mercy, ___ is the leader of the Order of Shadow, an organization he created with the intent of militarizing Ionia's magical and martial traditions to drive out Noxian invaders." - }, - { - "championName": "Zeri", - "quote": "The Spark of Zaun — A headstrong, spirited young woman from Zaun's working-class, ___ channels her electric magic to charge herself and her custom-crafted gun." - }, - { - "championName": "Ziggs", - "quote": "the Hexplosives Expert — With a love of big bombs and short fuses, the yordle ___ is an explosive force of nature." - }, - { - "championName": "Zilean", - "quote": "the Chronokeeper — Once a powerful Icathian mage, ___ became obsessed with the passage of time after witnessing his homeland's destruction by the Void." - }, - { - "championName": "Zoe", - "quote": "the Aspect of Twilight — As the embodiment of mischief, imagination, and change, ___ acts as the cosmic messenger of Targon, heralding major events that reshape worlds." - }, - { - "championName": "Zyra", - "quote": "Rise of the Thorns — Born in an ancient, sorcerous catastrophe, ___ is the wrath of nature given form—an alluring hybrid of plant and human, kindling new life with every step." - } -] diff --git a/src/modules/loldle-quote/render.js b/src/modules/loldle-quote/render.js deleted file mode 100644 index f42eb59..0000000 --- a/src/modules/loldle-quote/render.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @file Render the quote board — italic quote block + list of wrong guesses. - * Quote text is HTML-escaped before wrapping in so stray `<`, `&`, or - * apostrophes from the data source can't break Telegram's HTML parse mode. - */ - -import { escapeHtml } from "../../util/escape-html.js"; - -export function renderBoard(quote, guesses, max) { - const clue = `🎭 ${escapeHtml(quote)}`; - if (guesses.length === 0) { - return `${clue}\n\nNo guesses yet. Reply with /loldle_quote <champion>.`; - } - const lines = guesses.map((name) => ` • ${escapeHtml(name)} ❌`).join("\n"); - return `${clue}\n\nGuesses (${guesses.length}/${max}):\n${lines}`; -} diff --git a/src/modules/loldle-quote/state.js b/src/modules/loldle-quote/state.js deleted file mode 100644 index cf24182..0000000 --- a/src/modules/loldle-quote/state.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * @file Game + stats persistence for loldle-quote. Shape identical to - * loldle-emoji and classic loldle — only MAX_GUESSES differs. - */ - -const MAX_GUESSES = 6; -const MAX_GUESSES_CAP = 10; -const GAME_TTL_SECONDS = 60 * 60 * 24 * 7; - -const gameKey = (subject) => `game:${subject}`; -const statsKey = (subject) => `stats:${subject}`; -const configKey = (subject) => `config:${subject}`; - -export { MAX_GUESSES, MAX_GUESSES_CAP }; - -export async function getMaxGuesses(db, subject) { - const cfg = await db.getJSON(configKey(subject)); - const n = cfg?.maxGuesses; - if (Number.isInteger(n) && n >= 1 && n <= MAX_GUESSES_CAP) return n; - return MAX_GUESSES; -} - -export async function setMaxGuesses(db, subject, n) { - if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { - throw new RangeError(`maxGuesses must be an integer in [1, ${MAX_GUESSES_CAP}]`); - } - await db.putJSON(configKey(subject), { maxGuesses: n }); -} - -export async function loadGame(db, subject) { - return db.getJSON(gameKey(subject)); -} - -export async function saveGame(db, subject, state) { - await db.putJSON(gameKey(subject), state, { expirationTtl: GAME_TTL_SECONDS }); -} - -export async function clearGame(db, subject) { - await db.delete(gameKey(subject)); -} - -export async function loadStats(db, subject) { - return ( - (await db.getJSON(statsKey(subject))) ?? { - played: 0, - wins: 0, - streak: 0, - bestStreak: 0, - } - ); -} - -export async function recordResult(db, subject, won) { - const s = await loadStats(db, subject); - s.played += 1; - if (won) { - s.wins += 1; - s.streak += 1; - if (s.streak > s.bestStreak) s.bestStreak = s.streak; - } else { - s.streak = 0; - } - await db.putJSON(statsKey(subject), s); - return s; -} diff --git a/src/modules/loldle-splash/README.md b/src/modules/loldle-splash/README.md deleted file mode 100644 index fd16285..0000000 --- a/src/modules/loldle-splash/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# loldle-splash - -Guess the champion from splash art (random skin from loldle.net's full -non-chroma pool). Binary right/wrong. 4 guesses per round. - -## Commands - -| Command | Visibility | Description | -|---|---|---| -| `/loldle_splash` | public | Show current splash / submit a guess with `/loldle_splash ` | -| `/loldle_splash_giveup` | public | Reveal the answer, record loss | -| `/loldle_splash_stats` | public | Show per-subject play stats | - -## Storage - -KV prefix: `loldle-splash:` -- `game:` — `{target, skinId, guesses, startedAt}`. `skinId` - persists so the same splash shows across every guess. -- `stats:` — `{played, wins, streak, bestStreak}` - -## Data source - -`splashes.json` is generated by `npm run fetch:ddragon-data`: -- **Skin pool** scraped from [loldle.net](https://loldle.net/)'s JS bundle - (same approach as classic loldle). Guarantees pool parity with their - splash mode — no chromas, same inclusion rules. -- **Splash URLs** built against [Riot Data Dragon](https://ddragon.leagueoflegends.com/)'s - stable-across-patches pattern: `cdn/img/champion/splash/_.jpg`. - -Credits: -- Game concept and skin pool curation from [loldle.net](https://loldle.net/). -- Splash art © Riot Games, served from their public Data Dragon CDN. - -## Design notes - -- **No progressive crop** — full splash from turn 1. Tightest guess budget - in the family (4) since the full image is shown. -- **Random across ALL listed skins** (Default + all named skins, chromas - excluded by loldle.net's pool). diff --git a/src/modules/loldle-splash/handlers.js b/src/modules/loldle-splash/handlers.js deleted file mode 100644 index 5adbae1..0000000 --- a/src/modules/loldle-splash/handlers.js +++ /dev/null @@ -1,189 +0,0 @@ -/** - * @file loldle-splash command handlers. - * - * Binary right/wrong — 4 guesses. Random skin (including all non-Default - * skins) drawn per round. `skinId` pinned in round state so the same - * splash persists across every guess. - */ - -import { escapeHtml } from "../../util/escape-html.js"; -import { findChampion } from "./lookup.js"; -import splashesData from "./splashes.json" with { type: "json" }; -import { - MAX_GUESSES_CAP, - clearGame, - getMaxGuesses, - loadGame, - loadStats, - recordResult, - saveGame, - setMaxGuesses, -} from "./state.js"; - -const POOL = splashesData.filter((c) => c.skins && c.skins.length > 0); - -const NEW_ROUND_HINT = - "🆕 Send /loldle_splash or /loldle_splash <champion> to start a new round."; - -function getSubject(ctx) { - const type = ctx.chat?.type; - if (type === "group" || type === "supergroup") return ctx.chat.id; - return ctx.from?.id ?? null; -} - -function argAfterCommand(text) { - if (!text) return ""; - const idx = text.indexOf(" "); - return idx === -1 ? "" : text.slice(idx + 1).trim(); -} - -function pickRandom() { - return POOL[Math.floor(Math.random() * POOL.length)]; -} - -function findByName(name) { - return POOL.find((c) => c.championName === name); -} - -function pickSkin(champ) { - return champ.skins[Math.floor(Math.random() * champ.skins.length)]; -} - -function getSkinById(champ, id) { - return champ.skins.find((s) => s.id === id); -} - -async function startFreshGame(db, subject) { - const target = pickRandom(); - const skin = pickSkin(target); - const fresh = { - target: target.championName, - skinId: skin.id, - guesses: [], - startedAt: null, - }; - await saveGame(db, subject, fresh); - return fresh; -} - -async function getOrInitGame(db, subject, maxGuesses) { - const existing = await loadGame(db, subject); - if (existing && existing.guesses.length < maxGuesses) return existing; - return startFreshGame(db, subject); -} - -function caption(guesses, maxGuesses) { - return `🎨 Guess the champion from this splash art. ${guesses.length}/${maxGuesses} guesses so far.`; -} - -export async function handleSplash(ctx, db) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const arg = argAfterCommand(ctx.message?.text ?? ""); - - const maxGuesses = await getMaxGuesses(db, subject); - const game = await getOrInitGame(db, subject, maxGuesses); - const target = findByName(game.target); - const skin = target && getSkinById(target, game.skinId); - if (!target || !skin) { - await clearGame(db, subject); - return ctx.reply(`Splash data was updated since this round started. ${NEW_ROUND_HINT}`, { - parse_mode: "HTML", - }); - } - - if (!arg) { - return ctx.replyWithPhoto(skin.url, { caption: caption(game.guesses, maxGuesses) }); - } - - const guess = findChampion(POOL, arg); - if (!guess) return ctx.reply(`Champion not found: "${arg}".`); - - if (game.guesses.includes(guess.championName)) { - return ctx.reply( - `🔁 ${escapeHtml(guess.championName)} was already guessed this round — try another champion.`, - { parse_mode: "HTML" }, - ); - } - - if (game.startedAt == null) game.startedAt = Date.now(); - game.guesses.push(guess.championName); - const won = guess.championName === target.championName; - const answer = escapeHtml(target.championName); - const skinLabel = `${escapeHtml(skin.name)} skin`; - - if (won) { - const s = await recordResult(db, subject, true); - await clearGame(db, subject); - return ctx.reply( - `🎉 Got it! That was ${answer} in ${skinLabel}. Solved in ${game.guesses.length}/${maxGuesses}\n🔥 Streak: ${s.streak}\n${NEW_ROUND_HINT}`, - { parse_mode: "HTML" }, - ); - } - - if (game.guesses.length >= maxGuesses) { - await recordResult(db, subject, false); - await clearGame(db, subject); - return ctx.reply( - `❌ Out of guesses. Answer was ${answer} in ${skinLabel}.\n${NEW_ROUND_HINT}`, - { parse_mode: "HTML" }, - ); - } - - await saveGame(db, subject, game); - return ctx.reply( - `❌ Not ${escapeHtml(guess.championName)}. Guess ${game.guesses.length}/${maxGuesses}.`, - { parse_mode: "HTML" }, - ); -} - -/** - * Hidden — set the per-subject MAX_GUESSES override (1..MAX_GUESSES_CAP). - * - * @param {import("grammy").Context} ctx - * @param {import("../../db/kv-store-interface.js").KVStore} db - */ -export async function handleSetMax(ctx, db) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const arg = argAfterCommand(ctx.message?.text ?? ""); - const n = Number.parseInt(arg, 10); - if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { - return ctx.reply(`Usage: /loldle_splash_setmax <1-${MAX_GUESSES_CAP}>`); - } - await setMaxGuesses(db, subject, n); - return ctx.reply(`✅ Loldle splash max guesses set to ${n} (applies to the next round).`); -} - -export async function handleGiveup(ctx, db) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const existing = await loadGame(db, subject); - if (!existing) { - return ctx.reply(`No active round. ${NEW_ROUND_HINT}`, { parse_mode: "HTML" }); - } - await recordResult(db, subject, false); - const target = findByName(existing.target); - const skin = target && getSkinById(target, existing.skinId); - await clearGame(db, subject); - const skinLabel = skin ? ` in ${escapeHtml(skin.name)} skin` : ""; - return ctx.reply( - `🏳️ Answer was ${escapeHtml(existing.target)}${skinLabel}.\n${NEW_ROUND_HINT}`, - { parse_mode: "HTML" }, - ); -} - -export async function handleStats(ctx, db) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const s = await loadStats(db, subject); - const winRate = s.played ? Math.round((s.wins / s.played) * 100) : 0; - const scope = ctx.chat?.type === "private" ? "your" : "group"; - return ctx.reply( - `📊 Loldle Splash ${scope} stats\n` + - `Played: ${s.played}\n` + - `Wins: ${s.wins} (${winRate}%)\n` + - `Current streak: ${s.streak}\n` + - `Best streak: ${s.bestStreak}`, - ); -} diff --git a/src/modules/loldle-splash/index.js b/src/modules/loldle-splash/index.js deleted file mode 100644 index ee3928b..0000000 --- a/src/modules/loldle-splash/index.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * @file loldle-splash — guess the champion from splash art (random skin). - * Skin pool scraped from loldle.net, splash URLs from Data Dragon CDN. - */ - -import { handleGiveup, handleSetMax, handleSplash, handleStats } from "./handlers.js"; - -/** @type {import("../../db/kv-store-interface.js").KVStore | null} */ -let db = null; - -/** @type {import("../registry.js").BotModule} */ -const loldleSplashModule = { - name: "loldle-splash", - init: async ({ db: store }) => { - db = store; - }, - commands: [ - { - name: "loldle_splash", - visibility: "public", - description: "Splash loldle — guess the champion from splash art", - handler: (ctx) => handleSplash(ctx, db), - }, - { - name: "loldle_splash_giveup", - visibility: "public", - description: "Reveal the current splash loldle answer", - handler: (ctx) => handleGiveup(ctx, db), - }, - { - name: "loldle_splash_stats", - visibility: "public", - description: "Show your splash loldle stats (wins, streak)", - handler: (ctx) => handleStats(ctx, db), - }, - { - name: "loldle_splash_setmax", - visibility: "private", - description: "Override splash loldle max guesses per round (1-10)", - handler: (ctx) => handleSetMax(ctx, db), - }, - ], -}; - -export default loldleSplashModule; diff --git a/src/modules/loldle-splash/lookup.js b/src/modules/loldle-splash/lookup.js deleted file mode 100644 index 16fd087..0000000 --- a/src/modules/loldle-splash/lookup.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @file Champion lookup over the splash pool — case/space/punctuation- - * insensitive with unique-prefix fallback. - */ - -import { normalize } from "../../util/normalize-name.js"; - -export function findChampion(pool, input) { - const q = normalize(input); - if (!q) return null; - - const exact = pool.find((c) => normalize(c.championName) === q); - if (exact) return exact; - - const prefix = pool.filter((c) => normalize(c.championName).startsWith(q)); - return prefix.length === 1 ? prefix[0] : null; -} diff --git a/src/modules/loldle-splash/splashes.json b/src/modules/loldle-splash/splashes.json deleted file mode 100644 index c85f7c5..0000000 --- a/src/modules/loldle-splash/splashes.json +++ /dev/null @@ -1,10557 +0,0 @@ -[ - { - "championName": "Aatrox", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aatrox_0.jpg" - }, - { - "id": 1, - "name": "Justicar Aatrox", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aatrox_1.jpg" - }, - { - "id": 2, - "name": "Mecha Aatrox", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aatrox_2.jpg" - }, - { - "id": 3, - "name": "Sea Hunter Aatrox", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aatrox_3.jpg" - }, - { - "id": 7, - "name": "Blood Moon Aatrox", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aatrox_7.jpg" - }, - { - "id": 8, - "name": "Prestige Blood Moon Aatrox", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aatrox_8.jpg" - }, - { - "id": 9, - "name": "Victorious Aatrox", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aatrox_9.jpg" - }, - { - "id": 11, - "name": "Odyssey Aatrox", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aatrox_11.jpg" - }, - { - "id": 21, - "name": "Lunar Eclipse Aatrox", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aatrox_21.jpg" - }, - { - "id": 30, - "name": "DRX Aatrox", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aatrox_30.jpg" - }, - { - "id": 31, - "name": "Prestige DRX Aatrox", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aatrox_31.jpg" - }, - { - "id": 33, - "name": "Primordian Aatrox", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aatrox_33.jpg" - } - ] - }, - { - "championName": "Ahri", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_0.jpg" - }, - { - "id": 1, - "name": "Dynasty Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_1.jpg" - }, - { - "id": 2, - "name": "Midnight Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_2.jpg" - }, - { - "id": 3, - "name": "Foxfire Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_3.jpg" - }, - { - "id": 4, - "name": "Popstar Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_4.jpg" - }, - { - "id": 5, - "name": "Challenger Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_5.jpg" - }, - { - "id": 6, - "name": "Academy Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_6.jpg" - }, - { - "id": 7, - "name": "Arcade Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_7.jpg" - }, - { - "id": 14, - "name": "Star Guardian Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_14.jpg" - }, - { - "id": 15, - "name": "K/DA Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_15.jpg" - }, - { - "id": 16, - "name": "Prestige K/DA Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_16.jpg" - }, - { - "id": 17, - "name": "Elderwood Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_17.jpg" - }, - { - "id": 27, - "name": "Spirit Blossom Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_27.jpg" - }, - { - "id": 28, - "name": "K/DA ALL OUT Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_28.jpg" - }, - { - "id": 42, - "name": "Coven Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_42.jpg" - }, - { - "id": 66, - "name": "Arcana Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_66.jpg" - }, - { - "id": 76, - "name": "Snow Moon Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_76.jpg" - }, - { - "id": 85, - "name": "Risen Legend Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_85.jpg" - }, - { - "id": 86, - "name": "Immortalized Legend Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_86.jpg" - }, - { - "id": 88, - "name": "Spirit Blossom Springs Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_88.jpg" - }, - { - "id": 89, - "name": "After Hours Spirit Blossom Springs Ahri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_89.jpg" - } - ] - }, - { - "championName": "Akali", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_0.jpg" - }, - { - "id": 1, - "name": "Stinger Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_1.jpg" - }, - { - "id": 2, - "name": "Infernal Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_2.jpg" - }, - { - "id": 3, - "name": "All-star Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_3.jpg" - }, - { - "id": 4, - "name": "Nurse Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_4.jpg" - }, - { - "id": 5, - "name": "Blood Moon Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_5.jpg" - }, - { - "id": 6, - "name": "Silverfang Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_6.jpg" - }, - { - "id": 7, - "name": "Headhunter Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_7.jpg" - }, - { - "id": 8, - "name": "Sashimi Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_8.jpg" - }, - { - "id": 9, - "name": "K/DA Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_9.jpg" - }, - { - "id": 13, - "name": "Prestige K/DA Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_13.jpg" - }, - { - "id": 14, - "name": "PROJECT: Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_14.jpg" - }, - { - "id": 15, - "name": "True Damage Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_15.jpg" - }, - { - "id": 32, - "name": "K/DA ALL OUT Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_32.jpg" - }, - { - "id": 50, - "name": "Crime City Nightmare Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_50.jpg" - }, - { - "id": 61, - "name": "Star Guardian Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_61.jpg" - }, - { - "id": 68, - "name": "DRX Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_68.jpg" - }, - { - "id": 70, - "name": "Coven Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_70.jpg" - }, - { - "id": 71, - "name": "Prestige Coven Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_71.jpg" - }, - { - "id": 82, - "name": "Empyrean Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_82.jpg" - }, - { - "id": 92, - "name": "Spirit Blossom Akali", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akali_92.jpg" - } - ] - }, - { - "championName": "Akshan", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akshan_0.jpg" - }, - { - "id": 1, - "name": "Cyber Pop Akshan", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akshan_1.jpg" - }, - { - "id": 10, - "name": "Crystal Rose Akshan", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akshan_10.jpg" - }, - { - "id": 20, - "name": "Three Honors Akshan", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Akshan_20.jpg" - } - ] - }, - { - "championName": "Alistar", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Alistar_0.jpg" - }, - { - "id": 1, - "name": "Black Alistar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Alistar_1.jpg" - }, - { - "id": 2, - "name": "Golden Alistar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Alistar_2.jpg" - }, - { - "id": 3, - "name": "Matador Alistar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Alistar_3.jpg" - }, - { - "id": 4, - "name": "Longhorn Alistar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Alistar_4.jpg" - }, - { - "id": 5, - "name": "Unchained Alistar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Alistar_5.jpg" - }, - { - "id": 6, - "name": "Infernal Alistar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Alistar_6.jpg" - }, - { - "id": 7, - "name": "Sweeper Alistar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Alistar_7.jpg" - }, - { - "id": 8, - "name": "Marauder Alistar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Alistar_8.jpg" - }, - { - "id": 9, - "name": "SKT T1 Alistar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Alistar_9.jpg" - }, - { - "id": 10, - "name": "Moo Cow Alistar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Alistar_10.jpg" - }, - { - "id": 19, - "name": "Hextech Alistar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Alistar_19.jpg" - }, - { - "id": 20, - "name": "Conqueror Alistar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Alistar_20.jpg" - }, - { - "id": 22, - "name": "Blackfrost Alistar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Alistar_22.jpg" - }, - { - "id": 29, - "name": "Lunar Beast Alistar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Alistar_29.jpg" - }, - { - "id": 40, - "name": "Elderwood Alistar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Alistar_40.jpg" - } - ] - }, - { - "championName": "Ambessa", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ambessa_0.jpg" - }, - { - "id": 1, - "name": "Chosen of the Wolf Ambessa", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ambessa_1.jpg" - } - ] - }, - { - "championName": "Amumu", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Amumu_0.jpg" - }, - { - "id": 1, - "name": "Pharaoh Amumu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Amumu_1.jpg" - }, - { - "id": 2, - "name": "Vancouver Amumu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Amumu_2.jpg" - }, - { - "id": 3, - "name": "Emumu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Amumu_3.jpg" - }, - { - "id": 4, - "name": "Re-Gifted Amumu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Amumu_4.jpg" - }, - { - "id": 5, - "name": "Almost-Prom King Amumu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Amumu_5.jpg" - }, - { - "id": 6, - "name": "Little Knight Amumu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Amumu_6.jpg" - }, - { - "id": 7, - "name": "Sad Robot Amumu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Amumu_7.jpg" - }, - { - "id": 8, - "name": "Surprise Party Amumu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Amumu_8.jpg" - }, - { - "id": 17, - "name": "Infernal Amumu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Amumu_17.jpg" - }, - { - "id": 23, - "name": "Hextech Amumu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Amumu_23.jpg" - }, - { - "id": 24, - "name": "Pumpkin Prince Amumu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Amumu_24.jpg" - }, - { - "id": 34, - "name": "Porcelain Amumu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Amumu_34.jpg" - }, - { - "id": 44, - "name": "Heartache Amumu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Amumu_44.jpg" - }, - { - "id": 53, - "name": "Dumpling Darlings Amumu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Amumu_53.jpg" - } - ] - }, - { - "championName": "Anivia", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Anivia_0.jpg" - }, - { - "id": 1, - "name": "Team Spirit Anivia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Anivia_1.jpg" - }, - { - "id": 2, - "name": "Bird of Prey Anivia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Anivia_2.jpg" - }, - { - "id": 3, - "name": "Noxus Hunter Anivia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Anivia_3.jpg" - }, - { - "id": 4, - "name": "Hextech Anivia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Anivia_4.jpg" - }, - { - "id": 5, - "name": "Blackfrost Anivia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Anivia_5.jpg" - }, - { - "id": 6, - "name": "Prehistoric Anivia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Anivia_6.jpg" - }, - { - "id": 7, - "name": "Festival Queen Anivia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Anivia_7.jpg" - }, - { - "id": 8, - "name": "Papercraft Anivia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Anivia_8.jpg" - }, - { - "id": 17, - "name": "Cosmic Flight Anivia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Anivia_17.jpg" - }, - { - "id": 27, - "name": "Divine Phoenix Anivia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Anivia_27.jpg" - }, - { - "id": 37, - "name": "Bewitching Batnivia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Anivia_37.jpg" - }, - { - "id": 46, - "name": "Victorious Anivia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Anivia_46.jpg" - } - ] - }, - { - "championName": "Annie", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_0.jpg" - }, - { - "id": 1, - "name": "Goth Annie", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_1.jpg" - }, - { - "id": 2, - "name": "Red Riding Annie", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_2.jpg" - }, - { - "id": 3, - "name": "Annie in Wonderland", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_3.jpg" - }, - { - "id": 4, - "name": "Prom Queen Annie", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_4.jpg" - }, - { - "id": 5, - "name": "Frostfire Annie", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_5.jpg" - }, - { - "id": 6, - "name": "Reverse Annie", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_6.jpg" - }, - { - "id": 7, - "name": "FrankenTibbers Annie", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_7.jpg" - }, - { - "id": 8, - "name": "Panda Annie", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_8.jpg" - }, - { - "id": 9, - "name": "Sweetheart Annie", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_9.jpg" - }, - { - "id": 10, - "name": "Hextech Annie", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_10.jpg" - }, - { - "id": 11, - "name": "Super Galaxy Annie", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_11.jpg" - }, - { - "id": 12, - "name": "Annie-Versary", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_12.jpg" - }, - { - "id": 13, - "name": "Lunar Beast Annie", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_13.jpg" - }, - { - "id": 22, - "name": "Cafe Cuties Annie", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_22.jpg" - }, - { - "id": 31, - "name": "Fright Night Annie", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_31.jpg" - }, - { - "id": 40, - "name": "Winterblessed Annie", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_40.jpg" - }, - { - "id": 50, - "name": "Battle Princess Annie", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Annie_50.jpg" - } - ] - }, - { - "championName": "Aphelios", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aphelios_0.jpg" - }, - { - "id": 1, - "name": "Nightbringer Aphelios", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aphelios_1.jpg" - }, - { - "id": 9, - "name": "Lunar Beast Aphelios", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aphelios_9.jpg" - }, - { - "id": 18, - "name": "EDG Aphelios", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aphelios_18.jpg" - }, - { - "id": 20, - "name": "Spirit Blossom Aphelios", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aphelios_20.jpg" - }, - { - "id": 30, - "name": "HEARTSTEEL Aphelios", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aphelios_30.jpg" - }, - { - "id": 40, - "name": "Prestige Spirit Blossom Springs Aphelios", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aphelios_40.jpg" - } - ] - }, - { - "championName": "Ashe", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_0.jpg" - }, - { - "id": 1, - "name": "Freljord Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_1.jpg" - }, - { - "id": 2, - "name": "Sherwood Forest Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_2.jpg" - }, - { - "id": 3, - "name": "Woad Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_3.jpg" - }, - { - "id": 4, - "name": "Queen Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_4.jpg" - }, - { - "id": 5, - "name": "Amethyst Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_5.jpg" - }, - { - "id": 6, - "name": "Heartseeker Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_6.jpg" - }, - { - "id": 7, - "name": "Marauder Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_7.jpg" - }, - { - "id": 8, - "name": "PROJECT: Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_8.jpg" - }, - { - "id": 11, - "name": "Cosmic Queen Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_11.jpg" - }, - { - "id": 17, - "name": "High Noon Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_17.jpg" - }, - { - "id": 23, - "name": "Fae Dragon Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_23.jpg" - }, - { - "id": 32, - "name": "Coven Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_32.jpg" - }, - { - "id": 43, - "name": "Ocean Song Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_43.jpg" - }, - { - "id": 52, - "name": "Lunar Empress Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_52.jpg" - }, - { - "id": 63, - "name": "DRX Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_63.jpg" - }, - { - "id": 65, - "name": "Crystalis Motus Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_65.jpg" - }, - { - "id": 67, - "name": "Infernal Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_67.jpg" - }, - { - "id": 76, - "name": "Spirit Blossom Ashe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ashe_76.jpg" - } - ] - }, - { - "championName": "Aurelion Sol", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/AurelionSol_0.jpg" - }, - { - "id": 1, - "name": "Ashen Lord Aurelion Sol", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/AurelionSol_1.jpg" - }, - { - "id": 2, - "name": "Mecha Aurelion Sol", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/AurelionSol_2.jpg" - }, - { - "id": 11, - "name": "Storm Dragon Aurelion Sol", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/AurelionSol_11.jpg" - } - ] - }, - { - "championName": "Aurora", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aurora_0.jpg" - }, - { - "id": 1, - "name": "Battle Bunny Aurora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aurora_1.jpg" - }, - { - "id": 11, - "name": "Arcana Aurora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Aurora_11.jpg" - } - ] - }, - { - "championName": "Azir", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Azir_0.jpg" - }, - { - "id": 1, - "name": "Galactic Azir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Azir_1.jpg" - }, - { - "id": 2, - "name": "Gravelord Azir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Azir_2.jpg" - }, - { - "id": 3, - "name": "SKT T1 Azir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Azir_3.jpg" - }, - { - "id": 4, - "name": "Warring Kingdoms Azir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Azir_4.jpg" - }, - { - "id": 5, - "name": "Elderwood Azir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Azir_5.jpg" - }, - { - "id": 14, - "name": "Worlds 2022 Azir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Azir_14.jpg" - }, - { - "id": 19, - "name": "Attorney Azir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Azir_19.jpg" - } - ] - }, - { - "championName": "Bard", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Bard_0.jpg" - }, - { - "id": 1, - "name": "Elderwood Bard", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Bard_1.jpg" - }, - { - "id": 5, - "name": "Snow Day Bard", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Bard_5.jpg" - }, - { - "id": 6, - "name": "Bard Bard", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Bard_6.jpg" - }, - { - "id": 8, - "name": "Astronaut Bard", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Bard_8.jpg" - }, - { - "id": 17, - "name": "Cafe Cuties Bard", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Bard_17.jpg" - }, - { - "id": 26, - "name": "Shan Hai Scrolls Bard", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Bard_26.jpg" - }, - { - "id": 35, - "name": "T1 Bard", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Bard_35.jpg" - }, - { - "id": 37, - "name": "Spirit Blossom Bard", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Bard_37.jpg" - } - ] - }, - { - "championName": "Bel'Veth", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Belveth_0.jpg" - }, - { - "id": 1, - "name": "Battle Boss Bel'Veth", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Belveth_1.jpg" - } - ] - }, - { - "championName": "Blitzcrank", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Blitzcrank_0.jpg" - }, - { - "id": 1, - "name": "Rusty Blitzcrank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Blitzcrank_1.jpg" - }, - { - "id": 2, - "name": "Goalkeeper Blitzcrank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Blitzcrank_2.jpg" - }, - { - "id": 3, - "name": "Boom Boom Blitzcrank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Blitzcrank_3.jpg" - }, - { - "id": 4, - "name": "Piltover Customs Blitzcrank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Blitzcrank_4.jpg" - }, - { - "id": 5, - "name": "Definitely Not Blitzcrank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Blitzcrank_5.jpg" - }, - { - "id": 6, - "name": "iBlitzcrank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Blitzcrank_6.jpg" - }, - { - "id": 7, - "name": "Riot Blitzcrank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Blitzcrank_7.jpg" - }, - { - "id": 11, - "name": "Battle Boss Blitzcrank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Blitzcrank_11.jpg" - }, - { - "id": 20, - "name": "Lancer Rogue Blitzcrank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Blitzcrank_20.jpg" - }, - { - "id": 21, - "name": "Lancer Paragon Blitzcrank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Blitzcrank_21.jpg" - }, - { - "id": 22, - "name": "Witch's Brew Blitzcrank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Blitzcrank_22.jpg" - }, - { - "id": 29, - "name": "Space Groove Blitz & Crank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Blitzcrank_29.jpg" - }, - { - "id": 36, - "name": "Victorious Blitzcrank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Blitzcrank_36.jpg" - }, - { - "id": 47, - "name": "Zenith Games Blitzcrank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Blitzcrank_47.jpg" - }, - { - "id": 56, - "name": "Beezcrank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Blitzcrank_56.jpg" - } - ] - }, - { - "championName": "Brand", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Brand_0.jpg" - }, - { - "id": 1, - "name": "Apocalyptic Brand", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Brand_1.jpg" - }, - { - "id": 2, - "name": "Vandal Brand", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Brand_2.jpg" - }, - { - "id": 3, - "name": "Cryocore Brand", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Brand_3.jpg" - }, - { - "id": 4, - "name": "Zombie Brand", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Brand_4.jpg" - }, - { - "id": 5, - "name": "Spirit Fire Brand", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Brand_5.jpg" - }, - { - "id": 6, - "name": "Battle Boss Brand", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Brand_6.jpg" - }, - { - "id": 7, - "name": "Arclight Brand", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Brand_7.jpg" - }, - { - "id": 8, - "name": "Eternal Dragon Brand", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Brand_8.jpg" - }, - { - "id": 21, - "name": "Debonair Brand", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Brand_21.jpg" - }, - { - "id": 22, - "name": "Prestige Debonair Brand", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Brand_22.jpg" - }, - { - "id": 33, - "name": "Street Demons Brand", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Brand_33.jpg" - }, - { - "id": 42, - "name": "Empyrean Brand", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Brand_42.jpg" - } - ] - }, - { - "championName": "Braum", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Braum_0.jpg" - }, - { - "id": 1, - "name": "Dragonslayer Braum", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Braum_1.jpg" - }, - { - "id": 2, - "name": "El Tigre Braum", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Braum_2.jpg" - }, - { - "id": 3, - "name": "Braum Lionheart", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Braum_3.jpg" - }, - { - "id": 10, - "name": "Santa Braum", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Braum_10.jpg" - }, - { - "id": 11, - "name": "Crime City Braum", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Braum_11.jpg" - }, - { - "id": 24, - "name": "Sugar Rush Braum", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Braum_24.jpg" - }, - { - "id": 33, - "name": "Pool Party Braum", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Braum_33.jpg" - }, - { - "id": 42, - "name": "Grill Master Braum", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Braum_42.jpg" - } - ] - }, - { - "championName": "Briar", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Briar_0.jpg" - }, - { - "id": 1, - "name": "Street Demons Briar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Briar_1.jpg" - }, - { - "id": 10, - "name": "Primordian Briar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Briar_10.jpg" - } - ] - }, - { - "championName": "Caitlyn", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_0.jpg" - }, - { - "id": 1, - "name": "Resistance Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_1.jpg" - }, - { - "id": 2, - "name": "Sheriff Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_2.jpg" - }, - { - "id": 3, - "name": "Safari Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_3.jpg" - }, - { - "id": 4, - "name": "Arctic Warfare Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_4.jpg" - }, - { - "id": 5, - "name": "Officer Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_5.jpg" - }, - { - "id": 6, - "name": "Headhunter Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_6.jpg" - }, - { - "id": 10, - "name": "Lunar Wraith Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_10.jpg" - }, - { - "id": 11, - "name": "Pulsefire Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_11.jpg" - }, - { - "id": 13, - "name": "Pool Party Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_13.jpg" - }, - { - "id": 19, - "name": "Arcade Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_19.jpg" - }, - { - "id": 20, - "name": "Prestige Arcade Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_20.jpg" - }, - { - "id": 22, - "name": "Battle Academia Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_22.jpg" - }, - { - "id": 30, - "name": "Snow Moon Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_30.jpg" - }, - { - "id": 39, - "name": "Heartthrob Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_39.jpg" - }, - { - "id": 48, - "name": "DRX Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_48.jpg" - }, - { - "id": 50, - "name": "Arcane Commander Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_50.jpg" - }, - { - "id": 51, - "name": "Prestige Arcane Commander Caitlyn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Caitlyn_51.jpg" - } - ] - }, - { - "championName": "Camille", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Camille_0.jpg" - }, - { - "id": 1, - "name": "Program Camille", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Camille_1.jpg" - }, - { - "id": 2, - "name": "Coven Camille", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Camille_2.jpg" - }, - { - "id": 10, - "name": "iG Camille", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Camille_10.jpg" - }, - { - "id": 11, - "name": "Arcana Camille", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Camille_11.jpg" - }, - { - "id": 21, - "name": "Strike Commander Camille", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Camille_21.jpg" - }, - { - "id": 31, - "name": "Winterblessed Camille", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Camille_31.jpg" - }, - { - "id": 32, - "name": "Prestige Winterblessed Camille", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Camille_32.jpg" - } - ] - }, - { - "championName": "Cassiopeia", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Cassiopeia_0.jpg" - }, - { - "id": 1, - "name": "Desperada Cassiopeia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Cassiopeia_1.jpg" - }, - { - "id": 2, - "name": "Siren Cassiopeia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Cassiopeia_2.jpg" - }, - { - "id": 3, - "name": "Mythic Cassiopeia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Cassiopeia_3.jpg" - }, - { - "id": 4, - "name": "Jade Fang Cassiopeia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Cassiopeia_4.jpg" - }, - { - "id": 8, - "name": "Eternum Cassiopeia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Cassiopeia_8.jpg" - }, - { - "id": 9, - "name": "Spirit Blossom Cassiopeia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Cassiopeia_9.jpg" - }, - { - "id": 18, - "name": "Coven Cassiopeia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Cassiopeia_18.jpg" - }, - { - "id": 28, - "name": "Bewitching Cassiopeia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Cassiopeia_28.jpg" - }, - { - "id": 38, - "name": "Prestige Mythmaker Cassiopeia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Cassiopeia_38.jpg" - } - ] - }, - { - "championName": "Cho'Gath", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Chogath_0.jpg" - }, - { - "id": 1, - "name": "Nightmare Cho'Gath", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Chogath_1.jpg" - }, - { - "id": 2, - "name": "Gentleman Cho'Gath", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Chogath_2.jpg" - }, - { - "id": 3, - "name": "Loch Ness Cho'Gath", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Chogath_3.jpg" - }, - { - "id": 4, - "name": "Jurassic Cho'Gath", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Chogath_4.jpg" - }, - { - "id": 5, - "name": "Battlecast Prime Cho'Gath", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Chogath_5.jpg" - }, - { - "id": 6, - "name": "Prehistoric Cho'Gath", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Chogath_6.jpg" - }, - { - "id": 7, - "name": "Dark Star Cho'Gath", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Chogath_7.jpg" - }, - { - "id": 14, - "name": "Shan Hai Scrolls Cho'Gath", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Chogath_14.jpg" - } - ] - }, - { - "championName": "Corki", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Corki_0.jpg" - }, - { - "id": 1, - "name": "UFO Corki", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Corki_1.jpg" - }, - { - "id": 2, - "name": "Ice Toboggan Corki", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Corki_2.jpg" - }, - { - "id": 3, - "name": "Red Baron Corki", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Corki_3.jpg" - }, - { - "id": 4, - "name": "Hot Rod Corki", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Corki_4.jpg" - }, - { - "id": 5, - "name": "Urfrider Corki", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Corki_5.jpg" - }, - { - "id": 6, - "name": "Dragonwing Corki", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Corki_6.jpg" - }, - { - "id": 7, - "name": "Fnatic Corki", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Corki_7.jpg" - }, - { - "id": 8, - "name": "Arcade Corki", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Corki_8.jpg" - }, - { - "id": 18, - "name": "Corgi Corki", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Corki_18.jpg" - }, - { - "id": 26, - "name": "Astronaut Corki", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Corki_26.jpg" - }, - { - "id": 36, - "name": "Brick Toy Corki", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Corki_36.jpg" - } - ] - }, - { - "championName": "Darius", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Darius_0.jpg" - }, - { - "id": 1, - "name": "Lord Darius", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Darius_1.jpg" - }, - { - "id": 2, - "name": "Bioforge Darius", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Darius_2.jpg" - }, - { - "id": 3, - "name": "Woad King Darius", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Darius_3.jpg" - }, - { - "id": 4, - "name": "Dunkmaster Darius", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Darius_4.jpg" - }, - { - "id": 8, - "name": "Academy Darius", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Darius_8.jpg" - }, - { - "id": 14, - "name": "Dreadnova Darius", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Darius_14.jpg" - }, - { - "id": 15, - "name": "God-King Darius", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Darius_15.jpg" - }, - { - "id": 16, - "name": "High Noon Darius", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Darius_16.jpg" - }, - { - "id": 24, - "name": "Lunar Beast Darius", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Darius_24.jpg" - }, - { - "id": 33, - "name": "Crime City Nightmare Darius", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Darius_33.jpg" - }, - { - "id": 43, - "name": "Spirit Blossom Darius", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Darius_43.jpg" - }, - { - "id": 54, - "name": "Porcelain Darius", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Darius_54.jpg" - }, - { - "id": 64, - "name": "Divine God-King Darius", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Darius_64.jpg" - }, - { - "id": 65, - "name": "Prestige Triumphant General Darius", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Darius_65.jpg" - } - ] - }, - { - "championName": "Diana", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Diana_0.jpg" - }, - { - "id": 1, - "name": "Dark Valkyrie Diana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Diana_1.jpg" - }, - { - "id": 2, - "name": "Lunar Goddess Diana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Diana_2.jpg" - }, - { - "id": 3, - "name": "Infernal Diana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Diana_3.jpg" - }, - { - "id": 11, - "name": "Blood Moon Diana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Diana_11.jpg" - }, - { - "id": 12, - "name": "Dark Waters Diana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Diana_12.jpg" - }, - { - "id": 18, - "name": "Dragonslayer Diana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Diana_18.jpg" - }, - { - "id": 25, - "name": "Battle Queen Diana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Diana_25.jpg" - }, - { - "id": 26, - "name": "Prestige Battle Queen Diana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Diana_26.jpg" - }, - { - "id": 27, - "name": "Sentinel Diana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Diana_27.jpg" - }, - { - "id": 37, - "name": "Firecracker Diana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Diana_37.jpg" - }, - { - "id": 47, - "name": "Winterblessed Diana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Diana_47.jpg" - }, - { - "id": 54, - "name": "Heavenscale Diana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Diana_54.jpg" - }, - { - "id": 64, - "name": "Dark Cosmic Diana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Diana_64.jpg" - }, - { - "id": 65, - "name": "Prestige Dark Cosmic Diana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Diana_65.jpg" - } - ] - }, - { - "championName": "Dr. Mundo", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/DrMundo_0.jpg" - }, - { - "id": 1, - "name": "Toxic Dr. Mundo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/DrMundo_1.jpg" - }, - { - "id": 2, - "name": "Mr. Mundoverse", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/DrMundo_2.jpg" - }, - { - "id": 3, - "name": "Corporate Mundo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/DrMundo_3.jpg" - }, - { - "id": 4, - "name": "Mundo Mundo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/DrMundo_4.jpg" - }, - { - "id": 5, - "name": "Executioner Mundo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/DrMundo_5.jpg" - }, - { - "id": 6, - "name": "Rageborn Mundo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/DrMundo_6.jpg" - }, - { - "id": 7, - "name": "TPA Mundo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/DrMundo_7.jpg" - }, - { - "id": 8, - "name": "Pool Party Mundo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/DrMundo_8.jpg" - }, - { - "id": 9, - "name": "El Macho Mundo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/DrMundo_9.jpg" - }, - { - "id": 10, - "name": "Frozen Prince Mundo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/DrMundo_10.jpg" - } - ] - }, - { - "championName": "Draven", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Draven_0.jpg" - }, - { - "id": 1, - "name": "Soul Reaver Draven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Draven_1.jpg" - }, - { - "id": 2, - "name": "Gladiator Draven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Draven_2.jpg" - }, - { - "id": 3, - "name": "Primetime Draven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Draven_3.jpg" - }, - { - "id": 4, - "name": "Pool Party Draven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Draven_4.jpg" - }, - { - "id": 5, - "name": "Beast Hunter Draven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Draven_5.jpg" - }, - { - "id": 6, - "name": "Draven Draven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Draven_6.jpg" - }, - { - "id": 12, - "name": "Santa Draven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Draven_12.jpg" - }, - { - "id": 13, - "name": "Mecha Kingdoms Draven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Draven_13.jpg" - }, - { - "id": 20, - "name": "Ruined Draven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Draven_20.jpg" - }, - { - "id": 29, - "name": "Debonair Draven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Draven_29.jpg" - }, - { - "id": 39, - "name": "Fright Night Draven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Draven_39.jpg" - }, - { - "id": 48, - "name": "La Ilusión Draven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Draven_48.jpg" - }, - { - "id": 58, - "name": "Grand Reckoning Draven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Draven_58.jpg" - } - ] - }, - { - "championName": "Ekko", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ekko_0.jpg" - }, - { - "id": 1, - "name": "Sandstorm Ekko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ekko_1.jpg" - }, - { - "id": 2, - "name": "Academy Ekko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ekko_2.jpg" - }, - { - "id": 3, - "name": "PROJECT: Ekko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ekko_3.jpg" - }, - { - "id": 11, - "name": "SKT T1 Ekko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ekko_11.jpg" - }, - { - "id": 12, - "name": "Trick or Treat Ekko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ekko_12.jpg" - }, - { - "id": 19, - "name": "True Damage Ekko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ekko_19.jpg" - }, - { - "id": 28, - "name": "Pulsefire Ekko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ekko_28.jpg" - }, - { - "id": 45, - "name": "Star Guardian Ekko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ekko_45.jpg" - }, - { - "id": 46, - "name": "Prestige Star Guardian Ekko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ekko_46.jpg" - }, - { - "id": 56, - "name": "Breakout True Damage Ekko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ekko_56.jpg" - }, - { - "id": 57, - "name": "Arcane Last Stand Ekko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ekko_57.jpg" - } - ] - }, - { - "championName": "Elise", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Elise_0.jpg" - }, - { - "id": 1, - "name": "Death Blossom Elise", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Elise_1.jpg" - }, - { - "id": 2, - "name": "Victorious Elise", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Elise_2.jpg" - }, - { - "id": 3, - "name": "Blood Moon Elise", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Elise_3.jpg" - }, - { - "id": 4, - "name": "SKT T1 Elise", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Elise_4.jpg" - }, - { - "id": 5, - "name": "Super Galaxy Elise", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Elise_5.jpg" - }, - { - "id": 6, - "name": "Bewitching Elise", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Elise_6.jpg" - }, - { - "id": 15, - "name": "Withered Rose Elise", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Elise_15.jpg" - }, - { - "id": 24, - "name": "Coven Elise", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Elise_24.jpg" - }, - { - "id": 34, - "name": "Masque of the Black Rose Elise", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Elise_34.jpg" - } - ] - }, - { - "championName": "Evelynn", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Evelynn_0.jpg" - }, - { - "id": 1, - "name": "Shadow Evelynn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Evelynn_1.jpg" - }, - { - "id": 2, - "name": "Masquerade Evelynn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Evelynn_2.jpg" - }, - { - "id": 3, - "name": "Tango Evelynn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Evelynn_3.jpg" - }, - { - "id": 4, - "name": "Safecracker Evelynn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Evelynn_4.jpg" - }, - { - "id": 5, - "name": "Blood Moon Evelynn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Evelynn_5.jpg" - }, - { - "id": 6, - "name": "K/DA Evelynn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Evelynn_6.jpg" - }, - { - "id": 7, - "name": "Prestige K/DA Evelynn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Evelynn_7.jpg" - }, - { - "id": 8, - "name": "Sugar Rush Evelynn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Evelynn_8.jpg" - }, - { - "id": 15, - "name": "K/DA ALL OUT Evelynn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Evelynn_15.jpg" - }, - { - "id": 24, - "name": "Coven Evelynn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Evelynn_24.jpg" - }, - { - "id": 32, - "name": "Spirit Blossom Evelynn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Evelynn_32.jpg" - }, - { - "id": 42, - "name": "Soul Fighter Evelynn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Evelynn_42.jpg" - }, - { - "id": 52, - "name": "High Noon Evelynn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Evelynn_52.jpg" - }, - { - "id": 53, - "name": "Prestige High Noon Evelynn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Evelynn_53.jpg" - }, - { - "id": 64, - "name": "Nightbringer Evelynn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Evelynn_64.jpg" - } - ] - }, - { - "championName": "Ezreal", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_0.jpg" - }, - { - "id": 1, - "name": "Nottingham Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_1.jpg" - }, - { - "id": 2, - "name": "Striker Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_2.jpg" - }, - { - "id": 3, - "name": "Frosted Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_3.jpg" - }, - { - "id": 4, - "name": "Explorer Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_4.jpg" - }, - { - "id": 5, - "name": "Pulsefire Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_5.jpg" - }, - { - "id": 6, - "name": "TPA Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_6.jpg" - }, - { - "id": 7, - "name": "Debonair Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_7.jpg" - }, - { - "id": 8, - "name": "Ace of Spades Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_8.jpg" - }, - { - "id": 9, - "name": "Arcade Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_9.jpg" - }, - { - "id": 18, - "name": "Star Guardian Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_18.jpg" - }, - { - "id": 19, - "name": "SSG Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_19.jpg" - }, - { - "id": 20, - "name": "Pajama Guardian Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_20.jpg" - }, - { - "id": 21, - "name": "Battle Academia Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_21.jpg" - }, - { - "id": 22, - "name": "PsyOps Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_22.jpg" - }, - { - "id": 23, - "name": "Prestige PsyOps Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_23.jpg" - }, - { - "id": 25, - "name": "Porcelain Protector Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_25.jpg" - }, - { - "id": 33, - "name": "Faerie Court Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_33.jpg" - }, - { - "id": 43, - "name": "HEARTSTEEL Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_43.jpg" - }, - { - "id": 44, - "name": "Heavenscale Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_44.jpg" - }, - { - "id": 54, - "name": "Prestige Heavenscale Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_54.jpg" - }, - { - "id": 65, - "name": "Masque of the Black Rose Ezreal", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ezreal_65.jpg" - } - ] - }, - { - "championName": "Fiddlesticks", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiddlesticks_0.jpg" - }, - { - "id": 1, - "name": "Spectral Fiddlesticks", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiddlesticks_1.jpg" - }, - { - "id": 2, - "name": "Union Jack Fiddlesticks", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiddlesticks_2.jpg" - }, - { - "id": 3, - "name": "Bandito Fiddlesticks", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiddlesticks_3.jpg" - }, - { - "id": 4, - "name": "Pumpkinhead Fiddlesticks", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiddlesticks_4.jpg" - }, - { - "id": 5, - "name": "Fiddle Me Timbers", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiddlesticks_5.jpg" - }, - { - "id": 6, - "name": "Surprise Party Fiddlesticks", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiddlesticks_6.jpg" - }, - { - "id": 7, - "name": "Dark Candy Fiddlesticks", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiddlesticks_7.jpg" - }, - { - "id": 8, - "name": "Risen Fiddlesticks", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiddlesticks_8.jpg" - }, - { - "id": 9, - "name": "Praetorian Fiddlesticks", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiddlesticks_9.jpg" - }, - { - "id": 27, - "name": "Star Nemesis Fiddlesticks", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiddlesticks_27.jpg" - }, - { - "id": 37, - "name": "Blood Moon Fiddlesticks", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiddlesticks_37.jpg" - } - ] - }, - { - "championName": "Fiora", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_0.jpg" - }, - { - "id": 1, - "name": "Royal Guard Fiora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_1.jpg" - }, - { - "id": 2, - "name": "Nightraven Fiora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_2.jpg" - }, - { - "id": 3, - "name": "Headmistress Fiora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_3.jpg" - }, - { - "id": 4, - "name": "PROJECT: Fiora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_4.jpg" - }, - { - "id": 5, - "name": "Pool Party Fiora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_5.jpg" - }, - { - "id": 22, - "name": "Soaring Sword Fiora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_22.jpg" - }, - { - "id": 23, - "name": "Heartpiercer Fiora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_23.jpg" - }, - { - "id": 31, - "name": "iG Fiora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_31.jpg" - }, - { - "id": 41, - "name": "Pulsefire Fiora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_41.jpg" - }, - { - "id": 50, - "name": "Lunar Beast Fiora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_50.jpg" - }, - { - "id": 51, - "name": "Prestige Lunar Beast Fiora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_51.jpg" - }, - { - "id": 60, - "name": "Bewitching Fiora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_60.jpg" - }, - { - "id": 69, - "name": "Faerie Court Fiora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_69.jpg" - }, - { - "id": 80, - "name": "Dragonmancer Fiora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_80.jpg" - }, - { - "id": 89, - "name": "Battle Queen Fiora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_89.jpg" - }, - { - "id": 98, - "name": "Victorious Fiora", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fiora_98.jpg" - } - ] - }, - { - "championName": "Fizz", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fizz_0.jpg" - }, - { - "id": 1, - "name": "Atlantean Fizz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fizz_1.jpg" - }, - { - "id": 2, - "name": "Tundra Fizz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fizz_2.jpg" - }, - { - "id": 3, - "name": "Fisherman Fizz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fizz_3.jpg" - }, - { - "id": 4, - "name": "Void Fizz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fizz_4.jpg" - }, - { - "id": 8, - "name": "Cottontail Fizz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fizz_8.jpg" - }, - { - "id": 9, - "name": "Super Galaxy Fizz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fizz_9.jpg" - }, - { - "id": 10, - "name": "Omega Squad Fizz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fizz_10.jpg" - }, - { - "id": 14, - "name": "Fuzz Fizz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fizz_14.jpg" - }, - { - "id": 15, - "name": "Prestige Fuzz Fizz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fizz_15.jpg" - }, - { - "id": 16, - "name": "Little Devil Fizz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fizz_16.jpg" - }, - { - "id": 26, - "name": "Astronaut Fizz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fizz_26.jpg" - }, - { - "id": 35, - "name": "Rain Shepherd Fizz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Fizz_35.jpg" - } - ] - }, - { - "championName": "Galio", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Galio_0.jpg" - }, - { - "id": 1, - "name": "Enchanted Galio", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Galio_1.jpg" - }, - { - "id": 2, - "name": "Hextech Galio", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Galio_2.jpg" - }, - { - "id": 3, - "name": "Commando Galio", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Galio_3.jpg" - }, - { - "id": 4, - "name": "Gatekeeper Galio", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Galio_4.jpg" - }, - { - "id": 5, - "name": "Debonair Galio", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Galio_5.jpg" - }, - { - "id": 6, - "name": "Birdio", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Galio_6.jpg" - }, - { - "id": 13, - "name": "Infernal Galio", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Galio_13.jpg" - }, - { - "id": 19, - "name": "Dragon Guardian Galio", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Galio_19.jpg" - }, - { - "id": 28, - "name": "Mythmaker Galio", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Galio_28.jpg" - } - ] - }, - { - "championName": "Gangplank", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gangplank_0.jpg" - }, - { - "id": 1, - "name": "Spooky Gangplank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gangplank_1.jpg" - }, - { - "id": 2, - "name": "Minuteman Gangplank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gangplank_2.jpg" - }, - { - "id": 3, - "name": "Sailor Gangplank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gangplank_3.jpg" - }, - { - "id": 4, - "name": "Toy Soldier Gangplank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gangplank_4.jpg" - }, - { - "id": 5, - "name": "Special Forces Gangplank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gangplank_5.jpg" - }, - { - "id": 6, - "name": "Sultan Gangplank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gangplank_6.jpg" - }, - { - "id": 7, - "name": "Captain Gangplank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gangplank_7.jpg" - }, - { - "id": 8, - "name": "Dreadnova Gangplank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gangplank_8.jpg" - }, - { - "id": 14, - "name": "Pool Party Gangplank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gangplank_14.jpg" - }, - { - "id": 21, - "name": "FPX Gangplank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gangplank_21.jpg" - }, - { - "id": 23, - "name": "Gangplank the Betrayer", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gangplank_23.jpg" - }, - { - "id": 33, - "name": "PROJECT: Gangplank", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gangplank_33.jpg" - } - ] - }, - { - "championName": "Garen", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_0.jpg" - }, - { - "id": 1, - "name": "Sanguine Garen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_1.jpg" - }, - { - "id": 2, - "name": "Desert Trooper Garen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_2.jpg" - }, - { - "id": 3, - "name": "Commando Garen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_3.jpg" - }, - { - "id": 4, - "name": "Dreadknight Garen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_4.jpg" - }, - { - "id": 5, - "name": "Rugged Garen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_5.jpg" - }, - { - "id": 6, - "name": "Steel Legion Garen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_6.jpg" - }, - { - "id": 10, - "name": "Rogue Admiral Garen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_10.jpg" - }, - { - "id": 11, - "name": "Warring Kingdoms Garen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_11.jpg" - }, - { - "id": 13, - "name": "God-King Garen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_13.jpg" - }, - { - "id": 14, - "name": "Demacia Vice Garen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_14.jpg" - }, - { - "id": 22, - "name": "Mecha Kingdoms Garen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_22.jpg" - }, - { - "id": 23, - "name": "Prestige Mecha Kingdoms Garen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_23.jpg" - }, - { - "id": 24, - "name": "Battle Academia Garen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_24.jpg" - }, - { - "id": 33, - "name": "Mythmaker Garen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_33.jpg" - }, - { - "id": 44, - "name": "Fallen God-King Garen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_44.jpg" - }, - { - "id": 46, - "name": "Visions of the Fallen Garen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Garen_46.jpg" - } - ] - }, - { - "championName": "Gnar", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gnar_0.jpg" - }, - { - "id": 1, - "name": "Dino Gnar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gnar_1.jpg" - }, - { - "id": 2, - "name": "Gentleman Gnar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gnar_2.jpg" - }, - { - "id": 3, - "name": "Snow Day Gnar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gnar_3.jpg" - }, - { - "id": 4, - "name": "El León Gnar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gnar_4.jpg" - }, - { - "id": 13, - "name": "Super Galaxy Gnar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gnar_13.jpg" - }, - { - "id": 14, - "name": "SSG Gnar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gnar_14.jpg" - }, - { - "id": 15, - "name": "Astronaut Gnar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gnar_15.jpg" - }, - { - "id": 22, - "name": "Elderwood Gnar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gnar_22.jpg" - }, - { - "id": 31, - "name": "La Ilusión Gnar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gnar_31.jpg" - }, - { - "id": 41, - "name": "T1 Gnar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gnar_41.jpg" - } - ] - }, - { - "championName": "Gragas", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gragas_0.jpg" - }, - { - "id": 1, - "name": "Scuba Gragas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gragas_1.jpg" - }, - { - "id": 2, - "name": "Hillbilly Gragas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gragas_2.jpg" - }, - { - "id": 3, - "name": "Santa Gragas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gragas_3.jpg" - }, - { - "id": 4, - "name": "Gragas, Esq.", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gragas_4.jpg" - }, - { - "id": 5, - "name": "Vandal Gragas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gragas_5.jpg" - }, - { - "id": 6, - "name": "Oktoberfest Gragas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gragas_6.jpg" - }, - { - "id": 7, - "name": "Superfan Gragas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gragas_7.jpg" - }, - { - "id": 8, - "name": "Fnatic Gragas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gragas_8.jpg" - }, - { - "id": 9, - "name": "Gragas Caskbreaker", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gragas_9.jpg" - }, - { - "id": 10, - "name": "Arctic Ops Gragas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gragas_10.jpg" - }, - { - "id": 11, - "name": "Warden Gragas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gragas_11.jpg" - }, - { - "id": 20, - "name": "Space Groove Gragas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gragas_20.jpg" - }, - { - "id": 29, - "name": "High Noon Gragas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gragas_29.jpg" - }, - { - "id": 39, - "name": "Music Fan Gragas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gragas_39.jpg" - } - ] - }, - { - "championName": "Graves", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Graves_0.jpg" - }, - { - "id": 1, - "name": "Hired Gun Graves", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Graves_1.jpg" - }, - { - "id": 2, - "name": "Jailbreak Graves", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Graves_2.jpg" - }, - { - "id": 3, - "name": "Crime City Graves", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Graves_3.jpg" - }, - { - "id": 4, - "name": "Riot Graves", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Graves_4.jpg" - }, - { - "id": 5, - "name": "Pool Party Graves", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Graves_5.jpg" - }, - { - "id": 6, - "name": "Cutthroat Graves", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Graves_6.jpg" - }, - { - "id": 7, - "name": "Snow Day Graves", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Graves_7.jpg" - }, - { - "id": 14, - "name": "Victorious Graves", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Graves_14.jpg" - }, - { - "id": 18, - "name": "Praetorian Graves", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Graves_18.jpg" - }, - { - "id": 25, - "name": "Battle Professor Graves", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Graves_25.jpg" - }, - { - "id": 35, - "name": "Sentinel Graves", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Graves_35.jpg" - }, - { - "id": 42, - "name": "EDG Graves", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Graves_42.jpg" - }, - { - "id": 45, - "name": "Porcelain Graves", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Graves_45.jpg" - } - ] - }, - { - "championName": "Gwen", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gwen_0.jpg" - }, - { - "id": 1, - "name": "Space Groove Gwen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gwen_1.jpg" - }, - { - "id": 11, - "name": "Cafe Cuties Gwen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gwen_11.jpg" - }, - { - "id": 20, - "name": "Soul Fighter Gwen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gwen_20.jpg" - }, - { - "id": 30, - "name": "Battle Queen Gwen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Gwen_30.jpg" - } - ] - }, - { - "championName": "Hecarim", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Hecarim_0.jpg" - }, - { - "id": 1, - "name": "Blood Knight Hecarim", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Hecarim_1.jpg" - }, - { - "id": 2, - "name": "Reaper Hecarim", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Hecarim_2.jpg" - }, - { - "id": 3, - "name": "Headless Hecarim", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Hecarim_3.jpg" - }, - { - "id": 4, - "name": "Arcade Hecarim", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Hecarim_4.jpg" - }, - { - "id": 5, - "name": "Elderwood Hecarim", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Hecarim_5.jpg" - }, - { - "id": 6, - "name": "Worldbreaker Hecarim", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Hecarim_6.jpg" - }, - { - "id": 7, - "name": "Lancer Zero Hecarim", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Hecarim_7.jpg" - }, - { - "id": 8, - "name": "High Noon Hecarim", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Hecarim_8.jpg" - }, - { - "id": 14, - "name": "Cosmic Charger Hecarim", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Hecarim_14.jpg" - }, - { - "id": 22, - "name": "Arcana Hecarim", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Hecarim_22.jpg" - }, - { - "id": 31, - "name": "Winterblessed Hecarim", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Hecarim_31.jpg" - }, - { - "id": 41, - "name": "Nightbringer Hecarim", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Hecarim_41.jpg" - } - ] - }, - { - "championName": "Heimerdinger", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Heimerdinger_0.jpg" - }, - { - "id": 1, - "name": "Alien Invader Heimerdinger", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Heimerdinger_1.jpg" - }, - { - "id": 2, - "name": "Blast Zone Heimerdinger", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Heimerdinger_2.jpg" - }, - { - "id": 3, - "name": "Piltover Customs Heimerdinger", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Heimerdinger_3.jpg" - }, - { - "id": 4, - "name": "Snowmerdinger", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Heimerdinger_4.jpg" - }, - { - "id": 5, - "name": "Hazmat Heimerdinger", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Heimerdinger_5.jpg" - }, - { - "id": 6, - "name": "Dragon Trainer Heimerdinger", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Heimerdinger_6.jpg" - }, - { - "id": 15, - "name": "Pool Party Heimerdinger", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Heimerdinger_15.jpg" - }, - { - "id": 24, - "name": "Heimerstinger", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Heimerdinger_24.jpg" - }, - { - "id": 33, - "name": "Arcane Professor Heimerdinger", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Heimerdinger_33.jpg" - } - ] - }, - { - "championName": "Hwei", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Hwei_0.jpg" - }, - { - "id": 1, - "name": "Winterblessed Hwei", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Hwei_1.jpg" - }, - { - "id": 11, - "name": "Spirit Blossom Hwei", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Hwei_11.jpg" - } - ] - }, - { - "championName": "Illaoi", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Illaoi_0.jpg" - }, - { - "id": 1, - "name": "Void Bringer Illaoi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Illaoi_1.jpg" - }, - { - "id": 2, - "name": "Resistance Illaoi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Illaoi_2.jpg" - }, - { - "id": 10, - "name": "Cosmic Invoker Illaoi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Illaoi_10.jpg" - }, - { - "id": 18, - "name": "Snow Moon Illaoi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Illaoi_18.jpg" - }, - { - "id": 27, - "name": "Battle Bear Illaoi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Illaoi_27.jpg" - } - ] - }, - { - "championName": "Irelia", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Irelia_0.jpg" - }, - { - "id": 1, - "name": "Nightblade Irelia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Irelia_1.jpg" - }, - { - "id": 2, - "name": "Aviator Irelia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Irelia_2.jpg" - }, - { - "id": 3, - "name": "Infiltrator Irelia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Irelia_3.jpg" - }, - { - "id": 4, - "name": "Frostblade Irelia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Irelia_4.jpg" - }, - { - "id": 5, - "name": "Order of the Lotus Irelia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Irelia_5.jpg" - }, - { - "id": 6, - "name": "Divine Sword Irelia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Irelia_6.jpg" - }, - { - "id": 15, - "name": "iG Irelia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Irelia_15.jpg" - }, - { - "id": 16, - "name": "PROJECT: Irelia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Irelia_16.jpg" - }, - { - "id": 17, - "name": "Prestige PROJECT: Irelia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Irelia_17.jpg" - }, - { - "id": 18, - "name": "High Noon Irelia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Irelia_18.jpg" - }, - { - "id": 26, - "name": "Sentinel Irelia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Irelia_26.jpg" - }, - { - "id": 37, - "name": "Mythmaker Irelia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Irelia_37.jpg" - }, - { - "id": 45, - "name": "Porcelain Irelia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Irelia_45.jpg" - }, - { - "id": 55, - "name": "Spirit Blossom Irelia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Irelia_55.jpg" - } - ] - }, - { - "championName": "Ivern", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ivern_0.jpg" - }, - { - "id": 1, - "name": "Candy King Ivern", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ivern_1.jpg" - }, - { - "id": 2, - "name": "Dunkmaster Ivern", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ivern_2.jpg" - }, - { - "id": 11, - "name": "Old God Ivern", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ivern_11.jpg" - }, - { - "id": 20, - "name": "Astronaut Ivern", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ivern_20.jpg" - }, - { - "id": 30, - "name": "Spirit Blossom Ivern", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ivern_30.jpg" - } - ] - }, - { - "championName": "Janna", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_0.jpg" - }, - { - "id": 1, - "name": "Tempest Janna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_1.jpg" - }, - { - "id": 2, - "name": "Hextech Janna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_2.jpg" - }, - { - "id": 3, - "name": "Frost Queen Janna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_3.jpg" - }, - { - "id": 4, - "name": "Victorious Janna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_4.jpg" - }, - { - "id": 5, - "name": "Forecast Janna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_5.jpg" - }, - { - "id": 6, - "name": "Fnatic Janna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_6.jpg" - }, - { - "id": 7, - "name": "Star Guardian Janna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_7.jpg" - }, - { - "id": 8, - "name": "Sacred Sword Janna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_8.jpg" - }, - { - "id": 13, - "name": "Bewitching Janna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_13.jpg" - }, - { - "id": 20, - "name": "Guardian of the Sands Janna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_20.jpg" - }, - { - "id": 27, - "name": "Battle Queen Janna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_27.jpg" - }, - { - "id": 36, - "name": "Crystal Rose Janna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_36.jpg" - }, - { - "id": 45, - "name": "Cyber Halo Janna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_45.jpg" - }, - { - "id": 46, - "name": "Prestige Cyber Halo Janna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_46.jpg" - }, - { - "id": 56, - "name": "Heavenscale Janna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_56.jpg" - }, - { - "id": 66, - "name": "Dawnbringer Janna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Janna_66.jpg" - } - ] - }, - { - "championName": "Jarvan IV", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/JarvanIV_0.jpg" - }, - { - "id": 1, - "name": "Commando Jarvan IV", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/JarvanIV_1.jpg" - }, - { - "id": 2, - "name": "Dragonslayer Jarvan IV", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/JarvanIV_2.jpg" - }, - { - "id": 3, - "name": "Darkforge Jarvan IV", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/JarvanIV_3.jpg" - }, - { - "id": 4, - "name": "Victorious Jarvan IV", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/JarvanIV_4.jpg" - }, - { - "id": 5, - "name": "Warring Kingdoms Jarvan IV", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/JarvanIV_5.jpg" - }, - { - "id": 6, - "name": "Fnatic Jarvan IV", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/JarvanIV_6.jpg" - }, - { - "id": 7, - "name": "Dark Star Jarvan IV", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/JarvanIV_7.jpg" - }, - { - "id": 8, - "name": "SSG Jarvan IV", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/JarvanIV_8.jpg" - }, - { - "id": 9, - "name": "Hextech Jarvan IV", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/JarvanIV_9.jpg" - }, - { - "id": 11, - "name": "Pool Party Jarvan IV", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/JarvanIV_11.jpg" - }, - { - "id": 21, - "name": "Lunar Beast Jarvan IV", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/JarvanIV_21.jpg" - }, - { - "id": 30, - "name": "Worlds 2021 Jarvan IV", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/JarvanIV_30.jpg" - } - ] - }, - { - "championName": "Jax", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_0.jpg" - }, - { - "id": 1, - "name": "The Mighty Jax", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_1.jpg" - }, - { - "id": 2, - "name": "Vandal Jax", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_2.jpg" - }, - { - "id": 3, - "name": "Angler Jax", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_3.jpg" - }, - { - "id": 4, - "name": "PAX Jax", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_4.jpg" - }, - { - "id": 5, - "name": "Jaximus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_5.jpg" - }, - { - "id": 6, - "name": "Temple Jax", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_6.jpg" - }, - { - "id": 7, - "name": "Nemesis Jax", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_7.jpg" - }, - { - "id": 8, - "name": "SKT T1 Jax", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_8.jpg" - }, - { - "id": 12, - "name": "Warden Jax", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_12.jpg" - }, - { - "id": 13, - "name": "God Staff Jax", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_13.jpg" - }, - { - "id": 14, - "name": "Mecha Kingdoms Jax", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_14.jpg" - }, - { - "id": 20, - "name": "Conqueror Jax", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_20.jpg" - }, - { - "id": 21, - "name": "Prestige Conqueror Jax", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_21.jpg" - }, - { - "id": 22, - "name": "Empyrean Jax", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_22.jpg" - }, - { - "id": 32, - "name": "Neo PAX Jax", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_32.jpg" - }, - { - "id": 33, - "name": "PROJECT: Jax", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jax_33.jpg" - } - ] - }, - { - "championName": "Jayce", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jayce_0.jpg" - }, - { - "id": 1, - "name": "Full Metal Jayce", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jayce_1.jpg" - }, - { - "id": 2, - "name": "Debonair Jayce", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jayce_2.jpg" - }, - { - "id": 3, - "name": "Forsaken Jayce", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jayce_3.jpg" - }, - { - "id": 4, - "name": "Jayce Brighthammer", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jayce_4.jpg" - }, - { - "id": 5, - "name": "Battle Academia Jayce", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jayce_5.jpg" - }, - { - "id": 15, - "name": "Resistance Jayce", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jayce_15.jpg" - }, - { - "id": 25, - "name": "Zenith Games Jayce", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jayce_25.jpg" - }, - { - "id": 34, - "name": "T1 Jayce", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jayce_34.jpg" - }, - { - "id": 35, - "name": "Arcane Survivor Jayce", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jayce_35.jpg" - }, - { - "id": 36, - "name": "Prestige T1 Jayce", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jayce_36.jpg" - } - ] - }, - { - "championName": "Jhin", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jhin_0.jpg" - }, - { - "id": 1, - "name": "High Noon Jhin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jhin_1.jpg" - }, - { - "id": 2, - "name": "Blood Moon Jhin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jhin_2.jpg" - }, - { - "id": 3, - "name": "SKT T1 Jhin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jhin_3.jpg" - }, - { - "id": 4, - "name": "PROJECT: Jhin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jhin_4.jpg" - }, - { - "id": 5, - "name": "Dark Cosmic Jhin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jhin_5.jpg" - }, - { - "id": 14, - "name": "Shan Hai Scrolls Jhin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jhin_14.jpg" - }, - { - "id": 23, - "name": "DWG Jhin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jhin_23.jpg" - }, - { - "id": 25, - "name": "Empyrean Jhin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jhin_25.jpg" - }, - { - "id": 36, - "name": "Soul Fighter Jhin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jhin_36.jpg" - }, - { - "id": 37, - "name": "Dark Cosmic Erasure Jhin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jhin_37.jpg" - }, - { - "id": 47, - "name": "Mythmaker Jhin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jhin_47.jpg" - }, - { - "id": 55, - "name": "Arcana Jhin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jhin_55.jpg" - } - ] - }, - { - "championName": "Jinx", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jinx_0.jpg" - }, - { - "id": 1, - "name": "Crime City Jinx", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jinx_1.jpg" - }, - { - "id": 2, - "name": "Firecracker Jinx", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jinx_2.jpg" - }, - { - "id": 3, - "name": "Zombie Slayer Jinx", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jinx_3.jpg" - }, - { - "id": 4, - "name": "Star Guardian Jinx", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jinx_4.jpg" - }, - { - "id": 12, - "name": "Ambitious Elf Jinx", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jinx_12.jpg" - }, - { - "id": 13, - "name": "Odyssey Jinx", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jinx_13.jpg" - }, - { - "id": 20, - "name": "PROJECT: Jinx", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jinx_20.jpg" - }, - { - "id": 29, - "name": "Heartseeker Jinx", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jinx_29.jpg" - }, - { - "id": 38, - "name": "Battle Cat Jinx", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jinx_38.jpg" - }, - { - "id": 40, - "name": "Prestige Battle Cat Jinx", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jinx_40.jpg" - }, - { - "id": 51, - "name": "Cafe Cuties Jinx", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jinx_51.jpg" - }, - { - "id": 60, - "name": "Arcane Fractured Jinx", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jinx_60.jpg" - }, - { - "id": 62, - "name": "T1 Jinx", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Jinx_62.jpg" - } - ] - }, - { - "championName": "K'Sante", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KSante_0.jpg" - }, - { - "id": 1, - "name": "Empyrean K'Sante", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KSante_1.jpg" - }, - { - "id": 2, - "name": "Prestige Empyrean K'Sante", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KSante_2.jpg" - } - ] - }, - { - "championName": "Kai'Sa", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kaisa_0.jpg" - }, - { - "id": 1, - "name": "Bullet Angel Kai'Sa", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kaisa_1.jpg" - }, - { - "id": 14, - "name": "K/DA Kai'Sa", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kaisa_14.jpg" - }, - { - "id": 15, - "name": "Prestige K/DA Kai'Sa", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kaisa_15.jpg" - }, - { - "id": 16, - "name": "iG Kai'Sa", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kaisa_16.jpg" - }, - { - "id": 17, - "name": "Arcade Kai'Sa", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kaisa_17.jpg" - }, - { - "id": 26, - "name": "K/DA ALL OUT Kai'Sa", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kaisa_26.jpg" - }, - { - "id": 27, - "name": "Prestige K/DA ALL OUT Kai'Sa", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kaisa_27.jpg" - }, - { - "id": 29, - "name": "Lagoon Dragon Kai'Sa", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kaisa_29.jpg" - }, - { - "id": 40, - "name": "Star Guardian Kai'Sa", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kaisa_40.jpg" - } - ] - }, - { - "championName": "Kalista", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kalista_0.jpg" - }, - { - "id": 1, - "name": "Blood Moon Kalista", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kalista_1.jpg" - }, - { - "id": 3, - "name": "SKT T1 Kalista", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kalista_3.jpg" - }, - { - "id": 5, - "name": "Marauder Kalista", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kalista_5.jpg" - }, - { - "id": 14, - "name": "Faerie Court Kalista", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kalista_14.jpg" - }, - { - "id": 24, - "name": "Dawnbringer Kalista", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kalista_24.jpg" - } - ] - }, - { - "championName": "Karma", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karma_0.jpg" - }, - { - "id": 1, - "name": "Sun Goddess Karma", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karma_1.jpg" - }, - { - "id": 2, - "name": "Sakura Karma", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karma_2.jpg" - }, - { - "id": 3, - "name": "Traditional Karma", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karma_3.jpg" - }, - { - "id": 4, - "name": "Order of the Lotus Karma", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karma_4.jpg" - }, - { - "id": 5, - "name": "Warden Karma", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karma_5.jpg" - }, - { - "id": 6, - "name": "Winter Wonder Karma", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karma_6.jpg" - }, - { - "id": 7, - "name": "Conqueror Karma", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karma_7.jpg" - }, - { - "id": 8, - "name": "Dark Star Karma", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karma_8.jpg" - }, - { - "id": 19, - "name": "Dawnbringer Karma", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karma_19.jpg" - }, - { - "id": 26, - "name": "Odyssey Karma", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karma_26.jpg" - }, - { - "id": 27, - "name": "Ruined Karma", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karma_27.jpg" - }, - { - "id": 44, - "name": "Tranquility Dragon Karma", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karma_44.jpg" - }, - { - "id": 54, - "name": "Faerie Queen Karma", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karma_54.jpg" - }, - { - "id": 61, - "name": "Infernal Karma", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karma_61.jpg" - }, - { - "id": 70, - "name": "Spirit Blossom Karma", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karma_70.jpg" - } - ] - }, - { - "championName": "Karthus", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karthus_0.jpg" - }, - { - "id": 1, - "name": "Phantom Karthus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karthus_1.jpg" - }, - { - "id": 2, - "name": "Statue of Karthus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karthus_2.jpg" - }, - { - "id": 3, - "name": "Grim Reaper Karthus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karthus_3.jpg" - }, - { - "id": 4, - "name": "Pentakill Karthus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karthus_4.jpg" - }, - { - "id": 5, - "name": "Fnatic Karthus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karthus_5.jpg" - }, - { - "id": 9, - "name": "Karthus Lightsbane", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karthus_9.jpg" - }, - { - "id": 10, - "name": "Infernal Karthus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karthus_10.jpg" - }, - { - "id": 17, - "name": "Pentakill III: Lost Chapter Karthus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karthus_17.jpg" - }, - { - "id": 26, - "name": "Elderwood Karthus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karthus_26.jpg" - }, - { - "id": 35, - "name": "Arcana Karthus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Karthus_35.jpg" - } - ] - }, - { - "championName": "Kassadin", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kassadin_0.jpg" - }, - { - "id": 1, - "name": "Festival Kassadin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kassadin_1.jpg" - }, - { - "id": 2, - "name": "Deep One Kassadin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kassadin_2.jpg" - }, - { - "id": 3, - "name": "Pre-Void Kassadin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kassadin_3.jpg" - }, - { - "id": 4, - "name": "Harbinger Kassadin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kassadin_4.jpg" - }, - { - "id": 5, - "name": "Cosmic Reaver Kassadin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kassadin_5.jpg" - }, - { - "id": 6, - "name": "Count Kassadin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kassadin_6.jpg" - }, - { - "id": 14, - "name": "Hextech Kassadin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kassadin_14.jpg" - }, - { - "id": 15, - "name": "Shockblade Kassadin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kassadin_15.jpg" - }, - { - "id": 24, - "name": "Dragonmancer Kassadin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kassadin_24.jpg" - } - ] - }, - { - "championName": "Katarina", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_0.jpg" - }, - { - "id": 1, - "name": "Mercenary Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_1.jpg" - }, - { - "id": 2, - "name": "Red Card Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_2.jpg" - }, - { - "id": 3, - "name": "Bilgewater Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_3.jpg" - }, - { - "id": 4, - "name": "Kitty Cat Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_4.jpg" - }, - { - "id": 5, - "name": "High Command Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_5.jpg" - }, - { - "id": 6, - "name": "Sandstorm Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_6.jpg" - }, - { - "id": 7, - "name": "Slay Belle Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_7.jpg" - }, - { - "id": 8, - "name": "Warring Kingdoms Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_8.jpg" - }, - { - "id": 9, - "name": "PROJECT: Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_9.jpg" - }, - { - "id": 10, - "name": "Death Sworn Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_10.jpg" - }, - { - "id": 12, - "name": "Battle Academia Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_12.jpg" - }, - { - "id": 21, - "name": "Blood Moon Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_21.jpg" - }, - { - "id": 29, - "name": "Battle Queen Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_29.jpg" - }, - { - "id": 37, - "name": "High Noon Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_37.jpg" - }, - { - "id": 47, - "name": "Faerie Court Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_47.jpg" - }, - { - "id": 48, - "name": "Prestige Faerie Court Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_48.jpg" - }, - { - "id": 59, - "name": "Chosen of the Wolf Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_59.jpg" - }, - { - "id": 60, - "name": "Prestige Masque of the Black Rose Katarina", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Katarina_60.jpg" - } - ] - }, - { - "championName": "Kayle", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_0.jpg" - }, - { - "id": 1, - "name": "Silver Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_1.jpg" - }, - { - "id": 2, - "name": "Viridian Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_2.jpg" - }, - { - "id": 3, - "name": "Transcended Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_3.jpg" - }, - { - "id": 4, - "name": "Battleborn Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_4.jpg" - }, - { - "id": 5, - "name": "Judgment Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_5.jpg" - }, - { - "id": 6, - "name": "Aether Wing Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_6.jpg" - }, - { - "id": 7, - "name": "Riot Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_7.jpg" - }, - { - "id": 8, - "name": "Iron Inquisitor Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_8.jpg" - }, - { - "id": 9, - "name": "Pentakill Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_9.jpg" - }, - { - "id": 15, - "name": "PsyOps Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_15.jpg" - }, - { - "id": 24, - "name": "Dragonslayer Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_24.jpg" - }, - { - "id": 33, - "name": "Pentakill III: Lost Chapter Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_33.jpg" - }, - { - "id": 42, - "name": "Sun-Eater Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_42.jpg" - }, - { - "id": 57, - "name": "Immortal Journey Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_57.jpg" - }, - { - "id": 66, - "name": "Empyrean Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_66.jpg" - }, - { - "id": 67, - "name": "Prestige Empyrean Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_67.jpg" - }, - { - "id": 78, - "name": "Spirit Blossom Kayle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayle_78.jpg" - } - ] - }, - { - "championName": "Kayn", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayn_0.jpg" - }, - { - "id": 1, - "name": "Soulhunter Kayn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayn_1.jpg" - }, - { - "id": 2, - "name": "Odyssey Kayn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayn_2.jpg" - }, - { - "id": 8, - "name": "Nightbringer Kayn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayn_8.jpg" - }, - { - "id": 9, - "name": "Prestige Nightbringer Kayn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayn_9.jpg" - }, - { - "id": 15, - "name": "Snow Moon Kayn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayn_15.jpg" - }, - { - "id": 20, - "name": "HEARTSTEEL Kayn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayn_20.jpg" - }, - { - "id": 26, - "name": "Battle Academia Kayn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kayn_26.jpg" - } - ] - }, - { - "championName": "Kennen", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kennen_0.jpg" - }, - { - "id": 1, - "name": "Deadly Kennen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kennen_1.jpg" - }, - { - "id": 2, - "name": "Swamp Master Kennen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kennen_2.jpg" - }, - { - "id": 3, - "name": "Karate Kennen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kennen_3.jpg" - }, - { - "id": 4, - "name": "Kennen M.D.", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kennen_4.jpg" - }, - { - "id": 5, - "name": "Arctic Ops Kennen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kennen_5.jpg" - }, - { - "id": 6, - "name": "Blood Moon Kennen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kennen_6.jpg" - }, - { - "id": 7, - "name": "Super Kennen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kennen_7.jpg" - }, - { - "id": 8, - "name": "Infernal Kennen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kennen_8.jpg" - }, - { - "id": 23, - "name": "DWG Kennen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kennen_23.jpg" - }, - { - "id": 25, - "name": "Astronaut Kennen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kennen_25.jpg" - } - ] - }, - { - "championName": "Kha'Zix", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Khazix_0.jpg" - }, - { - "id": 1, - "name": "Mecha Kha'Zix", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Khazix_1.jpg" - }, - { - "id": 2, - "name": "Guardian of the Sands Kha'Zix", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Khazix_2.jpg" - }, - { - "id": 3, - "name": "Death Blossom Kha'Zix", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Khazix_3.jpg" - }, - { - "id": 4, - "name": "Dark Star Kha'Zix", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Khazix_4.jpg" - }, - { - "id": 60, - "name": "Odyssey Kha'Zix", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Khazix_60.jpg" - } - ] - }, - { - "championName": "Kindred", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kindred_0.jpg" - }, - { - "id": 1, - "name": "Shadowfire Kindred", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kindred_1.jpg" - }, - { - "id": 2, - "name": "Super Galaxy Kindred", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kindred_2.jpg" - }, - { - "id": 3, - "name": "Spirit Blossom Kindred", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kindred_3.jpg" - }, - { - "id": 12, - "name": "Porcelain Kindred", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kindred_12.jpg" - }, - { - "id": 22, - "name": "Woof and Lamb Kindred", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kindred_22.jpg" - }, - { - "id": 23, - "name": "DRX Kindred", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kindred_23.jpg" - }, - { - "id": 33, - "name": "Prestige Porcelain Kindred", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kindred_33.jpg" - }, - { - "id": 34, - "name": "Chosen of the Wolf Kindred", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kindred_34.jpg" - } - ] - }, - { - "championName": "Kled", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kled_0.jpg" - }, - { - "id": 1, - "name": "Sir Kled", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kled_1.jpg" - }, - { - "id": 2, - "name": "Count Kledula", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kled_2.jpg" - }, - { - "id": 9, - "name": "Marauder Kled", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kled_9.jpg" - }, - { - "id": 18, - "name": "Kibble-Head Kled", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Kled_18.jpg" - } - ] - }, - { - "championName": "Kog'Maw", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KogMaw_0.jpg" - }, - { - "id": 1, - "name": "Caterpillar Kog'Maw", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KogMaw_1.jpg" - }, - { - "id": 2, - "name": "Sonoran Kog'Maw", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KogMaw_2.jpg" - }, - { - "id": 3, - "name": "Monarch Kog'Maw", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KogMaw_3.jpg" - }, - { - "id": 4, - "name": "Reindeer Kog'Maw", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KogMaw_4.jpg" - }, - { - "id": 5, - "name": "Lion Dance Kog'Maw", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KogMaw_5.jpg" - }, - { - "id": 6, - "name": "Deep Sea Kog'Maw", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KogMaw_6.jpg" - }, - { - "id": 7, - "name": "Jurassic Kog'Maw", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KogMaw_7.jpg" - }, - { - "id": 8, - "name": "Battlecast Kog'Maw", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KogMaw_8.jpg" - }, - { - "id": 9, - "name": "Pug'Maw", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KogMaw_9.jpg" - }, - { - "id": 10, - "name": "Hextech Kog'Maw", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KogMaw_10.jpg" - }, - { - "id": 19, - "name": "Arcanist Kog'Maw", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KogMaw_19.jpg" - }, - { - "id": 28, - "name": "Bee'Maw", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KogMaw_28.jpg" - }, - { - "id": 37, - "name": "Zap'Maw", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/KogMaw_37.jpg" - } - ] - }, - { - "championName": "LeBlanc", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leblanc_0.jpg" - }, - { - "id": 1, - "name": "Wicked LeBlanc", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leblanc_1.jpg" - }, - { - "id": 2, - "name": "Prestigious LeBlanc", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leblanc_2.jpg" - }, - { - "id": 3, - "name": "Mistletoe LeBlanc", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leblanc_3.jpg" - }, - { - "id": 4, - "name": "Ravenborn LeBlanc", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leblanc_4.jpg" - }, - { - "id": 5, - "name": "Elderwood LeBlanc", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leblanc_5.jpg" - }, - { - "id": 12, - "name": "Program LeBlanc", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leblanc_12.jpg" - }, - { - "id": 19, - "name": "iG LeBlanc", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leblanc_19.jpg" - }, - { - "id": 20, - "name": "Coven LeBlanc", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leblanc_20.jpg" - }, - { - "id": 33, - "name": "Prestige Coven LeBlanc", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leblanc_33.jpg" - }, - { - "id": 35, - "name": "Debonair LeBlanc", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leblanc_35.jpg" - } - ] - }, - { - "championName": "Lee Sin", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/LeeSin_0.jpg" - }, - { - "id": 1, - "name": "Traditional Lee Sin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/LeeSin_1.jpg" - }, - { - "id": 2, - "name": "Acolyte Lee Sin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/LeeSin_2.jpg" - }, - { - "id": 3, - "name": "Dragon Fist Lee Sin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/LeeSin_3.jpg" - }, - { - "id": 4, - "name": "Muay Thai Lee Sin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/LeeSin_4.jpg" - }, - { - "id": 5, - "name": "Pool Party Lee Sin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/LeeSin_5.jpg" - }, - { - "id": 6, - "name": "SKT T1 Lee Sin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/LeeSin_6.jpg" - }, - { - "id": 10, - "name": "Knockout Lee Sin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/LeeSin_10.jpg" - }, - { - "id": 11, - "name": "God Fist Lee Sin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/LeeSin_11.jpg" - }, - { - "id": 12, - "name": "Playmaker Lee Sin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/LeeSin_12.jpg" - }, - { - "id": 27, - "name": "Nightbringer Lee Sin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/LeeSin_27.jpg" - }, - { - "id": 28, - "name": "Prestige Nightbringer Lee Sin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/LeeSin_28.jpg" - }, - { - "id": 29, - "name": "FPX Lee Sin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/LeeSin_29.jpg" - }, - { - "id": 31, - "name": "Storm Dragon Lee Sin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/LeeSin_31.jpg" - }, - { - "id": 41, - "name": "Zenith Games Lee Sin", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/LeeSin_41.jpg" - } - ] - }, - { - "championName": "Leona", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_0.jpg" - }, - { - "id": 1, - "name": "Valkyrie Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_1.jpg" - }, - { - "id": 2, - "name": "Defender Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_2.jpg" - }, - { - "id": 3, - "name": "Iron Solari Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_3.jpg" - }, - { - "id": 4, - "name": "Pool Party Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_4.jpg" - }, - { - "id": 8, - "name": "PROJECT: Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_8.jpg" - }, - { - "id": 9, - "name": "Barbecue Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_9.jpg" - }, - { - "id": 10, - "name": "Solar Eclipse Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_10.jpg" - }, - { - "id": 11, - "name": "Lunar Eclipse Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_11.jpg" - }, - { - "id": 12, - "name": "Mecha Kingdoms Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_12.jpg" - }, - { - "id": 21, - "name": "Battle Academia Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_21.jpg" - }, - { - "id": 22, - "name": "DWG Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_22.jpg" - }, - { - "id": 23, - "name": "Prestige Battle Academia Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_23.jpg" - }, - { - "id": 33, - "name": "Debonair Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_33.jpg" - }, - { - "id": 34, - "name": "High Noon Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_34.jpg" - }, - { - "id": 50, - "name": "Crystalis Motus Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_50.jpg" - }, - { - "id": 52, - "name": "Battle Lion Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_52.jpg" - }, - { - "id": 53, - "name": "Prestige Battle Lion Leona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Leona_53.jpg" - } - ] - }, - { - "championName": "Lillia", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lillia_0.jpg" - }, - { - "id": 1, - "name": "Spirit Blossom Lillia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lillia_1.jpg" - }, - { - "id": 10, - "name": "Nightbringer Lillia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lillia_10.jpg" - }, - { - "id": 19, - "name": "Shan Hai Scrolls Lillia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lillia_19.jpg" - }, - { - "id": 28, - "name": "Faerie Court Lillia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lillia_28.jpg" - }, - { - "id": 37, - "name": "Bowling League Lillia", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lillia_37.jpg" - } - ] - }, - { - "championName": "Lissandra", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lissandra_0.jpg" - }, - { - "id": 1, - "name": "Bloodstone Lissandra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lissandra_1.jpg" - }, - { - "id": 2, - "name": "Blade Queen Lissandra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lissandra_2.jpg" - }, - { - "id": 3, - "name": "Program Lissandra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lissandra_3.jpg" - }, - { - "id": 4, - "name": "Coven Lissandra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lissandra_4.jpg" - }, - { - "id": 12, - "name": "Dark Cosmic Lissandra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lissandra_12.jpg" - }, - { - "id": 23, - "name": "Porcelain Lissandra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lissandra_23.jpg" - }, - { - "id": 33, - "name": "Prestige Porcelain Lissandra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lissandra_33.jpg" - }, - { - "id": 34, - "name": "Space Groove Lissandra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lissandra_34.jpg" - } - ] - }, - { - "championName": "Lucian", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lucian_0.jpg" - }, - { - "id": 1, - "name": "Hired Gun Lucian", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lucian_1.jpg" - }, - { - "id": 2, - "name": "Striker Lucian", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lucian_2.jpg" - }, - { - "id": 6, - "name": "PROJECT: Lucian", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lucian_6.jpg" - }, - { - "id": 7, - "name": "Heartseeker Lucian", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lucian_7.jpg" - }, - { - "id": 8, - "name": "High Noon Lucian", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lucian_8.jpg" - }, - { - "id": 9, - "name": "Demacia Vice Lucian", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lucian_9.jpg" - }, - { - "id": 18, - "name": "Pulsefire Lucian", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lucian_18.jpg" - }, - { - "id": 19, - "name": "Prestige Pulsefire Lucian", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lucian_19.jpg" - }, - { - "id": 25, - "name": "Victorious Lucian", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lucian_25.jpg" - }, - { - "id": 31, - "name": "Arcana Lucian", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lucian_31.jpg" - }, - { - "id": 40, - "name": "Strike Paladin Lucian", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lucian_40.jpg" - }, - { - "id": 52, - "name": "Winterblessed Lucian", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lucian_52.jpg" - }, - { - "id": 62, - "name": "Masked Justice Lucian", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lucian_62.jpg" - } - ] - }, - { - "championName": "Lulu", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lulu_0.jpg" - }, - { - "id": 1, - "name": "Bittersweet Lulu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lulu_1.jpg" - }, - { - "id": 2, - "name": "Wicked Lulu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lulu_2.jpg" - }, - { - "id": 3, - "name": "Dragon Trainer Lulu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lulu_3.jpg" - }, - { - "id": 4, - "name": "Winter Wonder Lulu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lulu_4.jpg" - }, - { - "id": 5, - "name": "Pool Party Lulu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lulu_5.jpg" - }, - { - "id": 6, - "name": "Star Guardian Lulu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lulu_6.jpg" - }, - { - "id": 14, - "name": "Cosmic Enchantress Lulu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lulu_14.jpg" - }, - { - "id": 15, - "name": "Pajama Guardian Lulu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lulu_15.jpg" - }, - { - "id": 26, - "name": "Space Groove Lulu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lulu_26.jpg" - }, - { - "id": 27, - "name": "Prestige Space Groove Lulu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lulu_27.jpg" - }, - { - "id": 37, - "name": "Monster Tamer Lulu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lulu_37.jpg" - }, - { - "id": 46, - "name": "Cafe Cuties Lulu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lulu_46.jpg" - }, - { - "id": 55, - "name": "Arcana Lulu", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lulu_55.jpg" - } - ] - }, - { - "championName": "Lux", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_0.jpg" - }, - { - "id": 1, - "name": "Sorceress Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_1.jpg" - }, - { - "id": 2, - "name": "Spellthief Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_2.jpg" - }, - { - "id": 3, - "name": "Commando Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_3.jpg" - }, - { - "id": 4, - "name": "Imperial Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_4.jpg" - }, - { - "id": 5, - "name": "Steel Legion Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_5.jpg" - }, - { - "id": 6, - "name": "Star Guardian Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_6.jpg" - }, - { - "id": 7, - "name": "Elementalist Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_7.jpg" - }, - { - "id": 8, - "name": "Lunar Empress Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_8.jpg" - }, - { - "id": 14, - "name": "Pajama Guardian Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_14.jpg" - }, - { - "id": 15, - "name": "Battle Academia Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_15.jpg" - }, - { - "id": 16, - "name": "Prestige Battle Academia Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_16.jpg" - }, - { - "id": 17, - "name": "Dark Cosmic Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_17.jpg" - }, - { - "id": 18, - "name": "Cosmic Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_18.jpg" - }, - { - "id": 19, - "name": "Space Groove Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_19.jpg" - }, - { - "id": 29, - "name": "Porcelain Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_29.jpg" - }, - { - "id": 38, - "name": "Soul Fighter Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_38.jpg" - }, - { - "id": 40, - "name": "Prestige Porcelain Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_40.jpg" - }, - { - "id": 42, - "name": "Empyrean Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_42.jpg" - }, - { - "id": 61, - "name": "Faerie Court Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_61.jpg" - }, - { - "id": 70, - "name": "Prestige Spirit Blossom Lux", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Lux_70.jpg" - } - ] - }, - { - "championName": "Malphite", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malphite_0.jpg" - }, - { - "id": 1, - "name": "Shamrock Malphite", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malphite_1.jpg" - }, - { - "id": 2, - "name": "Coral Reef Malphite", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malphite_2.jpg" - }, - { - "id": 3, - "name": "Marble Malphite", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malphite_3.jpg" - }, - { - "id": 4, - "name": "Obsidian Malphite", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malphite_4.jpg" - }, - { - "id": 5, - "name": "Glacial Malphite", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malphite_5.jpg" - }, - { - "id": 6, - "name": "Mecha Malphite", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malphite_6.jpg" - }, - { - "id": 7, - "name": "Ironside Malphite", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malphite_7.jpg" - }, - { - "id": 16, - "name": "Odyssey Malphite", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malphite_16.jpg" - }, - { - "id": 23, - "name": "Dark Star Malphite", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malphite_23.jpg" - }, - { - "id": 24, - "name": "Prestige Dark Star Malphite", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malphite_24.jpg" - }, - { - "id": 25, - "name": "FPX Malphite", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malphite_25.jpg" - }, - { - "id": 27, - "name": "Old God Malphite", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malphite_27.jpg" - }, - { - "id": 37, - "name": "Lunar Guardian Malphite", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malphite_37.jpg" - }, - { - "id": 48, - "name": "Pool Party Malphite", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malphite_48.jpg" - } - ] - }, - { - "championName": "Malzahar", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malzahar_0.jpg" - }, - { - "id": 1, - "name": "Vizier Malzahar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malzahar_1.jpg" - }, - { - "id": 2, - "name": "Shadow Prince Malzahar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malzahar_2.jpg" - }, - { - "id": 3, - "name": "Djinn Malzahar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malzahar_3.jpg" - }, - { - "id": 4, - "name": "Overlord Malzahar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malzahar_4.jpg" - }, - { - "id": 5, - "name": "Snow Day Malzahar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malzahar_5.jpg" - }, - { - "id": 6, - "name": "Battle Boss Malzahar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malzahar_6.jpg" - }, - { - "id": 7, - "name": "Hextech Malzahar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malzahar_7.jpg" - }, - { - "id": 9, - "name": "Worldbreaker Malzahar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malzahar_9.jpg" - }, - { - "id": 18, - "name": "Beezahar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malzahar_18.jpg" - }, - { - "id": 28, - "name": "Debonair Malzahar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malzahar_28.jpg" - }, - { - "id": 38, - "name": "Three Honors Malzahar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malzahar_38.jpg" - }, - { - "id": 39, - "name": "Empyrean Malzahar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malzahar_39.jpg" - }, - { - "id": 49, - "name": "Fatebreaker Malzahar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Malzahar_49.jpg" - } - ] - }, - { - "championName": "Maokai", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Maokai_0.jpg" - }, - { - "id": 1, - "name": "Charred Maokai", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Maokai_1.jpg" - }, - { - "id": 2, - "name": "Totemic Maokai", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Maokai_2.jpg" - }, - { - "id": 3, - "name": "Festive Maokai", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Maokai_3.jpg" - }, - { - "id": 4, - "name": "Haunted Maokai", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Maokai_4.jpg" - }, - { - "id": 5, - "name": "Goalkeeper Maokai", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Maokai_5.jpg" - }, - { - "id": 6, - "name": "Meowkai", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Maokai_6.jpg" - }, - { - "id": 7, - "name": "Victorious Maokai", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Maokai_7.jpg" - }, - { - "id": 16, - "name": "Worldbreaker Maokai", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Maokai_16.jpg" - }, - { - "id": 24, - "name": "Astronaut Maokai", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Maokai_24.jpg" - }, - { - "id": 33, - "name": "DRX Maokai", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Maokai_33.jpg" - } - ] - }, - { - "championName": "Master Yi", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MasterYi_0.jpg" - }, - { - "id": 1, - "name": "Assassin Master Yi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MasterYi_1.jpg" - }, - { - "id": 2, - "name": "Chosen Master Yi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MasterYi_2.jpg" - }, - { - "id": 3, - "name": "Ionia Master Yi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MasterYi_3.jpg" - }, - { - "id": 4, - "name": "Samurai Yi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MasterYi_4.jpg" - }, - { - "id": 5, - "name": "Headhunter Master Yi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MasterYi_5.jpg" - }, - { - "id": 9, - "name": "PROJECT: Yi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MasterYi_9.jpg" - }, - { - "id": 10, - "name": "Cosmic Blade Master Yi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MasterYi_10.jpg" - }, - { - "id": 11, - "name": "Eternal Sword Yi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MasterYi_11.jpg" - }, - { - "id": 17, - "name": "Snow Man Yi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MasterYi_17.jpg" - }, - { - "id": 24, - "name": "Blood Moon Master Yi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MasterYi_24.jpg" - }, - { - "id": 33, - "name": "PsyOps Master Yi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MasterYi_33.jpg" - }, - { - "id": 42, - "name": "Debonair Master Yi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MasterYi_42.jpg" - } - ] - }, - { - "championName": "Mel", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Mel_0.jpg" - }, - { - "id": 1, - "name": "Arcane Councilor Mel", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Mel_1.jpg" - } - ] - }, - { - "championName": "Milio", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Milio_0.jpg" - }, - { - "id": 1, - "name": "Faerie Court Milio", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Milio_1.jpg" - }, - { - "id": 11, - "name": "Rain Shepherd Milio", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Milio_11.jpg" - } - ] - }, - { - "championName": "Miss Fortune", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_0.jpg" - }, - { - "id": 1, - "name": "Cowgirl Miss Fortune", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_1.jpg" - }, - { - "id": 2, - "name": "Waterloo Miss Fortune", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_2.jpg" - }, - { - "id": 3, - "name": "Secret Agent Miss Fortune", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_3.jpg" - }, - { - "id": 4, - "name": "Candy Cane Miss Fortune", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_4.jpg" - }, - { - "id": 5, - "name": "Road Warrior Miss Fortune", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_5.jpg" - }, - { - "id": 6, - "name": "Crime City Miss Fortune", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_6.jpg" - }, - { - "id": 7, - "name": "Arcade Miss Fortune", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_7.jpg" - }, - { - "id": 8, - "name": "Captain Fortune", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_8.jpg" - }, - { - "id": 9, - "name": "Pool Party Miss Fortune", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_9.jpg" - }, - { - "id": 15, - "name": "Star Guardian Miss Fortune", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_15.jpg" - }, - { - "id": 16, - "name": "Gun Goddess Miss Fortune", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_16.jpg" - }, - { - "id": 17, - "name": "Pajama Guardian Miss Fortune", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_17.jpg" - }, - { - "id": 18, - "name": "Bewitching Miss Fortune", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_18.jpg" - }, - { - "id": 20, - "name": "Prestige Bewitching Miss Fortune", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_20.jpg" - }, - { - "id": 21, - "name": "Ruined Miss Fortune", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_21.jpg" - }, - { - "id": 31, - "name": "Battle Bunny Miss Fortune", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MissFortune_31.jpg" - } - ] - }, - { - "championName": "Mordekaiser", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Mordekaiser_0.jpg" - }, - { - "id": 1, - "name": "Dragon Knight Mordekaiser", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Mordekaiser_1.jpg" - }, - { - "id": 2, - "name": "Infernal Mordekaiser", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Mordekaiser_2.jpg" - }, - { - "id": 3, - "name": "Pentakill Mordekaiser", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Mordekaiser_3.jpg" - }, - { - "id": 4, - "name": "Lord Mordekaiser", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Mordekaiser_4.jpg" - }, - { - "id": 5, - "name": "King of Clubs Mordekaiser", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Mordekaiser_5.jpg" - }, - { - "id": 6, - "name": "Dark Star Mordekaiser", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Mordekaiser_6.jpg" - }, - { - "id": 13, - "name": "PROJECT: Mordekaiser", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Mordekaiser_13.jpg" - }, - { - "id": 23, - "name": "Pentakill III: Lost Chapter Mordekaiser", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Mordekaiser_23.jpg" - }, - { - "id": 32, - "name": "High Noon Mordekaiser", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Mordekaiser_32.jpg" - }, - { - "id": 42, - "name": "Ashen Graveknight Mordekaiser", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Mordekaiser_42.jpg" - }, - { - "id": 44, - "name": "Old God Mordekaiser", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Mordekaiser_44.jpg" - }, - { - "id": 54, - "name": "Sahn-Uzal Mordekaiser", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Mordekaiser_54.jpg" - } - ] - }, - { - "championName": "Morgana", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_0.jpg" - }, - { - "id": 1, - "name": "Exiled Morgana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_1.jpg" - }, - { - "id": 2, - "name": "Sinful Succulence Morgana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_2.jpg" - }, - { - "id": 3, - "name": "Blade Mistress Morgana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_3.jpg" - }, - { - "id": 4, - "name": "Blackthorn Morgana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_4.jpg" - }, - { - "id": 5, - "name": "Ghost Bride Morgana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_5.jpg" - }, - { - "id": 6, - "name": "Victorious Morgana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_6.jpg" - }, - { - "id": 10, - "name": "Lunar Wraith Morgana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_10.jpg" - }, - { - "id": 11, - "name": "Bewitching Morgana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_11.jpg" - }, - { - "id": 17, - "name": "Majestic Empress Morgana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_17.jpg" - }, - { - "id": 26, - "name": "Coven Morgana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_26.jpg" - }, - { - "id": 39, - "name": "Dawnbringer Morgana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_39.jpg" - }, - { - "id": 41, - "name": "Prestige Bewitching Morgana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_41.jpg" - }, - { - "id": 50, - "name": "Star Nemesis Morgana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_50.jpg" - }, - { - "id": 60, - "name": "Snow Moon Morgana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_60.jpg" - }, - { - "id": 70, - "name": "Porcelain Morgana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_70.jpg" - }, - { - "id": 80, - "name": "Spirit Blossom Morgana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Morgana_80.jpg" - } - ] - }, - { - "championName": "Naafiri", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Naafiri_0.jpg" - }, - { - "id": 1, - "name": "Soul Fighter Naafiri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Naafiri_1.jpg" - }, - { - "id": 11, - "name": "PROJECT: Naafiri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Naafiri_11.jpg" - }, - { - "id": 20, - "name": "Glizzy Naafiri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Naafiri_20.jpg" - } - ] - }, - { - "championName": "Nami", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nami_0.jpg" - }, - { - "id": 1, - "name": "Koi Nami", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nami_1.jpg" - }, - { - "id": 2, - "name": "River Spirit Nami", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nami_2.jpg" - }, - { - "id": 3, - "name": "Urf the Nami-tee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nami_3.jpg" - }, - { - "id": 7, - "name": "Deep Sea Nami", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nami_7.jpg" - }, - { - "id": 8, - "name": "SKT T1 Nami", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nami_8.jpg" - }, - { - "id": 9, - "name": "Program Nami", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nami_9.jpg" - }, - { - "id": 15, - "name": "Splendid Staff Nami", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nami_15.jpg" - }, - { - "id": 24, - "name": "Cosmic Destiny Nami", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nami_24.jpg" - }, - { - "id": 32, - "name": "Bewitching Nami", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nami_32.jpg" - }, - { - "id": 41, - "name": "Space Groove Nami", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nami_41.jpg" - }, - { - "id": 42, - "name": "Prestige Space Groove Nami", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nami_42.jpg" - }, - { - "id": 51, - "name": "Coven Nami", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nami_51.jpg" - }, - { - "id": 58, - "name": "Mythmaker Nami", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nami_58.jpg" - } - ] - }, - { - "championName": "Nasus", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nasus_0.jpg" - }, - { - "id": 1, - "name": "Galactic Nasus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nasus_1.jpg" - }, - { - "id": 2, - "name": "Pharaoh Nasus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nasus_2.jpg" - }, - { - "id": 3, - "name": "Dreadknight Nasus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nasus_3.jpg" - }, - { - "id": 4, - "name": "Riot K-9 Nasus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nasus_4.jpg" - }, - { - "id": 5, - "name": "Infernal Nasus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nasus_5.jpg" - }, - { - "id": 6, - "name": "Archduke Nasus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nasus_6.jpg" - }, - { - "id": 10, - "name": "Worldbreaker Nasus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nasus_10.jpg" - }, - { - "id": 11, - "name": "Lunar Guardian Nasus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nasus_11.jpg" - }, - { - "id": 16, - "name": "Battlecast Nasus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nasus_16.jpg" - }, - { - "id": 25, - "name": "Space Groove Nasus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nasus_25.jpg" - }, - { - "id": 35, - "name": "Armored Titan Nasus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nasus_35.jpg" - }, - { - "id": 45, - "name": "Nightbringer Nasus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nasus_45.jpg" - }, - { - "id": 54, - "name": "Fatemaker Nasus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nasus_54.jpg" - } - ] - }, - { - "championName": "Nautilus", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nautilus_0.jpg" - }, - { - "id": 1, - "name": "Abyssal Nautilus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nautilus_1.jpg" - }, - { - "id": 2, - "name": "Subterranean Nautilus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nautilus_2.jpg" - }, - { - "id": 3, - "name": "AstroNautilus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nautilus_3.jpg" - }, - { - "id": 4, - "name": "Warden Nautilus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nautilus_4.jpg" - }, - { - "id": 5, - "name": "Worldbreaker Nautilus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nautilus_5.jpg" - }, - { - "id": 6, - "name": "Conqueror Nautilus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nautilus_6.jpg" - }, - { - "id": 9, - "name": "Shan Hai Scrolls Nautilus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nautilus_9.jpg" - }, - { - "id": 18, - "name": "Fright Night Nautilus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nautilus_18.jpg" - }, - { - "id": 27, - "name": "Cosmic Paladin Nautilus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nautilus_27.jpg" - }, - { - "id": 36, - "name": "Crystalis Indomitus Nautilus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nautilus_36.jpg" - } - ] - }, - { - "championName": "Neeko", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Neeko_0.jpg" - }, - { - "id": 1, - "name": "Winter Wonder Neeko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Neeko_1.jpg" - }, - { - "id": 10, - "name": "Star Guardian Neeko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Neeko_10.jpg" - }, - { - "id": 11, - "name": "Prestige Star Guardian Neeko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Neeko_11.jpg" - }, - { - "id": 12, - "name": "Shan Hai Scrolls Neeko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Neeko_12.jpg" - }, - { - "id": 22, - "name": "Bewitching Neeko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Neeko_22.jpg" - }, - { - "id": 31, - "name": "Street Demons Neeko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Neeko_31.jpg" - }, - { - "id": 40, - "name": "Cosplayer Neeko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Neeko_40.jpg" - } - ] - }, - { - "championName": "Nidalee", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_0.jpg" - }, - { - "id": 1, - "name": "Snow Bunny Nidalee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_1.jpg" - }, - { - "id": 2, - "name": "Leopard Nidalee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_2.jpg" - }, - { - "id": 3, - "name": "French Maid Nidalee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_3.jpg" - }, - { - "id": 4, - "name": "Pharaoh Nidalee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_4.jpg" - }, - { - "id": 5, - "name": "Bewitching Nidalee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_5.jpg" - }, - { - "id": 6, - "name": "Headhunter Nidalee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_6.jpg" - }, - { - "id": 7, - "name": "Warring Kingdoms Nidalee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_7.jpg" - }, - { - "id": 8, - "name": "Challenger Nidalee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_8.jpg" - }, - { - "id": 9, - "name": "Super Galaxy Nidalee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_9.jpg" - }, - { - "id": 11, - "name": "Dawnbringer Nidalee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_11.jpg" - }, - { - "id": 18, - "name": "Cosmic Huntress Nidalee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_18.jpg" - }, - { - "id": 27, - "name": "DWG Nidalee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_27.jpg" - }, - { - "id": 29, - "name": "Ocean Song Nidalee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_29.jpg" - }, - { - "id": 39, - "name": "Kittalee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_39.jpg" - }, - { - "id": 48, - "name": "La Ilusión Nidalee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_48.jpg" - }, - { - "id": 58, - "name": "Spirit Blossom Nidalee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nidalee_58.jpg" - } - ] - }, - { - "championName": "Nilah", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nilah_0.jpg" - }, - { - "id": 1, - "name": "Star Guardian Nilah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nilah_1.jpg" - }, - { - "id": 11, - "name": "Coven Nilah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nilah_11.jpg" - }, - { - "id": 21, - "name": "Inkshadow Nilah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nilah_21.jpg" - } - ] - }, - { - "championName": "Nocturne", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nocturne_0.jpg" - }, - { - "id": 1, - "name": "Frozen Terror Nocturne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nocturne_1.jpg" - }, - { - "id": 2, - "name": "Void Nocturne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nocturne_2.jpg" - }, - { - "id": 3, - "name": "Ravager Nocturne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nocturne_3.jpg" - }, - { - "id": 4, - "name": "Haunting Nocturne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nocturne_4.jpg" - }, - { - "id": 5, - "name": "Eternum Nocturne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nocturne_5.jpg" - }, - { - "id": 6, - "name": "Cursed Revenant Nocturne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nocturne_6.jpg" - }, - { - "id": 7, - "name": "Old God Nocturne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nocturne_7.jpg" - }, - { - "id": 16, - "name": "Hextech Nocturne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nocturne_16.jpg" - }, - { - "id": 17, - "name": "Broken Covenant Nocturne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nocturne_17.jpg" - }, - { - "id": 26, - "name": "Empyrean Nocturne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nocturne_26.jpg" - } - ] - }, - { - "championName": "Nunu & Willump", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nunu_0.jpg" - }, - { - "id": 1, - "name": "Sasquatch Nunu & Willump", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nunu_1.jpg" - }, - { - "id": 2, - "name": "Workshop Nunu & Willump", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nunu_2.jpg" - }, - { - "id": 3, - "name": "Grungy Nunu & Willump", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nunu_3.jpg" - }, - { - "id": 4, - "name": "Nunu & Willump Bot", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nunu_4.jpg" - }, - { - "id": 5, - "name": "Demolisher Nunu & Willump", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nunu_5.jpg" - }, - { - "id": 6, - "name": "TPA Nunu & Willump", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nunu_6.jpg" - }, - { - "id": 7, - "name": "Zombie Nunu & Willump", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nunu_7.jpg" - }, - { - "id": 8, - "name": "Papercraft Nunu & Willump", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nunu_8.jpg" - }, - { - "id": 16, - "name": "Space Groove Nunu & Willump", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nunu_16.jpg" - }, - { - "id": 26, - "name": "Nunu & Beelump", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Nunu_26.jpg" - } - ] - }, - { - "championName": "Olaf", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Olaf_0.jpg" - }, - { - "id": 1, - "name": "Forsaken Olaf", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Olaf_1.jpg" - }, - { - "id": 2, - "name": "Glacial Olaf", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Olaf_2.jpg" - }, - { - "id": 3, - "name": "Brolaf", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Olaf_3.jpg" - }, - { - "id": 4, - "name": "Pentakill Olaf", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Olaf_4.jpg" - }, - { - "id": 5, - "name": "Marauder Olaf", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Olaf_5.jpg" - }, - { - "id": 6, - "name": "Butcher Olaf", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Olaf_6.jpg" - }, - { - "id": 15, - "name": "SKT T1 Olaf", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Olaf_15.jpg" - }, - { - "id": 16, - "name": "Dragonslayer Olaf", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Olaf_16.jpg" - }, - { - "id": 25, - "name": "Sentinel Olaf", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Olaf_25.jpg" - }, - { - "id": 35, - "name": "Pentakill III: Lost Chapter Olaf", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Olaf_35.jpg" - }, - { - "id": 44, - "name": "Infernal Olaf", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Olaf_44.jpg" - } - ] - }, - { - "championName": "Orianna", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Orianna_0.jpg" - }, - { - "id": 1, - "name": "Gothic Orianna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Orianna_1.jpg" - }, - { - "id": 2, - "name": "Sewn Chaos Orianna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Orianna_2.jpg" - }, - { - "id": 3, - "name": "Bladecraft Orianna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Orianna_3.jpg" - }, - { - "id": 4, - "name": "TPA Orianna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Orianna_4.jpg" - }, - { - "id": 5, - "name": "Winter Wonder Orianna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Orianna_5.jpg" - }, - { - "id": 6, - "name": "Heartseeker Orianna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Orianna_6.jpg" - }, - { - "id": 7, - "name": "Dark Star Orianna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Orianna_7.jpg" - }, - { - "id": 8, - "name": "Victorious Orianna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Orianna_8.jpg" - }, - { - "id": 11, - "name": "Pool Party Orianna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Orianna_11.jpg" - }, - { - "id": 20, - "name": "Orbeeanna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Orianna_20.jpg" - }, - { - "id": 29, - "name": "Star Guardian Orianna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Orianna_29.jpg" - }, - { - "id": 38, - "name": "T1 Orianna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Orianna_38.jpg" - } - ] - }, - { - "championName": "Ornn", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ornn_0.jpg" - }, - { - "id": 1, - "name": "Thunder Lord Ornn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ornn_1.jpg" - }, - { - "id": 2, - "name": "Elderwood Ornn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ornn_2.jpg" - }, - { - "id": 11, - "name": "Space Groove Ornn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ornn_11.jpg" - }, - { - "id": 20, - "name": "Choo-Choo Ornn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ornn_20.jpg" - } - ] - }, - { - "championName": "Pantheon", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pantheon_0.jpg" - }, - { - "id": 1, - "name": "Myrmidon Pantheon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pantheon_1.jpg" - }, - { - "id": 2, - "name": "Ruthless Pantheon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pantheon_2.jpg" - }, - { - "id": 3, - "name": "Perseus Pantheon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pantheon_3.jpg" - }, - { - "id": 4, - "name": "Full Metal Pantheon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pantheon_4.jpg" - }, - { - "id": 5, - "name": "Glaive Warrior Pantheon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pantheon_5.jpg" - }, - { - "id": 6, - "name": "Dragonslayer Pantheon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pantheon_6.jpg" - }, - { - "id": 7, - "name": "Zombie Slayer Pantheon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pantheon_7.jpg" - }, - { - "id": 8, - "name": "Baker Pantheon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pantheon_8.jpg" - }, - { - "id": 16, - "name": "Pulsefire Pantheon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pantheon_16.jpg" - }, - { - "id": 25, - "name": "Ruined Pantheon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pantheon_25.jpg" - }, - { - "id": 26, - "name": "Prestige Ascended Pantheon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pantheon_26.jpg" - }, - { - "id": 36, - "name": "Ashen Conqueror Pantheon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pantheon_36.jpg" - }, - { - "id": 38, - "name": "Chosen of the Wolf Pantheon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pantheon_38.jpg" - } - ] - }, - { - "championName": "Poppy", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Poppy_0.jpg" - }, - { - "id": 1, - "name": "Noxus Poppy", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Poppy_1.jpg" - }, - { - "id": 2, - "name": "Lollipoppy", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Poppy_2.jpg" - }, - { - "id": 3, - "name": "Blacksmith Poppy", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Poppy_3.jpg" - }, - { - "id": 4, - "name": "Ragdoll Poppy", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Poppy_4.jpg" - }, - { - "id": 5, - "name": "Battle Regalia Poppy", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Poppy_5.jpg" - }, - { - "id": 6, - "name": "Scarlet Hammer Poppy", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Poppy_6.jpg" - }, - { - "id": 7, - "name": "Star Guardian Poppy", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Poppy_7.jpg" - }, - { - "id": 14, - "name": "Snow Fawn Poppy", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Poppy_14.jpg" - }, - { - "id": 15, - "name": "Hextech Poppy", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Poppy_15.jpg" - }, - { - "id": 16, - "name": "Astronaut Poppy", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Poppy_16.jpg" - }, - { - "id": 24, - "name": "Bewitching Poppy", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Poppy_24.jpg" - }, - { - "id": 33, - "name": "Cafe Cuties Poppy", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Poppy_33.jpg" - } - ] - }, - { - "championName": "Pyke", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pyke_0.jpg" - }, - { - "id": 1, - "name": "Sand Wraith Pyke", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pyke_1.jpg" - }, - { - "id": 9, - "name": "Blood Moon Pyke", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pyke_9.jpg" - }, - { - "id": 16, - "name": "PROJECT: Pyke", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pyke_16.jpg" - }, - { - "id": 25, - "name": "PsyOps Pyke", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pyke_25.jpg" - }, - { - "id": 34, - "name": "Sentinel Pyke", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pyke_34.jpg" - }, - { - "id": 44, - "name": "Ashen Knight Pyke", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pyke_44.jpg" - }, - { - "id": 45, - "name": "Empyrean Pyke", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pyke_45.jpg" - }, - { - "id": 53, - "name": "Soul Fighter Pyke", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pyke_53.jpg" - }, - { - "id": 54, - "name": "Prestige Soul Fighter Pyke", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pyke_54.jpg" - }, - { - "id": 64, - "name": "Fright Night Pyke", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pyke_64.jpg" - }, - { - "id": 74, - "name": "Inkshadow Pyke", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pyke_74.jpg" - }, - { - "id": 75, - "name": "T1 Pyke", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Pyke_75.jpg" - } - ] - }, - { - "championName": "Qiyana", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Qiyana_0.jpg" - }, - { - "id": 1, - "name": "Battle Boss Qiyana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Qiyana_1.jpg" - }, - { - "id": 2, - "name": "True Damage Qiyana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Qiyana_2.jpg" - }, - { - "id": 10, - "name": "Prestige True Damage Qiyana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Qiyana_10.jpg" - }, - { - "id": 11, - "name": "Battle Queen Qiyana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Qiyana_11.jpg" - }, - { - "id": 20, - "name": "Shockblade Qiyana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Qiyana_20.jpg" - }, - { - "id": 30, - "name": "Lunar Empress Qiyana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Qiyana_30.jpg" - }, - { - "id": 40, - "name": "La Ilusión Qiyana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Qiyana_40.jpg" - }, - { - "id": 50, - "name": "Prestige Battle Academia Qiyana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Qiyana_50.jpg" - } - ] - }, - { - "championName": "Quinn", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Quinn_0.jpg" - }, - { - "id": 1, - "name": "Phoenix Quinn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Quinn_1.jpg" - }, - { - "id": 2, - "name": "Woad Scout Quinn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Quinn_2.jpg" - }, - { - "id": 3, - "name": "Corsair Quinn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Quinn_3.jpg" - }, - { - "id": 4, - "name": "Heartseeker Quinn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Quinn_4.jpg" - }, - { - "id": 5, - "name": "Warden Quinn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Quinn_5.jpg" - }, - { - "id": 14, - "name": "Star Guardian Quinn", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Quinn_14.jpg" - } - ] - }, - { - "championName": "Rakan", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rakan_0.jpg" - }, - { - "id": 1, - "name": "Cosmic Dawn Rakan", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rakan_1.jpg" - }, - { - "id": 2, - "name": "Sweetheart Rakan", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rakan_2.jpg" - }, - { - "id": 3, - "name": "SSG Rakan", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rakan_3.jpg" - }, - { - "id": 4, - "name": "iG Rakan", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rakan_4.jpg" - }, - { - "id": 5, - "name": "Star Guardian Rakan", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rakan_5.jpg" - }, - { - "id": 9, - "name": "Elderwood Rakan", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rakan_9.jpg" - }, - { - "id": 18, - "name": "Arcana Rakan", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rakan_18.jpg" - }, - { - "id": 27, - "name": "Broken Covenant Rakan", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rakan_27.jpg" - }, - { - "id": 36, - "name": "Redeemed Star Guardian Rakan", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rakan_36.jpg" - }, - { - "id": 37, - "name": "Dragonmancer Rakan", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rakan_37.jpg" - }, - { - "id": 38, - "name": "Prestige Dragonmancer Rakan", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rakan_38.jpg" - }, - { - "id": 47, - "name": "Battle Academia Rakan", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rakan_47.jpg" - } - ] - }, - { - "championName": "Rammus", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rammus_0.jpg" - }, - { - "id": 1, - "name": "King Rammus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rammus_1.jpg" - }, - { - "id": 2, - "name": "Chrome Rammus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rammus_2.jpg" - }, - { - "id": 3, - "name": "Molten Rammus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rammus_3.jpg" - }, - { - "id": 4, - "name": "Freljord Rammus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rammus_4.jpg" - }, - { - "id": 5, - "name": "Ninja Rammus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rammus_5.jpg" - }, - { - "id": 6, - "name": "Full Metal Rammus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rammus_6.jpg" - }, - { - "id": 7, - "name": "Guardian of the Sands Rammus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rammus_7.jpg" - }, - { - "id": 8, - "name": "Sweeper Rammus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rammus_8.jpg" - }, - { - "id": 16, - "name": "Hextech Rammus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rammus_16.jpg" - }, - { - "id": 17, - "name": "Astronaut Rammus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rammus_17.jpg" - }, - { - "id": 26, - "name": "Durian Defender Rammus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rammus_26.jpg" - } - ] - }, - { - "championName": "Rek'Sai", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/RekSai_0.jpg" - }, - { - "id": 1, - "name": "Eternum Rek'Sai", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/RekSai_1.jpg" - }, - { - "id": 2, - "name": "Pool Party Rek'Sai", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/RekSai_2.jpg" - }, - { - "id": 9, - "name": "Blackfrost Rek'Sai", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/RekSai_9.jpg" - }, - { - "id": 17, - "name": "Elderwood Rek'Sai", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/RekSai_17.jpg" - } - ] - }, - { - "championName": "Rell", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rell_0.jpg" - }, - { - "id": 1, - "name": "Battle Queen Rell", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rell_1.jpg" - }, - { - "id": 10, - "name": "Star Guardian Rell", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rell_10.jpg" - }, - { - "id": 20, - "name": "High Noon Rell", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rell_20.jpg" - }, - { - "id": 30, - "name": "Grand Reckoning Rell", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rell_30.jpg" - } - ] - }, - { - "championName": "Renata Glasc", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renata_0.jpg" - }, - { - "id": 1, - "name": "Admiral Glasc", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renata_1.jpg" - }, - { - "id": 10, - "name": "Fright Night Renata Glasc", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renata_10.jpg" - } - ] - }, - { - "championName": "Renekton", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renekton_0.jpg" - }, - { - "id": 1, - "name": "Galactic Renekton", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renekton_1.jpg" - }, - { - "id": 2, - "name": "Outback Renekton", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renekton_2.jpg" - }, - { - "id": 3, - "name": "Bloodfury Renekton", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renekton_3.jpg" - }, - { - "id": 4, - "name": "Rune Wars Renekton", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renekton_4.jpg" - }, - { - "id": 5, - "name": "Scorched Earth Renekton", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renekton_5.jpg" - }, - { - "id": 6, - "name": "Pool Party Renekton", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renekton_6.jpg" - }, - { - "id": 7, - "name": "Prehistoric Renekton", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renekton_7.jpg" - }, - { - "id": 8, - "name": "SKT T1 Renekton", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renekton_8.jpg" - }, - { - "id": 9, - "name": "Renektoy", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renekton_9.jpg" - }, - { - "id": 17, - "name": "Hextech Renekton", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renekton_17.jpg" - }, - { - "id": 18, - "name": "Blackfrost Renekton", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renekton_18.jpg" - }, - { - "id": 26, - "name": "PROJECT: Renekton", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renekton_26.jpg" - }, - { - "id": 33, - "name": "Dawnbringer Renekton", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renekton_33.jpg" - }, - { - "id": 42, - "name": "Worlds 2023 Renekton", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renekton_42.jpg" - }, - { - "id": 48, - "name": "Inkshadow Renekton", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Renekton_48.jpg" - } - ] - }, - { - "championName": "Rengar", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rengar_0.jpg" - }, - { - "id": 1, - "name": "Headhunter Rengar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rengar_1.jpg" - }, - { - "id": 2, - "name": "Night Hunter Rengar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rengar_2.jpg" - }, - { - "id": 3, - "name": "SSW Rengar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rengar_3.jpg" - }, - { - "id": 8, - "name": "Mecha Rengar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rengar_8.jpg" - }, - { - "id": 15, - "name": "Pretty Kitty Rengar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rengar_15.jpg" - }, - { - "id": 23, - "name": "Guardian of the Sands Rengar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rengar_23.jpg" - }, - { - "id": 30, - "name": "Sentinel Rengar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rengar_30.jpg" - }, - { - "id": 40, - "name": "Street Demons Rengar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rengar_40.jpg" - } - ] - }, - { - "championName": "Riven", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Riven_0.jpg" - }, - { - "id": 1, - "name": "Redeemed Riven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Riven_1.jpg" - }, - { - "id": 2, - "name": "Crimson Elite Riven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Riven_2.jpg" - }, - { - "id": 3, - "name": "Battle Bunny Riven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Riven_3.jpg" - }, - { - "id": 5, - "name": "Dragonblade Riven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Riven_5.jpg" - }, - { - "id": 6, - "name": "Arcade Riven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Riven_6.jpg" - }, - { - "id": 7, - "name": "Reignited Worlds 2012 Riven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Riven_7.jpg" - }, - { - "id": 16, - "name": "Dawnbringer Riven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Riven_16.jpg" - }, - { - "id": 18, - "name": "Pulsefire Riven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Riven_18.jpg" - }, - { - "id": 20, - "name": "Valiant Sword Riven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Riven_20.jpg" - }, - { - "id": 22, - "name": "Prestige Valiant Sword Riven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Riven_22.jpg" - }, - { - "id": 23, - "name": "Spirit Blossom Riven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Riven_23.jpg" - }, - { - "id": 34, - "name": "Sentinel Riven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Riven_34.jpg" - }, - { - "id": 44, - "name": "Battle Bunny Prime Riven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Riven_44.jpg" - }, - { - "id": 55, - "name": "Broken Covenant Riven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Riven_55.jpg" - }, - { - "id": 63, - "name": "Primal Ambush Riven", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Riven_63.jpg" - } - ] - }, - { - "championName": "Rumble", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rumble_0.jpg" - }, - { - "id": 1, - "name": "Rumble in the Jungle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rumble_1.jpg" - }, - { - "id": 2, - "name": "Bilgerat Rumble", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rumble_2.jpg" - }, - { - "id": 3, - "name": "Super Galaxy Rumble", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rumble_3.jpg" - }, - { - "id": 4, - "name": "Badlands Baron Rumble", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rumble_4.jpg" - }, - { - "id": 13, - "name": "Space Groove Rumble", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rumble_13.jpg" - }, - { - "id": 23, - "name": "Cafe Cuties Rumble", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Rumble_23.jpg" - } - ] - }, - { - "championName": "Ryze", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ryze_0.jpg" - }, - { - "id": 1, - "name": "Young Ryze", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ryze_1.jpg" - }, - { - "id": 2, - "name": "Tribal Ryze", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ryze_2.jpg" - }, - { - "id": 3, - "name": "Uncle Ryze", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ryze_3.jpg" - }, - { - "id": 4, - "name": "Triumphant Ryze", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ryze_4.jpg" - }, - { - "id": 5, - "name": "Professor Ryze", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ryze_5.jpg" - }, - { - "id": 6, - "name": "Zombie Ryze", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ryze_6.jpg" - }, - { - "id": 7, - "name": "Dark Crystal Ryze", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ryze_7.jpg" - }, - { - "id": 8, - "name": "Pirate Ryze", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ryze_8.jpg" - }, - { - "id": 9, - "name": "Ryze Whitebeard", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ryze_9.jpg" - }, - { - "id": 10, - "name": "SKT T1 Ryze", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ryze_10.jpg" - }, - { - "id": 13, - "name": "Guardian of the Sands Ryze", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ryze_13.jpg" - }, - { - "id": 20, - "name": "Arcana Ryze", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ryze_20.jpg" - }, - { - "id": 29, - "name": "Blood Moon Ryze", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ryze_29.jpg" - } - ] - }, - { - "championName": "Samira", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Samira_0.jpg" - }, - { - "id": 1, - "name": "PsyOps Samira", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Samira_1.jpg" - }, - { - "id": 10, - "name": "Space Groove Samira", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Samira_10.jpg" - }, - { - "id": 20, - "name": "High Noon Samira", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Samira_20.jpg" - }, - { - "id": 30, - "name": "Soul Fighter Samira", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Samira_30.jpg" - }, - { - "id": 33, - "name": "Masque of the Black Rose Samira", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Samira_33.jpg" - } - ] - }, - { - "championName": "Sejuani", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sejuani_0.jpg" - }, - { - "id": 1, - "name": "Sabretusk Sejuani", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sejuani_1.jpg" - }, - { - "id": 2, - "name": "Darkrider Sejuani", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sejuani_2.jpg" - }, - { - "id": 3, - "name": "Traditional Sejuani", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sejuani_3.jpg" - }, - { - "id": 4, - "name": "Bear Cavalry Sejuani", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sejuani_4.jpg" - }, - { - "id": 5, - "name": "Poro Rider Sejuani", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sejuani_5.jpg" - }, - { - "id": 6, - "name": "Beast Hunter Sejuani", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sejuani_6.jpg" - }, - { - "id": 7, - "name": "Sejuani Dawnchaser", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sejuani_7.jpg" - }, - { - "id": 8, - "name": "Firecracker Sejuani", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sejuani_8.jpg" - }, - { - "id": 15, - "name": "Hextech Sejuani", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sejuani_15.jpg" - }, - { - "id": 16, - "name": "PROJECT: Sejuani", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sejuani_16.jpg" - }, - { - "id": 26, - "name": "Solar Eclipse Sejuani", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sejuani_26.jpg" - }, - { - "id": 36, - "name": "Victorious Sejuani", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sejuani_36.jpg" - } - ] - }, - { - "championName": "Senna", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Senna_0.jpg" - }, - { - "id": 1, - "name": "True Damage Senna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Senna_1.jpg" - }, - { - "id": 9, - "name": "Prestige True Damage Senna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Senna_9.jpg" - }, - { - "id": 10, - "name": "High Noon Senna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Senna_10.jpg" - }, - { - "id": 16, - "name": "PROJECT: Senna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Senna_16.jpg" - }, - { - "id": 26, - "name": "Lunar Eclipse Senna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Senna_26.jpg" - }, - { - "id": 27, - "name": "Prestige Lunar Eclipse Senna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Senna_27.jpg" - }, - { - "id": 36, - "name": "Bewitching Senna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Senna_36.jpg" - }, - { - "id": 46, - "name": "Star Guardian Senna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Senna_46.jpg" - }, - { - "id": 56, - "name": "Winterblessed Senna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Senna_56.jpg" - }, - { - "id": 63, - "name": "Masked Justice Senna", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Senna_63.jpg" - } - ] - }, - { - "championName": "Seraphine", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Seraphine_0.jpg" - }, - { - "id": 1, - "name": "K/DA ALL OUT Seraphine Indie", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Seraphine_1.jpg" - }, - { - "id": 2, - "name": "K/DA ALL OUT Seraphine Rising Star", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Seraphine_2.jpg" - }, - { - "id": 3, - "name": "K/DA ALL OUT Seraphine Superstar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Seraphine_3.jpg" - }, - { - "id": 4, - "name": "Graceful Phoenix Seraphine", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Seraphine_4.jpg" - }, - { - "id": 14, - "name": "Ocean Song Seraphine", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Seraphine_14.jpg" - }, - { - "id": 15, - "name": "Prestige Ocean Song Seraphine", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Seraphine_15.jpg" - }, - { - "id": 24, - "name": "Faerie Court Seraphine", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Seraphine_24.jpg" - }, - { - "id": 34, - "name": "Star Guardian Seraphine", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Seraphine_34.jpg" - }, - { - "id": 43, - "name": "Battle Dove Seraphine", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Seraphine_43.jpg" - }, - { - "id": 50, - "name": "Dumpling Darlings Seraphine", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Seraphine_50.jpg" - } - ] - }, - { - "championName": "Sett", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sett_0.jpg" - }, - { - "id": 1, - "name": "Mecha Kingdoms Sett", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sett_1.jpg" - }, - { - "id": 8, - "name": "Obsidian Dragon Sett", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sett_8.jpg" - }, - { - "id": 9, - "name": "Prestige Obsidian Dragon Sett", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sett_9.jpg" - }, - { - "id": 10, - "name": "Pool Party Sett", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sett_10.jpg" - }, - { - "id": 19, - "name": "Firecracker Sett", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sett_19.jpg" - }, - { - "id": 38, - "name": "Spirit Blossom Sett", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sett_38.jpg" - }, - { - "id": 45, - "name": "Soul Fighter Sett", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sett_45.jpg" - }, - { - "id": 56, - "name": "HEARTSTEEL Sett", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sett_56.jpg" - }, - { - "id": 66, - "name": "Radiant Serpent Sett", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sett_66.jpg" - }, - { - "id": 67, - "name": "Spirit Blossom Springs Sett", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sett_67.jpg" - } - ] - }, - { - "championName": "Shaco", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shaco_0.jpg" - }, - { - "id": 1, - "name": "Mad Hatter Shaco", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shaco_1.jpg" - }, - { - "id": 2, - "name": "Royal Shaco", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shaco_2.jpg" - }, - { - "id": 3, - "name": "Nutcracko", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shaco_3.jpg" - }, - { - "id": 4, - "name": "Workshop Shaco", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shaco_4.jpg" - }, - { - "id": 5, - "name": "Asylum Shaco", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shaco_5.jpg" - }, - { - "id": 6, - "name": "Masked Shaco", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shaco_6.jpg" - }, - { - "id": 7, - "name": "Wild Card Shaco", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shaco_7.jpg" - }, - { - "id": 8, - "name": "Dark Star Shaco", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shaco_8.jpg" - }, - { - "id": 15, - "name": "Arcanist Shaco", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shaco_15.jpg" - }, - { - "id": 23, - "name": "Crime City Nightmare Shaco", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shaco_23.jpg" - }, - { - "id": 33, - "name": "Winterblessed Shaco", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shaco_33.jpg" - }, - { - "id": 43, - "name": "Soul Fighter Shaco", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shaco_43.jpg" - }, - { - "id": 44, - "name": "Prestige Soul Fighter Shaco", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shaco_44.jpg" - }, - { - "id": 54, - "name": "Fright Night Shaco", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shaco_54.jpg" - }, - { - "id": 64, - "name": "Cat-in-the-Box Shaco", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shaco_64.jpg" - } - ] - }, - { - "championName": "Shen", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shen_0.jpg" - }, - { - "id": 1, - "name": "Frozen Shen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shen_1.jpg" - }, - { - "id": 2, - "name": "Yellow Jacket Shen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shen_2.jpg" - }, - { - "id": 3, - "name": "Surgeon Shen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shen_3.jpg" - }, - { - "id": 4, - "name": "Blood Moon Shen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shen_4.jpg" - }, - { - "id": 5, - "name": "Warlord Shen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shen_5.jpg" - }, - { - "id": 6, - "name": "TPA Shen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shen_6.jpg" - }, - { - "id": 15, - "name": "Pulsefire Shen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shen_15.jpg" - }, - { - "id": 16, - "name": "Infernal Shen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shen_16.jpg" - }, - { - "id": 22, - "name": "PsyOps Shen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shen_22.jpg" - }, - { - "id": 40, - "name": "Shockblade Shen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shen_40.jpg" - }, - { - "id": 49, - "name": "Ashen Guardian Shen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shen_49.jpg" - }, - { - "id": 51, - "name": "Three Honors Shen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shen_51.jpg" - } - ] - }, - { - "championName": "Shyvana", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shyvana_0.jpg" - }, - { - "id": 2, - "name": "Boneclaw Shyvana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shyvana_2.jpg" - }, - { - "id": 3, - "name": "Darkflame Shyvana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shyvana_3.jpg" - }, - { - "id": 4, - "name": "Ice Drake Shyvana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shyvana_4.jpg" - }, - { - "id": 6, - "name": "Super Galaxy Shyvana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shyvana_6.jpg" - }, - { - "id": 8, - "name": "Ruined Shyvana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shyvana_8.jpg" - }, - { - "id": 17, - "name": "Immortal Journey Shyvana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Shyvana_17.jpg" - } - ] - }, - { - "championName": "Singed", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Singed_0.jpg" - }, - { - "id": 1, - "name": "Riot Squad Singed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Singed_1.jpg" - }, - { - "id": 2, - "name": "Hextech Singed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Singed_2.jpg" - }, - { - "id": 3, - "name": "Surfer Singed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Singed_3.jpg" - }, - { - "id": 4, - "name": "Mad Scientist Singed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Singed_4.jpg" - }, - { - "id": 5, - "name": "Augmented Singed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Singed_5.jpg" - }, - { - "id": 6, - "name": "Snow Day Singed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Singed_6.jpg" - }, - { - "id": 7, - "name": "SSW Singed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Singed_7.jpg" - }, - { - "id": 8, - "name": "Black Scourge Singed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Singed_8.jpg" - }, - { - "id": 9, - "name": "Beekeeper Singed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Singed_9.jpg" - }, - { - "id": 10, - "name": "Resistance Singed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Singed_10.jpg" - }, - { - "id": 19, - "name": "Astronaut Singed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Singed_19.jpg" - }, - { - "id": 28, - "name": "Arcane Shimmer Lab Singed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Singed_28.jpg" - } - ] - }, - { - "championName": "Sion", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sion_0.jpg" - }, - { - "id": 1, - "name": "Hextech Sion", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sion_1.jpg" - }, - { - "id": 2, - "name": "Barbarian Sion", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sion_2.jpg" - }, - { - "id": 3, - "name": "Lumberjack Sion", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sion_3.jpg" - }, - { - "id": 4, - "name": "Warmonger Sion", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sion_4.jpg" - }, - { - "id": 5, - "name": "Mecha Zero Sion", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sion_5.jpg" - }, - { - "id": 14, - "name": "Worldbreaker Sion", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sion_14.jpg" - }, - { - "id": 22, - "name": "Blackfrost Sion", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sion_22.jpg" - }, - { - "id": 30, - "name": "High Noon Sion", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sion_30.jpg" - }, - { - "id": 40, - "name": "Cosmic Paladin Sion", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sion_40.jpg" - }, - { - "id": 49, - "name": "Grand Reckoning Sion", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sion_49.jpg" - } - ] - }, - { - "championName": "Sivir", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_0.jpg" - }, - { - "id": 1, - "name": "Warrior Princess Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_1.jpg" - }, - { - "id": 2, - "name": "Spectacular Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_2.jpg" - }, - { - "id": 3, - "name": "Huntress Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_3.jpg" - }, - { - "id": 4, - "name": "Bandit Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_4.jpg" - }, - { - "id": 5, - "name": "PAX Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_5.jpg" - }, - { - "id": 6, - "name": "Snowstorm Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_6.jpg" - }, - { - "id": 7, - "name": "Warden Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_7.jpg" - }, - { - "id": 8, - "name": "Victorious Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_8.jpg" - }, - { - "id": 9, - "name": "Neo PAX Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_9.jpg" - }, - { - "id": 10, - "name": "Pizza Delivery Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_10.jpg" - }, - { - "id": 16, - "name": "Blood Moon Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_16.jpg" - }, - { - "id": 25, - "name": "Odyssey Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_25.jpg" - }, - { - "id": 34, - "name": "Cafe Cuties Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_34.jpg" - }, - { - "id": 43, - "name": "Solar Eclipse Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_43.jpg" - }, - { - "id": 50, - "name": "Mythmaker Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_50.jpg" - }, - { - "id": 51, - "name": "Prestige Mythmaker Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_51.jpg" - }, - { - "id": 61, - "name": "Primal Ambush Sivir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_61.jpg" - }, - { - "id": 70, - "name": "Ann-Sivir-sary", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sivir_70.jpg" - } - ] - }, - { - "championName": "Skarner", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Skarner_0.jpg" - }, - { - "id": 1, - "name": "Sandscourge Skarner", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Skarner_1.jpg" - }, - { - "id": 2, - "name": "Earthrune Skarner", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Skarner_2.jpg" - }, - { - "id": 3, - "name": "Battlecast Alpha Skarner", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Skarner_3.jpg" - }, - { - "id": 4, - "name": "Guardian of the Sands Skarner", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Skarner_4.jpg" - }, - { - "id": 5, - "name": "Cosmic Sting Skarner", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Skarner_5.jpg" - } - ] - }, - { - "championName": "Smolder", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Smolder_0.jpg" - }, - { - "id": 1, - "name": "Heavenscale Smolder", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Smolder_1.jpg" - } - ] - }, - { - "championName": "Sona", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sona_0.jpg" - }, - { - "id": 1, - "name": "Muse Sona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sona_1.jpg" - }, - { - "id": 2, - "name": "Pentakill Sona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sona_2.jpg" - }, - { - "id": 3, - "name": "Silent Night Sona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sona_3.jpg" - }, - { - "id": 4, - "name": "Guqin Sona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sona_4.jpg" - }, - { - "id": 5, - "name": "Arcade Sona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sona_5.jpg" - }, - { - "id": 6, - "name": "DJ Sona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sona_6.jpg" - }, - { - "id": 7, - "name": "Sweetheart Sona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sona_7.jpg" - }, - { - "id": 9, - "name": "Odyssey Sona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sona_9.jpg" - }, - { - "id": 17, - "name": "PsyOps Sona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sona_17.jpg" - }, - { - "id": 26, - "name": "Pentakill III: Lost Chapter Sona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sona_26.jpg" - }, - { - "id": 35, - "name": "Star Guardian Sona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sona_35.jpg" - }, - { - "id": 45, - "name": "Immortal Journey Sona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sona_45.jpg" - }, - { - "id": 46, - "name": "Prestige Immortal Journey Sona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sona_46.jpg" - }, - { - "id": 56, - "name": "Victorious Sona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sona_56.jpg" - }, - { - "id": 66, - "name": "Spirit Blossom Springs Sona", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sona_66.jpg" - } - ] - }, - { - "championName": "Soraka", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_0.jpg" - }, - { - "id": 1, - "name": "Dryad Soraka", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_1.jpg" - }, - { - "id": 2, - "name": "Divine Soraka", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_2.jpg" - }, - { - "id": 3, - "name": "Celestine Soraka", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_3.jpg" - }, - { - "id": 4, - "name": "Reaper Soraka", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_4.jpg" - }, - { - "id": 5, - "name": "Order of the Banana Soraka", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_5.jpg" - }, - { - "id": 6, - "name": "Program Soraka", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_6.jpg" - }, - { - "id": 7, - "name": "Star Guardian Soraka", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_7.jpg" - }, - { - "id": 8, - "name": "Pajama Guardian Soraka", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_8.jpg" - }, - { - "id": 9, - "name": "Winter Wonder Soraka", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_9.jpg" - }, - { - "id": 15, - "name": "Dawnbringer Soraka", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_15.jpg" - }, - { - "id": 16, - "name": "Nightbringer Soraka", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_16.jpg" - }, - { - "id": 17, - "name": "Prestige Star Guardian Soraka", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_17.jpg" - }, - { - "id": 18, - "name": "Cafe Cuties Soraka", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_18.jpg" - }, - { - "id": 27, - "name": "Spirit Blossom Soraka", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_27.jpg" - }, - { - "id": 37, - "name": "Immortal Journey Soraka", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_37.jpg" - }, - { - "id": 44, - "name": "Faerie Court Soraka", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Soraka_44.jpg" - } - ] - }, - { - "championName": "Swain", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Swain_0.jpg" - }, - { - "id": 1, - "name": "Northern Front Swain", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Swain_1.jpg" - }, - { - "id": 2, - "name": "Bilgewater Swain", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Swain_2.jpg" - }, - { - "id": 3, - "name": "Tyrant Swain", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Swain_3.jpg" - }, - { - "id": 4, - "name": "Dragon Master Swain", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Swain_4.jpg" - }, - { - "id": 11, - "name": "Hextech Swain", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Swain_11.jpg" - }, - { - "id": 12, - "name": "Crystal Rose Swain", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Swain_12.jpg" - }, - { - "id": 21, - "name": "Winterblessed Swain", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Swain_21.jpg" - }, - { - "id": 32, - "name": "Chosen of the Wolf Swain", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Swain_32.jpg" - }, - { - "id": 33, - "name": "Prestige Chosen of the Wolf Swain", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Swain_33.jpg" - } - ] - }, - { - "championName": "Sylas", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sylas_0.jpg" - }, - { - "id": 1, - "name": "Lunar Wraith Sylas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sylas_1.jpg" - }, - { - "id": 8, - "name": "Freljord Sylas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sylas_8.jpg" - }, - { - "id": 13, - "name": "PROJECT: Sylas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sylas_13.jpg" - }, - { - "id": 14, - "name": "Prestige PROJECT: Sylas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sylas_14.jpg" - }, - { - "id": 24, - "name": "Battle Wolf Sylas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sylas_24.jpg" - }, - { - "id": 34, - "name": "Ashen Slayer Sylas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sylas_34.jpg" - }, - { - "id": 36, - "name": "Winterblessed Sylas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sylas_36.jpg" - }, - { - "id": 46, - "name": "Dark Star Sylas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sylas_46.jpg" - }, - { - "id": 53, - "name": "Prestige T1 Sylas", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Sylas_53.jpg" - } - ] - }, - { - "championName": "Syndra", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Syndra_0.jpg" - }, - { - "id": 1, - "name": "Justicar Syndra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Syndra_1.jpg" - }, - { - "id": 2, - "name": "Atlantean Syndra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Syndra_2.jpg" - }, - { - "id": 3, - "name": "Queen of Diamonds Syndra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Syndra_3.jpg" - }, - { - "id": 4, - "name": "Snow Day Syndra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Syndra_4.jpg" - }, - { - "id": 5, - "name": "SKT T1 Syndra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Syndra_5.jpg" - }, - { - "id": 6, - "name": "Star Guardian Syndra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Syndra_6.jpg" - }, - { - "id": 7, - "name": "Pool Party Syndra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Syndra_7.jpg" - }, - { - "id": 16, - "name": "Withered Rose Syndra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Syndra_16.jpg" - }, - { - "id": 25, - "name": "Bewitching Syndra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Syndra_25.jpg" - }, - { - "id": 34, - "name": "Prestige Star Guardian Syndra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Syndra_34.jpg" - }, - { - "id": 44, - "name": "Spirit Blossom Syndra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Syndra_44.jpg" - }, - { - "id": 54, - "name": "Coven Syndra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Syndra_54.jpg" - }, - { - "id": 65, - "name": "Dumpling Darlings Syndra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Syndra_65.jpg" - } - ] - }, - { - "championName": "Tahm Kench", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TahmKench_0.jpg" - }, - { - "id": 1, - "name": "Master Chef Tahm Kench", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TahmKench_1.jpg" - }, - { - "id": 2, - "name": "Urf Kench", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TahmKench_2.jpg" - }, - { - "id": 3, - "name": "Coin Emperor Tahm Kench", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TahmKench_3.jpg" - }, - { - "id": 11, - "name": "Arcana Tahm Kench", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TahmKench_11.jpg" - }, - { - "id": 20, - "name": "High Noon Tahm Kench", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TahmKench_20.jpg" - } - ] - }, - { - "championName": "Taliyah", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Taliyah_0.jpg" - }, - { - "id": 1, - "name": "Freljord Taliyah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Taliyah_1.jpg" - }, - { - "id": 2, - "name": "SSG Taliyah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Taliyah_2.jpg" - }, - { - "id": 3, - "name": "Pool Party Taliyah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Taliyah_3.jpg" - }, - { - "id": 11, - "name": "Star Guardian Taliyah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Taliyah_11.jpg" - }, - { - "id": 21, - "name": "Crystalis Motus Taliyah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Taliyah_21.jpg" - } - ] - }, - { - "championName": "Talon", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Talon_0.jpg" - }, - { - "id": 1, - "name": "Renegade Talon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Talon_1.jpg" - }, - { - "id": 2, - "name": "Crimson Elite Talon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Talon_2.jpg" - }, - { - "id": 3, - "name": "Dragonblade Talon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Talon_3.jpg" - }, - { - "id": 4, - "name": "SSW Talon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Talon_4.jpg" - }, - { - "id": 5, - "name": "Blood Moon Talon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Talon_5.jpg" - }, - { - "id": 12, - "name": "Enduring Sword Talon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Talon_12.jpg" - }, - { - "id": 20, - "name": "Talon Blackwood", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Talon_20.jpg" - }, - { - "id": 29, - "name": "Withered Rose Talon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Talon_29.jpg" - }, - { - "id": 38, - "name": "High Noon Talon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Talon_38.jpg" - }, - { - "id": 39, - "name": "Prestige High Noon Talon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Talon_39.jpg" - }, - { - "id": 49, - "name": "Primal Ambush Talon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Talon_49.jpg" - }, - { - "id": 59, - "name": "Grand Reckoning Talon", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Talon_59.jpg" - } - ] - }, - { - "championName": "Taric", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Taric_0.jpg" - }, - { - "id": 1, - "name": "Emerald Taric", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Taric_1.jpg" - }, - { - "id": 2, - "name": "Armor of the Fifth Age Taric", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Taric_2.jpg" - }, - { - "id": 3, - "name": "Bloodstone Taric", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Taric_3.jpg" - }, - { - "id": 4, - "name": "Pool Party Taric", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Taric_4.jpg" - }, - { - "id": 9, - "name": "Taric Luminshield", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Taric_9.jpg" - }, - { - "id": 18, - "name": "Space Groove Taric", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Taric_18.jpg" - }, - { - "id": 27, - "name": "Fatebreaker Taric", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Taric_27.jpg" - } - ] - }, - { - "championName": "Teemo", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Teemo_0.jpg" - }, - { - "id": 1, - "name": "Happy Elf Teemo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Teemo_1.jpg" - }, - { - "id": 2, - "name": "Recon Teemo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Teemo_2.jpg" - }, - { - "id": 3, - "name": "Badger Teemo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Teemo_3.jpg" - }, - { - "id": 4, - "name": "Astronaut Teemo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Teemo_4.jpg" - }, - { - "id": 5, - "name": "Cottontail Teemo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Teemo_5.jpg" - }, - { - "id": 6, - "name": "Super Teemo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Teemo_6.jpg" - }, - { - "id": 7, - "name": "Panda Teemo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Teemo_7.jpg" - }, - { - "id": 8, - "name": "Omega Squad Teemo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Teemo_8.jpg" - }, - { - "id": 14, - "name": "Little Devil Teemo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Teemo_14.jpg" - }, - { - "id": 18, - "name": "Beemo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Teemo_18.jpg" - }, - { - "id": 25, - "name": "Spirit Blossom Teemo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Teemo_25.jpg" - }, - { - "id": 27, - "name": "Prestige Spirit Blossom Teemo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Teemo_27.jpg" - }, - { - "id": 37, - "name": "Firecracker Teemo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Teemo_37.jpg" - }, - { - "id": 47, - "name": "Space Groove Teemo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Teemo_47.jpg" - }, - { - "id": 54, - "name": "Spirit Blossom Springs Teemo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Teemo_54.jpg" - } - ] - }, - { - "championName": "Thresh", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Thresh_0.jpg" - }, - { - "id": 1, - "name": "Deep Terror Thresh", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Thresh_1.jpg" - }, - { - "id": 3, - "name": "Blood Moon Thresh", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Thresh_3.jpg" - }, - { - "id": 4, - "name": "SSW Thresh", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Thresh_4.jpg" - }, - { - "id": 5, - "name": "Dark Star Thresh", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Thresh_5.jpg" - }, - { - "id": 6, - "name": "High Noon Thresh", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Thresh_6.jpg" - }, - { - "id": 13, - "name": "Pulsefire Thresh", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Thresh_13.jpg" - }, - { - "id": 14, - "name": "Prestige Pulsefire Thresh", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Thresh_14.jpg" - }, - { - "id": 15, - "name": "FPX Thresh", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Thresh_15.jpg" - }, - { - "id": 17, - "name": "Spirit Blossom Thresh", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Thresh_17.jpg" - }, - { - "id": 27, - "name": "Unbound Thresh", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Thresh_27.jpg" - }, - { - "id": 28, - "name": "Steel Dragon Thresh", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Thresh_28.jpg" - }, - { - "id": 39, - "name": "Lunar Emperor Thresh", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Thresh_39.jpg" - }, - { - "id": 49, - "name": "Winterblessed Thresh", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Thresh_49.jpg" - }, - { - "id": 59, - "name": "Janitor Thresh", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Thresh_59.jpg" - } - ] - }, - { - "championName": "Tristana", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tristana_0.jpg" - }, - { - "id": 1, - "name": "Riot Girl Tristana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tristana_1.jpg" - }, - { - "id": 2, - "name": "Earnest Elf Tristana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tristana_2.jpg" - }, - { - "id": 3, - "name": "Firefighter Tristana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tristana_3.jpg" - }, - { - "id": 4, - "name": "Guerilla Tristana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tristana_4.jpg" - }, - { - "id": 5, - "name": "Buccaneer Tristana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tristana_5.jpg" - }, - { - "id": 6, - "name": "Rocket Girl Tristana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tristana_6.jpg" - }, - { - "id": 10, - "name": "Dragon Trainer Tristana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tristana_10.jpg" - }, - { - "id": 11, - "name": "Bewitching Tristana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tristana_11.jpg" - }, - { - "id": 12, - "name": "Omega Squad Tristana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tristana_12.jpg" - }, - { - "id": 24, - "name": "Little Demon Tristana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tristana_24.jpg" - }, - { - "id": 33, - "name": "Pengu Cosplay Tristana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tristana_33.jpg" - }, - { - "id": 40, - "name": "Hextech Tristana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tristana_40.jpg" - }, - { - "id": 41, - "name": "Firecracker Tristana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tristana_41.jpg" - }, - { - "id": 51, - "name": "Spirit Blossom Tristana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tristana_51.jpg" - }, - { - "id": 61, - "name": "Faerie Court Tristana", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tristana_61.jpg" - } - ] - }, - { - "championName": "Trundle", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Trundle_0.jpg" - }, - { - "id": 1, - "name": "Lil' Slugger Trundle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Trundle_1.jpg" - }, - { - "id": 2, - "name": "Junkyard Trundle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Trundle_2.jpg" - }, - { - "id": 3, - "name": "Traditional Trundle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Trundle_3.jpg" - }, - { - "id": 4, - "name": "Constable Trundle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Trundle_4.jpg" - }, - { - "id": 5, - "name": "Worldbreaker Trundle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Trundle_5.jpg" - }, - { - "id": 6, - "name": "Dragonslayer Trundle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Trundle_6.jpg" - }, - { - "id": 12, - "name": "Fright Night Trundle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Trundle_12.jpg" - }, - { - "id": 21, - "name": "Esports Fan Trundle", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Trundle_21.jpg" - } - ] - }, - { - "championName": "Tryndamere", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tryndamere_0.jpg" - }, - { - "id": 1, - "name": "Highland Tryndamere", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tryndamere_1.jpg" - }, - { - "id": 2, - "name": "King Tryndamere", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tryndamere_2.jpg" - }, - { - "id": 3, - "name": "Viking Tryndamere", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tryndamere_3.jpg" - }, - { - "id": 4, - "name": "Demonblade Tryndamere", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tryndamere_4.jpg" - }, - { - "id": 5, - "name": "Sultan Tryndamere", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tryndamere_5.jpg" - }, - { - "id": 6, - "name": "Warring Kingdoms Tryndamere", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tryndamere_6.jpg" - }, - { - "id": 7, - "name": "Nightmare Tryndamere", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tryndamere_7.jpg" - }, - { - "id": 8, - "name": "Beast Hunter Tryndamere", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tryndamere_8.jpg" - }, - { - "id": 9, - "name": "Chemtech Tryndamere", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tryndamere_9.jpg" - }, - { - "id": 10, - "name": "Blood Moon Tryndamere", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tryndamere_10.jpg" - }, - { - "id": 18, - "name": "Nightbringer Tryndamere", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tryndamere_18.jpg" - }, - { - "id": 27, - "name": "Victorious Tryndamere", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tryndamere_27.jpg" - }, - { - "id": 37, - "name": "Visions of the Fallen Tryndamere", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Tryndamere_37.jpg" - } - ] - }, - { - "championName": "Twisted Fate", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TwistedFate_0.jpg" - }, - { - "id": 1, - "name": "PAX Twisted Fate", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TwistedFate_1.jpg" - }, - { - "id": 2, - "name": "Jack of Hearts Twisted Fate", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TwistedFate_2.jpg" - }, - { - "id": 3, - "name": "The Magnificent Twisted Fate", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TwistedFate_3.jpg" - }, - { - "id": 4, - "name": "Tango Twisted Fate", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TwistedFate_4.jpg" - }, - { - "id": 5, - "name": "High Noon Twisted Fate", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TwistedFate_5.jpg" - }, - { - "id": 6, - "name": "Musketeer Twisted Fate", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TwistedFate_6.jpg" - }, - { - "id": 7, - "name": "Underworld Twisted Fate", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TwistedFate_7.jpg" - }, - { - "id": 8, - "name": "Red Card Twisted Fate", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TwistedFate_8.jpg" - }, - { - "id": 9, - "name": "Cutpurse Twisted Fate", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TwistedFate_9.jpg" - }, - { - "id": 10, - "name": "Blood Moon Twisted Fate", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TwistedFate_10.jpg" - }, - { - "id": 11, - "name": "Pulsefire Twisted Fate", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TwistedFate_11.jpg" - }, - { - "id": 13, - "name": "Odyssey Twisted Fate", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TwistedFate_13.jpg" - }, - { - "id": 23, - "name": "DWG Twisted Fate", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TwistedFate_23.jpg" - }, - { - "id": 25, - "name": "Crime City Nightmare Twisted Fate", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/TwistedFate_25.jpg" - } - ] - }, - { - "championName": "Twitch", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Twitch_0.jpg" - }, - { - "id": 1, - "name": "Kingpin Twitch", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Twitch_1.jpg" - }, - { - "id": 2, - "name": "Whistler Village Twitch", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Twitch_2.jpg" - }, - { - "id": 3, - "name": "Medieval Twitch", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Twitch_3.jpg" - }, - { - "id": 4, - "name": "Crime City Twitch", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Twitch_4.jpg" - }, - { - "id": 5, - "name": "Vandal Twitch", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Twitch_5.jpg" - }, - { - "id": 6, - "name": "Pickpocket Twitch", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Twitch_6.jpg" - }, - { - "id": 7, - "name": "SSW Twitch", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Twitch_7.jpg" - }, - { - "id": 8, - "name": "Omega Squad Twitch", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Twitch_8.jpg" - }, - { - "id": 12, - "name": "Ice King Twitch", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Twitch_12.jpg" - }, - { - "id": 27, - "name": "Twitch Shadowfoot", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Twitch_27.jpg" - }, - { - "id": 36, - "name": "Dragonslayer Twitch", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Twitch_36.jpg" - }, - { - "id": 45, - "name": "High Noon Twitch", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Twitch_45.jpg" - }, - { - "id": 55, - "name": "Cheddar Chief Twitch", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Twitch_55.jpg" - }, - { - "id": 64, - "name": "Pool Party Twitch", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Twitch_64.jpg" - } - ] - }, - { - "championName": "Udyr", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Udyr_0.jpg" - }, - { - "id": 1, - "name": "Black Belt Udyr", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Udyr_1.jpg" - }, - { - "id": 2, - "name": "Primal Udyr", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Udyr_2.jpg" - }, - { - "id": 3, - "name": "Spirit Guard Udyr", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Udyr_3.jpg" - }, - { - "id": 4, - "name": "Definitely Not Udyr", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Udyr_4.jpg" - }, - { - "id": 5, - "name": "Dragon Oracle Udyr", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Udyr_5.jpg" - }, - { - "id": 6, - "name": "Inkshadow Udyr", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Udyr_6.jpg" - } - ] - }, - { - "championName": "Urgot", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Urgot_0.jpg" - }, - { - "id": 1, - "name": "Giant Enemy Crabgot", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Urgot_1.jpg" - }, - { - "id": 2, - "name": "Butcher Urgot", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Urgot_2.jpg" - }, - { - "id": 3, - "name": "Battlecast Urgot", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Urgot_3.jpg" - }, - { - "id": 9, - "name": "High Noon Urgot", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Urgot_9.jpg" - }, - { - "id": 15, - "name": "Pajama Guardian Cosplay Urgot", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Urgot_15.jpg" - }, - { - "id": 23, - "name": "Fright Night Urgot", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Urgot_23.jpg" - }, - { - "id": 32, - "name": "Urgot the Clogfather", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Urgot_32.jpg" - } - ] - }, - { - "championName": "Varus", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Varus_0.jpg" - }, - { - "id": 1, - "name": "Blight Crystal Varus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Varus_1.jpg" - }, - { - "id": 2, - "name": "Arclight Varus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Varus_2.jpg" - }, - { - "id": 3, - "name": "Arctic Ops Varus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Varus_3.jpg" - }, - { - "id": 4, - "name": "Heartseeker Varus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Varus_4.jpg" - }, - { - "id": 5, - "name": "Varus Swiftbolt", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Varus_5.jpg" - }, - { - "id": 6, - "name": "Dark Star Varus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Varus_6.jpg" - }, - { - "id": 7, - "name": "Conqueror Varus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Varus_7.jpg" - }, - { - "id": 9, - "name": "Infernal Varus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Varus_9.jpg" - }, - { - "id": 16, - "name": "PROJECT: Varus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Varus_16.jpg" - }, - { - "id": 17, - "name": "Cosmic Hunter Varus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Varus_17.jpg" - }, - { - "id": 34, - "name": "High Noon Varus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Varus_34.jpg" - }, - { - "id": 44, - "name": "Snow Moon Varus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Varus_44.jpg" - }, - { - "id": 53, - "name": "Empyrean Varus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Varus_53.jpg" - }, - { - "id": 60, - "name": "Spirit Blossom Varus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Varus_60.jpg" - }, - { - "id": 69, - "name": "T1 Varus", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Varus_69.jpg" - } - ] - }, - { - "championName": "Vayne", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_0.jpg" - }, - { - "id": 1, - "name": "Vindicator Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_1.jpg" - }, - { - "id": 2, - "name": "Aristocrat Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_2.jpg" - }, - { - "id": 3, - "name": "Dragonslayer Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_3.jpg" - }, - { - "id": 4, - "name": "Heartseeker Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_4.jpg" - }, - { - "id": 5, - "name": "SKT T1 Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_5.jpg" - }, - { - "id": 6, - "name": "Arclight Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_6.jpg" - }, - { - "id": 10, - "name": "Soulstealer Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_10.jpg" - }, - { - "id": 11, - "name": "PROJECT: Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_11.jpg" - }, - { - "id": 12, - "name": "Firecracker Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_12.jpg" - }, - { - "id": 13, - "name": "Prestige Firecracker Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_13.jpg" - }, - { - "id": 14, - "name": "Spirit Blossom Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_14.jpg" - }, - { - "id": 15, - "name": "FPX Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_15.jpg" - }, - { - "id": 25, - "name": "Sentinel Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_25.jpg" - }, - { - "id": 32, - "name": "Battle Bat Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_32.jpg" - }, - { - "id": 44, - "name": "Dawnbringer Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_44.jpg" - }, - { - "id": 55, - "name": "Dragonmancer Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_55.jpg" - }, - { - "id": 64, - "name": "Risen Legend Vayne", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vayne_64.jpg" - } - ] - }, - { - "championName": "Veigar", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Veigar_0.jpg" - }, - { - "id": 1, - "name": "White Mage Veigar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Veigar_1.jpg" - }, - { - "id": 2, - "name": "Curling Veigar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Veigar_2.jpg" - }, - { - "id": 3, - "name": "Veigar Greybeard", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Veigar_3.jpg" - }, - { - "id": 4, - "name": "Leprechaun Veigar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Veigar_4.jpg" - }, - { - "id": 5, - "name": "Baron Von Veigar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Veigar_5.jpg" - }, - { - "id": 6, - "name": "Superb Villain Veigar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Veigar_6.jpg" - }, - { - "id": 7, - "name": "Bad Santa Veigar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Veigar_7.jpg" - }, - { - "id": 8, - "name": "Final Boss Veigar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Veigar_8.jpg" - }, - { - "id": 9, - "name": "Omega Squad Veigar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Veigar_9.jpg" - }, - { - "id": 13, - "name": "Elderwood Veigar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Veigar_13.jpg" - }, - { - "id": 23, - "name": "Furyhorn Cosplay Veigar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Veigar_23.jpg" - }, - { - "id": 32, - "name": "Astronaut Veigar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Veigar_32.jpg" - }, - { - "id": 41, - "name": "Monster Tamer Veigar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Veigar_41.jpg" - }, - { - "id": 51, - "name": "King Beegar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Veigar_51.jpg" - }, - { - "id": 60, - "name": "Fright Night Veigar", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Veigar_60.jpg" - } - ] - }, - { - "championName": "Vel'Koz", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Velkoz_0.jpg" - }, - { - "id": 1, - "name": "Battlecast Vel'Koz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Velkoz_1.jpg" - }, - { - "id": 2, - "name": "Arclight Vel'Koz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Velkoz_2.jpg" - }, - { - "id": 3, - "name": "Definitely Not Vel'Koz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Velkoz_3.jpg" - }, - { - "id": 4, - "name": "Infernal Vel'Koz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Velkoz_4.jpg" - }, - { - "id": 11, - "name": "Blackfrost Vel'Koz", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Velkoz_11.jpg" - } - ] - }, - { - "championName": "Vex", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vex_0.jpg" - }, - { - "id": 1, - "name": "Dawnbringer Vex", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vex_1.jpg" - }, - { - "id": 10, - "name": "Empyrean Vex", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vex_10.jpg" - }, - { - "id": 20, - "name": "Stargazer Vex", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vex_20.jpg" - } - ] - }, - { - "championName": "Vi", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vi_0.jpg" - }, - { - "id": 1, - "name": "Neon Strike Vi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vi_1.jpg" - }, - { - "id": 2, - "name": "Officer Vi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vi_2.jpg" - }, - { - "id": 3, - "name": "Debonair Vi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vi_3.jpg" - }, - { - "id": 4, - "name": "Demon Vi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vi_4.jpg" - }, - { - "id": 5, - "name": "Warring Kingdoms Vi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vi_5.jpg" - }, - { - "id": 11, - "name": "PROJECT: Vi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vi_11.jpg" - }, - { - "id": 12, - "name": "Heartbreaker Vi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vi_12.jpg" - }, - { - "id": 20, - "name": "PsyOps Vi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vi_20.jpg" - }, - { - "id": 30, - "name": "Heartache Vi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vi_30.jpg" - }, - { - "id": 39, - "name": "Primal Ambush Vi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vi_39.jpg" - }, - { - "id": 48, - "name": "Arcane Brawler Vi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vi_48.jpg" - }, - { - "id": 49, - "name": "T1 Vi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vi_49.jpg" - } - ] - }, - { - "championName": "Viego", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Viego_0.jpg" - }, - { - "id": 1, - "name": "Lunar Beast Viego", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Viego_1.jpg" - }, - { - "id": 10, - "name": "Dissonance of Pentakill Viego", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Viego_10.jpg" - }, - { - "id": 19, - "name": "EDG Viego", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Viego_19.jpg" - }, - { - "id": 21, - "name": "King Viego", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Viego_21.jpg" - }, - { - "id": 30, - "name": "Soul Fighter Viego", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Viego_30.jpg" - }, - { - "id": 37, - "name": "Worlds 2024 Viego", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Viego_37.jpg" - } - ] - }, - { - "championName": "Viktor", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Viktor_0.jpg" - }, - { - "id": 1, - "name": "Full Machine Viktor", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Viktor_1.jpg" - }, - { - "id": 2, - "name": "Prototype Viktor", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Viktor_2.jpg" - }, - { - "id": 3, - "name": "Creator Viktor", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Viktor_3.jpg" - }, - { - "id": 4, - "name": "Death Sworn Viktor", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Viktor_4.jpg" - }, - { - "id": 5, - "name": "PsyOps Viktor", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Viktor_5.jpg" - }, - { - "id": 14, - "name": "High Noon Viktor", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Viktor_14.jpg" - }, - { - "id": 24, - "name": "Arcane Savior Viktor", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Viktor_24.jpg" - } - ] - }, - { - "championName": "Vladimir", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vladimir_0.jpg" - }, - { - "id": 1, - "name": "Count Vladimir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vladimir_1.jpg" - }, - { - "id": 2, - "name": "Marquis Vladimir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vladimir_2.jpg" - }, - { - "id": 3, - "name": "Nosferatu Vladimir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vladimir_3.jpg" - }, - { - "id": 4, - "name": "Vandal Vladimir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vladimir_4.jpg" - }, - { - "id": 5, - "name": "Blood Lord Vladimir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vladimir_5.jpg" - }, - { - "id": 6, - "name": "Soulstealer Vladimir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vladimir_6.jpg" - }, - { - "id": 7, - "name": "Academy Vladimir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vladimir_7.jpg" - }, - { - "id": 8, - "name": "Dark Waters Vladimir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vladimir_8.jpg" - }, - { - "id": 14, - "name": "Nightbringer Vladimir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vladimir_14.jpg" - }, - { - "id": 21, - "name": "Cosmic Devourer Vladimir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vladimir_21.jpg" - }, - { - "id": 30, - "name": "Cafe Cuties Vladimir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vladimir_30.jpg" - }, - { - "id": 39, - "name": "Broken Covenant Vladimir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vladimir_39.jpg" - }, - { - "id": 48, - "name": "Masque of the Black Rose Vladimir", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Vladimir_48.jpg" - } - ] - }, - { - "championName": "Volibear", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Volibear_0.jpg" - }, - { - "id": 1, - "name": "Thunder Lord Volibear", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Volibear_1.jpg" - }, - { - "id": 2, - "name": "Northern Storm Volibear", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Volibear_2.jpg" - }, - { - "id": 3, - "name": "Runeguard Volibear", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Volibear_3.jpg" - }, - { - "id": 4, - "name": "Captain Volibear", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Volibear_4.jpg" - }, - { - "id": 5, - "name": "El Rayo Volibear", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Volibear_5.jpg" - }, - { - "id": 6, - "name": "The Thousand-Pierced Bear", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Volibear_6.jpg" - }, - { - "id": 7, - "name": "Duality Dragon Volibear", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Volibear_7.jpg" - }, - { - "id": 9, - "name": "Prestige Duality Dragon Volibear", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Volibear_9.jpg" - }, - { - "id": 19, - "name": "Inkshadow Volibear", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Volibear_19.jpg" - }, - { - "id": 29, - "name": "Spirit Blossom Springs Volibear", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Volibear_29.jpg" - } - ] - }, - { - "championName": "Warwick", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Warwick_0.jpg" - }, - { - "id": 1, - "name": "Grey Warwick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Warwick_1.jpg" - }, - { - "id": 2, - "name": "Urf the Manatee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Warwick_2.jpg" - }, - { - "id": 3, - "name": "Big Bad Warwick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Warwick_3.jpg" - }, - { - "id": 4, - "name": "Tundra Hunter Warwick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Warwick_4.jpg" - }, - { - "id": 5, - "name": "Feral Warwick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Warwick_5.jpg" - }, - { - "id": 6, - "name": "Firefang Warwick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Warwick_6.jpg" - }, - { - "id": 7, - "name": "Hyena Warwick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Warwick_7.jpg" - }, - { - "id": 8, - "name": "Marauder Warwick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Warwick_8.jpg" - }, - { - "id": 9, - "name": "Urfwick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Warwick_9.jpg" - }, - { - "id": 10, - "name": "Lunar Guardian Warwick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Warwick_10.jpg" - }, - { - "id": 16, - "name": "PROJECT: Warwick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Warwick_16.jpg" - }, - { - "id": 35, - "name": "Old God Warwick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Warwick_35.jpg" - }, - { - "id": 45, - "name": "Winterblessed Warwick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Warwick_45.jpg" - }, - { - "id": 46, - "name": "Prestige Winterblessed Warwick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Warwick_46.jpg" - }, - { - "id": 56, - "name": "Arcane Vander Warwick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Warwick_56.jpg" - } - ] - }, - { - "championName": "Wukong", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MonkeyKing_0.jpg" - }, - { - "id": 1, - "name": "Volcanic Wukong", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MonkeyKing_1.jpg" - }, - { - "id": 2, - "name": "General Wukong", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MonkeyKing_2.jpg" - }, - { - "id": 3, - "name": "Jade Dragon Wukong", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MonkeyKing_3.jpg" - }, - { - "id": 4, - "name": "Underworld Wukong", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MonkeyKing_4.jpg" - }, - { - "id": 5, - "name": "Radiant Wukong", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MonkeyKing_5.jpg" - }, - { - "id": 6, - "name": "Lancer Stratus Wukong", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MonkeyKing_6.jpg" - }, - { - "id": 7, - "name": "Battle Academia Wukong", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/MonkeyKing_7.jpg" - } - ] - }, - { - "championName": "Xayah", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xayah_0.jpg" - }, - { - "id": 1, - "name": "Cosmic Dusk Xayah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xayah_1.jpg" - }, - { - "id": 2, - "name": "Sweetheart Xayah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xayah_2.jpg" - }, - { - "id": 3, - "name": "SSG Xayah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xayah_3.jpg" - }, - { - "id": 4, - "name": "Star Guardian Xayah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xayah_4.jpg" - }, - { - "id": 8, - "name": "Elderwood Xayah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xayah_8.jpg" - }, - { - "id": 17, - "name": "Brave Phoenix Xayah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xayah_17.jpg" - }, - { - "id": 26, - "name": "Prestige Brave Phoenix Xayah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xayah_26.jpg" - }, - { - "id": 28, - "name": "Arcana Xayah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xayah_28.jpg" - }, - { - "id": 37, - "name": "Broken Covenant Xayah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xayah_37.jpg" - }, - { - "id": 38, - "name": "Redeemed Star Guardian Xayah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xayah_38.jpg" - }, - { - "id": 47, - "name": "Battle Bat Xayah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xayah_47.jpg" - }, - { - "id": 57, - "name": "Battle Academia Xayah", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xayah_57.jpg" - } - ] - }, - { - "championName": "Xerath", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xerath_0.jpg" - }, - { - "id": 1, - "name": "Runeborn Xerath", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xerath_1.jpg" - }, - { - "id": 2, - "name": "Battlecast Xerath", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xerath_2.jpg" - }, - { - "id": 3, - "name": "Scorched Earth Xerath", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xerath_3.jpg" - }, - { - "id": 4, - "name": "Guardian of the Sands Xerath", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xerath_4.jpg" - }, - { - "id": 5, - "name": "Dark Star Xerath", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xerath_5.jpg" - }, - { - "id": 12, - "name": "Arcana Xerath", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xerath_12.jpg" - }, - { - "id": 21, - "name": "Astronaut Xerath", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xerath_21.jpg" - }, - { - "id": 30, - "name": "Crystalis Indomitus Xerath", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Xerath_30.jpg" - } - ] - }, - { - "championName": "Xin Zhao", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/XinZhao_0.jpg" - }, - { - "id": 1, - "name": "Commando Xin Zhao", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/XinZhao_1.jpg" - }, - { - "id": 2, - "name": "Imperial Xin Zhao", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/XinZhao_2.jpg" - }, - { - "id": 3, - "name": "Viscero Xin Zhao", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/XinZhao_3.jpg" - }, - { - "id": 4, - "name": "Winged Hussar Xin Zhao", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/XinZhao_4.jpg" - }, - { - "id": 5, - "name": "Warring Kingdoms Xin Zhao", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/XinZhao_5.jpg" - }, - { - "id": 6, - "name": "Secret Agent Xin Zhao", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/XinZhao_6.jpg" - }, - { - "id": 13, - "name": "Dragonslayer Xin Zhao", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/XinZhao_13.jpg" - }, - { - "id": 20, - "name": "Cosmic Defender Xin Zhao", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/XinZhao_20.jpg" - }, - { - "id": 27, - "name": "Marauder Xin Zhao", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/XinZhao_27.jpg" - }, - { - "id": 36, - "name": "Firecracker Xin Zhao", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/XinZhao_36.jpg" - } - ] - }, - { - "championName": "Yasuo", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_0.jpg" - }, - { - "id": 1, - "name": "High Noon Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_1.jpg" - }, - { - "id": 2, - "name": "PROJECT: Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_2.jpg" - }, - { - "id": 3, - "name": "Blood Moon Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_3.jpg" - }, - { - "id": 9, - "name": "Nightbringer Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_9.jpg" - }, - { - "id": 10, - "name": "Odyssey Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_10.jpg" - }, - { - "id": 17, - "name": "Battle Boss Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_17.jpg" - }, - { - "id": 18, - "name": "True Damage Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_18.jpg" - }, - { - "id": 35, - "name": "Prestige True Damage Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_35.jpg" - }, - { - "id": 36, - "name": "Spirit Blossom Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_36.jpg" - }, - { - "id": 45, - "name": "Sea Dog Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_45.jpg" - }, - { - "id": 54, - "name": "Truth Dragon Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_54.jpg" - }, - { - "id": 55, - "name": "Dream Dragon Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_55.jpg" - }, - { - "id": 56, - "name": "Inkshadow Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_56.jpg" - }, - { - "id": 57, - "name": "Prestige Inkshadow Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_57.jpg" - }, - { - "id": 68, - "name": "Foreseen Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_68.jpg" - }, - { - "id": 77, - "name": "Battle Wolf Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_77.jpg" - }, - { - "id": 87, - "name": "Genesis Nightbringer Yasuo", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yasuo_87.jpg" - } - ] - }, - { - "championName": "Yone", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yone_0.jpg" - }, - { - "id": 1, - "name": "Spirit Blossom Yone", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yone_1.jpg" - }, - { - "id": 10, - "name": "Battle Academia Yone", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yone_10.jpg" - }, - { - "id": 19, - "name": "Dawnbringer Yone", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yone_19.jpg" - }, - { - "id": 26, - "name": "Ocean Song Yone", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yone_26.jpg" - }, - { - "id": 35, - "name": "Inkshadow Yone", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yone_35.jpg" - }, - { - "id": 45, - "name": "HEARTSTEEL Yone", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yone_45.jpg" - }, - { - "id": 46, - "name": "Prestige HEARTSTEEL Yone", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yone_46.jpg" - }, - { - "id": 55, - "name": "High Noon Yone", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yone_55.jpg" - }, - { - "id": 58, - "name": "Peacemaker High Noon Yone", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yone_58.jpg" - }, - { - "id": 65, - "name": "Masked Justice Yone", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yone_65.jpg" - }, - { - "id": 74, - "name": "T1 Yone", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yone_74.jpg" - } - ] - }, - { - "championName": "Yorick", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yorick_0.jpg" - }, - { - "id": 1, - "name": "Undertaker Yorick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yorick_1.jpg" - }, - { - "id": 2, - "name": "Pentakill Yorick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yorick_2.jpg" - }, - { - "id": 3, - "name": "Arclight Yorick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yorick_3.jpg" - }, - { - "id": 4, - "name": "Meowrick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yorick_4.jpg" - }, - { - "id": 12, - "name": "Resistance Yorick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yorick_12.jpg" - }, - { - "id": 21, - "name": "Pentakill III: Lost Chapter Yorick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yorick_21.jpg" - }, - { - "id": 30, - "name": "Spirit Blossom Yorick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yorick_30.jpg" - }, - { - "id": 40, - "name": "Dark Star Yorick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yorick_40.jpg" - }, - { - "id": 50, - "name": "High Noon Yorick", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yorick_50.jpg" - } - ] - }, - { - "championName": "Yunara", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yunara_0.jpg" - }, - { - "id": 1, - "name": "Spirit Blossom Springs Yunara", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yunara_1.jpg" - } - ] - }, - { - "championName": "Yuumi", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yuumi_0.jpg" - }, - { - "id": 1, - "name": "Battle Principal Yuumi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yuumi_1.jpg" - }, - { - "id": 11, - "name": "Heartseeker Yuumi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yuumi_11.jpg" - }, - { - "id": 19, - "name": "Yuubee", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yuumi_19.jpg" - }, - { - "id": 28, - "name": "Bewitching Yuumi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yuumi_28.jpg" - }, - { - "id": 37, - "name": "EDG Yuumi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yuumi_37.jpg" - }, - { - "id": 39, - "name": "Shiba Yuumi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yuumi_39.jpg" - }, - { - "id": 49, - "name": "Cyber Cat Yuumi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yuumi_49.jpg" - }, - { - "id": 50, - "name": "Prestige Cyber Cat Yuumi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yuumi_50.jpg" - }, - { - "id": 61, - "name": "Nightbringer Yuumi", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Yuumi_61.jpg" - } - ] - }, - { - "championName": "Zaahen", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zaahen_0.jpg" - }, - { - "id": 1, - "name": "Immortal Journey Zaahen", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zaahen_1.jpg" - } - ] - }, - { - "championName": "Zac", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zac_0.jpg" - }, - { - "id": 1, - "name": "Special Weapon Zac", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zac_1.jpg" - }, - { - "id": 2, - "name": "Pool Party Zac", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zac_2.jpg" - }, - { - "id": 6, - "name": "SKT T1 Zac", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zac_6.jpg" - }, - { - "id": 7, - "name": "Battlecast Zac", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zac_7.jpg" - }, - { - "id": 14, - "name": "Empyrean Zac", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zac_14.jpg" - }, - { - "id": 24, - "name": "Zesty Dip Zac", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zac_24.jpg" - } - ] - }, - { - "championName": "Zed", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zed_0.jpg" - }, - { - "id": 1, - "name": "Shockblade Zed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zed_1.jpg" - }, - { - "id": 2, - "name": "SKT T1 Zed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zed_2.jpg" - }, - { - "id": 3, - "name": "PROJECT: Zed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zed_3.jpg" - }, - { - "id": 11, - "name": "Death Sworn Zed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zed_11.jpg" - }, - { - "id": 13, - "name": "Galaxy Slayer Zed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zed_13.jpg" - }, - { - "id": 15, - "name": "PsyOps Zed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zed_15.jpg" - }, - { - "id": 30, - "name": "Prestige PROJECT: Zed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zed_30.jpg" - }, - { - "id": 31, - "name": "Debonair Zed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zed_31.jpg" - }, - { - "id": 38, - "name": "Empyrean Zed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zed_38.jpg" - }, - { - "id": 49, - "name": "Immortal Journey Zed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zed_49.jpg" - }, - { - "id": 58, - "name": "Blood Moon Zed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zed_58.jpg" - }, - { - "id": 68, - "name": "Quantum Galaxy Slayer Zed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zed_68.jpg" - }, - { - "id": 69, - "name": "Prestige Spirit Blossom Zed", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zed_69.jpg" - } - ] - }, - { - "championName": "Zeri", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zeri_0.jpg" - }, - { - "id": 1, - "name": "Withered Rose Zeri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zeri_1.jpg" - }, - { - "id": 10, - "name": "Ocean Song Zeri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zeri_10.jpg" - }, - { - "id": 19, - "name": "Immortal Journey Zeri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zeri_19.jpg" - }, - { - "id": 28, - "name": "Fright Night Zeri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zeri_28.jpg" - }, - { - "id": 29, - "name": "Prestige Fright Night Zeri", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zeri_29.jpg" - } - ] - }, - { - "championName": "Ziggs", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ziggs_0.jpg" - }, - { - "id": 1, - "name": "Mad Scientist Ziggs", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ziggs_1.jpg" - }, - { - "id": 2, - "name": "Major Ziggs", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ziggs_2.jpg" - }, - { - "id": 3, - "name": "Pool Party Ziggs", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ziggs_3.jpg" - }, - { - "id": 4, - "name": "Snow Day Ziggs", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ziggs_4.jpg" - }, - { - "id": 5, - "name": "Master Arcanist Ziggs", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ziggs_5.jpg" - }, - { - "id": 6, - "name": "Battle Boss Ziggs", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ziggs_6.jpg" - }, - { - "id": 7, - "name": "Odyssey Ziggs", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ziggs_7.jpg" - }, - { - "id": 14, - "name": "Sugar Rush Ziggs", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ziggs_14.jpg" - }, - { - "id": 23, - "name": "Hextech Ziggs", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ziggs_23.jpg" - }, - { - "id": 24, - "name": "BZZZiggs", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ziggs_24.jpg" - }, - { - "id": 33, - "name": "La Ilusión Ziggs", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ziggs_33.jpg" - } - ] - }, - { - "championName": "Zilean", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zilean_0.jpg" - }, - { - "id": 1, - "name": "Old Saint Zilean", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zilean_1.jpg" - }, - { - "id": 2, - "name": "Groovy Zilean", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zilean_2.jpg" - }, - { - "id": 3, - "name": "Shurima Desert Zilean", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zilean_3.jpg" - }, - { - "id": 4, - "name": "Time Machine Zilean", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zilean_4.jpg" - }, - { - "id": 5, - "name": "Blood Moon Zilean", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zilean_5.jpg" - }, - { - "id": 6, - "name": "Sugar Rush Zilean", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zilean_6.jpg" - }, - { - "id": 14, - "name": "Winterblessed Zilean", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zilean_14.jpg" - }, - { - "id": 24, - "name": "Arcana Zilean", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zilean_24.jpg" - } - ] - }, - { - "championName": "Zoe", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zoe_0.jpg" - }, - { - "id": 1, - "name": "Cyber Pop Zoe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zoe_1.jpg" - }, - { - "id": 2, - "name": "Pool Party Zoe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zoe_2.jpg" - }, - { - "id": 9, - "name": "Star Guardian Zoe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zoe_9.jpg" - }, - { - "id": 18, - "name": "Arcanist Zoe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zoe_18.jpg" - }, - { - "id": 19, - "name": "Prestige Arcanist Zoe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zoe_19.jpg" - }, - { - "id": 20, - "name": "EDG Zoe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zoe_20.jpg" - }, - { - "id": 22, - "name": "Winterblessed Zoe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zoe_22.jpg" - }, - { - "id": 33, - "name": "Dark Star Zoe", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zoe_33.jpg" - } - ] - }, - { - "championName": "Zyra", - "skins": [ - { - "id": 0, - "name": "Default", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zyra_0.jpg" - }, - { - "id": 1, - "name": "Wildfire Zyra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zyra_1.jpg" - }, - { - "id": 2, - "name": "Haunted Zyra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zyra_2.jpg" - }, - { - "id": 3, - "name": "SKT T1 Zyra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zyra_3.jpg" - }, - { - "id": 4, - "name": "Dragon Sorceress Zyra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zyra_4.jpg" - }, - { - "id": 5, - "name": "Coven Zyra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zyra_5.jpg" - }, - { - "id": 6, - "name": "Prestige Coven Zyra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zyra_6.jpg" - }, - { - "id": 7, - "name": "Crystal Rose Zyra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zyra_7.jpg" - }, - { - "id": 16, - "name": "Crime City Nightmare Zyra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zyra_16.jpg" - }, - { - "id": 36, - "name": "Mythmaker Zyra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zyra_36.jpg" - }, - { - "id": 46, - "name": "Street Demons Zyra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zyra_46.jpg" - }, - { - "id": 55, - "name": "Blood Moon Zyra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zyra_55.jpg" - }, - { - "id": 64, - "name": "Spirit Blossom Zyra", - "url": "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Zyra_64.jpg" - } - ] - } -] diff --git a/src/modules/loldle-splash/state.js b/src/modules/loldle-splash/state.js deleted file mode 100644 index 9787952..0000000 --- a/src/modules/loldle-splash/state.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * @file Game + stats persistence for loldle-splash. Adds `skinId` field so - * the SAME splash art persists across all guesses in a round. - */ - -const MAX_GUESSES = 4; -const MAX_GUESSES_CAP = 10; -const GAME_TTL_SECONDS = 60 * 60 * 24 * 7; - -const gameKey = (subject) => `game:${subject}`; -const statsKey = (subject) => `stats:${subject}`; -const configKey = (subject) => `config:${subject}`; - -export { MAX_GUESSES, MAX_GUESSES_CAP }; - -export async function getMaxGuesses(db, subject) { - const cfg = await db.getJSON(configKey(subject)); - const n = cfg?.maxGuesses; - if (Number.isInteger(n) && n >= 1 && n <= MAX_GUESSES_CAP) return n; - return MAX_GUESSES; -} - -export async function setMaxGuesses(db, subject, n) { - if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { - throw new RangeError(`maxGuesses must be an integer in [1, ${MAX_GUESSES_CAP}]`); - } - await db.putJSON(configKey(subject), { maxGuesses: n }); -} - -export async function loadGame(db, subject) { - return db.getJSON(gameKey(subject)); -} - -export async function saveGame(db, subject, state) { - await db.putJSON(gameKey(subject), state, { expirationTtl: GAME_TTL_SECONDS }); -} - -export async function clearGame(db, subject) { - await db.delete(gameKey(subject)); -} - -export async function loadStats(db, subject) { - return ( - (await db.getJSON(statsKey(subject))) ?? { - played: 0, - wins: 0, - streak: 0, - bestStreak: 0, - } - ); -} - -export async function recordResult(db, subject, won) { - const s = await loadStats(db, subject); - s.played += 1; - if (won) { - s.wins += 1; - s.streak += 1; - if (s.streak > s.bestStreak) s.bestStreak = s.streak; - } else { - s.streak = 0; - } - await db.putJSON(statsKey(subject), s); - return s; -} diff --git a/src/modules/loldle/README.md b/src/modules/loldle/README.md deleted file mode 100644 index 5b36a09..0000000 --- a/src/modules/loldle/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# Loldle Module - -Classic-mode League of Legends champion guessing game. Players get 8 guesses -(default; per-subject override via the hidden `/loldle_setmax` command, capped -at 10) to identify a hidden champion; each guess is compared across 7 -attributes and the board is rendered as a monospace Telegram table. - -## Data source - -All champion data comes from **[loldle.net](https://loldle.net/classic)**. -The site embeds its classic-mode dataset in plaintext inside its JS bundle, -so we scrape and store it verbatim — no transformation, no external merge. - -`scripts/scrape-loldle-data.js` fetches `loldle.net/classic`, extracts the -hashed `js/index..js` bundle URL, pulls every champion record via a -single regex, and writes the array to `src/modules/loldle/champions.json`. - -The bot imports that JSON directly (`with { type: "json" }`). No build step, -no wrapper module. - -**Regenerate manually:** `npm run scrape:loldle-data` -**Weekly refresh:** `.github/workflows/scrape-loldle-data.yml` (Mon 06:00 UTC) -opens a PR whenever the data changes. - -## Commands - -| Command | Visibility | Description | -|---------|-----------|-------------| -| `/loldle` | public | Show current board, or submit a champion guess | -| `/loldle_giveup` | public | Reveal the answer and end the round | -| `/loldle_stats` | public | Show your wins / streak | -| `/loldle_setmax ` | private | Override max guesses (1-10) for this subject; applies to next round | - -Submit a guess with `/loldle ` (e.g. `/loldle Ahri`). Names match -case/space/punctuation-insensitive with a unique-prefix fallback. A round -that ends (solved, gave up, or out of guesses) is cleared; the next round -is created lazily on the first `/loldle` call, and the timer (`startedAt`) -only starts when the player submits their first actual guess — viewing an -empty board gives no hints, so it shouldn't count against the clock. - -## Architecture - -- `compare.js` — attribute comparison across 7 axes from loldle.net's raw - schema (`gender`, `species`, `range_type`, `resource`, `regions`, - `positions`, `release_date`). Returns `correct` / `partial` / `wrong` per - row, plus an up/down `direction` hint for the year. -- `lookup.js` — normalizes user input to a champion record. -- `render.js` — Telegram HTML `
` monospace table with auto-widthed
-  label column.
-- `state.js` — KV persistence (`MAX_GUESSES = 8` default, `MAX_GUESSES_CAP = 10`,
-  per-subject stats and config override).
-- `handlers.js` — subject resolution (user id in DMs, chat id in groups) +
-  command flow.
-- `flavor.js` — win-message text helpers.
-- `stickers.js` — Telegram sticker pools per outcome.
-- `champions.json` — auto-generated data (never edit by hand).
-
-Subject resolution: private chats track per-user games; groups track
-per-chat shared games.
-
-## Storage
-
-KV namespace prefix: `loldle:`
-
-| Key | Value |
-|-----|-------|
-| `game:` | `{ target, guesses, startedAt }` — active round (TTL 7 days). `guesses` is a championName array; comparison rows are recomputed at render time. |
-| `stats:` | `{ played, wins, streak, bestStreak }` |
-| `config:` | `{ maxGuesses }` — optional per-subject round-length override (1-10). Absent ⇒ default `MAX_GUESSES`. |
-
-## Credits
-
-Champion data © Riot Games, via loldle.net.
diff --git a/src/modules/loldle/champions.json b/src/modules/loldle/champions.json
deleted file mode 100644
index 61a8b33..0000000
--- a/src/modules/loldle/champions.json
+++ /dev/null
@@ -1,3292 +0,0 @@
-[
-    {
-        "championName": "Aatrox",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Darkin"
-        ],
-        "resource": "Manaless",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Runeterra",
-            "Shurima"
-        ],
-        "release_date": "2013-06-13"
-    },
-    {
-        "championName": "Ahri",
-        "gender": "Female",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Vastayan"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2011-12-14"
-    },
-    {
-        "championName": "Akali",
-        "gender": "Female",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Energy",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2010-05-11"
-    },
-    {
-        "championName": "Akshan",
-        "gender": "Male",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Shurima"
-        ],
-        "release_date": "2021-07-22"
-    },
-    {
-        "championName": "Alistar",
-        "gender": "Male",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Minotaur"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Runeterra",
-            "Noxus"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Ambessa",
-        "gender": "Female",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Energy",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Piltover",
-            "Noxus"
-        ],
-        "release_date": "2024-11-06"
-    },
-    {
-        "championName": "Amumu",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Undead",
-            "Yordle"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Shurima"
-        ],
-        "release_date": "2009-06-26"
-    },
-    {
-        "championName": "Anivia",
-        "gender": "Female",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Spirit",
-            "God"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Freljord"
-        ],
-        "release_date": "2009-07-10"
-    },
-    {
-        "championName": "Annie",
-        "gender": "Female",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Magicborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Noxus",
-            "Runeterra"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Aphelios",
-        "gender": "Male",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Human",
-            "Spiritualist"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Targon"
-        ],
-        "release_date": "2019-12-11"
-    },
-    {
-        "championName": "Ashe",
-        "gender": "Female",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Human",
-            "Iceborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Freljord"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Aurelion Sol",
-        "gender": "Male",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Celestial",
-            "Dragon"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Runeterra",
-            "Targon"
-        ],
-        "release_date": "2016-03-24"
-    },
-    {
-        "championName": "Aurora",
-        "gender": "Female",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Vastayan"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Freljord"
-        ],
-        "release_date": "2024-07-17"
-    },
-    {
-        "championName": "Azir",
-        "gender": "Male",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "God-Warrior"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Shurima"
-        ],
-        "release_date": "2014-09-16"
-    },
-    {
-        "championName": "Bard",
-        "gender": "Male",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Celestial"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Runeterra"
-        ],
-        "release_date": "2015-03-12"
-    },
-    {
-        "championName": "Bel'Veth",
-        "gender": "Female",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Void-Being"
-        ],
-        "resource": "Manaless",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Void"
-        ],
-        "release_date": "2022-06-09"
-    },
-    {
-        "championName": "Blitzcrank",
-        "gender": "Other",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Golem"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Zaun"
-        ],
-        "release_date": "2009-09-02"
-    },
-    {
-        "championName": "Brand",
-        "gender": "Male",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Freljord",
-            "Runeterra"
-        ],
-        "release_date": "2011-04-12"
-    },
-    {
-        "championName": "Braum",
-        "gender": "Male",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Iceborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Freljord"
-        ],
-        "release_date": "2014-05-12"
-    },
-    {
-        "championName": "Briar",
-        "gender": "Female",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Golem"
-        ],
-        "resource": "Health costs",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Noxus"
-        ],
-        "release_date": "2023-09-14"
-    },
-    {
-        "championName": "Caitlyn",
-        "gender": "Female",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Piltover"
-        ],
-        "release_date": "2011-01-04"
-    },
-    {
-        "championName": "Camille",
-        "gender": "Female",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human",
-            "Cyborg"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Piltover"
-        ],
-        "release_date": "2016-12-07"
-    },
-    {
-        "championName": "Cassiopeia",
-        "gender": "Female",
-        "positions": [
-            "Top",
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Noxus",
-            "Shurima"
-        ],
-        "release_date": "2010-12-14"
-    },
-    {
-        "championName": "Cho'Gath",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Void-Being"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Void"
-        ],
-        "release_date": "2009-06-26"
-    },
-    {
-        "championName": "Corki",
-        "gender": "Male",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Yordle"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Bandle City",
-            "Piltover"
-        ],
-        "release_date": "2009-09-19"
-    },
-    {
-        "championName": "Darius",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Noxus"
-        ],
-        "release_date": "2012-05-23"
-    },
-    {
-        "championName": "Diana",
-        "gender": "Female",
-        "positions": [
-            "Jungle",
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Aspect"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Targon"
-        ],
-        "release_date": "2012-08-07"
-    },
-    {
-        "championName": "Dr. Mundo",
-        "gender": "Male",
-        "positions": [
-            "Top",
-            "Jungle"
-        ],
-        "species": [
-            "Human",
-            "Chemically Altered"
-        ],
-        "resource": "Health costs",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Zaun"
-        ],
-        "release_date": "2009-09-02"
-    },
-    {
-        "championName": "Draven",
-        "gender": "Male",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Noxus"
-        ],
-        "release_date": "2012-06-06"
-    },
-    {
-        "championName": "Ekko",
-        "gender": "Male",
-        "positions": [
-            "Jungle",
-            "Middle"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Zaun"
-        ],
-        "release_date": "2015-05-29"
-    },
-    {
-        "championName": "Elise",
-        "gender": "Female",
-        "positions": [
-            "Jungle",
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee",
-            "Ranged"
-        ],
-        "regions": [
-            "Shadow Isles",
-            "Noxus"
-        ],
-        "release_date": "2012-10-26"
-    },
-    {
-        "championName": "Evelynn",
-        "gender": "Female",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Demon",
-            "Spirit"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Runeterra"
-        ],
-        "release_date": "2009-05-01"
-    },
-    {
-        "championName": "Ezreal",
-        "gender": "Male",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Human",
-            "Magicborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Piltover"
-        ],
-        "release_date": "2010-03-16"
-    },
-    {
-        "championName": "Fiddlesticks",
-        "gender": "Other",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Demon",
-            "Spirit"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Runeterra"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Fiora",
-        "gender": "Female",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Demacia"
-        ],
-        "release_date": "2012-02-29"
-    },
-    {
-        "championName": "Fizz",
-        "gender": "Male",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Yordle"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Bilgewater"
-        ],
-        "release_date": "2011-11-15"
-    },
-    {
-        "championName": "Galio",
-        "gender": "Male",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Golem"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Demacia"
-        ],
-        "release_date": "2010-08-10"
-    },
-    {
-        "championName": "Gangplank",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Bilgewater"
-        ],
-        "release_date": "2009-08-19"
-    },
-    {
-        "championName": "Garen",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Manaless",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Demacia"
-        ],
-        "release_date": "2010-04-27"
-    },
-    {
-        "championName": "Gnar",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Yordle"
-        ],
-        "resource": "Rage",
-        "range_type": [
-            "Melee",
-            "Ranged"
-        ],
-        "regions": [
-            "Freljord"
-        ],
-        "release_date": "2014-08-14"
-    },
-    {
-        "championName": "Gragas",
-        "gender": "Male",
-        "positions": [
-            "Top",
-            "Jungle"
-        ],
-        "species": [
-            "Human",
-            "Iceborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Freljord"
-        ],
-        "release_date": "2010-02-02"
-    },
-    {
-        "championName": "Graves",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Bilgewater"
-        ],
-        "release_date": "2011-10-19"
-    },
-    {
-        "championName": "Gwen",
-        "gender": "Female",
-        "positions": [
-            "Top",
-            "Jungle"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Shadow Isles",
-            "Camavor"
-        ],
-        "release_date": "2021-04-15"
-    },
-    {
-        "championName": "Hecarim",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Undead"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Shadow Isles",
-            "Camavor"
-        ],
-        "release_date": "2012-04-18"
-    },
-    {
-        "championName": "Heimerdinger",
-        "gender": "Male",
-        "positions": [
-            "Top",
-            "Middle"
-        ],
-        "species": [
-            "Yordle"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Piltover"
-        ],
-        "release_date": "2009-10-10"
-    },
-    {
-        "championName": "Hwei",
-        "gender": "Male",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Magicborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2023-12-05"
-    },
-    {
-        "championName": "Illaoi",
-        "gender": "Female",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human",
-            "Spiritualist"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Bilgewater"
-        ],
-        "release_date": "2015-11-24"
-    },
-    {
-        "championName": "Irelia",
-        "gender": "Female",
-        "positions": [
-            "Top",
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Spiritualist"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2010-11-16"
-    },
-    {
-        "championName": "Ivern",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Ionia",
-            "Freljord"
-        ],
-        "release_date": "2016-10-05"
-    },
-    {
-        "championName": "Janna",
-        "gender": "Female",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Spirit",
-            "God"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Zaun",
-            "Shurima"
-        ],
-        "release_date": "2009-09-02"
-    },
-    {
-        "championName": "Jarvan IV",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Demacia"
-        ],
-        "release_date": "2011-03-01"
-    },
-    {
-        "championName": "Jax",
-        "gender": "Male",
-        "positions": [
-            "Top",
-            "Jungle"
-        ],
-        "species": [
-            "Unknown"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Runeterra",
-            "Icathia",
-            "Shurima"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Jayce",
-        "gender": "Male",
-        "positions": [
-            "Top",
-            "Jungle"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee",
-            "Ranged"
-        ],
-        "regions": [
-            "Piltover"
-        ],
-        "release_date": "2012-07-07"
-    },
-    {
-        "championName": "Jhin",
-        "gender": "Male",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Human",
-            "Spiritualist"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2016-02-01"
-    },
-    {
-        "championName": "Jinx",
-        "gender": "Female",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Human",
-            "Chemically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Zaun"
-        ],
-        "release_date": "2013-10-10"
-    },
-    {
-        "championName": "K'Sante",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Shurima"
-        ],
-        "release_date": "2022-11-02"
-    },
-    {
-        "championName": "Kai'Sa",
-        "gender": "Female",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Human",
-            "Void-Being"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Shurima",
-            "Void"
-        ],
-        "release_date": "2018-03-07"
-    },
-    {
-        "championName": "Kalista",
-        "gender": "Female",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Undead"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Shadow Isles",
-            "Camavor"
-        ],
-        "release_date": "2014-11-20"
-    },
-    {
-        "championName": "Karma",
-        "gender": "Female",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Spiritualist"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2011-02-01"
-    },
-    {
-        "championName": "Karthus",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Undead"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Shadow Isles",
-            "Noxus"
-        ],
-        "release_date": "2009-06-12"
-    },
-    {
-        "championName": "Kassadin",
-        "gender": "Male",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Void-Being"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Shurima",
-            "Void"
-        ],
-        "release_date": "2009-08-07"
-    },
-    {
-        "championName": "Katarina",
-        "gender": "Female",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Manaless",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Noxus"
-        ],
-        "release_date": "2009-09-19"
-    },
-    {
-        "championName": "Kayle",
-        "gender": "Female",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered",
-            "Aspect"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee",
-            "Ranged"
-        ],
-        "regions": [
-            "Demacia",
-            "Targon"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Kayn",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Darkin",
-            "Human",
-            "Magically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ionia",
-            "Runeterra",
-            "Noxus",
-            "Shurima"
-        ],
-        "release_date": "2017-07-12"
-    },
-    {
-        "championName": "Kennen",
-        "gender": "Male",
-        "positions": [
-            "Top",
-            "Middle"
-        ],
-        "species": [
-            "Yordle"
-        ],
-        "resource": "Energy",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2010-04-08"
-    },
-    {
-        "championName": "Kha'Zix",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Void-Being"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Void"
-        ],
-        "release_date": "2012-09-27"
-    },
-    {
-        "championName": "Kindred",
-        "gender": "Other",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Spirit",
-            "God"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Runeterra"
-        ],
-        "release_date": "2015-10-14"
-    },
-    {
-        "championName": "Kled",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Yordle"
-        ],
-        "resource": "Courage",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Noxus"
-        ],
-        "release_date": "2016-08-10"
-    },
-    {
-        "championName": "Kog'Maw",
-        "gender": "Male",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Void-Being"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Void"
-        ],
-        "release_date": "2010-06-24"
-    },
-    {
-        "championName": "LeBlanc",
-        "gender": "Female",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Noxus"
-        ],
-        "release_date": "2010-11-02"
-    },
-    {
-        "championName": "Lee Sin",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Human",
-            "Spiritualist"
-        ],
-        "resource": "Energy",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2011-04-01"
-    },
-    {
-        "championName": "Leona",
-        "gender": "Female",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Aspect"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Targon"
-        ],
-        "release_date": "2011-07-13"
-    },
-    {
-        "championName": "Lillia",
-        "gender": "Female",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Spirit"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2020-07-22"
-    },
-    {
-        "championName": "Lissandra",
-        "gender": "Female",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Iceborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Freljord"
-        ],
-        "release_date": "2013-04-30"
-    },
-    {
-        "championName": "Lucian",
-        "gender": "Male",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Demacia",
-            "Shadow Isles"
-        ],
-        "release_date": "2013-08-22"
-    },
-    {
-        "championName": "Lulu",
-        "gender": "Female",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Yordle"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Bandle City"
-        ],
-        "release_date": "2012-03-20"
-    },
-    {
-        "championName": "Lux",
-        "gender": "Female",
-        "positions": [
-            "Middle",
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Magicborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Demacia"
-        ],
-        "release_date": "2010-10-19"
-    },
-    {
-        "championName": "Malphite",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Golem"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ixtal",
-            "Shurima"
-        ],
-        "release_date": "2009-09-02"
-    },
-    {
-        "championName": "Malzahar",
-        "gender": "Male",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Void-Being"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Shurima",
-            "Void"
-        ],
-        "release_date": "2010-06-01"
-    },
-    {
-        "championName": "Maokai",
-        "gender": "Male",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Spirit"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Shadow Isles"
-        ],
-        "release_date": "2011-02-16"
-    },
-    {
-        "championName": "Master Yi",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Human",
-            "Spiritualist"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Mel",
-        "gender": "Female",
-        "positions": [
-            "Middle",
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Noxus",
-            "Piltover"
-        ],
-        "release_date": "2025-01-23"
-    },
-    {
-        "championName": "Milio",
-        "gender": "Male",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Magicborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Ixtal"
-        ],
-        "release_date": "2023-03-23"
-    },
-    {
-        "championName": "Miss Fortune",
-        "gender": "Female",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Bilgewater"
-        ],
-        "release_date": "2010-09-08"
-    },
-    {
-        "championName": "Mordekaiser",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Revenant"
-        ],
-        "resource": "Shield",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Noxus",
-            "Shadow Isles"
-        ],
-        "release_date": "2010-02-24"
-    },
-    {
-        "championName": "Morgana",
-        "gender": "Female",
-        "positions": [
-            "Middle",
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered",
-            "Aspect"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Demacia",
-            "Targon"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Naafiri",
-        "gender": "Female",
-        "positions": [
-            "Jungle",
-            "Middle"
-        ],
-        "species": [
-            "Dog",
-            "Darkin"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Shurima"
-        ],
-        "release_date": "2023-07-19"
-    },
-    {
-        "championName": "Nami",
-        "gender": "Female",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Vastayan"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Runeterra",
-            "Bilgewater"
-        ],
-        "release_date": "2012-12-07"
-    },
-    {
-        "championName": "Nasus",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "God-Warrior"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Shurima"
-        ],
-        "release_date": "2009-10-01"
-    },
-    {
-        "championName": "Nautilus",
-        "gender": "Male",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Revenant"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Bilgewater"
-        ],
-        "release_date": "2012-02-14"
-    },
-    {
-        "championName": "Neeko",
-        "gender": "Female",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Vastayan"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Ixtal"
-        ],
-        "release_date": "2018-12-05"
-    },
-    {
-        "championName": "Nidalee",
-        "gender": "Female",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Human",
-            "Spiritualist"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee",
-            "Ranged"
-        ],
-        "regions": [
-            "Ixtal"
-        ],
-        "release_date": "2009-12-17"
-    },
-    {
-        "championName": "Nilah",
-        "gender": "Female",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Bilgewater"
-        ],
-        "release_date": "2022-07-13"
-    },
-    {
-        "championName": "Nocturne",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Demon",
-            "Spirit"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Runeterra"
-        ],
-        "release_date": "2011-03-15"
-    },
-    {
-        "championName": "Nunu & Willump",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Human",
-            "Yeti"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Freljord"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Olaf",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human",
-            "Iceborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Freljord"
-        ],
-        "release_date": "2010-06-09"
-    },
-    {
-        "championName": "Orianna",
-        "gender": "Female",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Golem"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Piltover"
-        ],
-        "release_date": "2011-06-01"
-    },
-    {
-        "championName": "Ornn",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Spirit",
-            "God"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Freljord"
-        ],
-        "release_date": "2017-08-23"
-    },
-    {
-        "championName": "Pantheon",
-        "gender": "Male",
-        "positions": [
-            "Top",
-            "Jungle",
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Aspect"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Targon"
-        ],
-        "release_date": "2010-02-02"
-    },
-    {
-        "championName": "Poppy",
-        "gender": "Female",
-        "positions": [
-            "Top",
-            "Support"
-        ],
-        "species": [
-            "Yordle"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Demacia"
-        ],
-        "release_date": "2010-01-13"
-    },
-    {
-        "championName": "Pyke",
-        "gender": "Male",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Revenant"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Bilgewater"
-        ],
-        "release_date": "2018-05-31"
-    },
-    {
-        "championName": "Qiyana",
-        "gender": "Female",
-        "positions": [
-            "Jungle",
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Magicborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ixtal"
-        ],
-        "release_date": "2019-06-28"
-    },
-    {
-        "championName": "Quinn",
-        "gender": "Female",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Demacia"
-        ],
-        "release_date": "2013-03-01"
-    },
-    {
-        "championName": "Rakan",
-        "gender": "Male",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Vastayan"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2017-04-19"
-    },
-    {
-        "championName": "Rammus",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Unknown"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Shurima"
-        ],
-        "release_date": "2009-07-10"
-    },
-    {
-        "championName": "Rek'Sai",
-        "gender": "Female",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Void-Being"
-        ],
-        "resource": "Rage",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Shurima",
-            "Void"
-        ],
-        "release_date": "2014-12-11"
-    },
-    {
-        "championName": "Rell",
-        "gender": "Female",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Magicborn",
-            "Magically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Noxus"
-        ],
-        "release_date": "2020-12-10"
-    },
-    {
-        "championName": "Renata Glasc",
-        "gender": "Female",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Chemically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Zaun"
-        ],
-        "release_date": "2022-02-17"
-    },
-    {
-        "championName": "Renekton",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "God-Warrior"
-        ],
-        "resource": "Fury",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Shurima"
-        ],
-        "release_date": "2011-01-18"
-    },
-    {
-        "championName": "Rengar",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Vastayan"
-        ],
-        "resource": "Ferocity",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ixtal",
-            "Shurima"
-        ],
-        "release_date": "2012-08-21"
-    },
-    {
-        "championName": "Riven",
-        "gender": "Female",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Manaless",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ionia",
-            "Noxus"
-        ],
-        "release_date": "2011-09-14"
-    },
-    {
-        "championName": "Rumble",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Yordle"
-        ],
-        "resource": "Heat",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Bandle City"
-        ],
-        "release_date": "2011-04-26"
-    },
-    {
-        "championName": "Ryze",
-        "gender": "Male",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Runeterra"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Samira",
-        "gender": "Female",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Shurima",
-            "Noxus"
-        ],
-        "release_date": "2020-09-21"
-    },
-    {
-        "championName": "Sejuani",
-        "gender": "Female",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Human",
-            "Iceborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Freljord"
-        ],
-        "release_date": "2012-01-17"
-    },
-    {
-        "championName": "Senna",
-        "gender": "Female",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Undead"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Shadow Isles"
-        ],
-        "release_date": "2019-11-10"
-    },
-    {
-        "championName": "Seraphine",
-        "gender": "Female",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Magicborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Piltover",
-            "Zaun"
-        ],
-        "release_date": "2020-10-29"
-    },
-    {
-        "championName": "Sett",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human",
-            "Vastayan"
-        ],
-        "resource": "Grit",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2020-01-14"
-    },
-    {
-        "championName": "Shaco",
-        "gender": "Male",
-        "positions": [
-            "Jungle",
-            "Support"
-        ],
-        "species": [
-            "Spirit"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Runeterra"
-        ],
-        "release_date": "2009-10-10"
-    },
-    {
-        "championName": "Shen",
-        "gender": "Male",
-        "positions": [
-            "Top",
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Spiritualist"
-        ],
-        "resource": "Energy",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2010-03-24"
-    },
-    {
-        "championName": "Shyvana",
-        "gender": "Female",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Dragon",
-            "Magically Altered"
-        ],
-        "resource": "Fury",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Demacia"
-        ],
-        "release_date": "2011-11-01"
-    },
-    {
-        "championName": "Singed",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human",
-            "Chemically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Zaun",
-            "Piltover"
-        ],
-        "release_date": "2009-04-18"
-    },
-    {
-        "championName": "Sion",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Revenant"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Noxus"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Sivir",
-        "gender": "Female",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Shurima"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Skarner",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Brackern"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ixtal"
-        ],
-        "release_date": "2011-08-09"
-    },
-    {
-        "championName": "Smolder",
-        "gender": "Male",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Dragon"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Camavor",
-            "Noxus"
-        ],
-        "release_date": "2024-01-31"
-    },
-    {
-        "championName": "Sona",
-        "gender": "Female",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Magicborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Demacia",
-            "Ionia"
-        ],
-        "release_date": "2010-09-21"
-    },
-    {
-        "championName": "Soraka",
-        "gender": "Female",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Celestial"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Targon",
-            "Ionia"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Swain",
-        "gender": "Male",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Noxus"
-        ],
-        "release_date": "2010-10-05"
-    },
-    {
-        "championName": "Sylas",
-        "gender": "Male",
-        "positions": [
-            "Jungle",
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Magicborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Demacia",
-            "Freljord"
-        ],
-        "release_date": "2019-01-25"
-    },
-    {
-        "championName": "Syndra",
-        "gender": "Female",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Magicborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2012-09-13"
-    },
-    {
-        "championName": "Tahm Kench",
-        "gender": "Male",
-        "positions": [
-            "Top",
-            "Support"
-        ],
-        "species": [
-            "Demon",
-            "Spirit"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Bilgewater",
-            "Runeterra"
-        ],
-        "release_date": "2015-07-09"
-    },
-    {
-        "championName": "Taliyah",
-        "gender": "Female",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Magicborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Shurima"
-        ],
-        "release_date": "2016-05-18"
-    },
-    {
-        "championName": "Talon",
-        "gender": "Male",
-        "positions": [
-            "Jungle",
-            "Middle"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Noxus"
-        ],
-        "release_date": "2011-08-24"
-    },
-    {
-        "championName": "Taric",
-        "gender": "Male",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Aspect"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Targon",
-            "Demacia"
-        ],
-        "release_date": "2009-08-19"
-    },
-    {
-        "championName": "Teemo",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Yordle"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Bandle City"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Thresh",
-        "gender": "Male",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Undead"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Shadow Isles"
-        ],
-        "release_date": "2013-01-23"
-    },
-    {
-        "championName": "Tristana",
-        "gender": "Female",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Yordle"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Bandle City"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Trundle",
-        "gender": "Male",
-        "positions": [
-            "Top",
-            "Jungle"
-        ],
-        "species": [
-            "Troll",
-            "Iceborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Freljord"
-        ],
-        "release_date": "2010-12-01"
-    },
-    {
-        "championName": "Tryndamere",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered"
-        ],
-        "resource": "Fury",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Freljord"
-        ],
-        "release_date": "2009-05-01"
-    },
-    {
-        "championName": "Twisted Fate",
-        "gender": "Male",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Magicborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Bilgewater"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Twitch",
-        "gender": "Male",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Rat",
-            "Chemically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Zaun"
-        ],
-        "release_date": "2009-05-01"
-    },
-    {
-        "championName": "Udyr",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Human",
-            "Spiritualist"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Freljord",
-            "Ionia"
-        ],
-        "release_date": "2009-12-02"
-    },
-    {
-        "championName": "Urgot",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human",
-            "Chemically Altered",
-            "Cyborg"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Noxus",
-            "Zaun"
-        ],
-        "release_date": "2010-08-24"
-    },
-    {
-        "championName": "Varus",
-        "gender": "Male",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Darkin",
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Ionia",
-            "Runeterra",
-            "Shurima"
-        ],
-        "release_date": "2012-05-08"
-    },
-    {
-        "championName": "Vayne",
-        "gender": "Female",
-        "positions": [
-            "Top",
-            "Bottom"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Demacia"
-        ],
-        "release_date": "2011-05-10"
-    },
-    {
-        "championName": "Veigar",
-        "gender": "Male",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Yordle"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Runeterra",
-            "Bandle City",
-            "Shadow Isles"
-        ],
-        "release_date": "2009-07-24"
-    },
-    {
-        "championName": "Vel'Koz",
-        "gender": "Male",
-        "positions": [
-            "Middle",
-            "Support"
-        ],
-        "species": [
-            "Void-Being"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Void"
-        ],
-        "release_date": "2014-02-27"
-    },
-    {
-        "championName": "Vex",
-        "gender": "Female",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Yordle"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Shadow Isles",
-            "Bandle City"
-        ],
-        "release_date": "2021-09-23"
-    },
-    {
-        "championName": "Vi",
-        "gender": "Female",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Piltover",
-            "Zaun"
-        ],
-        "release_date": "2012-12-19"
-    },
-    {
-        "championName": "Viego",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Undead"
-        ],
-        "resource": "Manaless",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Shadow Isles",
-            "Camavor"
-        ],
-        "release_date": "2021-01-21"
-    },
-    {
-        "championName": "Viktor",
-        "gender": "Male",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Cyborg"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Piltover",
-            "Zaun"
-        ],
-        "release_date": "2011-12-29"
-    },
-    {
-        "championName": "Vladimir",
-        "gender": "Male",
-        "positions": [
-            "Top",
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered"
-        ],
-        "resource": "Bloodthirst",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Noxus",
-            "Shadow Isles",
-            "Camavor"
-        ],
-        "release_date": "2010-07-27"
-    },
-    {
-        "championName": "Volibear",
-        "gender": "Male",
-        "positions": [
-            "Top",
-            "Jungle"
-        ],
-        "species": [
-            "Spirit",
-            "God"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Freljord"
-        ],
-        "release_date": "2011-11-29"
-    },
-    {
-        "championName": "Warwick",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Human",
-            "Chemically Altered",
-            "Cyborg"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Zaun"
-        ],
-        "release_date": "2009-02-21"
-    },
-    {
-        "championName": "Wukong",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Vastayan"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2011-07-26"
-    },
-    {
-        "championName": "Xayah",
-        "gender": "Female",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Vastayan"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2017-04-19"
-    },
-    {
-        "championName": "Xerath",
-        "gender": "Male",
-        "positions": [
-            "Middle",
-            "Support"
-        ],
-        "species": [
-            "Baccai"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Shurima"
-        ],
-        "release_date": "2011-10-05"
-    },
-    {
-        "championName": "Xin Zhao",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Demacia",
-            "Ionia",
-            "Noxus"
-        ],
-        "release_date": "2010-07-13"
-    },
-    {
-        "championName": "Yasuo",
-        "gender": "Male",
-        "positions": [
-            "Top",
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Magicborn"
-        ],
-        "resource": "Flow",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2013-12-13"
-    },
-    {
-        "championName": "Yone",
-        "gender": "Male",
-        "positions": [
-            "Top",
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered"
-        ],
-        "resource": "Manaless",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2020-08-06"
-    },
-    {
-        "championName": "Yorick",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Shadow Isles"
-        ],
-        "release_date": "2011-06-22"
-    },
-    {
-        "championName": "Yunara",
-        "gender": "Female",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Human",
-            "Spiritualist"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2025-07-16"
-    },
-    {
-        "championName": "Yuumi",
-        "gender": "Female",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Cat",
-            "Magically Altered"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Bandle City"
-        ],
-        "release_date": "2019-05-14"
-    },
-    {
-        "championName": "Zaahen",
-        "gender": "Male",
-        "positions": [
-            "Top"
-        ],
-        "species": [
-            "Darkin",
-            "Human"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Shurima",
-            "Ionia"
-        ],
-        "release_date": "2025-11-19"
-    },
-    {
-        "championName": "Zac",
-        "gender": "Male",
-        "positions": [
-            "Jungle"
-        ],
-        "species": [
-            "Golem"
-        ],
-        "resource": "Health costs",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Zaun"
-        ],
-        "release_date": "2013-03-29"
-    },
-    {
-        "championName": "Zed",
-        "gender": "Male",
-        "positions": [
-            "Jungle",
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Magically Altered"
-        ],
-        "resource": "Energy",
-        "range_type": [
-            "Melee"
-        ],
-        "regions": [
-            "Ionia"
-        ],
-        "release_date": "2012-11-13"
-    },
-    {
-        "championName": "Zeri",
-        "gender": "Female",
-        "positions": [
-            "Bottom"
-        ],
-        "species": [
-            "Human",
-            "Magicborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Zaun"
-        ],
-        "release_date": "2022-01-20"
-    },
-    {
-        "championName": "Ziggs",
-        "gender": "Male",
-        "positions": [
-            "Middle",
-            "Bottom"
-        ],
-        "species": [
-            "Yordle"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Zaun"
-        ],
-        "release_date": "2012-02-01"
-    },
-    {
-        "championName": "Zilean",
-        "gender": "Male",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Magicborn"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Runeterra",
-            "Icathia",
-            "Shurima"
-        ],
-        "release_date": "2009-04-18"
-    },
-    {
-        "championName": "Zoe",
-        "gender": "Female",
-        "positions": [
-            "Middle"
-        ],
-        "species": [
-            "Human",
-            "Aspect"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Targon"
-        ],
-        "release_date": "2017-11-21"
-    },
-    {
-        "championName": "Zyra",
-        "gender": "Female",
-        "positions": [
-            "Support"
-        ],
-        "species": [
-            "Human",
-            "Plant"
-        ],
-        "resource": "Mana",
-        "range_type": [
-            "Ranged"
-        ],
-        "regions": [
-            "Ixtal"
-        ],
-        "release_date": "2012-07-24"
-    }
-]
diff --git a/src/modules/loldle/compare.js b/src/modules/loldle/compare.js
deleted file mode 100644
index a77048e..0000000
--- a/src/modules/loldle/compare.js
+++ /dev/null
@@ -1,92 +0,0 @@
-/**
- * @file Classic-mode champion comparison.
- *
- * Champion records use loldle.net's raw schema: `gender` is a string
- * ("Male"/"Female"/"Other"), multi-value axes (positions, species, regions,
- * range_type) are arrays, and `release_date` is an ISO "YYYY-MM-DD" string.
- */
-
-// Labels match loldle.net's classic-mode grid headers verbatim.
-export const CLASSIC_ATTRIBUTES = [
-  { key: "gender", label: "Gender", type: "exact" },
-  { key: "species", label: "Species", type: "multi" },
-  { key: "range_type", label: "Range type", type: "multi" },
-  { key: "resource", label: "Resource", type: "exact" },
-  { key: "regions", label: "Region(s)", type: "multi" },
-  { key: "positions", label: "Position(s)", type: "multi" },
-  { key: "release_date", label: "Release year", type: "year" },
-];
-
-/**
- * Compare a guess champion against the target, returning one row per attribute.
- * @param {Record} guess
- * @param {Record} target
- */
-export function compareChampions(guess, target) {
-  return CLASSIC_ATTRIBUTES.map((attr) => {
-    const g = guess[attr.key];
-    const t = target[attr.key];
-
-    if (attr.type === "year") {
-      return {
-        ...attr,
-        guessValue: parseYear(g) || "?",
-        targetValue: parseYear(t) || "?",
-        ...compareYear(g, t),
-      };
-    }
-
-    const row = {
-      ...attr,
-      guessValue: formatValue(g),
-      targetValue: formatValue(t),
-    };
-    row.result =
-      attr.type === "exact"
-        ? String(g ?? "").toLowerCase() === String(t ?? "").toLowerCase()
-          ? "correct"
-          : "wrong"
-        : compareMultiValue(g, t);
-    return row;
-  });
-}
-
-function compareMultiValue(guess, target) {
-  const a = toSet(guess);
-  const b = toSet(target);
-  if (a.size === 0 && b.size === 0) return "correct";
-  if (a.size === 0 || b.size === 0) return "wrong";
-  if (setsEqual(a, b)) return "correct";
-  for (const v of a) if (b.has(v)) return "partial";
-  return "wrong";
-}
-
-function parseYear(val) {
-  const m = String(val ?? "").match(/^(\d{4})/);
-  return m ? Number(m[1]) : 0;
-}
-
-function compareYear(guess, target) {
-  const g = parseYear(guess);
-  const t = parseYear(target);
-  if (!g || !t) return { result: "wrong" };
-  if (g === t) return { result: "correct" };
-  return { result: "wrong", direction: g < t ? "up" : "down" };
-}
-
-function toSet(val) {
-  const arr = Array.isArray(val) ? val : String(val ?? "").split(",");
-  return new Set(arr.map((s) => String(s).trim().toLowerCase()).filter(Boolean));
-}
-
-function setsEqual(a, b) {
-  if (a.size !== b.size) return false;
-  for (const v of a) if (!b.has(v)) return false;
-  return true;
-}
-
-function formatValue(val) {
-  if (val == null || val === "") return "—";
-  if (Array.isArray(val)) return val.length === 0 ? "—" : val.join(", ");
-  return String(val);
-}
diff --git a/src/modules/loldle/flavor.js b/src/modules/loldle/flavor.js
deleted file mode 100644
index ce98731..0000000
--- a/src/modules/loldle/flavor.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * @file Win-message text helpers — one-word reaction + elapsed-time format.
- */
-
-/**
- * One-word reaction keyed by how many guesses it took to solve.
- *
- * @param {number} attempt — 1-indexed count of the guess that won (1..max)
- * @param {number} max — MAX_GUESSES (8 for loldle)
- * @returns {string}
- */
-export function attemptFlavor(attempt, max) {
-  if (attempt <= 1) return "First try!";
-  if (attempt === 2) return "Sharp!";
-  if (attempt >= max) return "Phew — last one!";
-  if (attempt >= max - 2) return "Close call!";
-  return "Nice.";
-}
-
-/**
- * Format an elapsed duration in ms as a compact human string.
- *   < 60s       → "42s"
- *   < 60min     → "3m 14s"
- *   otherwise   → "1h 12m"
- *
- * @param {number} ms
- * @returns {string}
- */
-export function formatDuration(ms) {
-  const total = Math.max(0, Math.round(ms / 1000));
-  if (total < 60) return `${total}s`;
-  const minutes = Math.floor(total / 60);
-  const seconds = total % 60;
-  if (minutes < 60) return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
-  const hours = Math.floor(minutes / 60);
-  const remMin = minutes % 60;
-  return remMin === 0 ? `${hours}h` : `${hours}h ${remMin}m`;
-}
diff --git a/src/modules/loldle/handlers.js b/src/modules/loldle/handlers.js
deleted file mode 100644
index 5c464a9..0000000
--- a/src/modules/loldle/handlers.js
+++ /dev/null
@@ -1,232 +0,0 @@
-/**
- * @file Loldle command handlers.
- *
- * Subject resolution:
- *   private chat → user id (per-user game)
- *   group/supergroup chat → chat id (shared game — everyone plays together)
- *
- * Round lifecycle: a round is created lazily on the first /loldle call after
- * the previous round ended, and the timer (`startedAt`) only starts when the
- * player submits their first actual guess — viewing an empty board gives no
- * hints, so it shouldn't count against the clock.
- *
- * Commands:
- *   /loldle              → show the current board (or start a round)
- *   /loldle    → submit a guess
- *   /loldle_giveup       → reveal the answer and end the round
- *   /loldle_stats        → show wins / streak for the current subject
- */
-
-import { escapeHtml } from "../../util/escape-html.js";
-import championsData from "./champions.json" with { type: "json" };
-import { compareChampions } from "./compare.js";
-import { attemptFlavor, formatDuration } from "./flavor.js";
-import { findChampion } from "./lookup.js";
-import { renderBoard, renderGuess } from "./render.js";
-import {
-  MAX_GUESSES_CAP,
-  clearGame,
-  getMaxGuesses,
-  loadGame,
-  loadStats,
-  recordResult,
-  saveGame,
-  setMaxGuesses,
-} from "./state.js";
-import { GIVEUP_STICKERS, LOSE_STICKERS, WIN_STICKERS, pickSticker } from "./stickers.js";
-
-/** @type {Array>} */
-const champions = championsData;
-
-const NEW_ROUND_HINT =
-  "🆕 Send /loldle or /loldle <champion> to start a new round.";
-
-function getSubject(ctx) {
-  const type = ctx.chat?.type;
-  if (type === "group" || type === "supergroup") return ctx.chat.id;
-  return ctx.from?.id ?? null;
-}
-
-function argAfterCommand(text) {
-  if (!text) return "";
-  const idx = text.indexOf(" ");
-  return idx === -1 ? "" : text.slice(idx + 1).trim();
-}
-
-function pickRandomChampion() {
-  return champions[Math.floor(Math.random() * champions.length)];
-}
-
-function findByName(name) {
-  return champions.find((c) => c.championName === name);
-}
-
-/** Recompute comparison rows for each stored guess against the current target. */
-function rehydrateGuesses(game) {
-  const target = findByName(game.target);
-  if (!target) return [];
-  const rows = [];
-  for (const name of game.guesses) {
-    const guess = findByName(name);
-    if (!guess) continue;
-    rows.push({ champion: name, results: compareChampions(guess, target) });
-  }
-  return rows;
-}
-
-async function startFreshGame(db, subject) {
-  const target = pickRandomChampion();
-  // startedAt stays null until the player actually submits their first guess —
-  // seeing an empty board gives no hints, so the round hasn't really begun.
-  const fresh = { target: target.championName, guesses: [], startedAt: null };
-  await saveGame(db, subject, fresh);
-  return fresh;
-}
-
-async function getOrInitGame(db, subject, maxGuesses) {
-  const existing = await loadGame(db, subject);
-  if (existing && existing.guesses.length < maxGuesses) return existing;
-  return startFreshGame(db, subject);
-}
-
-/** Send a sticker, swallowing errors so a bad file_id never blocks the reply. */
-async function trySendSticker(ctx, pool) {
-  const sticker = pickSticker(pool);
-  if (!sticker) return;
-  try {
-    await ctx.replyWithSticker(sticker);
-  } catch {
-    // Invalid file_id or transient Telegram error — the outcome text is what matters.
-  }
-}
-
-/**
- * @param {import("grammy").Context} ctx
- * @param {import("../../db/kv-store-interface.js").KVStore} db
- */
-export async function handleLoldle(ctx, db) {
-  const subject = getSubject(ctx);
-  if (subject == null) return ctx.reply("Cannot identify chat.");
-  const arg = argAfterCommand(ctx.message?.text ?? "");
-
-  const maxGuesses = await getMaxGuesses(db, subject);
-  const game = await getOrInitGame(db, subject, maxGuesses);
-
-  if (!arg) {
-    const header = `Guess ${game.guesses.length}/${maxGuesses}. Use /loldle <champion>.`;
-    const board = renderBoard(rehydrateGuesses(game));
-    return ctx.reply(`${header}\n\n${board}`, { parse_mode: "HTML" });
-  }
-
-  const guess = findChampion(champions, arg);
-  if (!guess) return ctx.reply(`Champion not found: "${arg}".`);
-
-  if (game.guesses.includes(guess.championName)) {
-    return ctx.reply(
-      `🔁 ${escapeHtml(guess.championName)} was already guessed this round — try another champion.`,
-      { parse_mode: "HTML" },
-    );
-  }
-
-  const target = findByName(game.target);
-  // champions.json can be refreshed between rounds — an active target may disappear.
-  if (!target) {
-    await clearGame(db, subject);
-    return ctx.reply(`Champion data was updated since this round started. ${NEW_ROUND_HINT}`, {
-      parse_mode: "HTML",
-    });
-  }
-
-  const results = compareChampions(guess, target);
-  // Stamp the clock on the first guess — prior /loldle views don't count.
-  if (game.startedAt == null) game.startedAt = Date.now();
-  game.guesses.push(guess.championName);
-  const won = guess.championName === target.championName;
-
-  const reply = renderGuess(guess.championName, results);
-  const elapsed = formatDuration(Date.now() - game.startedAt);
-  const champ = escapeHtml(target.championName);
-
-  if (won) {
-    const s = await recordResult(db, subject, true);
-    await clearGame(db, subject);
-    await trySendSticker(ctx, WIN_STICKERS);
-    const flavor = attemptFlavor(game.guesses.length, maxGuesses);
-    return ctx.reply(
-      `${reply}\n\n🎉 ${flavor} ${champ}\n⏱ ${elapsed} · 🔥 Streak: ${s.streak} (${game.guesses.length}/${maxGuesses})\n${NEW_ROUND_HINT}`,
-      { parse_mode: "HTML" },
-    );
-  }
-
-  if (game.guesses.length >= maxGuesses) {
-    await recordResult(db, subject, false);
-    await clearGame(db, subject);
-    await trySendSticker(ctx, LOSE_STICKERS);
-    return ctx.reply(`${reply}\n\n❌ Out of guesses. Answer was ${champ}.\n${NEW_ROUND_HINT}`, {
-      parse_mode: "HTML",
-    });
-  }
-
-  await saveGame(db, subject, game);
-  return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${maxGuesses}.`, {
-    parse_mode: "HTML",
-  });
-}
-
-/**
- * Hidden command — set the per-subject MAX_GUESSES override (1..MAX_GUESSES_CAP).
- * Takes effect on the next round; in-progress rounds keep the old value.
- *
- * @param {import("grammy").Context} ctx
- * @param {import("../../db/kv-store-interface.js").KVStore} db
- */
-export async function handleSetMax(ctx, db) {
-  const subject = getSubject(ctx);
-  if (subject == null) return ctx.reply("Cannot identify chat.");
-  const arg = argAfterCommand(ctx.message?.text ?? "");
-  const n = Number.parseInt(arg, 10);
-  if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) {
-    return ctx.reply(`Usage: /loldle_setmax <1-${MAX_GUESSES_CAP}>`);
-  }
-  await setMaxGuesses(db, subject, n);
-  return ctx.reply(`✅ Loldle max guesses set to ${n} (applies to the next round).`);
-}
-
-/**
- * @param {import("grammy").Context} ctx
- * @param {import("../../db/kv-store-interface.js").KVStore} db
- */
-export async function handleGiveup(ctx, db) {
-  const subject = getSubject(ctx);
-  if (subject == null) return ctx.reply("Cannot identify chat.");
-  const existing = await loadGame(db, subject);
-  // No active round — nothing to give up on.
-  if (!existing) {
-    return ctx.reply(`No active round. ${NEW_ROUND_HINT}`, { parse_mode: "HTML" });
-  }
-  await recordResult(db, subject, false);
-  const target = findByName(existing.target);
-  await clearGame(db, subject);
-  await trySendSticker(ctx, GIVEUP_STICKERS);
-  const answer = target ? escapeHtml(target.championName) : escapeHtml(existing.target);
-  return ctx.reply(`🏳️ Answer was ${answer}.\n${NEW_ROUND_HINT}`, { parse_mode: "HTML" });
-}
-
-/**
- * @param {import("grammy").Context} ctx
- * @param {import("../../db/kv-store-interface.js").KVStore} db
- */
-export async function handleStats(ctx, db) {
-  const subject = getSubject(ctx);
-  if (subject == null) return ctx.reply("Cannot identify chat.");
-  const s = await loadStats(db, subject);
-  const winRate = s.played ? Math.round((s.wins / s.played) * 100) : 0;
-  const scope = ctx.chat?.type === "private" ? "your" : "group";
-  return ctx.reply(
-    `📊 Loldle ${scope} stats\n` +
-      `Played: ${s.played}\n` +
-      `Wins: ${s.wins} (${winRate}%)\n` +
-      `Current streak: ${s.streak}\n` +
-      `Best streak: ${s.bestStreak}`,
-  );
-}
diff --git a/src/modules/loldle/index.js b/src/modules/loldle/index.js
deleted file mode 100644
index e4dcea2..0000000
--- a/src/modules/loldle/index.js
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * @file Loldle module — classic-mode champion guessing game.
- * Champion data is scraped weekly from loldle.net.
- */
-
-import { handleGiveup, handleLoldle, handleSetMax, handleStats } from "./handlers.js";
-
-/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
-let db = null;
-
-/** @type {import("../registry.js").BotModule} */
-const loldleModule = {
-  name: "loldle",
-  init: async ({ db: store }) => {
-    db = store;
-  },
-  commands: [
-    {
-      name: "loldle",
-      visibility: "public",
-      description: "Classic loldle — guess the current champion",
-      handler: (ctx) => handleLoldle(ctx, db),
-    },
-    {
-      name: "loldle_giveup",
-      visibility: "public",
-      description: "Reveal the current loldle answer (auto-starts a fresh round)",
-      handler: (ctx) => handleGiveup(ctx, db),
-    },
-    {
-      name: "loldle_stats",
-      visibility: "public",
-      description: "Show your loldle stats (wins, streak)",
-      handler: (ctx) => handleStats(ctx, db),
-    },
-    {
-      name: "loldle_setmax",
-      visibility: "private",
-      description: "Override max guesses per round (1-10) for this chat",
-      handler: (ctx) => handleSetMax(ctx, db),
-    },
-  ],
-};
-
-export default loldleModule;
diff --git a/src/modules/loldle/lookup.js b/src/modules/loldle/lookup.js
deleted file mode 100644
index 4c97ec6..0000000
--- a/src/modules/loldle/lookup.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @file Champion name lookup — resolve user input to a champion record.
- * Match is case/space/punctuation-insensitive with a unique-prefix fallback.
- */
-
-import { normalize } from "../../util/normalize-name.js";
-
-/**
- * @param {Array>} champions
- * @param {string} input
- * @returns {Record | null}
- */
-export function findChampion(champions, input) {
-  const q = normalize(input);
-  if (!q) return null;
-
-  for (const c of champions) {
-    if (normalize(c.championName) === q) return c;
-  }
-
-  const prefixMatches = champions.filter((c) => normalize(c.championName).startsWith(q));
-  return prefixMatches.length === 1 ? prefixMatches[0] : null;
-}
diff --git a/src/modules/loldle/render.js b/src/modules/loldle/render.js
deleted file mode 100644
index 9312a99..0000000
--- a/src/modules/loldle/render.js
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * @file Render comparison results as a monospace-aligned Telegram HTML table.
- *
- * Markers: 🎯 guessed champion · ✅ correct · 🟨 partial · ❌ wrong
- *          ⬆️ / ⬇️ year direction hint.
- */
-
-import { escapeHtml } from "../../util/escape-html.js";
-
-const MARKER = { correct: "✅", partial: "🟨", wrong: "❌" };
-const ARROW = { up: "⬆️", down: "⬇️" };
-const NAME_LABEL = "Champion";
-const NAME_MARKER = "🎯";
-
-/**
- * Build the label/value rows for a single guess (name row + one row per attribute).
- * @param {string} championName
- * @param {ReturnType} results
- * @returns {Array<{marker: string, label: string, value: string}>}
- */
-function buildRows(championName, results) {
-  const rows = [{ marker: NAME_MARKER, label: NAME_LABEL, value: championName.toUpperCase() }];
-  for (const r of results) {
-    const marker = MARKER[r.result] ?? MARKER.wrong;
-    let value = String(r.guessValue ?? "");
-    if (r.key === "release_date" && r.result !== "correct" && r.direction) {
-      const arrow = ARROW[r.direction];
-      if (arrow) value = `${value} ${arrow}`;
-    }
-    rows.push({ marker, label: r.label, value });
-  }
-  return rows;
-}
-
-/**
- * Render one or more row-groups as a single aligned monospace block. Label
- * column width = max label length across ALL groups, so stacked guesses on a
- * board line up with each other.
- * @param {Array>} rowGroups
- */
-function formatRowGroups(rowGroups) {
-  const width = Math.max(...rowGroups.flat().map((r) => r.label.length));
-  const blocks = rowGroups.map((rows) =>
-    rows.map((r) => `${r.marker} ${r.label.padEnd(width)} ${escapeHtml(r.value)}`).join("\n"),
-  );
-  return `
${blocks.join("\n\n")}
`; -} - -/** - * Render a single guess row-group. - * @param {string} championName - * @param {ReturnType} results - */ -export function renderGuess(championName, results) { - return formatRowGroups([buildRows(championName, results)]); -} - -/** - * Render the current board — all prior guesses stacked in one aligned block. - * @param {Array<{champion: string, results: any[]}>} guesses - */ -export function renderBoard(guesses) { - if (guesses.length === 0) { - return "No guesses yet. Reply with /loldle <champion>."; - } - return formatRowGroups(guesses.map((g) => buildRows(g.champion, g.results))); -} diff --git a/src/modules/loldle/state.js b/src/modules/loldle/state.js deleted file mode 100644 index f457d54..0000000 --- a/src/modules/loldle/state.js +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @file Game + stats persistence in KV, keyed by "subject" - * (user id in DMs, chat id in groups — so a group shares one round). - * - * Key layout (inside the module-prefixed store): - * game: -> { target, guesses, startedAt } - * stats: -> { played, wins, streak, bestStreak } - * - * `guesses` is a string[] of championNames — comparison rows are recomputed - * at render time, so the board always reflects the live champions.json. - */ - -// Default round length. Admins can override per-subject via the hidden -// /loldle_setmax command (bounded by MAX_GUESSES_CAP). -const MAX_GUESSES = 8; -const MAX_GUESSES_CAP = 10; -// Upper bound for a round — long enough for any real session, short enough -// that stale KV entries get reclaimed automatically. -const GAME_TTL_SECONDS = 60 * 60 * 24 * 7; - -const gameKey = (subject) => `game:${subject}`; -const statsKey = (subject) => `stats:${subject}`; -const configKey = (subject) => `config:${subject}`; - -/** - * @typedef {object} GameState - * @property {string} target — hidden champion's championName - * @property {string[]} guesses — championNames already tried this round - * @property {number|null} startedAt — epoch ms; null until the first guess is - * submitted (viewing an empty board shouldn't start the clock) - */ - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - * @returns {Promise} - */ -export async function loadGame(db, subject) { - return db.getJSON(gameKey(subject)); -} - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - * @param {GameState} state - */ -export async function saveGame(db, subject, state) { - await db.putJSON(gameKey(subject), state, { expirationTtl: GAME_TTL_SECONDS }); -} - -/** - * Remove the stored game so the next /loldle call creates a fresh round. - * Used after win / loss / giveup — the new round's timer should start on the - * player's next interaction, not at the moment the previous round ended. - * - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - */ -export async function clearGame(db, subject) { - await db.delete(gameKey(subject)); -} - -export { MAX_GUESSES, MAX_GUESSES_CAP }; - -/** - * Per-subject override for the round length, falling back to MAX_GUESSES. - * Values outside [1, MAX_GUESSES_CAP] are ignored as if unset. - * - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - * @returns {Promise} - */ -export async function getMaxGuesses(db, subject) { - const cfg = await db.getJSON(configKey(subject)); - const n = cfg?.maxGuesses; - if (Number.isInteger(n) && n >= 1 && n <= MAX_GUESSES_CAP) return n; - return MAX_GUESSES; -} - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - * @param {number} n - */ -export async function setMaxGuesses(db, subject, n) { - if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { - throw new RangeError(`maxGuesses must be an integer in [1, ${MAX_GUESSES_CAP}]`); - } - await db.putJSON(configKey(subject), { maxGuesses: n }); -} - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - */ -export async function loadStats(db, subject) { - return ( - (await db.getJSON(statsKey(subject))) ?? { - played: 0, - wins: 0, - streak: 0, - bestStreak: 0, - } - ); -} - -/** - * Record a finished round and update the streak. Streak increments on each - * win and resets to 0 on any non-win. - * - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - * @param {boolean} won - */ -export async function recordResult(db, subject, won) { - const s = await loadStats(db, subject); - s.played += 1; - if (won) { - s.wins += 1; - s.streak += 1; - if (s.streak > s.bestStreak) s.bestStreak = s.streak; - } else { - s.streak = 0; - } - await db.putJSON(statsKey(subject), s); - return s; -} diff --git a/src/modules/loldle/stickers.js b/src/modules/loldle/stickers.js deleted file mode 100644 index d51e602..0000000 --- a/src/modules/loldle/stickers.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @file Loldle sticker pools — keyed by round outcome. - * - * Telegram `file_id`s are bot-scoped: these IDs are only valid for @miti99bot. - * To add/replace stickers, DM a sticker to the bot and use `/stickerid` (util - * module, private) to capture the bot-scoped file_id, then paste it here. - * - * Empty pools are safe — `pickSticker` returns null and the handler skips the - * replyWithSticker call entirely. - */ - -/** Cheerful stickers shown before a win message. */ -export const WIN_STICKERS = [ - "CAACAgIAAxkBAAECGBVp56vuLw5TSWy8TiFGkQYAAWxi0wUAAh9EAAJTD8BJ2wQ6vyMilxs7BA", - "CAACAgIAAxkBAAECGBhp56wAATkzsBBcIxMSC_8mp2dBczsAAvBEAAL1vklJuCnu695nAvI7BA", - "CAACAgIAAxkBAAECGBlp56wBZ4rmluL3KNlOuWsyctN0FQACNEQAAue5OEv37J_IfMnpljsE", - "CAACAgIAAxkBAAECGCFp56w7WlD6vTIsHE2WUTs4C2IjXAAC9FsAAtgFMUu63usfH16ZpzsE", -]; - -/** Deflated stickers shown when the player runs out of guesses. */ -export const LOSE_STICKERS = [ - "CAACAgIAAxkBAAECGCBp56w3HUyYOHOeMwLfULT8p8SPtQACWkYAAiPesEjnptWk36YKZjsE", -]; - -/** Resigned stickers shown on /loldle_giveup. */ -export const GIVEUP_STICKERS = [ - "CAACAgIAAxkBAAECGCJp56xIk6B6McSfnYykLhgXVCSnmQACBlkAApSyWEo6G2rnqDvZxjsE", -]; - -/** - * Pick a random sticker from a pool, or null if the pool is empty. - * @param {readonly string[]} pool - * @returns {string|null} - */ -export function pickSticker(pool) { - if (!pool || pool.length === 0) return null; - return pool[Math.floor(Math.random() * pool.length)]; -} diff --git a/src/modules/lolschedule/README.md b/src/modules/lolschedule/README.md deleted file mode 100644 index 80e6719..0000000 --- a/src/modules/lolschedule/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# lolschedule - -LoL esports match schedule via the **lolesports.com** esports-api (the data feed behind the official lolesports.com website). - -## Commands - -| Command | Description | -|---|---| -| `/lolschedule_today` | Today's matches (ICT), grouped by league. Scores for played + live, times for upcoming. | -| `/lolschedule_week` | Next 7 days, grouped by league → day. | -| `/lolschedule_subscribe` | Opt the current chat into the daily 08:00 ICT digest. | -| `/lolschedule_unsubscribe` | Stop receiving the digest. | - -## Cron - -| Schedule (UTC) | Local (ICT) | Purpose | -|---|---|---| -| `0 1 * * *` | 08:00 | Fan today's major-league schedule out to every chat subscribed via `/lolschedule_subscribe`. Skipped when no subscribers, no token, or no matches today. | - -Subscribers are stored under the module's `subscribers` KV key as a JSON array of chat ids. Per-chat failures during fan-out are logged and swallowed so a single blocked chat can't stop the others. - -## Data source - -- Endpoint: `https://esports-api.lolesports.com/persisted/gw/getSchedule` -- Header: `x-api-key: ` — the same key lolesports.com's own web client sends. No registration, no token lifecycle. If Riot rotates it, lift the new value from their public JS bundle. -- Companion endpoints used during design: `getLive`, `getLeagues`. - -## Why not Leaguepedia? - -Initial design hit Fandom's anonymous IP rate limit even from Cloudflare Worker egress (~1–2 req/min). See `plans/reports/researcher-260421-0845-leaguepedia-api-verification.md` and the auth-token follow-up. lolesports.com API is: - -- Official Riot-operated -- Rate-limit friendly (powers the live site) -- Richer shape: state (`unstarted` / `inProgress` / `completed`), per-team `result.gameWins`, league metadata, bestOf strategy - -## Event shape (relevant fields) - -``` -{ - startTime: "2026-04-21T09:00:00Z", - state: "unstarted" | "inProgress" | "completed", - blockName: "Week 4", - league: { name: "LCK", slug: "lck" }, - match: { - id: "...", - teams: [ - { name, code, image, result?: { outcome, gameWins }, record?: {...} }, - ... - ], - strategy: { type: "bestOf", count: 3 } - } -} -``` - -## Caching - -Cache-first with KV. Key is `matches:{fromIso}:{toIso}`. - -- Fresh TTL: 120 s (catches live score updates quickly) -- Stale fallback: up to 1 h on upstream failure -- No cron pre-warm needed — upstream is cheap - -## Grouping - -- `/lolschedule_today` — one section per league (header + match lines). -- `/lolschedule_week` — one section per league; within each league, matches are sub-grouped by ICT day (italic date headers). -- League ordering follows `LEAGUE_ORDER` in `format.js` (worlds / msi / first_stand first, then LCK / LPL / LEC / LCS, then the rest). - -## Subscribers - -`/lolschedule_subscribe` adds `ctx.chat.id` to the module's `subscribers` KV key (JSON array). `/lolschedule_unsubscribe` removes it. Both are idempotent and reply with the new state. The daily cron reads this list; empty list means the cron skips cleanly. - -## Time zone - -All rendering is in **ICT (UTC+7)**. `startTime` is UTC ISO; day boundaries for the `/lolschedule_today` and `/lolschedule_week` windows are anchored to ICT midnight. - -## Files - -- `index.js` — module contract -- `api-client.js` — getSchedule client with pagination + cache -- `format.js` — pure renderers (`formatEventLine`, `renderToday`, `renderWeek`) -- `handlers.js` — grammY command handlers, ICT day boundaries, cron fan-out -- `subscribers.js` — KV-backed add/list/remove for the daily-push list diff --git a/src/modules/lolschedule/api-client.js b/src/modules/lolschedule/api-client.js deleted file mode 100644 index 604da4c..0000000 --- a/src/modules/lolschedule/api-client.js +++ /dev/null @@ -1,154 +0,0 @@ -/** - * @file lolesports.com esports-api client. - * - * Endpoint: https://esports-api.lolesports.com/persisted/gw/getSchedule - * Auth: `x-api-key` header — the public key embedded in lolesports.com's own - * web client. No registration. No practical rate limit (the same key serves - * the live site). If Riot ever rotates it, lift the new value from their - * public JS bundle. - * - * We cache responses in KV keyed by date range so concurrent user requests - * collapse to one upstream hit within the TTL window. - */ - -const API_URL = "https://esports-api.lolesports.com/persisted/gw/getSchedule"; -const API_KEY = "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z"; -const USER_AGENT = "miti99bot/0.1 (https://t.me/miti99bot)"; - -/** Short cache — schedule data changes minute-by-minute during live events. */ -const CACHE_TTL_SEC = 120; -/** Stale fallback ceiling for resilience during upstream hiccups. */ -const STALE_MAX_AGE_SEC = 60 * 60; - -/** - * @typedef {object} Team - * @property {string} name - * @property {string} code — short league tag, e.g. "T1", "GEN". - * @property {string} [image] - * @property {{ outcome: "win"|"loss", gameWins: number }} [result] - * @property {{ wins: number, losses: number }} [record] - */ - -/** - * @typedef {object} ScheduleEvent - * @property {string} startTime — ISO 8601 UTC. - * @property {"unstarted"|"inProgress"|"completed"} state - * @property {string} [blockName] — e.g. "Week 4". - * @property {{ name: string, slug: string, image?: string }} league - * @property {{ - * id: string, - * teams: Team[], - * strategy: { type: string, count: number } - * }} match - */ - -/** - * Fetch one page of schedule events. - * - * @param {object} [opts] - * @param {string} [opts.pageToken] — forward cursor from a previous call's `newerToken`. - * @returns {Promise<{ events: ScheduleEvent[], newerToken?: string }>} - */ -export async function fetchSchedulePage({ pageToken } = {}) { - const url = new URL(API_URL); - url.searchParams.set("hl", "en-US"); - if (pageToken) url.searchParams.set("pageToken", pageToken); - - const res = await fetch(url.toString(), { - headers: { - "x-api-key": API_KEY, - "User-Agent": USER_AGENT, - Accept: "application/json", - }, - cf: { cacheTtl: 60, cacheEverything: true }, - }); - const text = await res.text(); - if (!res.ok) { - console.log( - JSON.stringify({ msg: "lolschedule_fetch", status: res.status, body: text.slice(0, 500) }), - ); - throw new Error(`lolesports API HTTP ${res.status}`); - } - let json; - try { - json = JSON.parse(text); - } catch { - throw new Error("lolesports non-JSON response"); - } - const schedule = json?.data?.schedule; - const events = Array.isArray(schedule?.events) ? schedule.events : []; - const filtered = events.filter((e) => e?.type !== "show"); // drop pre/post shows - return { - events: /** @type {ScheduleEvent[]} */ (filtered), - newerToken: schedule?.pages?.newer, - }; -} - -/** - * Fetch enough pages forward in time to cover the given UTC window. - * Default page returns ~20 events; week view usually needs 1 extra page. - * - * @param {Date} from - * @param {Date} to - * @param {number} [maxPages] — hard cap to keep it bounded. - * @returns {Promise} - */ -export async function fetchEventsInRange(from, to, maxPages = 3) { - const fromMs = from.getTime(); - const toMs = to.getTime(); - /** @type {ScheduleEvent[]} */ - const collected = []; - let pageToken; - for (let i = 0; i < maxPages; i++) { - const { events, newerToken } = await fetchSchedulePage({ pageToken }); - collected.push(...events); - // If the latest event in the page is already past our window end, stop. - const lastMs = events.length ? Date.parse(events[events.length - 1].startTime) : null; - if (lastMs !== null && lastMs >= toMs) break; - if (!newerToken) break; - pageToken = newerToken; - } - return collected.filter((e) => { - const t = Date.parse(e.startTime); - return t >= fromMs && t < toMs; - }); -} - -/** Build the KV cache key for a date range. */ -function cacheKey(from, to) { - return `matches:${from.toISOString()}:${to.toISOString()}`; -} - -/** - * Cache-first lookup. Returns fresh cache within TTL, else tries upstream, - * else returns stale cache up to {@link STALE_MAX_AGE_SEC}, else throws. - * - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {Date} from - * @param {Date} to - * @returns {Promise} - */ -export async function getEventsCached(db, from, to) { - const key = cacheKey(from, to); - const cached = await db.getJSON(key); - if (cached?.ts && Date.now() - cached.ts < CACHE_TTL_SEC * 1000) { - return cached.events; - } - try { - const events = await fetchEventsInRange(from, to); - try { - await db.putJSON(key, { ts: Date.now(), events }, { expirationTtl: STALE_MAX_AGE_SEC }); - } catch (err) { - console.log(JSON.stringify({ msg: "lolschedule_kv_put_fail", err: String(err) })); - } - return events; - } catch (err) { - if (cached?.events && cached?.ts && Date.now() - cached.ts < STALE_MAX_AGE_SEC * 1000) { - console.log(JSON.stringify({ msg: "lolschedule_stale_fallback", err: String(err) })); - return cached.events; - } - throw err; - } -} - -export { CACHE_TTL_SEC, STALE_MAX_AGE_SEC }; diff --git a/src/modules/lolschedule/format.js b/src/modules/lolschedule/format.js deleted file mode 100644 index c6252cd..0000000 --- a/src/modules/lolschedule/format.js +++ /dev/null @@ -1,190 +0,0 @@ -/** - * @file Pure formatters — Telegram HTML output for today / week match lists. - * - * Takes lolesports.com schedule events and renders them, grouped by league - * (today) and by day → league (week). All user-influenced substrings are - * HTML-escaped. Times are shown in ICT (UTC+7). - */ - -import { escapeHtml } from "../../util/escape-html.js"; - -/** @typedef {import("./api-client.js").ScheduleEvent} ScheduleEvent */ -/** @typedef {import("./api-client.js").Team} Team */ - -const TZ_OFFSET_MS = 7 * 60 * 60 * 1000; // ICT = UTC+7 - -/** Ordering for league sections — most prestigious tournaments first. */ -const LEAGUE_ORDER = [ - "worlds", - "msi", - "first_stand", - "lck", - "lpl", - "lec", - "lcs", - "lcp", - "cblol-brazil", - "emea_masters", -]; - -/** Shift a UTC Date by the ICT offset so getUTC* yields ICT components. */ -function toIct(date) { - return new Date(date.getTime() + TZ_OFFSET_MS); -} - -function pad(n) { - return String(n).padStart(2, "0"); -} - -export function formatIctTime(date) { - const d = toIct(date); - return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; -} - -export function formatIctDayLabel(date) { - const d = toIct(date); - const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - const months = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ]; - return `${weekdays[d.getUTCDay()]} ${months[d.getUTCMonth()]} ${d.getUTCDate()}`; -} - -function ictDayKey(date) { - const d = toIct(date); - return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`; -} - -/** Pick the visible short tag for a team. */ -function teamLabel(team) { - if (!team) return "TBD"; - return team.code || team.name || "TBD"; -} - -/** - * Render one event line (no leading newline). The league name is omitted - * because events render under a league header. - * - * @param {ScheduleEvent} event - * @returns {string} escaped HTML - */ -export function formatEventLine(event) { - const teams = event?.match?.teams || []; - const t1Label = escapeHtml(teamLabel(teams[0])); - const t2Label = escapeHtml(teamLabel(teams[1])); - const block = event?.blockName ? ` (${escapeHtml(event.blockName)})` : ""; - const bestOf = event?.match?.strategy?.count; - const bo = bestOf ? ` · Bo${bestOf}` : ""; - - if (event?.state === "completed") { - const w1 = teams[0]?.result?.gameWins ?? 0; - const w2 = teams[1]?.result?.gameWins ?? 0; - const l = teams[0]?.result?.outcome === "win" ? `${t1Label}` : t1Label; - const r = teams[1]?.result?.outcome === "win" ? `${t2Label}` : t2Label; - return `✅ ${l} ${w1}–${w2} ${r}${bo}${block}`; - } - if (event?.state === "inProgress") { - const w1 = teams[0]?.result?.gameWins ?? 0; - const w2 = teams[1]?.result?.gameWins ?? 0; - return `🔴 LIVE ${t1Label} ${w1}–${w2} ${t2Label}${bo}${block}`; - } - const time = formatIctTime(new Date(event.startTime)); - return `🕒 ${time} ${t1Label} vs ${t2Label}${bo}${block}`; -} - -/** - * Group events by league slug, preserving LEAGUE_ORDER for known leagues and - * falling back to alphabetical for anything else. - * - * @param {ScheduleEvent[]} events - * @returns {Array<{ slug: string, name: string, events: ScheduleEvent[] }>} - */ -function groupByLeague(events) { - /** @type {Map} */ - const bySlug = new Map(); - for (const event of events) { - const slug = event?.league?.slug || "unknown"; - const name = event?.league?.name || slug; - let g = bySlug.get(slug); - if (!g) { - g = { slug, name, events: [] }; - bySlug.set(slug, g); - } - g.events.push(event); - } - const known = LEAGUE_ORDER.filter((slug) => bySlug.has(slug)).map((slug) => bySlug.get(slug)); - const unknown = [...bySlug.values()] - .filter((g) => !LEAGUE_ORDER.includes(g.slug)) - .sort((a, b) => a.name.localeCompare(b.name)); - return [...known, ...unknown]; -} - -/** Render a league section (header + lines). */ -function renderLeagueSection(group) { - const lines = group.events.map((e) => formatEventLine(e)); - return `${escapeHtml(group.name)}\n${lines.join("\n")}`; -} - -/** - * Render today's reply — grouped by league. - * - * @param {ScheduleEvent[]} events - * @param {Date} day — any moment on the target ICT day. - * @returns {string} - */ -export function renderToday(events, day) { - const header = `LoL — ${escapeHtml(formatIctDayLabel(day))} (ICT)`; - if (events.length === 0) return `${header}\nNo matches today.`; - const sections = groupByLeague(events).map(renderLeagueSection); - return `${header}\n\n${sections.join("\n\n")}`; -} - -/** - * Render week reply — grouped by league → ICT day. The league ordering is - * determined by LEAGUE_ORDER; within each league, days appear chronologically - * and each day section lists its matches. - * - * @param {ScheduleEvent[]} events - * @param {Date} from - * @param {Date} to - * @returns {string} - */ -export function renderWeek(events, from, to) { - const fromLbl = escapeHtml(formatIctDayLabel(from)); - const toLbl = escapeHtml(formatIctDayLabel(new Date(to.getTime() - 1))); - const header = `LoL — ${fromLbl} → ${toLbl} (ICT)`; - if (events.length === 0) return `${header}\nNo matches this week.`; - - const leagueBlocks = groupByLeague(events).map((league) => { - /** @type {Map} */ - const daysInLeague = new Map(); - for (const event of league.events) { - const d = new Date(event.startTime); - const key = ictDayKey(d); - let g = daysInLeague.get(key); - if (!g) { - g = { label: formatIctDayLabel(d), lines: [] }; - daysInLeague.set(key, g); - } - g.lines.push(formatEventLine(event)); - } - const daySections = [...daysInLeague.keys()].sort().map((key) => { - const day = daysInLeague.get(key); - return `${escapeHtml(day.label)}\n${day.lines.join("\n")}`; - }); - return `${escapeHtml(league.name)}\n${daySections.join("\n")}`; - }); - - return `${header}\n\n${leagueBlocks.join("\n\n")}`; -} diff --git a/src/modules/lolschedule/handlers.js b/src/modules/lolschedule/handlers.js deleted file mode 100644 index 38aab6a..0000000 --- a/src/modules/lolschedule/handlers.js +++ /dev/null @@ -1,224 +0,0 @@ -/** - * @file Command + cron handlers for lolschedule. - * - * Day boundaries are defined in ICT (UTC+7). Data comes from lolesports.com - * via a cache-first fetcher; no cron pre-warm is needed because the upstream - * API is rate-limit friendly. A daily cron fans today's schedule out to every - * chat opted in via /lolschedule_subscribe. - */ - -import { getEventsCached } from "./api-client.js"; -import { renderToday, renderWeek } from "./format.js"; -import { parseScheduleDate } from "./parse-date.js"; -import { addSubscriber, listSubscribers, removeSubscriber } from "./subscribers.js"; - -const ICT_OFFSET_MS = 7 * 60 * 60 * 1000; - -// Top-tier league allowlist. The API returns every regional/academy league -// (135+ events/week); filtering keeps the reply under Telegram's 4096-char -// limit and focuses on what most viewers care about. -const MAJOR_LEAGUE_SLUGS = new Set([ - "lck", - "lpl", - "lec", - "lcs", - "worlds", - "msi", - "first_stand", - "lcp", - "cblol-brazil", - "emea_masters", -]); - -function filterMajor(events) { - return events.filter((e) => MAJOR_LEAGUE_SLUGS.has(e?.league?.slug)); -} - -/** Start of the current ICT calendar day, expressed as a UTC `Date`. */ -export function ictDayStart(nowMs = Date.now()) { - const shifted = new Date(nowMs + ICT_OFFSET_MS); - shifted.setUTCHours(0, 0, 0, 0); - return new Date(shifted.getTime() - ICT_OFFSET_MS); -} - -/** Add `days` days to a Date, preserving time-of-day. */ -export function addDays(date, days) { - return new Date(date.getTime() + days * 24 * 60 * 60 * 1000); -} - -/** - * @param {import("grammy").Context} ctx - * @param {import("../../db/kv-store-interface.js").KVStore | null} db - */ -export async function handleToday(ctx, db) { - if (!db) { - await ctx.reply("lolschedule: storage unavailable"); - return; - } - const from = ictDayStart(); - const to = addDays(from, 1); - try { - const events = filterMajor(await getEventsCached(db, from, to)); - await ctx.reply(renderToday(events, from), { parse_mode: "HTML" }); - } catch (err) { - console.log(JSON.stringify({ msg: "lolschedule_today_fail", err: String(err) })); - await ctx.reply("Could not fetch today's matches. Try again later."); - } -} - -/** - * /lolschedule [date] — schedule for a specific ICT day. Accepts dd-mm-yyyy, - * dd/mm/yyyy, or ddmmyyyy with trailing month/year optional. Empty → today. - * - * @param {import("grammy").Context} ctx - * @param {import("../../db/kv-store-interface.js").KVStore | null} db - */ -export async function handleSchedule(ctx, db) { - if (!db) { - await ctx.reply("lolschedule: storage unavailable"); - return; - } - const arg = (ctx.match || "").trim(); - const parsed = parseScheduleDate(arg); - if (!parsed.ok) { - await ctx.reply(parsed.error); - return; - } - const from = parsed.date; - const to = addDays(from, 1); - try { - const events = filterMajor(await getEventsCached(db, from, to)); - await ctx.reply(renderToday(events, from), { parse_mode: "HTML" }); - } catch (err) { - console.log(JSON.stringify({ msg: "lolschedule_fail", err: String(err) })); - await ctx.reply("Could not fetch matches. Try again later."); - } -} - -/** - * @param {import("grammy").Context} ctx - * @param {import("../../db/kv-store-interface.js").KVStore | null} db - */ -export async function handleWeek(ctx, db) { - if (!db) { - await ctx.reply("lolschedule: storage unavailable"); - return; - } - const from = ictDayStart(); - const to = addDays(from, 7); - try { - const events = filterMajor(await getEventsCached(db, from, to)); - await ctx.reply(renderWeek(events, from, to), { parse_mode: "HTML" }); - } catch (err) { - console.log(JSON.stringify({ msg: "lolschedule_week_fail", err: String(err) })); - await ctx.reply("Could not fetch this week's matches. Try again later."); - } -} - -/** - * Send a Telegram HTML message directly via the Bot API. Used from the cron - * path where the grammY bot context is not available. - * - * @param {string} token - * @param {string|number} chatId - * @param {string} text - */ -async function sendTelegramMessage(token, chatId, text) { - const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - chat_id: chatId, - text, - parse_mode: "HTML", - disable_web_page_preview: true, - }), - }); - if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error(`telegram sendMessage HTTP ${res.status}: ${body.slice(0, 300)}`); - } -} - -/** - * /lolschedule_subscribe — opts the current chat into the daily push. - * @param {import("grammy").Context} ctx - * @param {import("../../db/kv-store-interface.js").KVStore | null} db - */ -export async function handleSubscribe(ctx, db) { - if (!db) { - await ctx.reply("lolschedule: storage unavailable"); - return; - } - const chatId = ctx.chat?.id; - if (chatId == null) { - await ctx.reply("Could not read chat id — subscribe failed."); - return; - } - const added = await addSubscriber(db, chatId); - await ctx.reply( - added ? "✅ Subscribed. You'll get today's LoL schedule at 08:00 ICT." : "Already subscribed.", - ); -} - -/** - * /lolschedule_unsubscribe — removes the current chat from the daily push. - * @param {import("grammy").Context} ctx - * @param {import("../../db/kv-store-interface.js").KVStore | null} db - */ -export async function handleUnsubscribe(ctx, db) { - if (!db) { - await ctx.reply("lolschedule: storage unavailable"); - return; - } - const chatId = ctx.chat?.id; - if (chatId == null) { - await ctx.reply("Could not read chat id — unsubscribe failed."); - return; - } - const removed = await removeSubscriber(db, chatId); - await ctx.reply(removed ? "Unsubscribed." : "You weren't subscribed."); -} - -/** - * Cron handler — pushes today's major-league schedule to every subscribed chat. - * Per-chat failures are swallowed so one blocked bot cannot stop the fan-out. - * - * @param {any} _event - * @param {{ db: import("../../db/kv-store-interface.js").KVStore, env: any }} ctx - */ -export async function handleDailyPushCron(_event, ctx) { - const { db, env } = ctx; - const token = env?.TELEGRAM_BOT_TOKEN; - if (!token) { - console.log(JSON.stringify({ msg: "lolschedule_cron_skip", reason: "no_token" })); - return; - } - const subscribers = await listSubscribers(db); - if (subscribers.length === 0) { - console.log(JSON.stringify({ msg: "lolschedule_cron_skip", reason: "no_subscribers" })); - return; - } - const from = ictDayStart(); - const to = addDays(from, 1); - let events; - try { - events = filterMajor(await getEventsCached(db, from, to)); - } catch (err) { - console.log(JSON.stringify({ msg: "lolschedule_cron_fail", err: String(err) })); - return; - } - if (events.length === 0) { - console.log(JSON.stringify({ msg: "lolschedule_cron_empty" })); - return; - } - const text = renderToday(events, from); - const results = await Promise.allSettled( - subscribers.map((chatId) => sendTelegramMessage(token, chatId, text)), - ); - const sent = results.filter((r) => r.status === "fulfilled").length; - const failed = results.length - sent; - console.log( - JSON.stringify({ msg: "lolschedule_cron_sent", events: events.length, sent, failed }), - ); -} diff --git a/src/modules/lolschedule/index.js b/src/modules/lolschedule/index.js deleted file mode 100644 index 5d14bcc..0000000 --- a/src/modules/lolschedule/index.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @file lolschedule module — LoL esports match schedule via the - * lolesports.com esports-api (the data feed behind lolesports.com). - * - * Commands: - * /lolschedule [date] — matches for a specific ICT day (defaults to today). - * Accepts dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy; trailing - * month/year may be omitted. - * /lolschedule_today — matches scheduled for the current ICT day, with live/played scores. - * /lolschedule_week — next 7 ICT days, grouped per day → league. - * - * Cron: - * 0 1 * * * (08:00 ICT) — push today's major-league schedule to every - * chat that has opted in via /lolschedule_subscribe. - * - * See the module README for data-source rationale and the verification - * reports under plans/reports/ for historical context. - */ - -import { - handleDailyPushCron, - handleSchedule, - handleSubscribe, - handleToday, - handleUnsubscribe, - handleWeek, -} from "./handlers.js"; - -/** @type {import("../../db/kv-store-interface.js").KVStore | null} */ -let db = null; - -/** @type {import("../registry.js").BotModule} */ -const lolscheduleModule = { - name: "lolschedule", - init: async ({ db: store }) => { - db = store; - }, - commands: [ - { - name: "lolschedule", - visibility: "public", - description: "LoL matches for a date (dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy; default today)", - handler: (ctx) => handleSchedule(ctx, db), - }, - { - name: "lolschedule_today", - visibility: "public", - description: "Today's LoL esports matches (scores if played)", - handler: (ctx) => handleToday(ctx, db), - }, - { - name: "lolschedule_week", - visibility: "public", - description: "LoL esports matches for the next 7 days", - handler: (ctx) => handleWeek(ctx, db), - }, - { - name: "lolschedule_subscribe", - visibility: "public", - description: "Get the daily LoL schedule digest at 08:00 ICT", - handler: (ctx) => handleSubscribe(ctx, db), - }, - { - name: "lolschedule_unsubscribe", - visibility: "public", - description: "Stop receiving the daily LoL schedule digest", - handler: (ctx) => handleUnsubscribe(ctx, db), - }, - ], - crons: [ - { - schedule: "0 1 * * *", - name: "daily-push", - handler: handleDailyPushCron, - }, - ], -}; - -export default lolscheduleModule; diff --git a/src/modules/lolschedule/parse-date.js b/src/modules/lolschedule/parse-date.js deleted file mode 100644 index a1b9751..0000000 --- a/src/modules/lolschedule/parse-date.js +++ /dev/null @@ -1,103 +0,0 @@ -/** - * @file Parse a user-supplied date param for /lolschedule. - * - * Supported formats: dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy. Trailing components - * may be omitted (year, then month) — falling back to the current ICT day. - * Empty input → today. Day is mandatory; month/year without day is rejected. - */ - -const ICT_OFFSET_MS = 7 * 60 * 60 * 1000; - -const FORMAT_HINT = "Use dd-mm-yyyy, dd/mm/yyyy, or ddmmyyyy."; - -function toIct(date) { - return new Date(date.getTime() + ICT_OFFSET_MS); -} - -function ictDayStartOf(now) { - const shifted = toIct(now); - shifted.setUTCHours(0, 0, 0, 0); - return new Date(shifted.getTime() - ICT_OFFSET_MS); -} - -/** - * Split the raw input into [dd, mm?, yyyy?] string parts, or return an error. - * - * @param {string} trimmed - * @returns {{ ok: true, parts: string[] } | { ok: false, error: string }} - */ -function splitParts(trimmed) { - if (trimmed.includes("-") || trimmed.includes("/")) { - const parts = trimmed.split(/[-/]/); - if (parts.length < 1 || parts.length > 3) { - return { ok: false, error: `Invalid date "${trimmed}". ${FORMAT_HINT}` }; - } - if (parts.some((p) => p === "" || !/^\d+$/.test(p))) { - return { ok: false, error: `Invalid date "${trimmed}". ${FORMAT_HINT}` }; - } - return { ok: true, parts }; - } - - if (!/^\d+$/.test(trimmed)) { - return { ok: false, error: `Invalid date "${trimmed}". ${FORMAT_HINT}` }; - } - if (trimmed.length === 1 || trimmed.length === 2) { - return { ok: true, parts: [trimmed] }; - } - if (trimmed.length === 4) { - return { ok: true, parts: [trimmed.slice(0, 2), trimmed.slice(2)] }; - } - if (trimmed.length === 8) { - return { - ok: true, - parts: [trimmed.slice(0, 2), trimmed.slice(2, 4), trimmed.slice(4)], - }; - } - return { ok: false, error: `Invalid date "${trimmed}". ${FORMAT_HINT}` }; -} - -/** - * Parse a user date param into the start of the requested ICT day. - * - * @param {string|undefined|null} input - * @param {Date} [now] - * @returns {{ ok: true, date: Date } | { ok: false, error: string }} - */ -export function parseScheduleDate(input, now = new Date()) { - const trimmed = (input ?? "").trim(); - if (!trimmed) return { ok: true, date: ictDayStartOf(now) }; - - const split = splitParts(trimmed); - if (!split.ok) return split; - - const ictNow = toIct(now); - const day = Number(split.parts[0]); - const month = split.parts.length >= 2 ? Number(split.parts[1]) : ictNow.getUTCMonth() + 1; - const year = split.parts.length >= 3 ? Number(split.parts[2]) : ictNow.getUTCFullYear(); - - if (!Number.isInteger(day) || day < 1 || day > 31) { - return { ok: false, error: `Invalid day "${split.parts[0]}" — must be 1–31.` }; - } - if (!Number.isInteger(month) || month < 1 || month > 12) { - return { ok: false, error: `Invalid month "${split.parts[1]}" — must be 1–12.` }; - } - if (!Number.isInteger(year) || year < 1970 || year > 2100) { - return { ok: false, error: `Invalid year "${split.parts[2]}".` }; - } - - // ICT midnight of requested day, expressed as a UTC instant. - const utcMs = Date.UTC(year, month - 1, day, 0, 0, 0) - ICT_OFFSET_MS; - const date = new Date(utcMs); - - // Reject impossible dates like 31-04 or 29-02 in non-leap years. - const verified = toIct(date); - if ( - verified.getUTCFullYear() !== year || - verified.getUTCMonth() + 1 !== month || - verified.getUTCDate() !== day - ) { - return { ok: false, error: `Invalid date — ${day}/${month}/${year} does not exist.` }; - } - - return { ok: true, date }; -} diff --git a/src/modules/lolschedule/subscribers.js b/src/modules/lolschedule/subscribers.js deleted file mode 100644 index 757d269..0000000 --- a/src/modules/lolschedule/subscribers.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @file Subscriber list persisted in KV under the module's `subscribers` key. - * - * Stored as a plain JSON array of chat ids (numbers). Kept intentionally small - * and dependency-free — idempotent add/remove, returns a boolean indicating - * whether the list changed. - */ - -const KEY = "subscribers"; - -/** @param {import("../../db/kv-store-interface.js").KVStore} db */ -export async function listSubscribers(db) { - const raw = await db.getJSON(KEY); - return Array.isArray(raw) ? raw : []; -} - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} chatId - * @returns {Promise} true if added, false if already subscribed - */ -export async function addSubscriber(db, chatId) { - const ids = await listSubscribers(db); - if (ids.includes(chatId)) return false; - ids.push(chatId); - await db.putJSON(KEY, ids); - return true; -} - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} chatId - * @returns {Promise} true if removed, false if not subscribed - */ -export async function removeSubscriber(db, chatId) { - const ids = await listSubscribers(db); - const next = ids.filter((id) => id !== chatId); - if (next.length === ids.length) return false; - await db.putJSON(KEY, next); - return true; -} diff --git a/src/modules/misc/README.md b/src/modules/misc/README.md deleted file mode 100644 index b3f50db..0000000 --- a/src/modules/misc/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Misc Module - -Health check and DB demonstration. Exercises all three visibility levels. - -## Commands - -| Command | Visibility | Description | -|---------|-----------|-------------| -| `/ping` | public | Health check — replies "pong" and records timestamp | -| `/mstats` | protected | Shows last `/ping` timestamp | -| `/fortytwo` | private | Easter egg — replies "The answer." | - -## Architecture - -- `/ping` writes a timestamp to KV on every call (best-effort — KV failure doesn't block the reply). -- `/mstats` reads the last ping timestamp and formats it as ISO string. -- `/fortytwo` is a hidden command demonstrating private visibility. - -## Database - -KV namespace prefix: `misc:` - -| Key | Type | Description | Example | -|-----|------|-------------|---------| -| `last_ping` | JSON | Timestamp of the most recent `/ping` call | `{ "at": 1713100000000 }` | - -### Schema: `last_ping` - -```json -{ - "at": 1713100000000 -} -``` - -- `at` — Unix epoch milliseconds (`Date.now()`) diff --git a/src/modules/misc/index.js b/src/modules/misc/index.js deleted file mode 100644 index 8816d2a..0000000 --- a/src/modules/misc/index.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @file misc module stub — proves the plugin system end-to-end AND - * demonstrates DB usage via getJSON/putJSON in /ping. - */ - -/** @type {import("../../db/kv-store-interface.js").KVStore | null} */ -let db = null; - -/** @type {import("../registry.js").BotModule} */ -const miscModule = { - name: "misc", - init: async ({ db: store }) => { - db = store; - }, - commands: [ - { - name: "ping", - visibility: "public", - description: "Health check — replies pong and records last ping", - handler: async (ctx) => { - // Best-effort write — if KV is unavailable, still reply. - try { - await db?.putJSON("last_ping", { at: Date.now() }); - } catch (err) { - console.warn("misc /ping: putJSON failed", String(err)); - } - await ctx.reply("pong"); - }, - }, - { - name: "mstats", - visibility: "protected", - description: "Show the timestamp of the last /ping", - handler: async (ctx) => { - const last = await db?.getJSON("last_ping"); - if (last?.at) { - await ctx.reply(`last ping: ${new Date(last.at).toISOString()}`); - } else { - await ctx.reply("last ping: never"); - } - }, - }, - { - name: "fortytwo", - visibility: "private", - description: "Easter egg — the answer", - handler: async (ctx) => { - await ctx.reply("The answer."); - }, - }, - ], -}; - -export default miscModule; diff --git a/src/modules/registry.js b/src/modules/registry.js deleted file mode 100644 index 07d78a4..0000000 --- a/src/modules/registry.js +++ /dev/null @@ -1,223 +0,0 @@ -/** - * @file registry — loads modules listed in env.MODULES, validates their - * commands, and builds the dispatcher's lookup tables. - * - * Design highlights (see plan phase-04): - * - Static import map (./index.js) filtered at runtime; preserves tree-shaking. - * - Three visibility-partitioned maps (`publicCommands`, `protectedCommands`, - * `privateCommands`) PLUS a flat `allCommands` map used by the dispatcher. - * - Unified namespace for conflict detection: two commands with the same name - * in different modules collide regardless of visibility. Fail loud at load. - * - Built once per warm instance (memoized behind `getCurrentRegistry`). - * - `resetRegistry()` exists for tests. - */ - -import { createSqlStore } from "../db/create-sql-store.js"; -import { createStore } from "../db/create-store.js"; -import { moduleRegistry as defaultModuleRegistry } from "./index.js"; -import { validateCommand } from "./validate-command.js"; -import { validateCron } from "./validate-cron.js"; - -/** - * @typedef {import("./validate-command.js").ModuleCommand} ModuleCommand - * - * @typedef {import("./validate-cron.js").ModuleCron} ModuleCron - * - * @typedef {object} BotModule - * @property {string} name - * @property {ModuleCommand[]} commands - * @property {ModuleCron[]} [crons] - * @property {(ctx: { db: any, sql: any, env: any }) => Promise} [init] - * - * @typedef {object} RegistryEntry - * @property {BotModule} module - * @property {ModuleCommand} cmd - * @property {"public"|"protected"|"private"} [visibility] - * - * @typedef {object} CronEntry - * @property {BotModule} module - * @property {string} schedule - * @property {string} name - * @property {ModuleCron["handler"]} handler - * - * @typedef {object} Registry - * @property {Map} publicCommands - * @property {Map} protectedCommands - * @property {Map} privateCommands - * @property {Map} allCommands - * @property {CronEntry[]} crons — flat list of all validated cron entries across modules. - * @property {BotModule[]} modules — ordered per env.MODULES for /help rendering. - */ - -/** @type {Registry | null} */ -let currentRegistry = null; - -/** - * Parse env.MODULES → trimmed, deduped array of module names. - * - * @param {{ MODULES?: string }} env - * @returns {string[]} - */ -function parseModulesEnv(env) { - const raw = env?.MODULES; - if (typeof raw !== "string" || raw.trim().length === 0) { - throw new Error("MODULES env var is empty"); - } - const seen = new Set(); - const out = []; - for (const piece of raw.split(",")) { - const name = piece.trim(); - if (name.length === 0) continue; - if (seen.has(name)) continue; - seen.add(name); - out.push(name); - } - if (out.length === 0) { - throw new Error("MODULES env var is empty after trim/dedupe"); - } - return out; -} - -/** - * Resolve each module name to its default export and validate shape. - * - * @param {any} env - * @param {Record Promise<{ default: BotModule }>>} [importMap] - * Optional injection for tests. Defaults to the static production map. - * @returns {Promise} - */ -export async function loadModules(env, importMap = defaultModuleRegistry) { - const names = parseModulesEnv(env); - const modules = []; - - for (const name of names) { - const loader = importMap[name]; - if (typeof loader !== "function") { - throw new Error(`unknown module: ${name}`); - } - - const loaded = await loader(); - const mod = loaded?.default; - if (!mod || typeof mod !== "object") { - throw new Error(`module "${name}" must have a default export`); - } - if (mod.name !== name) { - throw new Error(`module "${name}" default export has mismatched name "${mod.name}"`); - } - if (!Array.isArray(mod.commands)) { - throw new Error(`module "${name}" must export a commands array`); - } - for (const cmd of mod.commands) validateCommand(cmd, name); - - // Validate crons if present (optional field). - if (mod.crons !== undefined) { - if (!Array.isArray(mod.crons)) { - throw new Error(`module "${name}" crons must be an array`); - } - const cronNames = new Set(); - for (const cron of mod.crons) { - validateCron(cron, name); - if (cronNames.has(cron.name)) { - throw new Error(`module "${name}" has duplicate cron name "${cron.name}"`); - } - cronNames.add(cron.name); - } - } - - modules.push(mod); - } - - return modules; -} - -/** - * Build the registry: call each module's init, flatten commands into four maps, - * detect conflicts, and memoize for later getCurrentRegistry() calls. - * - * @param {any} env - * @param {Record Promise<{ default: BotModule }>>} [importMap] - * @returns {Promise} - */ -export async function buildRegistry(env, importMap) { - const modules = await loadModules(env, importMap); - - /** @type {Map} */ - const publicCommands = new Map(); - /** @type {Map} */ - const protectedCommands = new Map(); - /** @type {Map} */ - const privateCommands = new Map(); - /** @type {Map} */ - const allCommands = new Map(); - /** @type {CronEntry[]} */ - const crons = []; - - for (const mod of modules) { - if (typeof mod.init === "function") { - try { - await mod.init({ db: createStore(mod.name, env), sql: createSqlStore(mod.name, env), env }); - } catch (err) { - throw new Error( - `module "${mod.name}" init failed: ${err instanceof Error ? err.message : String(err)}`, - { cause: err }, - ); - } - } - - for (const cmd of mod.commands) { - const existing = allCommands.get(cmd.name); - if (existing) { - throw new Error( - `command conflict: /${cmd.name} registered by both "${existing.module.name}" and "${mod.name}"`, - ); - } - const entry = { module: mod, cmd, visibility: cmd.visibility }; - allCommands.set(cmd.name, entry); - - if (cmd.visibility === "public") publicCommands.set(cmd.name, entry); - else if (cmd.visibility === "protected") protectedCommands.set(cmd.name, entry); - else privateCommands.set(cmd.name, entry); - } - - // Collect cron entries (validated during loadModules). - if (Array.isArray(mod.crons)) { - for (const cron of mod.crons) { - crons.push({ - module: mod, - schedule: cron.schedule, - name: cron.name, - handler: cron.handler, - }); - } - } - } - - const registry = { - publicCommands, - protectedCommands, - privateCommands, - allCommands, - crons, - modules, - }; - currentRegistry = registry; - return registry; -} - -/** - * @returns {Registry} - * @throws if `buildRegistry` has not been called yet. - */ -export function getCurrentRegistry() { - if (!currentRegistry) { - throw new Error("registry not built yet — call buildRegistry(env) first"); - } - return currentRegistry; -} - -/** - * Test helper — forget the memoized registry so each test starts clean. - */ -export function resetRegistry() { - currentRegistry = null; -} diff --git a/src/modules/semantle/README.md b/src/modules/semantle/README.md deleted file mode 100644 index 115ee0c..0000000 --- a/src/modules/semantle/README.md +++ /dev/null @@ -1,96 +0,0 @@ -# Semantle Module - -Semantic-similarity guessing game. A secret word is picked from a local -curated pool and each guess is scored by cosine similarity between -embedding vectors produced by Cloudflare Workers AI. Unlimited guesses -per round — you play until you get the exact word (case-insensitive). - -## Commands - -| Command | Visibility | Description | -|---------|-----------|-------------| -| `/semantle` | public | Show current board or submit a word guess | -| `/semantle_giveup` | public | Reveal the answer and end the round (next `/semantle` starts a fresh one) | -| `/semantle_stats` | public | Show wins / best count / averages | - -Submit with `/semantle ` (e.g. `/semantle ocean`). Matching is -case-insensitive. Out-of-vocabulary words don't count toward the guess tally. -Repeating a prior guess replies with a `🔁 already guessed` notice and is -ignored (no cost, no stat inflation). - -## Data source - -**Target + vocabulary:** `words-data.js` ships the full -[google-10000-english](https://github.com/first20hours/google-10000-english) -list (~9.9k entries), ordered by Google Ngram frequency, normalized to -lowercase and deduped but otherwise unfiltered. The **same list is both -the target pool and the vocabulary** — so every legal guess could itself -have been the answer, and OOV detection is an O(1) `Set.has()` with no -upstream round-trip. Regenerate with `node scripts/build-semantle-words.js`. - -**Similarity:** `@cf/baai/bge-m3` multilingual text embeddings via the -`env.AI` binding. Each in-vocab guess runs one inference call batching -target + guess (1024-dim vectors) and the module scores them with local -cosine similarity. At 1075 Neurons per M input tokens (~0.002 N/guess -for short words), the Workers Free plan cap of 10k Neurons/day covers -~4.6M guesses/day. Same model as `doantu` so both share the binding. - -**Score calibration:** BGE embeddings live in a narrow cone, so raw -cosine for unrelated words already clusters at ~0.40–0.55 — reading as -misleadingly "warm". `format.js` applies a normalized sigmoid (FLOOR -0.40, CENTER 0.60, SCALE 8) to remap raw cosine → displayed 0-100. -Resulting curve: raw 0.40 → 0, 0.50 → 18, 0.60 → 42, 0.70 → 66, -0.80 → 84, 0.90 → 94, 1.00 → 100. Retune those three constants if you -swap models. - -OOV guesses short-circuit before inference — the player sees -"isn't in the vocabulary" instead of a noisy subword-based score. - -## Architecture - -- `api-client.js` — Workers AI wrapper: `randomWord()` picks from the - local pool, `similarity(a, b)` runs `env.AI.run()` and returns - `{ in_vocab_b, similarity }` along with canonical forms. - `UpstreamError` carries status/body metadata when inference fails. -- `words-data.js` — auto-generated dictionary (~9.9k entries). -- `wordlist.js` — one-function module exposing `randomLine()`. -- `state.js` — KV persistence for game + stats. Target stored lowercased. -- `lookup.js` — guess normalization (`trim + lowercase + collapse spaces`) - and shape validation (`/^[a-z]+$/`, max 64 chars). -- `format.js` — warmth-percent and emoji-bucket formatters. -- `render.js` — Telegram HTML `
` monospace board, sorted by similarity
-  desc, capped at top 15 rows to stay under Telegram's message-length limit.
-- `handlers.js` — subject resolution (user in DMs, chat in groups) + the
-  three command entry points.
-
-Subject resolution: private chats track per-user games; groups track
-per-chat shared games. Mirrors `loldle`/`wordle`.
-
-## Storage
-
-KV namespace prefix: `semantle:`
-
-| Key | Value |
-|-----|-------|
-| `game:` | `{ target, startedAt, solved, guesses[] }` — active round (TTL 7 days). `target` stored lowercased. |
-| `stats:` | `{ played, solved, totalGuesses, bestGuessCount, lastResultAt }` |
-
-Each `guesses[]` entry is `{ word, canonical, similarity }`.
-
-## Config
-
-No env vars. Model defaults to `@cf/baai/bge-m3`; override with
-`createClient(env.AI, { model: "@cf/baai/bge-small-en-v1.5" })` in a test
-or alternative deploy.
-
-## Why unlimited guesses?
-
-Classic Semantle offers up to 100s of guesses per day, and the fun is in
-the hunt — not the timer. Rounds stay open (TTL 7 days on KV) and skill is
-tracked via `bestGuessCount` — fewest guesses to solve across all rounds.
-
-## Credits
-
-- Embeddings: [`@cf/baai/bge-m3`](https://developers.cloudflare.com/workers-ai/models/bge-m3/) on Cloudflare Workers AI (multilingual).
-- Target dictionary: [google-10000-english](https://github.com/first20hours/google-10000-english) by Josh Kaufman, derived from Peter Norvig's Google Ngram analysis.
-- Game concept: [Semantle](https://semantle.com/) by David Turner.
diff --git a/src/modules/semantle/api-client.js b/src/modules/semantle/api-client.js
deleted file mode 100644
index 8682f11..0000000
--- a/src/modules/semantle/api-client.js
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * @file Cloudflare Workers AI client for the semantle module.
- *
- * Runs the `@cf/baai/bge-m3` text-embedding model via the `env.AI` binding,
- * then scores guesses by computing cosine similarity between the target
- * and guess vectors locally (no extra round-trip).
- *
- * Vocabulary: the curated `words-data.js` list (google-10k) doubles as our
- * in/out-of-vocabulary set — anything outside it is treated as OOV so players
- * get the "not in the vocabulary" reply instead of a noisy embedding score.
- */
-
-import { randomLine } from "./wordlist.js";
-import WORDS from "./words-data.js";
-
-// BGE-M3: multilingual, 1024 dimensions, 1075 Neurons per M input tokens —
-// cheaper than bge-small-en-v1.5 (1841 N/M) and matches the doantu sibling.
-const DEFAULT_MODEL = "@cf/baai/bge-m3";
-
-// O(1) membership lookup for OOV detection. Built once per isolate.
-const VOCAB = new Set(WORDS);
-
-export class UpstreamError extends Error {
-  /** @param {string} message @param {{status?: number, body?: string, cause?: unknown}} [meta] */
-  constructor(message, meta = {}) {
-    super(message);
-    this.name = "UpstreamError";
-    this.status = meta.status;
-    this.body = meta.body;
-    if (meta.cause !== undefined) this.cause = meta.cause;
-  }
-}
-
-function cosineSimilarity(a, b) {
-  if (!a || !b || a.length !== b.length) return null;
-  let dot = 0;
-  let normA = 0;
-  let normB = 0;
-  for (let i = 0; i < a.length; i++) {
-    dot += a[i] * b[i];
-    normA += a[i] * a[i];
-    normB += b[i] * b[i];
-  }
-  const denom = Math.sqrt(normA) * Math.sqrt(normB);
-  return denom === 0 ? null : dot / denom;
-}
-
-/**
- * @param {{ run: (model: string, input: { text: string[] }) => Promise<{ data: number[][] }> }} ai
- *   — Workers AI binding (`env.AI`). Tests pass a fake with the same `.run()` shape.
- * @param {{ model?: string }} [opts]
- */
-export function createClient(ai, { model = DEFAULT_MODEL } = {}) {
-  if (!ai || typeof ai.run !== "function") {
-    throw new TypeError("createClient: ai binding with .run(model, input) is required");
-  }
-
-  async function embedPair(a, b) {
-    let resp;
-    try {
-      resp = await ai.run(model, { text: [a, b] });
-    } catch (err) {
-      throw new UpstreamError("workers-ai embedding failed", { cause: err });
-    }
-    const data = resp?.data;
-    if (!Array.isArray(data) || data.length < 2) {
-      throw new UpstreamError("workers-ai returned malformed embedding payload");
-    }
-    return [data[0], data[1]];
-  }
-
-  return {
-    /**
-     * Pick a target word from the local pool. The pool IS our vocabulary,
-     * so every pick is trivially verified — no upstream check needed.
-     * @returns {Promise<{ word: string, verified: boolean }>}
-     */
-    async randomWord() {
-      return { word: randomLine(), verified: true };
-    },
-
-    /**
-     * Cosine similarity between `a` (target) and `b` (guess). Uses the local
-     * wordlist as the vocabulary — unknown words return `in_vocab_b: false`
-     * with `similarity: null` and skip the inference call entirely.
-     *
-     * @param {string} a
-     * @param {string} b
-     * @returns {Promise<{
-     *   a: string, b: string,
-     *   canonical_a: string, canonical_b: string,
-     *   in_vocab_a: boolean, in_vocab_b: boolean,
-     *   similarity: number | null,
-     * }>}
-     */
-    async similarity(a, b) {
-      const base = { a, b, canonical_a: a, canonical_b: b, in_vocab_a: true };
-      if (!VOCAB.has(b)) {
-        return { ...base, in_vocab_b: false, similarity: null };
-      }
-      const [vecA, vecB] = await embedPair(a, b);
-      const sim = cosineSimilarity(vecA, vecB);
-      return { ...base, in_vocab_b: true, similarity: sim };
-    },
-  };
-}
diff --git a/src/modules/semantle/format.js b/src/modules/semantle/format.js
deleted file mode 100644
index d1af145..0000000
--- a/src/modules/semantle/format.js
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * @file Display formatting helpers for similarity scores.
- *
- * BGE embeddings live in a narrow cone so raw cosines are compressed —
- * unrelated word pairs already score ~0.40-0.55, which reads as
- * misleadingly "warm" to the player. We remap raw cosine through a
- * normalized sigmoid so the displayed 0-100 score actually tracks
- * semantic closeness: unrelated → ≤30, related → 70+, near-identical → 90+.
- *
- * Hyperparameters tuned empirically for `@cf/baai/bge-m3`. If switching
- * models, re-measure random-pair cosines and retune CENTER/SCALE.
- */
-
-const FLOOR = 0.4;
-const CENTER = 0.6;
-const SCALE = 8.0;
-
-const sigmoid = (x) => 1 / (1 + Math.exp(-x));
-const FLOOR_SIG = sigmoid(SCALE * (FLOOR - CENTER));
-const ONE_SIG = sigmoid(SCALE * (1 - CENTER));
-const SIG_RANGE = ONE_SIG - FLOOR_SIG;
-
-/**
- * Map raw cosine ∈ [-1, 1] to a calibrated display score ∈ [0, 100].
- * @param {number} rawCosine
- */
-export function calibrate(rawCosine) {
-  if (rawCosine >= 1) return 100;
-  if (rawCosine <= FLOOR) return 0;
-  const s = sigmoid(SCALE * (rawCosine - CENTER));
-  return Math.max(0, Math.min(100, ((s - FLOOR_SIG) / SIG_RANGE) * 100));
-}
-
-/**
- * Zero-padded integer percent, width 2 (e.g. "07", "54", "100").
- * @param {number} score — calibrated score in [0, 100]
- */
-export function formatWarmth(score) {
-  const pct = Math.round(score);
-  return pct >= 100 ? "100" : String(pct).padStart(2, "0");
-}
-
-/**
- * Warmth emoji bucket. Thresholds operate on the CALIBRATED score,
- * not raw cosine.
- * @param {number} score
- */
-export function warmthEmoji(score) {
-  if (score >= 90) return "🎯";
-  if (score >= 70) return "🔥";
-  if (score >= 40) return "🌡️";
-  if (score >= 15) return "😐";
-  return "🥶";
-}
diff --git a/src/modules/semantle/handlers.js b/src/modules/semantle/handlers.js
deleted file mode 100644
index ad5cf14..0000000
--- a/src/modules/semantle/handlers.js
+++ /dev/null
@@ -1,170 +0,0 @@
-/**
- * @file Command handlers for the semantle module.
- *
- * Subject resolution mirrors loldle:
- *   private chat           → user id (per-user game)
- *   group/supergroup chat  → chat id (shared game — everyone plays together)
- *
- * Commands:
- *   /semantle              → show the board (or lazy-start a round)
- *   /semantle        → submit a guess
- *   /semantle_giveup       → reveal target and end current round (next /semantle starts fresh)
- *   /semantle_stats        → show per-subject stats
- */
-
-import { escapeHtml } from "../../util/escape-html.js";
-import { UpstreamError } from "./api-client.js";
-import { isValidShape, normalize } from "./lookup.js";
-import { renderBoard, renderGuess } from "./render.js";
-import { clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js";
-
-const UPSTREAM_FAIL = "⚠️ Upstream hiccup — try again in a few seconds.";
-
-function getSubject(ctx) {
-  const type = ctx.chat?.type;
-  if (type === "group" || type === "supergroup") return ctx.chat.id;
-  return ctx.from?.id ?? null;
-}
-
-function argAfterCommand(text) {
-  if (!text) return "";
-  const idx = text.indexOf(" ");
-  return idx === -1 ? "" : text.slice(idx + 1).trim();
-}
-
-function logFail(stage, err) {
-  console.log(
-    JSON.stringify({
-      msg: "semantle_upstream_fail",
-      stage,
-      err: err instanceof UpstreamError ? { status: err.status, body: err.body } : String(err),
-    }),
-  );
-}
-
-async function startFreshGame(db, client, subject) {
-  const picked = await client.randomWord();
-  const target = String(picked?.word ?? "").toLowerCase();
-  if (!target) throw new UpstreamError("empty target from randomWord");
-  const fresh = { target, startedAt: null, solved: false, guesses: [] };
-  await saveGame(db, subject, fresh);
-  return fresh;
-}
-
-async function getOrInitGame(db, client, subject) {
-  const existing = await loadGame(db, subject);
-  if (existing && !existing.solved) return existing;
-  return startFreshGame(db, client, subject);
-}
-
-export async function handleSemantle(ctx, { db, client }) {
-  const subject = getSubject(ctx);
-  if (subject == null) return ctx.reply("Cannot identify chat.");
-  const arg = argAfterCommand(ctx.message?.text ?? "");
-  let game;
-  try {
-    game = await getOrInitGame(db, client, subject);
-  } catch (err) {
-    logFail("random", err);
-    return ctx.reply(UPSTREAM_FAIL);
-  }
-  if (!arg) return ctx.reply(renderBoard(game.guesses), { parse_mode: "HTML" });
-  return submitGuess(ctx, { db, client }, subject, game, arg);
-}
-
-async function submitGuess(ctx, { db, client }, subject, game, arg) {
-  const guess = normalize(arg);
-  if (!isValidShape(guess)) {
-    return ctx.reply("Please provide a single letter-only word.");
-  }
-  // Fast-path duplicate check: normalized guess matches a prior raw input
-  // or a prior canonical form. Avoids a wasted API call on repeat submissions.
-  if (game.guesses.some((g) => g.word === guess || g.canonical === guess)) {
-    return ctx.reply(
-      `🔁 ${escapeHtml(guess)} was already guessed this round — try another word.`,
-      { parse_mode: "HTML" },
-    );
-  }
-  let res;
-  try {
-    res = await client.similarity(game.target, guess);
-  } catch (err) {
-    logFail("similarity", err);
-    return ctx.reply(UPSTREAM_FAIL);
-  }
-  if (!res?.in_vocab_b || res.similarity == null) {
-    return ctx.reply(`🤔 ${escapeHtml(guess)} isn't in the vocabulary.`, {
-      parse_mode: "HTML",
-    });
-  }
-
-  const entry = {
-    word: guess,
-    canonical: String(res.canonical_b ?? guess).toLowerCase(),
-    similarity: Number(res.similarity),
-  };
-  // Post-API dedup: a different input can canonicalize to a prior canonical.
-  if (game.guesses.some((g) => g.canonical === entry.canonical)) {
-    return ctx.reply(
-      `🔁 ${escapeHtml(entry.canonical)} was already guessed this round — try another word.`,
-      { parse_mode: "HTML" },
-    );
-  }
-  game.guesses.push(entry);
-  if (game.startedAt === null) game.startedAt = Date.now();
-
-  if (entry.canonical === game.target) {
-    game.solved = true;
-    const count = game.guesses.length;
-    await recordResult(db, subject, { solved: true, guessCount: count });
-    await clearGame(db, subject);
-    const board = renderBoard(game.guesses, entry.canonical);
-    return ctx.reply(`${board}\n✅ Solved in ${count} guess${count === 1 ? "" : "es"}!`, {
-      parse_mode: "HTML",
-    });
-  }
-
-  await saveGame(db, subject, game);
-  const body = `${renderGuess(entry)}\n${renderBoard(game.guesses, entry.canonical)}`;
-  return ctx.reply(body, { parse_mode: "HTML" });
-}
-
-export async function handleGiveup(ctx, { db }) {
-  const subject = getSubject(ctx);
-  if (subject == null) return ctx.reply("Cannot identify chat.");
-  const game = await loadGame(db, subject);
-  if (!game) {
-    return ctx.reply("No active round. Send /semantle to start one.", {
-      parse_mode: "HTML",
-    });
-  }
-  await recordResult(db, subject, {
-    solved: false,
-    guessCount: game.guesses.length,
-  });
-  await clearGame(db, subject);
-  return ctx.reply(
-    `🏳️ The target was ${escapeHtml(game.target)}. Send /semantle for a new round.`,
-    { parse_mode: "HTML" },
-  );
-}
-
-export async function handleStats(ctx, { db }) {
-  const subject = getSubject(ctx);
-  if (subject == null) return ctx.reply("Cannot identify chat.");
-  const s = await loadStats(db, subject);
-  if (s.played === 0) return ctx.reply("No semantle games played yet.");
-  const solveRate = Math.round((s.solved / s.played) * 100);
-  const avgPerRound = s.played > 0 ? Math.round(s.totalGuesses / s.played) : "—";
-  return ctx.reply(
-    [
-      "🎯 Semantle stats",
-      `Played: ${s.played}`,
-      `Solved: ${s.solved} (${solveRate}%)`,
-      `Total guesses: ${s.totalGuesses}`,
-      `Fewest to solve: ${s.bestGuessCount ?? "—"}`,
-      `Avg per round: ${avgPerRound}`,
-    ].join("\n"),
-    { parse_mode: "HTML" },
-  );
-}
diff --git a/src/modules/semantle/index.js b/src/modules/semantle/index.js
deleted file mode 100644
index 3349796..0000000
--- a/src/modules/semantle/index.js
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * @file Semantle module — similarity guessing game backed by Cloudflare Workers AI.
- *
- * Targets come from a curated local wordlist (same list doubles as the
- * vocabulary for OOV detection, so no upstream check is needed to pick or
- * validate a word). Similarity scores come from cosine distance between
- * `@cf/baai/bge-m3` multilingual embeddings produced by the `env.AI` binding.
- */
-
-import { createClient } from "./api-client.js";
-import { handleGiveup, handleSemantle, handleStats } from "./handlers.js";
-
-/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
-let db = null;
-/** @type {ReturnType | null} */
-let client = null;
-
-/** @type {import("../registry.js").BotModule} */
-const semantleModule = {
-  name: "semantle",
-  init: async ({ db: store, env }) => {
-    db = store;
-    client = createClient(env.AI);
-  },
-  commands: [
-    {
-      name: "semantle",
-      visibility: "public",
-      description: "Semantle — guess the hidden word (unlimited tries)",
-      handler: (ctx) => handleSemantle(ctx, { db, client }),
-    },
-    {
-      name: "semantle_giveup",
-      visibility: "public",
-      description: "Reveal the current semantle answer (auto-starts a fresh round)",
-      handler: (ctx) => handleGiveup(ctx, { db, client }),
-    },
-    {
-      name: "semantle_stats",
-      visibility: "public",
-      description: "Show your semantle stats",
-      handler: (ctx) => handleStats(ctx, { db, client }),
-    },
-  ],
-};
-
-export default semantleModule;
diff --git a/src/modules/semantle/lookup.js b/src/modules/semantle/lookup.js
deleted file mode 100644
index c676c01..0000000
--- a/src/modules/semantle/lookup.js
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * @file Guess normalization + shape validation.
- *
- * Keeps obviously-bad input out of the VOCAB lookup and the embedding call.
- * The wordlist is ASCII-letter-only at build time, so any guess outside
- * that shape is guaranteed OOV — fail fast.
- */
-
-/** @param {string} raw */
-export function normalize(raw) {
-  if (typeof raw !== "string") return "";
-  return raw.trim().replace(/\s+/g, " ").toLowerCase();
-}
-
-/** @param {string} word — must already be normalized */
-export function isValidShape(word) {
-  if (!word) return false;
-  if (word.length > 64) return false;
-  return /^[a-z]+$/.test(word);
-}
diff --git a/src/modules/semantle/render.js b/src/modules/semantle/render.js
deleted file mode 100644
index 84af1c4..0000000
--- a/src/modules/semantle/render.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * @file Render a semantle board as a Telegram HTML monospace block.
- *
- * Rows sorted by similarity desc; capped at top 15 so the message stays
- * under Telegram's 4096-char limit even after hundreds of guesses. The
- * latest guess gets an arrow marker so it's easy to spot when sort order
- * shuffles it into the middle of the board.
- */
-
-import { escapeHtml } from "../../util/escape-html.js";
-import { calibrate, formatWarmth, warmthEmoji } from "./format.js";
-
-const MAX_ROWS = 15;
-const LATEST_MARKER = "➡️";
-const PLAIN_MARKER = "  ";
-
-/** @typedef {import("./state.js").SemantleGuess} SemantleGuess */
-
-/**
- * @param {SemantleGuess[]} guesses
- * @param {string|null} [latestCanonical]
- */
-export function renderBoard(guesses, latestCanonical = null) {
-  const count = guesses.length;
-  const header = `🎯 Semantle — ${count} guess${count === 1 ? "" : "es"}`;
-  if (count === 0) {
-    return `${header}\n🆕 Round ready — reply with /semantle <word>.`;
-  }
-
-  const sorted = [...guesses].sort((a, b) => b.similarity - a.similarity).slice(0, MAX_ROWS);
-  const wordWidth = Math.min(20, Math.max(...sorted.map((g) => g.canonical.length)));
-  const rows = sorted.map((g, i) => {
-    const score = Math.round(calibrate(g.similarity));
-    const marker = g.canonical === latestCanonical ? LATEST_MARKER : PLAIN_MARKER;
-    const rank = String(i + 1).padStart(2);
-    const warmth = formatWarmth(score).padStart(3);
-    const word = escapeHtml(g.canonical.padEnd(wordWidth));
-    return `${marker} ${rank}  ${warmth}  ${word} ${warmthEmoji(score)}`;
-  });
-
-  const hidden = count - sorted.length;
-  const footer = hidden > 0 ? `\n…${hidden} older guess${hidden === 1 ? "" : "es"} hidden.` : "";
-  return `${header}\n
${rows.join("\n")}
${footer}`; -} - -/** - * Single-line summary for the submitted guess. - * @param {SemantleGuess} guess - */ -export function renderGuess(guess) { - const score = Math.round(calibrate(guess.similarity)); - return `${escapeHtml(guess.canonical)} → ${formatWarmth(score)} ${warmthEmoji(score)}`; -} diff --git a/src/modules/semantle/state.js b/src/modules/semantle/state.js deleted file mode 100644 index 8b5607f..0000000 --- a/src/modules/semantle/state.js +++ /dev/null @@ -1,98 +0,0 @@ -/** - * @file Game + stats persistence in KV, keyed by "subject" - * (user id in DMs, chat id in groups — so a group shares one round). - * - * Target is stored lowercased so the case-insensitive equality check - * is a single compare. Unlimited guesses — no MAX cap; rounds end only - * on solve or giveup. - * - * Key layout (inside the module-prefixed store): - * game: -> { target, startedAt, solved, guesses[] } - * stats: -> { played, solved, totalGuesses, bestGuessCount, lastResultAt } - */ - -// Long enough for any real session, short enough that stale rounds reclaim. -const GAME_TTL_SECONDS = 60 * 60 * 24 * 7; - -const gameKey = (subject) => `game:${subject}`; -const statsKey = (subject) => `stats:${subject}`; - -/** - * @typedef {object} SemantleGuess - * @property {string} word — raw user input (normalized) - * @property {string} canonical — model's canonical form of the guess, lowercased - * @property {number} similarity — cosine ∈ [-1, 1] - */ - -/** - * @typedef {object} SemantleGameState - * @property {string} target — secret word (lowercased) - * @property {number|null} startedAt — epoch ms; null until the first real guess - * @property {boolean} solved - * @property {SemantleGuess[]} guesses - */ - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - * @returns {Promise} - */ -export async function loadGame(db, subject) { - return db.getJSON(gameKey(subject)); -} - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - * @param {SemantleGameState} state - */ -export async function saveGame(db, subject, state) { - await db.putJSON(gameKey(subject), state, { expirationTtl: GAME_TTL_SECONDS }); -} - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - */ -export async function clearGame(db, subject) { - await db.delete(gameKey(subject)); -} - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - */ -export async function loadStats(db, subject) { - return ( - (await db.getJSON(statsKey(subject))) ?? { - played: 0, - solved: 0, - totalGuesses: 0, - bestGuessCount: null, - lastResultAt: null, - } - ); -} - -/** - * Record a finished round. `guessCount` counts scored guesses only — OOV - * rejections never reached the state, so they're not represented here. - * - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - * @param {{ solved: boolean, guessCount: number }} outcome - */ -export async function recordResult(db, subject, { solved, guessCount }) { - const s = await loadStats(db, subject); - s.played += 1; - s.totalGuesses += guessCount; - if (solved) { - s.solved += 1; - if (s.bestGuessCount === null || guessCount < s.bestGuessCount) { - s.bestGuessCount = guessCount; - } - } - s.lastResultAt = Date.now(); - await db.putJSON(statsKey(subject), s); - return s; -} diff --git a/src/modules/semantle/wordlist.js b/src/modules/semantle/wordlist.js deleted file mode 100644 index c170dc3..0000000 --- a/src/modules/semantle/wordlist.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @file Target-word pool for the semantle module. - * - * Data lives in the sibling `words-data.js` (auto-generated by - * `scripts/build-semantle-words.js` from google-10000-english, normalized - * to lowercase + deduped but otherwise unfiltered). - */ - -import LINES from "./words-data.js"; - -/** @returns {string} */ -export function randomLine() { - return LINES[Math.floor(Math.random() * LINES.length)]; -} diff --git a/src/modules/semantle/words-data.js b/src/modules/semantle/words-data.js deleted file mode 100644 index 22a283b..0000000 --- a/src/modules/semantle/words-data.js +++ /dev/null @@ -1,9900 +0,0 @@ -// Auto-generated from https://github.com/first20hours/google-10000-english -// Credits: Josh Kaufman (first20hours) — common English words by Google Ngram frequency. -// Normalized (lowercased, trimmed, deduped) but otherwise unfiltered. -// Regenerate with: node scripts/build-semantle-words.js -export default [ - "the", - "of", - "and", - "to", - "a", - "in", - "for", - "is", - "on", - "that", - "by", - "this", - "with", - "i", - "you", - "it", - "not", - "or", - "be", - "are", - "from", - "at", - "as", - "your", - "all", - "have", - "new", - "more", - "an", - "was", - "we", - "will", - "home", - "can", - "us", - "about", - "if", - "page", - "my", - "has", - "search", - "free", - "but", - "our", - "one", - "other", - "do", - "no", - "information", - "time", - "they", - "site", - "he", - "up", - "may", - "what", - "which", - "their", - "news", - "out", - "use", - "any", - "there", - "see", - "only", - "so", - "his", - "when", - "contact", - "here", - "business", - "who", - "web", - "also", - "now", - "help", - "get", - "pm", - "view", - "online", - "c", - "e", - "first", - "am", - "been", - "would", - "how", - "were", - "me", - "s", - "services", - "some", - "these", - "click", - "its", - "like", - "service", - "x", - "than", - "find", - "price", - "date", - "back", - "top", - "people", - "had", - "list", - "name", - "just", - "over", - "state", - "year", - "day", - "into", - "email", - "two", - "health", - "n", - "world", - "re", - "next", - "used", - "go", - "b", - "work", - "last", - "most", - "products", - "music", - "buy", - "data", - "make", - "them", - "should", - "product", - "system", - "post", - "her", - "city", - "t", - "add", - "policy", - "number", - "such", - "please", - "available", - "copyright", - "support", - "message", - "after", - "best", - "software", - "then", - "jan", - "good", - "video", - "well", - "d", - "where", - "info", - "rights", - "public", - "books", - "high", - "school", - "through", - "m", - "each", - "links", - "she", - "review", - "years", - "order", - "very", - "privacy", - "book", - "items", - "company", - "r", - "read", - "group", - "need", - "many", - "user", - "said", - "de", - "does", - "set", - "under", - "general", - "research", - "university", - "january", - "mail", - "full", - "map", - "reviews", - "program", - "life", - "know", - "games", - "way", - "days", - "management", - "p", - "part", - "could", - "great", - "united", - "hotel", - "real", - "f", - "item", - "international", - "center", - "ebay", - "must", - "store", - "travel", - "comments", - "made", - "development", - "report", - "off", - "member", - "details", - "line", - "terms", - "before", - "hotels", - "did", - "send", - "right", - "type", - "because", - "local", - "those", - "using", - "results", - "office", - "education", - "national", - "car", - "design", - "take", - "posted", - "internet", - "address", - "community", - "within", - "states", - "area", - "want", - "phone", - "dvd", - "shipping", - "reserved", - "subject", - "between", - "forum", - "family", - "l", - "long", - "based", - "w", - "code", - "show", - "o", - "even", - "black", - "check", - "special", - "prices", - "website", - "index", - "being", - "women", - "much", - "sign", - "file", - "link", - "open", - "today", - "technology", - "south", - "case", - "project", - "same", - "pages", - "uk", - "version", - "section", - "own", - "found", - "sports", - "house", - "related", - "security", - "both", - "g", - "county", - "american", - "photo", - "game", - "members", - "power", - "while", - "care", - "network", - "down", - "computer", - "systems", - "three", - "total", - "place", - "end", - "following", - "download", - "h", - "him", - "without", - "per", - "access", - "think", - "north", - "resources", - "current", - "posts", - "big", - "media", - "law", - "control", - "water", - "history", - "pictures", - "size", - "art", - "personal", - "since", - "including", - "guide", - "shop", - "directory", - "board", - "location", - "change", - "white", - "text", - "small", - "rating", - "rate", - "government", - "children", - "during", - "usa", - "return", - "students", - "v", - "shopping", - "account", - "times", - "sites", - "level", - "digital", - "profile", - "previous", - "form", - "events", - "love", - "old", - "john", - "main", - "call", - "hours", - "image", - "department", - "title", - "description", - "non", - "k", - "y", - "insurance", - "another", - "why", - "shall", - "property", - "class", - "cd", - "still", - "money", - "quality", - "every", - "listing", - "content", - "country", - "private", - "little", - "visit", - "save", - "tools", - "low", - "reply", - "customer", - "december", - "compare", - "movies", - "include", - "college", - "value", - "article", - "york", - "man", - "card", - "jobs", - "provide", - "j", - "food", - "source", - "author", - "different", - "press", - "u", - "learn", - "sale", - "around", - "print", - "course", - "job", - "canada", - "process", - "teen", - "room", - "stock", - "training", - "too", - "credit", - "point", - "join", - "science", - "men", - "categories", - "advanced", - "west", - "sales", - "look", - "english", - "left", - "team", - "estate", - "box", - "conditions", - "select", - "windows", - "photos", - "gay", - "thread", - "week", - "category", - "note", - "live", - "large", - "gallery", - "table", - "register", - "however", - "june", - "october", - "november", - "market", - "library", - "really", - "action", - "start", - "series", - "model", - "features", - "air", - "industry", - "plan", - "human", - "provided", - "tv", - "yes", - "required", - "second", - "hot", - "accessories", - "cost", - "movie", - "forums", - "march", - "la", - "september", - "better", - "say", - "questions", - "july", - "yahoo", - "going", - "medical", - "test", - "friend", - "come", - "dec", - "server", - "pc", - "study", - "application", - "cart", - "staff", - "articles", - "san", - "feedback", - "again", - "play", - "looking", - "issues", - "april", - "never", - "users", - "complete", - "street", - "topic", - "comment", - "financial", - "things", - "working", - "against", - "standard", - "tax", - "person", - "below", - "mobile", - "less", - "got", - "blog", - "party", - "payment", - "equipment", - "login", - "student", - "let", - "programs", - "offers", - "legal", - "above", - "recent", - "park", - "stores", - "side", - "act", - "problem", - "red", - "give", - "memory", - "performance", - "social", - "q", - "august", - "quote", - "language", - "story", - "sell", - "options", - "experience", - "rates", - "create", - "key", - "body", - "young", - "america", - "important", - "field", - "few", - "east", - "paper", - "single", - "ii", - "age", - "activities", - "club", - "example", - "girls", - "additional", - "password", - "z", - "latest", - "something", - "road", - "gift", - "question", - "changes", - "night", - "ca", - "hard", - "texas", - "oct", - "pay", - "four", - "poker", - "status", - "browse", - "issue", - "range", - "building", - "seller", - "court", - "february", - "always", - "result", - "audio", - "light", - "write", - "war", - "nov", - "offer", - "blue", - "groups", - "al", - "easy", - "given", - "files", - "event", - "release", - "analysis", - "request", - "fax", - "china", - "making", - "picture", - "needs", - "possible", - "might", - "professional", - "yet", - "month", - "major", - "star", - "areas", - "future", - "space", - "committee", - "hand", - "sun", - "cards", - "problems", - "london", - "washington", - "meeting", - "rss", - "become", - "interest", - "id", - "child", - "keep", - "enter", - "california", - "share", - "similar", - "garden", - "schools", - "million", - "added", - "reference", - "companies", - "listed", - "baby", - "learning", - "energy", - "run", - "delivery", - "net", - "popular", - "term", - "film", - "stories", - "put", - "computers", - "journal", - "reports", - "co", - "try", - "welcome", - "central", - "images", - "president", - "notice", - "original", - "head", - "radio", - "until", - "cell", - "color", - "self", - "council", - "away", - "includes", - "track", - "australia", - "discussion", - "archive", - "once", - "others", - "entertainment", - "agreement", - "format", - "least", - "society", - "months", - "log", - "safety", - "friends", - "sure", - "faq", - "trade", - "edition", - "cars", - "messages", - "marketing", - "tell", - "further", - "updated", - "association", - "able", - "having", - "provides", - "david", - "fun", - "already", - "green", - "studies", - "close", - "common", - "drive", - "specific", - "several", - "gold", - "feb", - "living", - "sep", - "collection", - "called", - "short", - "arts", - "lot", - "ask", - "display", - "limited", - "powered", - "solutions", - "means", - "director", - "daily", - "beach", - "past", - "natural", - "whether", - "due", - "et", - "electronics", - "five", - "upon", - "period", - "planning", - "database", - "says", - "official", - "weather", - "mar", - "land", - "average", - "done", - "technical", - "window", - "france", - "pro", - "region", - "island", - "record", - "direct", - "microsoft", - "conference", - "environment", - "records", - "st", - "district", - "calendar", - "costs", - "style", - "url", - "front", - "statement", - "update", - "parts", - "aug", - "ever", - "downloads", - "early", - "miles", - "sound", - "resource", - "present", - "applications", - "either", - "ago", - "document", - "word", - "works", - "material", - "bill", - "apr", - "written", - "talk", - "federal", - "hosting", - "rules", - "final", - "adult", - "tickets", - "thing", - "centre", - "requirements", - "via", - "cheap", - "kids", - "finance", - "true", - "minutes", - "else", - "mark", - "third", - "rock", - "gifts", - "europe", - "reading", - "topics", - "bad", - "individual", - "tips", - "plus", - "auto", - "cover", - "usually", - "edit", - "together", - "videos", - "percent", - "fast", - "function", - "fact", - "unit", - "getting", - "global", - "tech", - "meet", - "far", - "economic", - "en", - "player", - "projects", - "lyrics", - "often", - "subscribe", - "submit", - "germany", - "amount", - "watch", - "included", - "feel", - "though", - "bank", - "risk", - "thanks", - "everything", - "deals", - "various", - "words", - "linux", - "jul", - "production", - "commercial", - "james", - "weight", - "town", - "heart", - "advertising", - "received", - "choose", - "treatment", - "newsletter", - "archives", - "points", - "knowledge", - "magazine", - "error", - "camera", - "jun", - "girl", - "currently", - "construction", - "toys", - "registered", - "clear", - "golf", - "receive", - "domain", - "methods", - "chapter", - "makes", - "protection", - "policies", - "loan", - "wide", - "beauty", - "manager", - "india", - "position", - "taken", - "sort", - "listings", - "models", - "michael", - "known", - "half", - "cases", - "step", - "engineering", - "florida", - "simple", - "quick", - "none", - "wireless", - "license", - "paul", - "friday", - "lake", - "whole", - "annual", - "published", - "later", - "basic", - "sony", - "shows", - "corporate", - "google", - "church", - "method", - "purchase", - "customers", - "active", - "response", - "practice", - "hardware", - "figure", - "materials", - "fire", - "holiday", - "chat", - "enough", - "designed", - "along", - "among", - "death", - "writing", - "speed", - "html", - "countries", - "loss", - "face", - "brand", - "discount", - "higher", - "effects", - "created", - "remember", - "standards", - "oil", - "bit", - "yellow", - "political", - "increase", - "advertise", - "kingdom", - "base", - "near", - "environmental", - "thought", - "stuff", - "french", - "storage", - "oh", - "japan", - "doing", - "loans", - "shoes", - "entry", - "stay", - "nature", - "orders", - "availability", - "africa", - "summary", - "turn", - "mean", - "growth", - "notes", - "agency", - "king", - "monday", - "european", - "activity", - "copy", - "although", - "drug", - "pics", - "western", - "income", - "force", - "cash", - "employment", - "overall", - "bay", - "river", - "commission", - "ad", - "package", - "contents", - "seen", - "players", - "engine", - "port", - "album", - "regional", - "stop", - "supplies", - "started", - "administration", - "bar", - "institute", - "views", - "plans", - "double", - "dog", - "build", - "screen", - "exchange", - "types", - "soon", - "sponsored", - "lines", - "electronic", - "continue", - "across", - "benefits", - "needed", - "season", - "apply", - "someone", - "held", - "ny", - "anything", - "printer", - "condition", - "effective", - "believe", - "organization", - "effect", - "asked", - "eur", - "mind", - "sunday", - "selection", - "casino", - "pdf", - "lost", - "tour", - "menu", - "volume", - "cross", - "anyone", - "mortgage", - "hope", - "silver", - "corporation", - "wish", - "inside", - "solution", - "mature", - "role", - "rather", - "weeks", - "addition", - "came", - "supply", - "nothing", - "certain", - "usr", - "executive", - "running", - "lower", - "necessary", - "union", - "jewelry", - "according", - "dc", - "clothing", - "mon", - "com", - "particular", - "fine", - "names", - "robert", - "homepage", - "hour", - "gas", - "skills", - "six", - "bush", - "islands", - "advice", - "career", - "military", - "rental", - "decision", - "leave", - "british", - "teens", - "pre", - "huge", - "sat", - "woman", - "facilities", - "zip", - "bid", - "kind", - "sellers", - "middle", - "move", - "cable", - "opportunities", - "taking", - "values", - "division", - "coming", - "tuesday", - "object", - "lesbian", - "appropriate", - "machine", - "logo", - "length", - "actually", - "nice", - "score", - "statistics", - "client", - "ok", - "returns", - "capital", - "follow", - "sample", - "investment", - "sent", - "shown", - "saturday", - "christmas", - "england", - "culture", - "band", - "flash", - "ms", - "lead", - "george", - "choice", - "went", - "starting", - "registration", - "fri", - "thursday", - "courses", - "consumer", - "hi", - "airport", - "foreign", - "artist", - "outside", - "furniture", - "levels", - "channel", - "letter", - "mode", - "phones", - "ideas", - "wednesday", - "structure", - "fund", - "summer", - "allow", - "degree", - "contract", - "button", - "releases", - "wed", - "homes", - "super", - "male", - "matter", - "custom", - "virginia", - "almost", - "took", - "located", - "multiple", - "asian", - "distribution", - "editor", - "inn", - "industrial", - "cause", - "potential", - "song", - "cnet", - "ltd", - "los", - "hp", - "focus", - "late", - "fall", - "featured", - "idea", - "rooms", - "female", - "responsible", - "inc", - "communications", - "win", - "associated", - "thomas", - "primary", - "cancer", - "numbers", - "reason", - "tool", - "browser", - "spring", - "foundation", - "answer", - "voice", - "eg", - "friendly", - "schedule", - "documents", - "communication", - "purpose", - "feature", - "bed", - "comes", - "police", - "everyone", - "independent", - "ip", - "approach", - "cameras", - "brown", - "physical", - "operating", - "hill", - "maps", - "medicine", - "deal", - "hold", - "ratings", - "chicago", - "forms", - "glass", - "happy", - "tue", - "smith", - "wanted", - "developed", - "thank", - "safe", - "unique", - "survey", - "prior", - "telephone", - "sport", - "ready", - "feed", - "animal", - "sources", - "mexico", - "population", - "pa", - "regular", - "secure", - "navigation", - "operations", - "therefore", - "simply", - "evidence", - "station", - "christian", - "round", - "paypal", - "favorite", - "understand", - "option", - "master", - "valley", - "recently", - "probably", - "thu", - "rentals", - "sea", - "built", - "publications", - "blood", - "cut", - "worldwide", - "improve", - "connection", - "publisher", - "hall", - "larger", - "anti", - "networks", - "earth", - "parents", - "nokia", - "impact", - "transfer", - "introduction", - "kitchen", - "strong", - "tel", - "carolina", - "wedding", - "properties", - "hospital", - "ground", - "overview", - "ship", - "accommodation", - "owners", - "disease", - "tx", - "excellent", - "paid", - "italy", - "perfect", - "hair", - "opportunity", - "kit", - "classic", - "basis", - "command", - "cities", - "william", - "express", - "award", - "distance", - "tree", - "peter", - "assessment", - "ensure", - "thus", - "wall", - "ie", - "involved", - "el", - "extra", - "especially", - "interface", - "partners", - "budget", - "rated", - "guides", - "success", - "maximum", - "ma", - "operation", - "existing", - "quite", - "selected", - "boy", - "amazon", - "patients", - "restaurants", - "beautiful", - "warning", - "wine", - "locations", - "horse", - "vote", - "forward", - "flowers", - "stars", - "significant", - "lists", - "technologies", - "owner", - "retail", - "animals", - "useful", - "directly", - "manufacturer", - "ways", - "est", - "son", - "providing", - "rule", - "mac", - "housing", - "takes", - "iii", - "gmt", - "bring", - "catalog", - "searches", - "max", - "trying", - "mother", - "authority", - "considered", - "told", - "xml", - "traffic", - "programme", - "joined", - "input", - "strategy", - "feet", - "agent", - "valid", - "bin", - "modern", - "senior", - "ireland", - "teaching", - "door", - "grand", - "testing", - "trial", - "charge", - "units", - "instead", - "canadian", - "cool", - "normal", - "wrote", - "enterprise", - "ships", - "entire", - "educational", - "md", - "leading", - "metal", - "positive", - "fl", - "fitness", - "chinese", - "opinion", - "mb", - "asia", - "football", - "abstract", - "uses", - "output", - "funds", - "mr", - "greater", - "likely", - "develop", - "employees", - "artists", - "alternative", - "processing", - "responsibility", - "resolution", - "java", - "guest", - "seems", - "publication", - "pass", - "relations", - "trust", - "van", - "contains", - "session", - "multi", - "photography", - "republic", - "fees", - "components", - "vacation", - "century", - "academic", - "assistance", - "completed", - "skin", - "graphics", - "indian", - "prev", - "ads", - "mary", - "il", - "expected", - "ring", - "grade", - "dating", - "pacific", - "mountain", - "organizations", - "pop", - "filter", - "mailing", - "vehicle", - "longer", - "consider", - "int", - "northern", - "behind", - "panel", - "floor", - "german", - "buying", - "match", - "proposed", - "default", - "require", - "iraq", - "boys", - "outdoor", - "deep", - "morning", - "otherwise", - "allows", - "rest", - "protein", - "plant", - "reported", - "hit", - "transportation", - "mm", - "pool", - "mini", - "politics", - "partner", - "disclaimer", - "authors", - "boards", - "faculty", - "parties", - "fish", - "membership", - "mission", - "eye", - "string", - "sense", - "modified", - "pack", - "released", - "stage", - "internal", - "goods", - "recommended", - "born", - "unless", - "richard", - "detailed", - "japanese", - "race", - "approved", - "background", - "target", - "except", - "character", - "usb", - "maintenance", - "ability", - "maybe", - "functions", - "ed", - "moving", - "brands", - "places", - "php", - "pretty", - "trademarks", - "phentermine", - "spain", - "southern", - "yourself", - "etc", - "winter", - "battery", - "youth", - "pressure", - "submitted", - "boston", - "debt", - "keywords", - "medium", - "television", - "interested", - "core", - "break", - "purposes", - "throughout", - "sets", - "dance", - "wood", - "msn", - "itself", - "defined", - "papers", - "playing", - "awards", - "fee", - "studio", - "reader", - "virtual", - "device", - "established", - "answers", - "rent", - "las", - "remote", - "dark", - "programming", - "external", - "apple", - "le", - "regarding", - "instructions", - "min", - "offered", - "theory", - "enjoy", - "remove", - "aid", - "surface", - "minimum", - "visual", - "host", - "variety", - "teachers", - "isbn", - "martin", - "manual", - "block", - "subjects", - "agents", - "increased", - "repair", - "fair", - "civil", - "steel", - "understanding", - "songs", - "fixed", - "wrong", - "beginning", - "hands", - "associates", - "finally", - "az", - "updates", - "desktop", - "classes", - "paris", - "ohio", - "gets", - "sector", - "capacity", - "requires", - "jersey", - "un", - "fat", - "fully", - "father", - "electric", - "saw", - "instruments", - "quotes", - "officer", - "driver", - "businesses", - "dead", - "respect", - "unknown", - "specified", - "restaurant", - "mike", - "trip", - "pst", - "worth", - "mi", - "procedures", - "poor", - "teacher", - "eyes", - "relationship", - "workers", - "farm", - "georgia", - "peace", - "traditional", - "campus", - "tom", - "showing", - "creative", - "coast", - "benefit", - "progress", - "funding", - "devices", - "lord", - "grant", - "sub", - "agree", - "fiction", - "hear", - "sometimes", - "watches", - "careers", - "beyond", - "goes", - "families", - "led", - "museum", - "themselves", - "fan", - "transport", - "interesting", - "blogs", - "wife", - "evaluation", - "accepted", - "former", - "implementation", - "ten", - "hits", - "zone", - "complex", - "th", - "cat", - "galleries", - "references", - "die", - "presented", - "jack", - "flat", - "flow", - "agencies", - "literature", - "respective", - "parent", - "spanish", - "michigan", - "columbia", - "setting", - "dr", - "scale", - "stand", - "economy", - "highest", - "helpful", - "monthly", - "critical", - "frame", - "musical", - "definition", - "secretary", - "angeles", - "networking", - "path", - "australian", - "employee", - "chief", - "gives", - "kb", - "bottom", - "magazines", - "packages", - "detail", - "francisco", - "laws", - "changed", - "pet", - "heard", - "begin", - "individuals", - "colorado", - "royal", - "clean", - "switch", - "russian", - "largest", - "african", - "guy", - "titles", - "relevant", - "guidelines", - "justice", - "connect", - "bible", - "dev", - "cup", - "basket", - "applied", - "weekly", - "vol", - "installation", - "described", - "demand", - "pp", - "suite", - "vegas", - "na", - "square", - "chris", - "attention", - "advance", - "skip", - "diet", - "army", - "auction", - "gear", - "lee", - "os", - "difference", - "allowed", - "correct", - "charles", - "nation", - "selling", - "lots", - "piece", - "sheet", - "firm", - "seven", - "older", - "illinois", - "regulations", - "elements", - "species", - "jump", - "cells", - "module", - "resort", - "facility", - "random", - "pricing", - "dvds", - "certificate", - "minister", - "motion", - "looks", - "fashion", - "directions", - "visitors", - "documentation", - "monitor", - "trading", - "forest", - "calls", - "whose", - "coverage", - "couple", - "giving", - "chance", - "vision", - "ball", - "ending", - "clients", - "actions", - "listen", - "discuss", - "accept", - "automotive", - "naked", - "goal", - "successful", - "sold", - "wind", - "communities", - "clinical", - "situation", - "sciences", - "markets", - "lowest", - "highly", - "publishing", - "appear", - "emergency", - "developing", - "lives", - "currency", - "leather", - "determine", - "temperature", - "palm", - "announcements", - "patient", - "actual", - "historical", - "stone", - "bob", - "commerce", - "ringtones", - "perhaps", - "persons", - "difficult", - "scientific", - "satellite", - "fit", - "tests", - "village", - "accounts", - "amateur", - "ex", - "met", - "pain", - "xbox", - "particularly", - "factors", - "coffee", - "www", - "settings", - "buyer", - "cultural", - "steve", - "easily", - "oral", - "ford", - "poster", - "edge", - "functional", - "root", - "au", - "fi", - "closed", - "holidays", - "ice", - "pink", - "zealand", - "balance", - "monitoring", - "graduate", - "replies", - "shot", - "nc", - "architecture", - "initial", - "label", - "thinking", - "scott", - "llc", - "sec", - "recommend", - "canon", - "league", - "waste", - "minute", - "bus", - "provider", - "optional", - "dictionary", - "cold", - "accounting", - "manufacturing", - "sections", - "chair", - "fishing", - "effort", - "phase", - "fields", - "bag", - "fantasy", - "po", - "letters", - "motor", - "va", - "professor", - "context", - "install", - "shirt", - "apparel", - "generally", - "continued", - "foot", - "mass", - "crime", - "count", - "breast", - "techniques", - "ibm", - "rd", - "johnson", - "sc", - "quickly", - "dollars", - "websites", - "religion", - "claim", - "driving", - "permission", - "surgery", - "patch", - "heat", - "wild", - "measures", - "generation", - "kansas", - "miss", - "chemical", - "doctor", - "task", - "reduce", - "brought", - "himself", - "nor", - "component", - "enable", - "exercise", - "bug", - "santa", - "mid", - "guarantee", - "leader", - "diamond", - "israel", - "se", - "processes", - "soft", - "servers", - "alone", - "meetings", - "seconds", - "jones", - "arizona", - "keyword", - "interests", - "flight", - "congress", - "fuel", - "username", - "walk", - "produced", - "italian", - "paperback", - "classifieds", - "wait", - "supported", - "pocket", - "saint", - "rose", - "freedom", - "argument", - "competition", - "creating", - "jim", - "drugs", - "joint", - "premium", - "providers", - "fresh", - "characters", - "attorney", - "upgrade", - "di", - "factor", - "growing", - "thousands", - "km", - "stream", - "apartments", - "pick", - "hearing", - "eastern", - "auctions", - "therapy", - "entries", - "dates", - "generated", - "signed", - "upper", - "administrative", - "serious", - "prime", - "samsung", - "limit", - "began", - "louis", - "steps", - "errors", - "shops", - "del", - "efforts", - "informed", - "ga", - "ac", - "thoughts", - "creek", - "ft", - "worked", - "quantity", - "urban", - "practices", - "sorted", - "reporting", - "essential", - "myself", - "tours", - "platform", - "load", - "affiliate", - "labor", - "immediately", - "admin", - "nursing", - "defense", - "machines", - "designated", - "tags", - "heavy", - "covered", - "recovery", - "joe", - "guys", - "integrated", - "configuration", - "merchant", - "comprehensive", - "expert", - "universal", - "protect", - "drop", - "solid", - "cds", - "presentation", - "languages", - "became", - "orange", - "compliance", - "vehicles", - "prevent", - "theme", - "rich", - "im", - "campaign", - "marine", - "improvement", - "vs", - "guitar", - "finding", - "pennsylvania", - "examples", - "ipod", - "saying", - "spirit", - "ar", - "claims", - "challenge", - "motorola", - "acceptance", - "strategies", - "mo", - "seem", - "affairs", - "touch", - "intended", - "towards", - "sa", - "goals", - "hire", - "election", - "suggest", - "branch", - "charges", - "serve", - "affiliates", - "reasons", - "magic", - "mount", - "smart", - "talking", - "gave", - "ones", - "latin", - "multimedia", - "xp", - "avoid", - "certified", - "manage", - "corner", - "rank", - "computing", - "oregon", - "element", - "birth", - "virus", - "abuse", - "interactive", - "requests", - "separate", - "quarter", - "procedure", - "leadership", - "tables", - "define", - "racing", - "religious", - "facts", - "breakfast", - "kong", - "column", - "plants", - "faith", - "chain", - "developer", - "identify", - "avenue", - "missing", - "died", - "approximately", - "domestic", - "sitemap", - "recommendations", - "moved", - "houston", - "reach", - "comparison", - "mental", - "viewed", - "moment", - "extended", - "sequence", - "inch", - "attack", - "sorry", - "centers", - "opening", - "damage", - "lab", - "reserve", - "recipes", - "cvs", - "gamma", - "plastic", - "produce", - "snow", - "placed", - "truth", - "counter", - "failure", - "follows", - "eu", - "weekend", - "dollar", - "camp", - "ontario", - "automatically", - "des", - "minnesota", - "films", - "bridge", - "native", - "fill", - "williams", - "movement", - "printing", - "baseball", - "owned", - "approval", - "draft", - "chart", - "played", - "contacts", - "cc", - "jesus", - "readers", - "clubs", - "lcd", - "wa", - "jackson", - "equal", - "adventure", - "matching", - "offering", - "shirts", - "profit", - "leaders", - "posters", - "institutions", - "assistant", - "variable", - "ave", - "dj", - "advertisement", - "expect", - "parking", - "headlines", - "yesterday", - "compared", - "determined", - "wholesale", - "workshop", - "russia", - "gone", - "codes", - "kinds", - "extension", - "seattle", - "statements", - "golden", - "completely", - "teams", - "fort", - "cm", - "wi", - "lighting", - "senate", - "forces", - "funny", - "brother", - "gene", - "turned", - "portable", - "tried", - "electrical", - "applicable", - "disc", - "returned", - "pattern", - "ct", - "boat", - "named", - "theatre", - "laser", - "earlier", - "manufacturers", - "sponsor", - "classical", - "icon", - "warranty", - "dedicated", - "indiana", - "direction", - "harry", - "basketball", - "objects", - "ends", - "delete", - "evening", - "assembly", - "nuclear", - "taxes", - "mouse", - "signal", - "criminal", - "issued", - "brain", - "sexual", - "wisconsin", - "powerful", - "dream", - "obtained", - "false", - "da", - "cast", - "flower", - "felt", - "personnel", - "passed", - "supplied", - "identified", - "falls", - "pic", - "soul", - "aids", - "opinions", - "promote", - "stated", - "stats", - "hawaii", - "professionals", - "appears", - "carry", - "flag", - "decided", - "nj", - "covers", - "hr", - "em", - "advantage", - "hello", - "designs", - "maintain", - "tourism", - "priority", - "newsletters", - "adults", - "clips", - "savings", - "iv", - "graphic", - "atom", - "payments", - "rw", - "estimated", - "binding", - "brief", - "ended", - "winning", - "eight", - "anonymous", - "iron", - "straight", - "script", - "served", - "wants", - "miscellaneous", - "prepared", - "void", - "dining", - "alert", - "integration", - "atlanta", - "dakota", - "tag", - "interview", - "mix", - "framework", - "disk", - "installed", - "queen", - "vhs", - "credits", - "clearly", - "fix", - "handle", - "sweet", - "desk", - "criteria", - "pubmed", - "dave", - "massachusetts", - "diego", - "hong", - "vice", - "associate", - "ne", - "truck", - "behavior", - "enlarge", - "ray", - "frequently", - "revenue", - "measure", - "changing", - "votes", - "du", - "duty", - "looked", - "discussions", - "bear", - "gain", - "festival", - "laboratory", - "ocean", - "flights", - "experts", - "signs", - "lack", - "depth", - "iowa", - "whatever", - "logged", - "laptop", - "vintage", - "train", - "exactly", - "dry", - "explore", - "maryland", - "spa", - "concept", - "nearly", - "eligible", - "checkout", - "reality", - "forgot", - "handling", - "origin", - "knew", - "gaming", - "feeds", - "billion", - "destination", - "scotland", - "faster", - "intelligence", - "dallas", - "bought", - "con", - "ups", - "nations", - "route", - "followed", - "specifications", - "broken", - "tripadvisor", - "frank", - "alaska", - "zoom", - "blow", - "battle", - "residential", - "anime", - "speak", - "decisions", - "industries", - "protocol", - "query", - "clip", - "partnership", - "editorial", - "nt", - "expression", - "es", - "equity", - "provisions", - "speech", - "wire", - "principles", - "suggestions", - "rural", - "shared", - "sounds", - "replacement", - "tape", - "strategic", - "judge", - "spam", - "economics", - "acid", - "bytes", - "cent", - "forced", - "compatible", - "fight", - "apartment", - "height", - "null", - "zero", - "speaker", - "filed", - "gb", - "netherlands", - "obtain", - "bc", - "consulting", - "recreation", - "offices", - "designer", - "remain", - "managed", - "pr", - "failed", - "marriage", - "roll", - "korea", - "banks", - "fr", - "participants", - "secret", - "bath", - "aa", - "kelly", - "leads", - "negative", - "austin", - "favorites", - "toronto", - "theater", - "springs", - "missouri", - "andrew", - "var", - "perform", - "healthy", - "translation", - "estimates", - "font", - "assets", - "injury", - "mt", - "joseph", - "ministry", - "drivers", - "lawyer", - "figures", - "married", - "protected", - "proposal", - "sharing", - "philadelphia", - "portal", - "waiting", - "birthday", - "beta", - "fail", - "gratis", - "banking", - "officials", - "brian", - "toward", - "won", - "slightly", - "assist", - "conduct", - "contained", - "lingerie", - "legislation", - "calling", - "parameters", - "jazz", - "serving", - "bags", - "profiles", - "miami", - "comics", - "matters", - "houses", - "doc", - "postal", - "relationships", - "tennessee", - "wear", - "controls", - "breaking", - "combined", - "ultimate", - "wales", - "representative", - "frequency", - "introduced", - "minor", - "finish", - "departments", - "residents", - "noted", - "displayed", - "mom", - "reduced", - "physics", - "rare", - "spent", - "performed", - "extreme", - "samples", - "davis", - "daniel", - "bars", - "reviewed", - "row", - "oz", - "forecast", - "removed", - "helps", - "singles", - "administrator", - "cycle", - "amounts", - "contain", - "accuracy", - "dual", - "rise", - "usd", - "sleep", - "mg", - "bird", - "pharmacy", - "brazil", - "creation", - "static", - "scene", - "hunter", - "addresses", - "lady", - "crystal", - "famous", - "writer", - "chairman", - "violence", - "fans", - "oklahoma", - "speakers", - "drink", - "academy", - "dynamic", - "gender", - "eat", - "permanent", - "agriculture", - "dell", - "cleaning", - "constitutes", - "portfolio", - "practical", - "delivered", - "collectibles", - "infrastructure", - "exclusive", - "seat", - "concerns", - "colour", - "vendor", - "originally", - "intel", - "utilities", - "philosophy", - "regulation", - "officers", - "reduction", - "aim", - "bids", - "referred", - "supports", - "nutrition", - "recording", - "regions", - "junior", - "toll", - "les", - "cape", - "ann", - "rings", - "meaning", - "tip", - "secondary", - "wonderful", - "mine", - "ladies", - "henry", - "ticket", - "announced", - "guess", - "agreed", - "prevention", - "whom", - "ski", - "soccer", - "math", - "import", - "posting", - "presence", - "instant", - "mentioned", - "automatic", - "healthcare", - "viewing", - "maintained", - "ch", - "increasing", - "majority", - "connected", - "christ", - "dan", - "dogs", - "sd", - "directors", - "aspects", - "austria", - "ahead", - "moon", - "participation", - "scheme", - "utility", - "preview", - "fly", - "manner", - "matrix", - "containing", - "combination", - "devel", - "amendment", - "despite", - "strength", - "guaranteed", - "turkey", - "libraries", - "proper", - "distributed", - "degrees", - "singapore", - "enterprises", - "delta", - "fear", - "seeking", - "inches", - "phoenix", - "rs", - "convention", - "shares", - "principal", - "daughter", - "standing", - "comfort", - "colors", - "wars", - "cisco", - "ordering", - "kept", - "alpha", - "appeal", - "cruise", - "bonus", - "certification", - "previously", - "hey", - "bookmark", - "buildings", - "specials", - "beat", - "disney", - "household", - "batteries", - "adobe", - "smoking", - "bbc", - "becomes", - "drives", - "arms", - "alabama", - "tea", - "improved", - "trees", - "avg", - "achieve", - "positions", - "dress", - "subscription", - "dealer", - "contemporary", - "sky", - "utah", - "nearby", - "rom", - "carried", - "happen", - "exposure", - "panasonic", - "hide", - "permalink", - "signature", - "gambling", - "refer", - "miller", - "provision", - "outdoors", - "clothes", - "caused", - "luxury", - "babes", - "frames", - "certainly", - "indeed", - "newspaper", - "toy", - "circuit", - "layer", - "printed", - "slow", - "removal", - "easier", - "src", - "liability", - "trademark", - "hip", - "printers", - "faqs", - "nine", - "adding", - "kentucky", - "mostly", - "eric", - "spot", - "taylor", - "trackback", - "prints", - "spend", - "factory", - "interior", - "revised", - "grow", - "americans", - "optical", - "promotion", - "relative", - "amazing", - "clock", - "dot", - "hiv", - "identity", - "suites", - "conversion", - "feeling", - "hidden", - "reasonable", - "victoria", - "serial", - "relief", - "revision", - "broadband", - "influence", - "ratio", - "pda", - "importance", - "rain", - "onto", - "dsl", - "planet", - "webmaster", - "copies", - "recipe", - "zum", - "permit", - "seeing", - "proof", - "dna", - "diff", - "tennis", - "bass", - "prescription", - "bedroom", - "empty", - "instance", - "hole", - "pets", - "ride", - "licensed", - "orlando", - "specifically", - "tim", - "bureau", - "maine", - "sql", - "represent", - "conservation", - "pair", - "ideal", - "specs", - "recorded", - "don", - "pieces", - "finished", - "parks", - "dinner", - "lawyers", - "sydney", - "stress", - "cream", - "ss", - "runs", - "trends", - "yeah", - "discover", - "ap", - "patterns", - "boxes", - "louisiana", - "hills", - "javascript", - "fourth", - "nm", - "advisor", - "mn", - "marketplace", - "nd", - "evil", - "aware", - "wilson", - "shape", - "evolution", - "irish", - "certificates", - "objectives", - "stations", - "suggested", - "gps", - "op", - "remains", - "acc", - "greatest", - "firms", - "concerned", - "euro", - "operator", - "structures", - "generic", - "encyclopedia", - "usage", - "cap", - "ink", - "charts", - "continuing", - "mixed", - "census", - "interracial", - "peak", - "tn", - "competitive", - "exist", - "wheel", - "transit", - "suppliers", - "salt", - "compact", - "poetry", - "lights", - "tracking", - "angel", - "bell", - "keeping", - "preparation", - "attempt", - "receiving", - "matches", - "accordance", - "width", - "noise", - "engines", - "forget", - "array", - "discussed", - "accurate", - "stephen", - "elizabeth", - "climate", - "reservations", - "pin", - "playstation", - "alcohol", - "greek", - "instruction", - "managing", - "annotation", - "sister", - "raw", - "differences", - "walking", - "explain", - "smaller", - "newest", - "establish", - "gnu", - "happened", - "expressed", - "jeff", - "extent", - "sharp", - "lesbians", - "ben", - "lane", - "paragraph", - "kill", - "mathematics", - "aol", - "compensation", - "ce", - "export", - "managers", - "aircraft", - "modules", - "sweden", - "conflict", - "conducted", - "versions", - "employer", - "occur", - "percentage", - "knows", - "mississippi", - "describe", - "concern", - "backup", - "requested", - "citizens", - "connecticut", - "heritage", - "personals", - "immediate", - "holding", - "trouble", - "spread", - "coach", - "kevin", - "agricultural", - "expand", - "supporting", - "audience", - "assigned", - "jordan", - "collections", - "ages", - "participate", - "plug", - "specialist", - "cook", - "affect", - "virgin", - "experienced", - "investigation", - "raised", - "hat", - "institution", - "directed", - "dealers", - "searching", - "sporting", - "helping", - "perl", - "affected", - "lib", - "bike", - "totally", - "plate", - "expenses", - "indicate", - "blonde", - "ab", - "proceedings", - "favourite", - "transmission", - "anderson", - "utc", - "characteristics", - "der", - "lose", - "organic", - "seek", - "experiences", - "albums", - "cheats", - "extremely", - "verzeichnis", - "contracts", - "guests", - "hosted", - "diseases", - "concerning", - "developers", - "equivalent", - "chemistry", - "tony", - "neighborhood", - "nevada", - "kits", - "thailand", - "variables", - "agenda", - "anyway", - "continues", - "tracks", - "advisory", - "cam", - "curriculum", - "logic", - "template", - "prince", - "circle", - "soil", - "grants", - "anywhere", - "psychology", - "responses", - "atlantic", - "wet", - "circumstances", - "edward", - "investor", - "identification", - "ram", - "leaving", - "wildlife", - "appliances", - "matt", - "elementary", - "cooking", - "speaking", - "sponsors", - "fox", - "unlimited", - "respond", - "sizes", - "plain", - "exit", - "entered", - "iran", - "arm", - "keys", - "launch", - "wave", - "checking", - "costa", - "belgium", - "printable", - "holy", - "acts", - "guidance", - "mesh", - "trail", - "enforcement", - "symbol", - "crafts", - "highway", - "buddy", - "hardcover", - "observed", - "dean", - "setup", - "poll", - "booking", - "glossary", - "fiscal", - "celebrity", - "styles", - "denver", - "unix", - "filled", - "bond", - "channels", - "ericsson", - "appendix", - "notify", - "blues", - "chocolate", - "pub", - "portion", - "scope", - "hampshire", - "supplier", - "cables", - "cotton", - "bluetooth", - "controlled", - "requirement", - "authorities", - "biology", - "dental", - "killed", - "border", - "ancient", - "debate", - "representatives", - "starts", - "pregnancy", - "causes", - "arkansas", - "biography", - "leisure", - "attractions", - "learned", - "transactions", - "notebook", - "explorer", - "historic", - "attached", - "opened", - "tm", - "husband", - "disabled", - "authorized", - "crazy", - "upcoming", - "britain", - "concert", - "retirement", - "scores", - "financing", - "efficiency", - "sp", - "comedy", - "adopted", - "efficient", - "weblog", - "linear", - "commitment", - "specialty", - "bears", - "jean", - "hop", - "carrier", - "edited", - "constant", - "visa", - "mouth", - "jewish", - "meter", - "linked", - "portland", - "interviews", - "concepts", - "nh", - "gun", - "reflect", - "pure", - "deliver", - "wonder", - "lessons", - "fruit", - "begins", - "qualified", - "reform", - "lens", - "alerts", - "treated", - "discovery", - "draw", - "mysql", - "classified", - "relating", - "assume", - "confidence", - "alliance", - "fm", - "confirm", - "warm", - "neither", - "lewis", - "howard", - "offline", - "leaves", - "engineer", - "lifestyle", - "consistent", - "replace", - "clearance", - "connections", - "inventory", - "converter", - "organisation", - "babe", - "checks", - "reached", - "becoming", - "safari", - "objective", - "indicated", - "sugar", - "crew", - "legs", - "sam", - "stick", - "securities", - "allen", - "pdt", - "relation", - "enabled", - "genre", - "slide", - "montana", - "volunteer", - "tested", - "rear", - "democratic", - "enhance", - "switzerland", - "exact", - "bound", - "parameter", - "adapter", - "processor", - "node", - "formal", - "dimensions", - "contribute", - "lock", - "hockey", - "storm", - "micro", - "colleges", - "laptops", - "mile", - "showed", - "challenges", - "editors", - "mens", - "threads", - "bowl", - "supreme", - "brothers", - "recognition", - "presents", - "ref", - "tank", - "submission", - "dolls", - "estimate", - "encourage", - "navy", - "kid", - "regulatory", - "inspection", - "consumers", - "cancel", - "limits", - "territory", - "transaction", - "manchester", - "weapons", - "paint", - "delay", - "pilot", - "outlet", - "contributions", - "continuous", - "db", - "czech", - "resulting", - "cambridge", - "initiative", - "novel", - "pan", - "execution", - "disability", - "increases", - "ultra", - "winner", - "idaho", - "contractor", - "ph", - "episode", - "examination", - "potter", - "dish", - "plays", - "bulletin", - "ia", - "pt", - "indicates", - "modify", - "oxford", - "adam", - "truly", - "epinions", - "painting", - "committed", - "extensive", - "affordable", - "universe", - "candidate", - "databases", - "patent", - "slot", - "psp", - "outstanding", - "ha", - "eating", - "perspective", - "planned", - "watching", - "lodge", - "messenger", - "mirror", - "tournament", - "consideration", - "ds", - "discounts", - "sterling", - "sessions", - "kernel", - "stocks", - "buyers", - "journals", - "gray", - "catalogue", - "ea", - "jennifer", - "antonio", - "charged", - "broad", - "taiwan", - "und", - "chosen", - "demo", - "greece", - "lg", - "swiss", - "sarah", - "clark", - "labour", - "hate", - "terminal", - "publishers", - "nights", - "behalf", - "caribbean", - "liquid", - "rice", - "nebraska", - "loop", - "salary", - "reservation", - "foods", - "gourmet", - "guard", - "properly", - "orleans", - "saving", - "nfl", - "remaining", - "empire", - "resume", - "twenty", - "newly", - "raise", - "prepare", - "avatar", - "gary", - "depending", - "illegal", - "expansion", - "vary", - "hundreds", - "rome", - "arab", - "lincoln", - "helped", - "premier", - "tomorrow", - "purchased", - "milk", - "decide", - "consent", - "drama", - "visiting", - "performing", - "downtown", - "keyboard", - "contest", - "collected", - "nw", - "bands", - "boot", - "suitable", - "ff", - "absolutely", - "millions", - "lunch", - "audit", - "push", - "chamber", - "guinea", - "findings", - "muscle", - "featuring", - "iso", - "implement", - "clicking", - "scheduled", - "polls", - "typical", - "tower", - "yours", - "sum", - "misc", - "calculator", - "significantly", - "chicken", - "temporary", - "attend", - "shower", - "alan", - "sending", - "jason", - "tonight", - "dear", - "sufficient", - "holdem", - "shell", - "province", - "catholic", - "oak", - "vat", - "awareness", - "vancouver", - "governor", - "beer", - "seemed", - "contribution", - "measurement", - "swimming", - "spyware", - "formula", - "constitution", - "packaging", - "solar", - "jose", - "catch", - "jane", - "pakistan", - "ps", - "reliable", - "consultation", - "northwest", - "sir", - "doubt", - "earn", - "finder", - "unable", - "periods", - "classroom", - "tasks", - "democracy", - "attacks", - "kim", - "wallpaper", - "merchandise", - "const", - "resistance", - "doors", - "symptoms", - "resorts", - "biggest", - "memorial", - "visitor", - "twin", - "forth", - "insert", - "baltimore", - "gateway", - "ky", - "dont", - "alumni", - "drawing", - "candidates", - "charlotte", - "ordered", - "biological", - "fighting", - "transition", - "happens", - "preferences", - "spy", - "romance", - "instrument", - "bruce", - "split", - "themes", - "powers", - "heaven", - "br", - "bits", - "pregnant", - "twice", - "classification", - "focused", - "egypt", - "physician", - "hollywood", - "bargain", - "wikipedia", - "cellular", - "norway", - "vermont", - "asking", - "blocks", - "normally", - "lo", - "spiritual", - "hunting", - "diabetes", - "suit", - "ml", - "shift", - "chip", - "res", - "sit", - "bodies", - "photographs", - "cutting", - "wow", - "simon", - "writers", - "marks", - "flexible", - "loved", - "favourites", - "mapping", - "numerous", - "relatively", - "birds", - "satisfaction", - "represents", - "char", - "indexed", - "pittsburgh", - "superior", - "preferred", - "saved", - "paying", - "cartoon", - "shots", - "intellectual", - "moore", - "granted", - "choices", - "carbon", - "spending", - "comfortable", - "magnetic", - "interaction", - "listening", - "effectively", - "registry", - "crisis", - "outlook", - "massive", - "denmark", - "employed", - "bright", - "treat", - "header", - "cs", - "poverty", - "formed", - "piano", - "echo", - "que", - "grid", - "sheets", - "patrick", - "experimental", - "puerto", - "revolution", - "consolidation", - "displays", - "plasma", - "allowing", - "earnings", - "voip", - "mystery", - "landscape", - "dependent", - "mechanical", - "journey", - "delaware", - "bidding", - "consultants", - "risks", - "banner", - "applicant", - "charter", - "fig", - "barbara", - "cooperation", - "counties", - "acquisition", - "ports", - "implemented", - "sf", - "directories", - "recognized", - "dreams", - "blogger", - "notification", - "kg", - "licensing", - "stands", - "teach", - "occurred", - "textbooks", - "rapid", - "pull", - "hairy", - "diversity", - "cleveland", - "ut", - "reverse", - "deposit", - "seminar", - "investments", - "latina", - "nasa", - "wheels", - "specify", - "accessibility", - "dutch", - "sensitive", - "templates", - "formats", - "tab", - "depends", - "boots", - "holds", - "router", - "concrete", - "si", - "editing", - "poland", - "folder", - "womens", - "css", - "completion", - "upload", - "pulse", - "universities", - "technique", - "contractors", - "voting", - "courts", - "notices", - "subscriptions", - "calculate", - "mc", - "detroit", - "alexander", - "broadcast", - "converted", - "metro", - "toshiba", - "anniversary", - "improvements", - "strip", - "specification", - "pearl", - "accident", - "nick", - "accessible", - "accessory", - "resident", - "plot", - "qty", - "possibly", - "airline", - "typically", - "representation", - "regard", - "pump", - "exists", - "arrangements", - "smooth", - "conferences", - "uniprotkb", - "strike", - "consumption", - "birmingham", - "flashing", - "lp", - "narrow", - "afternoon", - "threat", - "surveys", - "sitting", - "putting", - "consultant", - "controller", - "ownership", - "committees", - "legislative", - "researchers", - "vietnam", - "trailer", - "anne", - "castle", - "gardens", - "missed", - "malaysia", - "unsubscribe", - "antique", - "labels", - "willing", - "bio", - "molecular", - "acting", - "heads", - "stored", - "exam", - "logos", - "residence", - "attorneys", - "antiques", - "density", - "hundred", - "ryan", - "operators", - "strange", - "sustainable", - "philippines", - "statistical", - "beds", - "mention", - "innovation", - "pcs", - "employers", - "grey", - "parallel", - "honda", - "amended", - "operate", - "bills", - "bold", - "bathroom", - "stable", - "opera", - "definitions", - "von", - "doctors", - "lesson", - "cinema", - "asset", - "ag", - "scan", - "elections", - "drinking", - "reaction", - "blank", - "enhanced", - "entitled", - "severe", - "generate", - "stainless", - "newspapers", - "hospitals", - "vi", - "deluxe", - "humor", - "aged", - "monitors", - "exception", - "lived", - "duration", - "bulk", - "successfully", - "indonesia", - "pursuant", - "sci", - "fabric", - "edt", - "visits", - "primarily", - "tight", - "domains", - "capabilities", - "pmid", - "contrast", - "recommendation", - "flying", - "recruitment", - "sin", - "berlin", - "cute", - "organized", - "ba", - "para", - "siemens", - "adoption", - "improving", - "cr", - "expensive", - "meant", - "capture", - "pounds", - "buffalo", - "organisations", - "plane", - "pg", - "explained", - "seed", - "programmes", - "desire", - "expertise", - "mechanism", - "camping", - "ee", - "jewellery", - "meets", - "welfare", - "peer", - "caught", - "eventually", - "marked", - "driven", - "measured", - "medline", - "bottle", - "agreements", - "considering", - "innovative", - "marshall", - "massage", - "rubber", - "conclusion", - "closing", - "tampa", - "thousand", - "meat", - "legend", - "grace", - "susan", - "ing", - "ks", - "adams", - "python", - "monster", - "alex", - "bang", - "villa", - "bone", - "columns", - "disorders", - "bugs", - "collaboration", - "hamilton", - "detection", - "ftp", - "cookies", - "inner", - "formation", - "tutorial", - "med", - "engineers", - "entity", - "cruises", - "gate", - "holder", - "proposals", - "moderator", - "sw", - "tutorials", - "settlement", - "portugal", - "lawrence", - "roman", - "duties", - "valuable", - "tone", - "collectables", - "ethics", - "forever", - "dragon", - "busy", - "captain", - "fantastic", - "imagine", - "brings", - "heating", - "leg", - "neck", - "hd", - "wing", - "governments", - "purchasing", - "scripts", - "abc", - "stereo", - "appointed", - "taste", - "dealing", - "commit", - "tiny", - "operational", - "rail", - "airlines", - "liberal", - "livecam", - "jay", - "trips", - "gap", - "sides", - "tube", - "turns", - "corresponding", - "descriptions", - "cache", - "belt", - "jacket", - "determination", - "animation", - "oracle", - "er", - "matthew", - "lease", - "productions", - "aviation", - "hobbies", - "proud", - "excess", - "disaster", - "console", - "commands", - "jr", - "telecommunications", - "instructor", - "giant", - "achieved", - "injuries", - "shipped", - "seats", - "approaches", - "biz", - "alarm", - "voltage", - "anthony", - "nintendo", - "usual", - "loading", - "stamps", - "appeared", - "franklin", - "angle", - "rob", - "vinyl", - "highlights", - "mining", - "designers", - "melbourne", - "ongoing", - "worst", - "imaging", - "betting", - "scientists", - "liberty", - "wyoming", - "blackjack", - "argentina", - "era", - "convert", - "possibility", - "analyst", - "commissioner", - "dangerous", - "garage", - "exciting", - "reliability", - "thongs", - "gcc", - "unfortunately", - "respectively", - "volunteers", - "attachment", - "ringtone", - "finland", - "morgan", - "derived", - "pleasure", - "honor", - "asp", - "oriented", - "eagle", - "desktops", - "pants", - "columbus", - "nurse", - "prayer", - "appointment", - "workshops", - "hurricane", - "quiet", - "luck", - "postage", - "producer", - "represented", - "mortgages", - "dial", - "responsibilities", - "cheese", - "comic", - "carefully", - "jet", - "productivity", - "investors", - "crown", - "par", - "underground", - "diagnosis", - "maker", - "crack", - "principle", - "picks", - "vacations", - "gang", - "semester", - "calculated", - "fetish", - "applies", - "casinos", - "appearance", - "smoke", - "apache", - "filters", - "incorporated", - "nv", - "craft", - "cake", - "notebooks", - "apart", - "fellow", - "blind", - "lounge", - "mad", - "algorithm", - "semi", - "coins", - "andy", - "gross", - "strongly", - "cafe", - "valentine", - "hilton", - "ken", - "proteins", - "horror", - "su", - "exp", - "familiar", - "capable", - "douglas", - "debian", - "till", - "involving", - "pen", - "investing", - "christopher", - "admission", - "epson", - "shoe", - "elected", - "carrying", - "victory", - "sand", - "madison", - "terrorism", - "joy", - "editions", - "cpu", - "mainly", - "ethnic", - "ran", - "parliament", - "actor", - "finds", - "seal", - "situations", - "fifth", - "allocated", - "citizen", - "vertical", - "corrections", - "structural", - "municipal", - "describes", - "prize", - "sr", - "occurs", - "jon", - "absolute", - "disabilities", - "consists", - "anytime", - "substance", - "prohibited", - "addressed", - "lies", - "pipe", - "soldiers", - "nr", - "guardian", - "lecture", - "simulation", - "layout", - "initiatives", - "ill", - "concentration", - "classics", - "lbs", - "lay", - "interpretation", - "horses", - "lol", - "dirty", - "deck", - "wayne", - "donate", - "taught", - "bankruptcy", - "mp", - "worker", - "optimization", - "alive", - "temple", - "substances", - "prove", - "discovered", - "wings", - "breaks", - "genetic", - "restrictions", - "participating", - "waters", - "promise", - "thin", - "exhibition", - "prefer", - "ridge", - "cabinet", - "modem", - "harris", - "mph", - "bringing", - "sick", - "dose", - "evaluate", - "tiffany", - "tropical", - "collect", - "bet", - "composition", - "toyota", - "streets", - "nationwide", - "vector", - "definitely", - "shaved", - "turning", - "buffer", - "purple", - "existence", - "commentary", - "larry", - "limousines", - "developments", - "def", - "immigration", - "destinations", - "lets", - "mutual", - "pipeline", - "necessarily", - "syntax", - "li", - "attribute", - "prison", - "skill", - "chairs", - "nl", - "everyday", - "apparently", - "surrounding", - "mountains", - "moves", - "popularity", - "inquiry", - "ethernet", - "checked", - "exhibit", - "throw", - "trend", - "sierra", - "visible", - "cats", - "desert", - "postposted", - "ya", - "oldest", - "rhode", - "nba", - "coordinator", - "obviously", - "mercury", - "steven", - "handbook", - "greg", - "navigate", - "worse", - "summit", - "victims", - "epa", - "spaces", - "fundamental", - "burning", - "escape", - "coupons", - "somewhat", - "receiver", - "substantial", - "tr", - "progressive", - "cialis", - "bb", - "boats", - "glance", - "scottish", - "championship", - "arcade", - "richmond", - "sacramento", - "impossible", - "ron", - "russell", - "tells", - "obvious", - "fiber", - "depression", - "graph", - "covering", - "platinum", - "judgment", - "bedrooms", - "talks", - "filing", - "foster", - "modeling", - "passing", - "awarded", - "testimonials", - "trials", - "tissue", - "nz", - "memorabilia", - "clinton", - "masters", - "bonds", - "cartridge", - "alberta", - "explanation", - "folk", - "org", - "commons", - "cincinnati", - "subsection", - "fraud", - "electricity", - "permitted", - "spectrum", - "arrival", - "okay", - "pottery", - "emphasis", - "roger", - "aspect", - "workplace", - "awesome", - "mexican", - "confirmed", - "counts", - "priced", - "wallpapers", - "hist", - "crash", - "lift", - "desired", - "inter", - "closer", - "assumes", - "heights", - "shadow", - "riding", - "infection", - "firefox", - "lisa", - "expense", - "grove", - "eligibility", - "venture", - "clinic", - "korean", - "healing", - "princess", - "mall", - "entering", - "packet", - "spray", - "studios", - "involvement", - "dad", - "buttons", - "placement", - "observations", - "vbulletin", - "funded", - "thompson", - "winners", - "extend", - "roads", - "subsequent", - "pat", - "dublin", - "rolling", - "fell", - "motorcycle", - "yard", - "disclosure", - "establishment", - "memories", - "nelson", - "te", - "arrived", - "creates", - "faces", - "tourist", - "av", - "mayor", - "murder", - "sean", - "adequate", - "senator", - "yield", - "presentations", - "grades", - "cartoons", - "pour", - "digest", - "reg", - "lodging", - "tion", - "dust", - "hence", - "wiki", - "entirely", - "replaced", - "radar", - "rescue", - "undergraduate", - "losses", - "combat", - "reducing", - "stopped", - "occupation", - "lakes", - "donations", - "associations", - "citysearch", - "closely", - "radiation", - "diary", - "seriously", - "kings", - "shooting", - "kent", - "adds", - "nsw", - "ear", - "flags", - "pci", - "baker", - "launched", - "elsewhere", - "pollution", - "conservative", - "guestbook", - "shock", - "effectiveness", - "walls", - "abroad", - "ebony", - "tie", - "ward", - "drawn", - "arthur", - "ian", - "visited", - "roof", - "walker", - "demonstrate", - "atmosphere", - "suggests", - "kiss", - "beast", - "ra", - "operated", - "experiment", - "targets", - "overseas", - "purchases", - "dodge", - "counsel", - "federation", - "pizza", - "invited", - "yards", - "assignment", - "chemicals", - "gordon", - "mod", - "farmers", - "rc", - "queries", - "bmw", - "rush", - "ukraine", - "absence", - "nearest", - "cluster", - "vendors", - "mpeg", - "whereas", - "yoga", - "serves", - "woods", - "surprise", - "lamp", - "rico", - "partial", - "shoppers", - "phil", - "everybody", - "couples", - "nashville", - "ranking", - "jokes", - "cst", - "http", - "ceo", - "simpson", - "twiki", - "sublime", - "counseling", - "palace", - "acceptable", - "satisfied", - "glad", - "wins", - "measurements", - "verify", - "globe", - "trusted", - "copper", - "milwaukee", - "rack", - "medication", - "warehouse", - "shareware", - "ec", - "rep", - "dicke", - "kerry", - "receipt", - "supposed", - "ordinary", - "nobody", - "ghost", - "violation", - "configure", - "stability", - "mit", - "applying", - "southwest", - "boss", - "pride", - "institutional", - "expectations", - "independence", - "knowing", - "reporter", - "metabolism", - "keith", - "champion", - "cloudy", - "linda", - "ross", - "personally", - "chile", - "anna", - "plenty", - "solo", - "sentence", - "throat", - "ignore", - "maria", - "uniform", - "excellence", - "wealth", - "tall", - "rm", - "somewhere", - "vacuum", - "dancing", - "attributes", - "recognize", - "brass", - "writes", - "plaza", - "pdas", - "outcomes", - "survival", - "quest", - "publish", - "sri", - "screening", - "toe", - "thumbnail", - "trans", - "jonathan", - "whenever", - "nova", - "lifetime", - "api", - "pioneer", - "booty", - "forgotten", - "acrobat", - "plates", - "acres", - "venue", - "athletic", - "thermal", - "essays", - "behaviour", - "vital", - "telling", - "fairly", - "coastal", - "config", - "cf", - "charity", - "intelligent", - "edinburgh", - "vt", - "excel", - "modes", - "obligation", - "campbell", - "wake", - "stupid", - "harbor", - "hungary", - "traveler", - "urw", - "segment", - "realize", - "regardless", - "lan", - "enemy", - "puzzle", - "rising", - "aluminum", - "wells", - "wishlist", - "opens", - "insight", - "sms", - "restricted", - "republican", - "secrets", - "lucky", - "latter", - "merchants", - "thick", - "trailers", - "repeat", - "syndrome", - "philips", - "attendance", - "penalty", - "drum", - "glasses", - "enables", - "nec", - "iraqi", - "builder", - "vista", - "jessica", - "chips", - "terry", - "flood", - "foto", - "ease", - "arguments", - "amsterdam", - "arena", - "adventures", - "pupils", - "stewart", - "announcement", - "tabs", - "outcome", - "appreciate", - "expanded", - "casual", - "grown", - "polish", - "lovely", - "extras", - "gm", - "centres", - "jerry", - "clause", - "smile", - "lands", - "ri", - "troops", - "indoor", - "bulgaria", - "armed", - "broker", - "charger", - "regularly", - "believed", - "pine", - "cooling", - "tend", - "gulf", - "rt", - "rick", - "trucks", - "cp", - "mechanisms", - "divorce", - "laura", - "shopper", - "tokyo", - "partly", - "nikon", - "customize", - "tradition", - "candy", - "pills", - "tiger", - "donald", - "folks", - "sensor", - "exposed", - "telecom", - "hunt", - "angels", - "deputy", - "indicators", - "sealed", - "thai", - "emissions", - "physicians", - "loaded", - "fred", - "complaint", - "scenes", - "experiments", - "afghanistan", - "dd", - "boost", - "spanking", - "scholarship", - "governance", - "mill", - "founded", - "supplements", - "chronic", - "icons", - "moral", - "den", - "catering", - "aud", - "finger", - "keeps", - "pound", - "locate", - "camcorder", - "pl", - "trained", - "burn", - "implementing", - "roses", - "labs", - "ourselves", - "bread", - "tobacco", - "wooden", - "motors", - "tough", - "roberts", - "incident", - "gonna", - "dynamics", - "lie", - "crm", - "rf", - "conversation", - "decrease", - "chest", - "pension", - "billy", - "revenues", - "emerging", - "worship", - "capability", - "ak", - "fe", - "craig", - "herself", - "producing", - "churches", - "precision", - "damages", - "reserves", - "contributed", - "solve", - "shorts", - "reproduction", - "minority", - "td", - "diverse", - "amp", - "ingredients", - "sb", - "ah", - "johnny", - "sole", - "franchise", - "recorder", - "complaints", - "facing", - "sm", - "nancy", - "promotions", - "tones", - "passion", - "rehabilitation", - "maintaining", - "sight", - "laid", - "clay", - "defence", - "patches", - "weak", - "refund", - "usc", - "towns", - "environments", - "trembl", - "divided", - "blvd", - "reception", - "amd", - "wise", - "emails", - "cyprus", - "wv", - "odds", - "correctly", - "insider", - "seminars", - "consequences", - "makers", - "hearts", - "geography", - "appearing", - "integrity", - "worry", - "ns", - "discrimination", - "eve", - "carter", - "legacy", - "marc", - "pleased", - "danger", - "vitamin", - "widely", - "processed", - "phrase", - "genuine", - "raising", - "implications", - "functionality", - "paradise", - "hybrid", - "reads", - "roles", - "intermediate", - "emotional", - "sons", - "leaf", - "pad", - "glory", - "platforms", - "ja", - "bigger", - "billing", - "diesel", - "versus", - "combine", - "overnight", - "geographic", - "exceed", - "bs", - "rod", - "saudi", - "fault", - "cuba", - "hrs", - "preliminary", - "districts", - "introduce", - "silk", - "promotional", - "kate", - "chevrolet", - "babies", - "bi", - "karen", - "compiled", - "romantic", - "revealed", - "specialists", - "generator", - "albert", - "examine", - "jimmy", - "graham", - "suspension", - "bristol", - "margaret", - "compaq", - "sad", - "correction", - "wolf", - "slowly", - "authentication", - "communicate", - "rugby", - "supplement", - "showtimes", - "cal", - "portions", - "infant", - "promoting", - "sectors", - "samuel", - "fluid", - "grounds", - "fits", - "kick", - "regards", - "meal", - "ta", - "hurt", - "machinery", - "bandwidth", - "unlike", - "equation", - "baskets", - "probability", - "pot", - "dimension", - "wright", - "img", - "barry", - "proven", - "schedules", - "admissions", - "cached", - "warren", - "slip", - "studied", - "reviewer", - "involves", - "quarterly", - "rpm", - "profits", - "devil", - "grass", - "comply", - "marie", - "florist", - "illustrated", - "cherry", - "continental", - "alternate", - "deutsch", - "achievement", - "limitations", - "kenya", - "webcam", - "cuts", - "funeral", - "nutten", - "earrings", - "enjoyed", - "automated", - "chapters", - "pee", - "charlie", - "quebec", - "passenger", - "convenient", - "dennis", - "mars", - "francis", - "tvs", - "sized", - "manga", - "noticed", - "socket", - "silent", - "literary", - "egg", - "mhz", - "signals", - "caps", - "orientation", - "pill", - "theft", - "childhood", - "swing", - "symbols", - "lat", - "meta", - "humans", - "analog", - "facial", - "choosing", - "talent", - "dated", - "flexibility", - "seeker", - "wisdom", - "shoot", - "boundary", - "mint", - "packard", - "offset", - "payday", - "philip", - "elite", - "gi", - "spin", - "holders", - "believes", - "swedish", - "poems", - "deadline", - "jurisdiction", - "robot", - "displaying", - "witness", - "collins", - "equipped", - "stages", - "encouraged", - "sur", - "winds", - "powder", - "broadway", - "acquired", - "assess", - "wash", - "cartridges", - "stones", - "entrance", - "gnome", - "roots", - "declaration", - "losing", - "attempts", - "gadgets", - "noble", - "glasgow", - "automation", - "impacts", - "rev", - "gospel", - "advantages", - "shore", - "loves", - "induced", - "ll", - "knight", - "preparing", - "loose", - "aims", - "recipient", - "linking", - "extensions", - "appeals", - "cl", - "earned", - "illness", - "islamic", - "athletics", - "southeast", - "ieee", - "ho", - "alternatives", - "pending", - "parker", - "determining", - "lebanon", - "corp", - "personalized", - "kennedy", - "gt", - "sh", - "conditioning", - "teenage", - "soap", - "ae", - "triple", - "cooper", - "nyc", - "vincent", - "jam", - "secured", - "unusual", - "answered", - "partnerships", - "destruction", - "slots", - "increasingly", - "migration", - "disorder", - "routine", - "toolbar", - "basically", - "rocks", - "conventional", - "titans", - "applicants", - "wearing", - "axis", - "sought", - "genes", - "mounted", - "habitat", - "firewall", - "median", - "guns", - "scanner", - "herein", - "occupational", - "animated", - "judicial", - "rio", - "hs", - "adjustment", - "hero", - "integer", - "treatments", - "bachelor", - "attitude", - "camcorders", - "engaged", - "falling", - "basics", - "montreal", - "carpet", - "rv", - "struct", - "lenses", - "binary", - "genetics", - "attended", - "difficulty", - "punk", - "collective", - "coalition", - "pi", - "dropped", - "enrollment", - "duke", - "walter", - "ai", - "pace", - "besides", - "wage", - "producers", - "ot", - "collector", - "arc", - "hosts", - "interfaces", - "advertisers", - "moments", - "atlas", - "strings", - "dawn", - "representing", - "observation", - "feels", - "torture", - "carl", - "deleted", - "coat", - "mitchell", - "mrs", - "rica", - "restoration", - "convenience", - "returning", - "ralph", - "opposition", - "container", - "yr", - "defendant", - "warner", - "confirmation", - "app", - "embedded", - "inkjet", - "supervisor", - "wizard", - "corps", - "actors", - "liver", - "peripherals", - "liable", - "brochure", - "morris", - "bestsellers", - "petition", - "eminem", - "recall", - "antenna", - "picked", - "assumed", - "departure", - "minneapolis", - "belief", - "killing", - "bikini", - "memphis", - "shoulder", - "decor", - "lookup", - "texts", - "harvard", - "brokers", - "roy", - "ion", - "diameter", - "ottawa", - "doll", - "ic", - "podcast", - "seasons", - "peru", - "interactions", - "refine", - "bidder", - "singer", - "evans", - "herald", - "literacy", - "fails", - "aging", - "nike", - "intervention", - "fed", - "plugin", - "attraction", - "diving", - "invite", - "modification", - "alice", - "latinas", - "suppose", - "customized", - "reed", - "involve", - "moderate", - "terror", - "younger", - "thirty", - "mice", - "opposite", - "understood", - "rapidly", - "dealtime", - "ban", - "temp", - "intro", - "mercedes", - "zus", - "assurance", - "clerk", - "happening", - "vast", - "mills", - "outline", - "amendments", - "tramadol", - "holland", - "receives", - "jeans", - "metropolitan", - "compilation", - "verification", - "fonts", - "ent", - "odd", - "wrap", - "refers", - "mood", - "favor", - "veterans", - "quiz", - "mx", - "sigma", - "gr", - "attractive", - "xhtml", - "occasion", - "recordings", - "jefferson", - "victim", - "demands", - "sleeping", - "careful", - "ext", - "beam", - "gardening", - "obligations", - "arrive", - "orchestra", - "sunset", - "tracked", - "moreover", - "minimal", - "polyphonic", - "lottery", - "tops", - "framed", - "aside", - "outsourcing", - "licence", - "adjustable", - "allocation", - "michelle", - "essay", - "discipline", - "amy", - "ts", - "demonstrated", - "dialogue", - "identifying", - "alphabetical", - "camps", - "declared", - "dispatched", - "aaron", - "handheld", - "trace", - "disposal", - "shut", - "florists", - "packs", - "ge", - "installing", - "switches", - "romania", - "voluntary", - "ncaa", - "thou", - "consult", - "phd", - "greatly", - "blogging", - "mask", - "cycling", - "midnight", - "ng", - "commonly", - "pe", - "photographer", - "inform", - "turkish", - "coal", - "cry", - "messaging", - "pentium", - "quantum", - "murray", - "intent", - "tt", - "zoo", - "largely", - "pleasant", - "announce", - "constructed", - "additions", - "requiring", - "spoke", - "aka", - "arrow", - "engagement", - "sampling", - "rough", - "weird", - "tee", - "refinance", - "lion", - "inspired", - "holes", - "weddings", - "blade", - "suddenly", - "oxygen", - "cookie", - "meals", - "canyon", - "goto", - "meters", - "merely", - "calendars", - "arrangement", - "conclusions", - "passes", - "bibliography", - "pointer", - "compatibility", - "stretch", - "durham", - "furthermore", - "permits", - "cooperative", - "muslim", - "xl", - "neil", - "sleeve", - "netscape", - "cleaner", - "cricket", - "beef", - "feeding", - "stroke", - "township", - "rankings", - "measuring", - "cad", - "hats", - "robin", - "robinson", - "jacksonville", - "strap", - "headquarters", - "sharon", - "crowd", - "tcp", - "transfers", - "surf", - "olympic", - "transformation", - "remained", - "attachments", - "dv", - "dir", - "entities", - "customs", - "administrators", - "personality", - "rainbow", - "hook", - "roulette", - "decline", - "gloves", - "israeli", - "medicare", - "cord", - "skiing", - "cloud", - "facilitate", - "subscriber", - "valve", - "val", - "hewlett", - "explains", - "proceed", - "flickr", - "feelings", - "knife", - "jamaica", - "priorities", - "shelf", - "bookstore", - "timing", - "liked", - "parenting", - "adopt", - "denied", - "fotos", - "incredible", - "britney", - "freeware", - "donation", - "outer", - "crop", - "deaths", - "rivers", - "commonwealth", - "pharmaceutical", - "manhattan", - "tales", - "katrina", - "workforce", - "islam", - "nodes", - "tu", - "fy", - "thumbs", - "seeds", - "cited", - "lite", - "ghz", - "hub", - "targeted", - "organizational", - "skype", - "realized", - "twelve", - "founder", - "decade", - "gamecube", - "rr", - "dispute", - "portuguese", - "tired", - "titten", - "adverse", - "everywhere", - "excerpt", - "eng", - "steam", - "discharge", - "ef", - "drinks", - "ace", - "voices", - "acute", - "halloween", - "climbing", - "stood", - "sing", - "tons", - "perfume", - "carol", - "honest", - "albany", - "hazardous", - "restore", - "stack", - "methodology", - "somebody", - "sue", - "ep", - "housewares", - "reputation", - "resistant", - "democrats", - "recycling", - "hang", - "gbp", - "curve", - "creator", - "amber", - "qualifications", - "museums", - "coding", - "slideshow", - "tracker", - "variation", - "passage", - "transferred", - "trunk", - "hiking", - "lb", - "pierre", - "jelsoft", - "headset", - "photograph", - "oakland", - "colombia", - "waves", - "camel", - "distributor", - "lamps", - "underlying", - "hood", - "wrestling", - "suicide", - "archived", - "photoshop", - "jp", - "chi", - "bt", - "arabia", - "gathering", - "projection", - "juice", - "chase", - "mathematical", - "logical", - "sauce", - "fame", - "extract", - "specialized", - "diagnostic", - "panama", - "indianapolis", - "af", - "payable", - "corporations", - "courtesy", - "criticism", - "automobile", - "confidential", - "rfc", - "statutory", - "accommodations", - "athens", - "northeast", - "downloaded", - "judges", - "sl", - "seo", - "retired", - "isp", - "remarks", - "detected", - "decades", - "paintings", - "walked", - "arising", - "nissan", - "bracelet", - "ins", - "eggs", - "juvenile", - "injection", - "yorkshire", - "populations", - "protective", - "afraid", - "acoustic", - "railway", - "cassette", - "initially", - "indicator", - "pointed", - "hb", - "jpg", - "causing", - "mistake", - "norton", - "locked", - "eliminate", - "tc", - "fusion", - "mineral", - "sunglasses", - "ruby", - "steering", - "beads", - "fortune", - "preference", - "canvas", - "threshold", - "parish", - "claimed", - "screens", - "cemetery", - "planner", - "croatia", - "flows", - "stadium", - "venezuela", - "exploration", - "mins", - "fewer", - "sequences", - "coupon", - "nurses", - "ssl", - "stem", - "proxy", - "astronomy", - "lanka", - "opt", - "edwards", - "drew", - "contests", - "flu", - "translate", - "announces", - "mlb", - "costume", - "tagged", - "berkeley", - "voted", - "killer", - "bikes", - "gates", - "adjusted", - "rap", - "tune", - "bishop", - "pulled", - "corn", - "gp", - "shaped", - "compression", - "seasonal", - "establishing", - "farmer", - "counters", - "puts", - "constitutional", - "grew", - "perfectly", - "tin", - "slave", - "instantly", - "cultures", - "norfolk", - "coaching", - "examined", - "trek", - "encoding", - "litigation", - "submissions", - "oem", - "heroes", - "painted", - "lycos", - "ir", - "zdnet", - "broadcasting", - "horizontal", - "artwork", - "cosmetic", - "resulted", - "portrait", - "terrorist", - "informational", - "ethical", - "carriers", - "ecommerce", - "mobility", - "floral", - "builders", - "ties", - "struggle", - "schemes", - "suffering", - "neutral", - "fisher", - "rat", - "spears", - "prospective", - "bedding", - "ultimately", - "joining", - "heading", - "equally", - "artificial", - "bearing", - "spectacular", - "coordination", - "connector", - "brad", - "combo", - "seniors", - "worlds", - "guilty", - "affiliated", - "activation", - "naturally", - "haven", - "tablet", - "jury", - "dos", - "tail", - "subscribers", - "charm", - "lawn", - "violent", - "mitsubishi", - "underwear", - "basin", - "soup", - "potentially", - "ranch", - "constraints", - "crossing", - "inclusive", - "dimensional", - "cottage", - "drunk", - "considerable", - "crimes", - "resolved", - "mozilla", - "byte", - "toner", - "nose", - "latex", - "branches", - "anymore", - "oclc", - "delhi", - "holdings", - "alien", - "locator", - "selecting", - "processors", - "pantyhose", - "plc", - "broke", - "nepal", - "zimbabwe", - "difficulties", - "juan", - "complexity", - "msg", - "constantly", - "browsing", - "resolve", - "barcelona", - "presidential", - "documentary", - "cod", - "territories", - "melissa", - "moscow", - "thesis", - "thru", - "jews", - "nylon", - "palestinian", - "discs", - "rocky", - "bargains", - "frequent", - "trim", - "nigeria", - "ceiling", - "pixels", - "ensuring", - "hispanic", - "cv", - "cb", - "legislature", - "hospitality", - "gen", - "anybody", - "procurement", - "diamonds", - "espn", - "fleet", - "untitled", - "bunch", - "totals", - "marriott", - "singing", - "theoretical", - "afford", - "exercises", - "starring", - "referral", - "nhl", - "surveillance", - "optimal", - "quit", - "distinct", - "protocols", - "lung", - "highlight", - "substitute", - "inclusion", - "hopefully", - "brilliant", - "turner", - "sucking", - "cents", - "reuters", - "ti", - "fc", - "gel", - "todd", - "spoken", - "omega", - "evaluated", - "stayed", - "civic", - "assignments", - "fw", - "manuals", - "doug", - "sees", - "termination", - "watched", - "saver", - "thereof", - "grill", - "households", - "gs", - "redeem", - "rogers", - "grain", - "aaa", - "authentic", - "regime", - "wanna", - "wishes", - "bull", - "montgomery", - "architectural", - "louisville", - "depend", - "differ", - "macintosh", - "movements", - "ranging", - "monica", - "repairs", - "breath", - "amenities", - "virtually", - "cole", - "mart", - "candle", - "hanging", - "colored", - "authorization", - "tale", - "verified", - "lynn", - "formerly", - "projector", - "bp", - "situated", - "comparative", - "std", - "seeks", - "herbal", - "loving", - "strictly", - "routing", - "docs", - "stanley", - "psychological", - "surprised", - "retailer", - "vitamins", - "elegant", - "gains", - "renewal", - "vid", - "genealogy", - "opposed", - "deemed", - "scoring", - "expenditure", - "brooklyn", - "liverpool", - "sisters", - "critics", - "connectivity", - "spots", - "oo", - "algorithms", - "hacker", - "madrid", - "similarly", - "margin", - "coin", - "solely", - "fake", - "salon", - "collaborative", - "norman", - "fda", - "excluding", - "turbo", - "headed", - "voters", - "cure", - "madonna", - "commander", - "arch", - "ni", - "murphy", - "thinks", - "thats", - "suggestion", - "hdtv", - "soldier", - "phillips", - "asin", - "aimed", - "justin", - "bomb", - "harm", - "interval", - "mirrors", - "spotlight", - "tricks", - "reset", - "brush", - "investigate", - "thy", - "expansys", - "panels", - "repeated", - "assault", - "connecting", - "spare", - "logistics", - "deer", - "kodak", - "tongue", - "bowling", - "tri", - "danish", - "pal", - "monkey", - "proportion", - "filename", - "skirt", - "florence", - "invest", - "honey", - "um", - "analyses", - "drawings", - "significance", - "scenario", - "ye", - "fs", - "lovers", - "atomic", - "approx", - "symposium", - "arabic", - "gauge", - "essentials", - "junction", - "protecting", - "nn", - "faced", - "mat", - "rachel", - "solving", - "transmitted", - "weekends", - "screenshots", - "produces", - "oven", - "ted", - "intensive", - "chains", - "kingston", - "sixth", - "engage", - "deviant", - "noon", - "switching", - "quoted", - "adapters", - "correspondence", - "farms", - "imports", - "supervision", - "cheat", - "bronze", - "expenditures", - "sandy", - "separation", - "testimony", - "suspect", - "celebrities", - "macro", - "sender", - "mandatory", - "boundaries", - "crucial", - "syndication", - "gym", - "celebration", - "kde", - "adjacent", - "filtering", - "tuition", - "spouse", - "exotic", - "viewer", - "signup", - "threats", - "luxembourg", - "puzzles", - "reaching", - "vb", - "damaged", - "cams", - "receptor", - "laugh", - "joel", - "surgical", - "destroy", - "citation", - "pitch", - "autos", - "yo", - "premises", - "perry", - "proved", - "offensive", - "imperial", - "dozen", - "benjamin", - "deployment", - "teeth", - "cloth", - "studying", - "colleagues", - "stamp", - "lotus", - "salmon", - "olympus", - "separated", - "proc", - "cargo", - "tan", - "directive", - "fx", - "salem", - "mate", - "dl", - "starter", - "upgrades", - "likes", - "butter", - "pepper", - "weapon", - "luggage", - "burden", - "chef", - "tapes", - "zones", - "races", - "isle", - "stylish", - "slim", - "maple", - "luke", - "grocery", - "offshore", - "governing", - "retailers", - "depot", - "kenneth", - "comp", - "alt", - "pie", - "blend", - "harrison", - "ls", - "julie", - "occasionally", - "cbs", - "attending", - "emission", - "pete", - "spec", - "finest", - "realty", - "janet", - "bow", - "penn", - "recruiting", - "apparent", - "instructional", - "phpbb", - "autumn", - "traveling", - "probe", - "midi", - "permissions", - "biotechnology", - "toilet", - "ranked", - "jackets", - "routes", - "packed", - "excited", - "outreach", - "helen", - "mounting", - "recover", - "tied", - "lopez", - "balanced", - "prescribed", - "catherine", - "timely", - "talked", - "debug", - "delayed", - "chuck", - "reproduced", - "hon", - "dale", - "explicit", - "calculation", - "villas", - "ebook", - "consolidated", - "exclude", - "peeing", - "occasions", - "brooks", - "equations", - "newton", - "oils", - "sept", - "exceptional", - "anxiety", - "bingo", - "whilst", - "spatial", - "respondents", - "unto", - "lt", - "ceramic", - "prompt", - "precious", - "minds", - "annually", - "considerations", - "scanners", - "atm", - "xanax", - "eq", - "pays", - "fingers", - "sunny", - "ebooks", - "delivers", - "je", - "queensland", - "necklace", - "musicians", - "leeds", - "composite", - "unavailable", - "cedar", - "arranged", - "lang", - "theaters", - "advocacy", - "raleigh", - "stud", - "fold", - "essentially", - "designing", - "threaded", - "uv", - "qualify", - "blair", - "hopes", - "assessments", - "cms", - "mason", - "diagram", - "burns", - "pumps", - "footwear", - "sg", - "vic", - "beijing", - "peoples", - "victor", - "mario", - "pos", - "attach", - "licenses", - "utils", - "removing", - "advised", - "brunswick", - "spider", - "phys", - "ranges", - "pairs", - "sensitivity", - "trails", - "preservation", - "hudson", - "isolated", - "calgary", - "interim", - "assisted", - "divine", - "streaming", - "approve", - "chose", - "compound", - "intensity", - "technological", - "syndicate", - "abortion", - "dialog", - "venues", - "blast", - "wellness", - "calcium", - "newport", - "antivirus", - "addressing", - "pole", - "discounted", - "indians", - "shield", - "harvest", - "membrane", - "prague", - "previews", - "bangladesh", - "constitute", - "locally", - "concluded", - "pickup", - "desperate", - "mothers", - "nascar", - "iceland", - "demonstration", - "governmental", - "manufactured", - "candles", - "graduation", - "mega", - "bend", - "sailing", - "variations", - "moms", - "sacred", - "addiction", - "morocco", - "chrome", - "tommy", - "springfield", - "refused", - "brake", - "exterior", - "greeting", - "ecology", - "oliver", - "congo", - "glen", - "botswana", - "nav", - "delays", - "synthesis", - "olive", - "undefined", - "unemployment", - "cyber", - "verizon", - "scored", - "enhancement", - "newcastle", - "clone", - "velocity", - "lambda", - "relay", - "composed", - "tears", - "performances", - "oasis", - "baseline", - "cab", - "angry", - "fa", - "societies", - "silicon", - "brazilian", - "identical", - "petroleum", - "compete", - "ist", - "norwegian", - "lover", - "belong", - "honolulu", - "beatles", - "lips", - "retention", - "exchanges", - "pond", - "rolls", - "thomson", - "barnes", - "soundtrack", - "wondering", - "malta", - "daddy", - "lc", - "ferry", - "rabbit", - "profession", - "seating", - "dam", - "cnn", - "separately", - "physiology", - "lil", - "collecting", - "das", - "exports", - "omaha", - "tire", - "participant", - "scholarships", - "recreational", - "dominican", - "chad", - "electron", - "loads", - "friendship", - "heather", - "passport", - "motel", - "unions", - "treasury", - "warrant", - "sys", - "solaris", - "frozen", - "occupied", - "josh", - "royalty", - "scales", - "rally", - "observer", - "sunshine", - "strain", - "drag", - "ceremony", - "somehow", - "arrested", - "expanding", - "provincial", - "investigations", - "icq", - "ripe", - "yamaha", - "rely", - "medications", - "hebrew", - "gained", - "rochester", - "dying", - "laundry", - "stuck", - "solomon", - "placing", - "stops", - "homework", - "adjust", - "assessed", - "advertiser", - "enabling", - "encryption", - "filling", - "downloadable", - "sophisticated", - "imposed", - "silence", - "scsi", - "focuses", - "soviet", - "possession", - "cu", - "laboratories", - "treaty", - "vocal", - "trainer", - "organ", - "stronger", - "volumes", - "advances", - "vegetables", - "lemon", - "toxic", - "dns", - "thumbnails", - "darkness", - "pty", - "ws", - "nuts", - "nail", - "bizrate", - "vienna", - "implied", - "span", - "stanford", - "sox", - "stockings", - "joke", - "respondent", - "packing", - "statute", - "rejected", - "satisfy", - "destroyed", - "shelter", - "chapel", - "gamespot", - "manufacture", - "layers", - "wordpress", - "guided", - "vulnerability", - "accountability", - "celebrate", - "accredited", - "appliance", - "compressed", - "bahamas", - "powell", - "mixture", - "bench", - "univ", - "tub", - "rider", - "scheduling", - "radius", - "perspectives", - "mortality", - "logging", - "hampton", - "christians", - "borders", - "therapeutic", - "pads", - "butts", - "inns", - "bobby", - "impressive", - "sheep", - "accordingly", - "architect", - "railroad", - "lectures", - "challenging", - "wines", - "nursery", - "harder", - "cups", - "ash", - "microwave", - "cheapest", - "accidents", - "travesti", - "relocation", - "stuart", - "contributors", - "salvador", - "ali", - "salad", - "np", - "monroe", - "tender", - "violations", - "foam", - "temperatures", - "paste", - "clouds", - "competitions", - "discretion", - "tft", - "tanzania", - "preserve", - "jvc", - "poem", - "unsigned", - "staying", - "cosmetics", - "easter", - "theories", - "repository", - "praise", - "jeremy", - "venice", - "concentrations", - "estonia", - "christianity", - "veteran", - "streams", - "landing", - "signing", - "executed", - "katie", - "negotiations", - "realistic", - "dt", - "cgi", - "showcase", - "integral", - "asks", - "relax", - "namibia", - "generating", - "christina", - "congressional", - "synopsis", - "hardly", - "prairie", - "reunion", - "composer", - "bean", - "sword", - "absent", - "photographic", - "sells", - "ecuador", - "hoping", - "accessed", - "spirits", - "modifications", - "coral", - "pixel", - "float", - "colin", - "bias", - "imported", - "paths", - "bubble", - "por", - "acquire", - "contrary", - "millennium", - "tribune", - "vessel", - "acids", - "focusing", - "viruses", - "cheaper", - "admitted", - "dairy", - "admit", - "mem", - "fancy", - "equality", - "samoa", - "gc", - "achieving", - "tap", - "stickers", - "fisheries", - "exceptions", - "reactions", - "leasing", - "lauren", - "beliefs", - "ci", - "macromedia", - "companion", - "squad", - "analyze", - "ashley", - "scroll", - "relate", - "divisions", - "swim", - "wages", - "additionally", - "suffer", - "forests", - "fellowship", - "nano", - "invalid", - "concerts", - "martial", - "males", - "victorian", - "retain", - "colours", - "execute", - "tunnel", - "genres", - "cambodia", - "patents", - "copyrights", - "yn", - "chaos", - "lithuania", - "mastercard", - "wheat", - "chronicles", - "obtaining", - "beaver", - "updating", - "distribute", - "readings", - "decorative", - "kijiji", - "confused", - "compiler", - "enlargement", - "eagles", - "bases", - "vii", - "accused", - "bee", - "campaigns", - "unity", - "loud", - "conjunction", - "bride", - "rats", - "defines", - "airports", - "instances", - "indigenous", - "begun", - "cfr", - "brunette", - "packets", - "anchor", - "socks", - "validation", - "parade", - "corruption", - "stat", - "trigger", - "incentives", - "cholesterol", - "gathered", - "essex", - "slovenia", - "notified", - "differential", - "beaches", - "folders", - "dramatic", - "surfaces", - "terrible", - "routers", - "cruz", - "pendant", - "dresses", - "baptist", - "scientist", - "starsmerchant", - "hiring", - "clocks", - "arthritis", - "bios", - "females", - "wallace", - "nevertheless", - "reflects", - "taxation", - "fever", - "pmc", - "cuisine", - "surely", - "practitioners", - "transcript", - "myspace", - "theorem", - "inflation", - "thee", - "nb", - "ruth", - "pray", - "stylus", - "compounds", - "pope", - "drums", - "contracting", - "arnold", - "structured", - "reasonably", - "jeep", - "chicks", - "bare", - "hung", - "cattle", - "mba", - "radical", - "graduates", - "rover", - "recommends", - "controlling", - "treasure", - "reload", - "distributors", - "flame", - "levitra", - "tanks", - "assuming", - "monetary", - "elderly", - "pit", - "arlington", - "mono", - "particles", - "floating", - "extraordinary", - "tile", - "indicating", - "bolivia", - "spell", - "hottest", - "stevens", - "coordinate", - "kuwait", - "exclusively", - "emily", - "alleged", - "limitation", - "widescreen", - "compile", - "webster", - "struck", - "rx", - "illustration", - "plymouth", - "warnings", - "construct", - "apps", - "inquiries", - "bridal", - "annex", - "mag", - "gsm", - "inspiration", - "tribal", - "curious", - "affecting", - "freight", - "rebate", - "meetup", - "eclipse", - "sudan", - "ddr", - "downloading", - "rec", - "shuttle", - "aggregate", - "stunning", - "cycles", - "affects", - "forecasts", - "detect", - "actively", - "ciao", - "ampland", - "knee", - "prep", - "pb", - "complicated", - "chem", - "fastest", - "butler", - "shopzilla", - "injured", - "decorating", - "payroll", - "cookbook", - "expressions", - "ton", - "courier", - "uploaded", - "shakespeare", - "hints", - "collapse", - "americas", - "connectors", - "unlikely", - "oe", - "gif", - "pros", - "conflicts", - "techno", - "beverage", - "tribute", - "wired", - "elvis", - "immune", - "latvia", - "travelers", - "forestry", - "barriers", - "cant", - "jd", - "rarely", - "gpl", - "infected", - "offerings", - "martha", - "genesis", - "barrier", - "argue", - "incorrect", - "trains", - "metals", - "bicycle", - "furnishings", - "letting", - "arise", - "guatemala", - "celtic", - "thereby", - "irc", - "jamie", - "particle", - "perception", - "minerals", - "advise", - "humidity", - "bottles", - "boxing", - "wy", - "dm", - "bangkok", - "renaissance", - "pathology", - "sara", - "bra", - "ordinance", - "hughes", - "photographers", - "infections", - "jeffrey", - "chess", - "operates", - "brisbane", - "configured", - "survive", - "oscar", - "festivals", - "menus", - "joan", - "possibilities", - "duck", - "reveal", - "canal", - "amino", - "phi", - "contributing", - "herbs", - "clinics", - "mls", - "cow", - "manitoba", - "analytical", - "missions", - "watson", - "lying", - "costumes", - "strict", - "dive", - "saddam", - "circulation", - "drill", - "offense", - "bryan", - "cet", - "protest", - "assumption", - "jerusalem", - "hobby", - "tries", - "transexuales", - "invention", - "nickname", - "fiji", - "technician", - "inline", - "executives", - "enquiries", - "washing", - "audi", - "staffing", - "cognitive", - "exploring", - "trick", - "enquiry", - "closure", - "raid", - "ppc", - "timber", - "volt", - "intense", - "div", - "playlist", - "registrar", - "showers", - "supporters", - "ruling", - "steady", - "dirt", - "statutes", - "withdrawal", - "myers", - "drops", - "predicted", - "wider", - "saskatchewan", - "jc", - "cancellation", - "plugins", - "enrolled", - "sensors", - "screw", - "ministers", - "publicly", - "hourly", - "blame", - "geneva", - "freebsd", - "veterinary", - "acer", - "prostores", - "reseller", - "dist", - "handed", - "suffered", - "intake", - "informal", - "relevance", - "incentive", - "butterfly", - "tucson", - "mechanics", - "heavily", - "swingers", - "fifty", - "headers", - "mistakes", - "numerical", - "ons", - "geek", - "uncle", - "defining", - "counting", - "reflection", - "sink", - "accompanied", - "assure", - "invitation", - "devoted", - "princeton", - "jacob", - "sodium", - "randy", - "spirituality", - "hormone", - "meanwhile", - "proprietary", - "timothy", - "childrens", - "brick", - "grip", - "naval", - "thumbzilla", - "medieval", - "porcelain", - "avi", - "bridges", - "pichunter", - "captured", - "watt", - "thehun", - "decent", - "casting", - "dayton", - "translated", - "shortly", - "cameron", - "columnists", - "pins", - "carlos", - "reno", - "donna", - "andreas", - "warrior", - "diploma", - "cabin", - "innocent", - "scanning", - "ide", - "consensus", - "polo", - "valium", - "copying", - "rpg", - "delivering", - "cordless", - "patricia", - "horn", - "eddie", - "uganda", - "fired", - "journalism", - "pd", - "prot", - "trivia", - "adidas", - "perth", - "frog", - "grammar", - "intention", - "syria", - "disagree", - "klein", - "harvey", - "tires", - "logs", - "undertaken", - "tgp", - "hazard", - "retro", - "leo", - "statewide", - "semiconductor", - "gregory", - "episodes", - "boolean", - "circular", - "anger", - "diy", - "mainland", - "illustrations", - "suits", - "chances", - "interact", - "snap", - "happiness", - "arg", - "substantially", - "bizarre", - "glenn", - "ur", - "auckland", - "olympics", - "fruits", - "identifier", - "geo", - "ribbon", - "calculations", - "doe", - "jpeg", - "conducting", - "startup", - "suzuki", - "trinidad", - "ati", - "kissing", - "wal", - "handy", - "swap", - "exempt", - "crops", - "reduces", - "accomplished", - "calculators", - "geometry", - "impression", - "abs", - "slovakia", - "flip", - "guild", - "correlation", - "gorgeous", - "capitol", - "sim", - "dishes", - "rna", - "barbados", - "chrysler", - "nervous", - "refuse", - "extends", - "fragrance", - "mcdonald", - "replica", - "plumbing", - "brussels", - "tribe", - "neighbors", - "trades", - "superb", - "buzz", - "transparent", - "nuke", - "rid", - "trinity", - "charleston", - "handled", - "legends", - "boom", - "calm", - "champions", - "floors", - "selections", - "projectors", - "inappropriate", - "exhaust", - "comparing", - "shanghai", - "speaks", - "burton", - "vocational", - "davidson", - "copied", - "scotia", - "farming", - "gibson", - "pharmacies", - "fork", - "troy", - "ln", - "roller", - "introducing", - "batch", - "organize", - "appreciated", - "alter", - "nicole", - "latino", - "ghana", - "edges", - "uc", - "mixing", - "handles", - "skilled", - "fitted", - "albuquerque", - "harmony", - "distinguished", - "asthma", - "projected", - "assumptions", - "shareholders", - "twins", - "developmental", - "rip", - "zope", - "regulated", - "triangle", - "amend", - "anticipated", - "oriental", - "reward", - "windsor", - "zambia", - "completing", - "gmbh", - "buf", - "ld", - "hydrogen", - "webshots", - "sprint", - "comparable", - "chick", - "advocate", - "sims", - "confusion", - "copyrighted", - "tray", - "inputs", - "warranties", - "genome", - "escorts", - "documented", - "thong", - "medal", - "paperbacks", - "coaches", - "vessels", - "harbour", - "walks", - "sol", - "keyboards", - "sage", - "knives", - "eco", - "vulnerable", - "arrange", - "artistic", - "bat", - "honors", - "booth", - "indie", - "reflected", - "unified", - "bones", - "breed", - "detector", - "ignored", - "polar", - "fallen", - "precise", - "sussex", - "respiratory", - "notifications", - "msgid", - "transexual", - "mainstream", - "invoice", - "evaluating", - "lip", - "subcommittee", - "sap", - "gather", - "suse", - "maternity", - "backed", - "alfred", - "colonial", - "mf", - "carey", - "motels", - "forming", - "embassy", - "cave", - "journalists", - "danny", - "rebecca", - "slight", - "proceeds", - "indirect", - "amongst", - "wool", - "foundations", - "msgstr", - "arrest", - "volleyball", - "mw", - "adipex", - "horizon", - "nu", - "deeply", - "toolbox", - "ict", - "marina", - "liabilities", - "prizes", - "bosnia", - "browsers", - "decreased", - "patio", - "dp", - "tolerance", - "surfing", - "creativity", - "lloyd", - "describing", - "optics", - "pursue", - "lightning", - "overcome", - "eyed", - "ou", - "quotations", - "grab", - "inspector", - "attract", - "brighton", - "beans", - "bookmarks", - "ellis", - "disable", - "snake", - "succeed", - "leonard", - "lending", - "oops", - "reminder", - "xi", - "searched", - "behavioral", - "riverside", - "bathrooms", - "plains", - "sku", - "ht", - "raymond", - "insights", - "abilities", - "initiated", - "sullivan", - "za", - "midwest", - "karaoke", - "trap", - "lonely", - "fool", - "ve", - "nonprofit", - "lancaster", - "suspended", - "hereby", - "observe", - "julia", - "containers", - "attitudes", - "karl", - "berry", - "collar", - "simultaneously", - "racial", - "integrate", - "bermuda", - "amanda", - "sociology", - "mobiles", - "screenshot", - "exhibitions", - "kelkoo", - "confident", - "retrieved", - "exhibits", - "officially", - "consortium", - "dies", - "terrace", - "bacteria", - "pts", - "replied", - "seafood", - "novels", - "rh", - "rrp", - "recipients", - "ought", - "delicious", - "traditions", - "fg", - "jail", - "safely", - "finite", - "kidney", - "periodically", - "fixes", - "sends", - "durable", - "mazda", - "allied", - "throws", - "moisture", - "hungarian", - "roster", - "referring", - "symantec", - "spencer", - "wichita", - "nasdaq", - "uruguay", - "ooo", - "hz", - "transform", - "timer", - "tablets", - "tuning", - "gotten", - "educators", - "tyler", - "futures", - "vegetable", - "verse", - "highs", - "humanities", - "independently", - "wanting", - "custody", - "scratch", - "launches", - "ipaq", - "alignment", - "henderson", - "bk", - "britannica", - "comm", - "ellen", - "competitors", - "nhs", - "rocket", - "aye", - "bullet", - "towers", - "racks", - "lace", - "nasty", - "visibility", - "latitude", - "consciousness", - "ste", - "tumor", - "ugly", - "deposits", - "beverly", - "mistress", - "encounter", - "trustees", - "watts", - "duncan", - "reprints", - "hart", - "bernard", - "resolutions", - "ment", - "accessing", - "forty", - "tubes", - "attempted", - "col", - "midlands", - "priest", - "floyd", - "ronald", - "analysts", - "queue", - "dx", - "sk", - "trance", - "locale", - "nicholas", - "biol", - "yu", - "bundle", - "hammer", - "invasion", - "witnesses", - "runner", - "rows", - "administered", - "notion", - "sq", - "skins", - "mailed", - "oc", - "fujitsu", - "spelling", - "arctic", - "exams", - "rewards", - "beneath", - "strengthen", - "defend", - "aj", - "frederick", - "medicaid", - "treo", - "infrared", - "seventh", - "gods", - "une", - "welsh", - "belly", - "aggressive", - "tex", - "advertisements", - "quarters", - "stolen", - "cia", - "soonest", - "haiti", - "disturbed", - "determines", - "sculpture", - "poly", - "ears", - "dod", - "wp", - "fist", - "naturals", - "neo", - "motivation", - "lenders", - "pharmacology", - "fitting", - "fixtures", - "bloggers", - "mere", - "agrees", - "passengers", - "quantities", - "petersburg", - "consistently", - "powerpoint", - "cons", - "surplus", - "elder", - "sonic", - "obituaries", - "cheers", - "dig", - "taxi", - "punishment", - "appreciation", - "subsequently", - "om", - "belarus", - "nat", - "zoning", - "gravity", - "providence", - "thumb", - "restriction", - "incorporate", - "backgrounds", - "treasurer", - "guitars", - "essence", - "flooring", - "lightweight", - "ethiopia", - "tp", - "mighty", - "athletes", - "humanity", - "transcription", - "jm", - "holmes", - "complications", - "scholars", - "dpi", - "scripting", - "gis", - "remembered", - "galaxy", - "chester", - "snapshot", - "caring", - "loc", - "worn", - "synthetic", - "shaw", - "vp", - "segments", - "testament", - "expo", - "dominant", - "twist", - "specifics", - "itunes", - "stomach", - "partially", - "buried", - "cn", - "newbie", - "minimize", - "darwin", - "ranks", - "wilderness", - "debut", - "generations", - "tournaments", - "bradley", - "deny", - "anatomy", - "bali", - "judy", - "sponsorship", - "headphones", - "fraction", - "trio", - "proceeding", - "cube", - "defects", - "volkswagen", - "uncertainty", - "breakdown", - "milton", - "marker", - "reconstruction", - "subsidiary", - "strengths", - "clarity", - "rugs", - "sandra", - "adelaide", - "encouraging", - "furnished", - "monaco", - "settled", - "folding", - "emirates", - "terrorists", - "airfare", - "comparisons", - "beneficial", - "distributions", - "vaccine", - "belize", - "fate", - "viewpicture", - "promised", - "volvo", - "penny", - "robust", - "bookings", - "threatened", - "minolta", - "republicans", - "discusses", - "gui", - "porter", - "gras", - "jungle", - "ver", - "rn", - "responded", - "rim", - "abstracts", - "zen", - "ivory", - "alpine", - "dis", - "prediction", - "pharmaceuticals", - "andale", - "fabulous", - "remix", - "alias", - "thesaurus", - "individually", - "battlefield", - "literally", - "newer", - "kay", - "ecological", - "spice", - "oval", - "implies", - "cg", - "soma", - "ser", - "cooler", - "appraisal", - "consisting", - "maritime", - "periodic", - "submitting", - "overhead", - "ascii", - "prospect", - "shipment", - "breeding", - "citations", - "geographical", - "donor", - "mozambique", - "tension", - "href", - "benz", - "trash", - "shapes", - "wifi", - "tier", - "fwd", - "earl", - "manor", - "envelope", - "diane", - "homeland", - "disclaimers", - "championships", - "excluded", - "andrea", - "breeds", - "rapids", - "disco", - "sheffield", - "bailey", - "aus", - "endif", - "finishing", - "emotions", - "wellington", - "incoming", - "prospects", - "lexmark", - "cleaners", - "bulgarian", - "hwy", - "eternal", - "cashiers", - "guam", - "cite", - "aboriginal", - "remarkable", - "rotation", - "nam", - "preventing", - "productive", - "boulevard", - "eugene", - "ix", - "gdp", - "pig", - "metric", - "compliant", - "minus", - "penalties", - "bennett", - "imagination", - "hotmail", - "refurbished", - "joshua", - "armenia", - "varied", - "grande", - "closest", - "activated", - "actress", - "mess", - "conferencing", - "assign", - "armstrong", - "politicians", - "trackbacks", - "lit", - "accommodate", - "tigers", - "aurora", - "una", - "slides", - "milan", - "premiere", - "lender", - "villages", - "shade", - "chorus", - "christine", - "rhythm", - "digit", - "argued", - "dietary", - "symphony", - "clarke", - "sudden", - "accepting", - "precipitation", - "marilyn", - "lions", - "findlaw", - "ada", - "pools", - "tb", - "lyric", - "claire", - "isolation", - "speeds", - "sustained", - "matched", - "approximate", - "rope", - "carroll", - "rational", - "programmer", - "fighters", - "chambers", - "dump", - "greetings", - "inherited", - "warming", - "incomplete", - "vocals", - "chronicle", - "fountain", - "chubby", - "grave", - "legitimate", - "biographies", - "burner", - "yrs", - "foo", - "investigator", - "gba", - "plaintiff", - "finnish", - "gentle", - "bm", - "prisoners", - "deeper", - "muslims", - "hose", - "mediterranean", - "nightlife", - "footage", - "howto", - "worthy", - "reveals", - "architects", - "saints", - "entrepreneur", - "carries", - "sig", - "freelance", - "duo", - "excessive", - "devon", - "screensaver", - "helena", - "saves", - "regarded", - "valuation", - "unexpected", - "cigarette", - "fog", - "characteristic", - "marion", - "lobby", - "egyptian", - "tunisia", - "metallica", - "outlined", - "consequently", - "headline", - "treating", - "punch", - "appointments", - "str", - "gotta", - "cowboy", - "narrative", - "bahrain", - "enormous", - "karma", - "consist", - "betty", - "queens", - "academics", - "pubs", - "quantitative", - "lucas", - "screensavers", - "subdivision", - "tribes", - "vip", - "defeat", - "clicks", - "distinction", - "honduras", - "naughty", - "hazards", - "insured", - "harper", - "livestock", - "mardi", - "exemption", - "tenant", - "sustainability", - "cabinets", - "tattoo", - "shake", - "algebra", - "shadows", - "holly", - "formatting", - "silly", - "nutritional", - "yea", - "mercy", - "hartford", - "freely", - "marcus", - "sunrise", - "wrapping", - "mild", - "fur", - "nicaragua", - "weblogs", - "timeline", - "tar", - "belongs", - "rj", - "readily", - "affiliation", - "soc", - "fence", - "nudist", - "infinite", - "diana", - "ensures", - "relatives", - "lindsay", - "clan", - "legally", - "shame", - "satisfactory", - "revolutionary", - "bracelets", - "sync", - "civilian", - "telephony", - "mesa", - "fatal", - "remedy", - "realtors", - "breathing", - "briefly", - "thickness", - "adjustments", - "graphical", - "genius", - "discussing", - "aerospace", - "fighter", - "meaningful", - "flesh", - "retreat", - "adapted", - "barely", - "wherever", - "estates", - "rug", - "democrat", - "borough", - "maintains", - "failing", - "shortcuts", - "ka", - "retained", - "voyeurweb", - "pamela", - "andrews", - "marble", - "extending", - "jesse", - "specifies", - "hull", - "logitech", - "surrey", - "briefing", - "belkin", - "dem", - "accreditation", - "wav", - "blackberry", - "highland", - "meditation", - "modular", - "microphone", - "macedonia", - "combining", - "brandon", - "instrumental", - "giants", - "organizing", - "shed", - "balloon", - "moderators", - "winston", - "memo", - "ham", - "solved", - "tide", - "kazakhstan", - "hawaiian", - "standings", - "partition", - "invisible", - "gratuit", - "consoles", - "funk", - "fbi", - "qatar", - "magnet", - "translations", - "porsche", - "cayman", - "jaguar", - "reel", - "sheer", - "commodity", - "posing", - "kilometers", - "rp", - "bind", - "thanksgiving", - "rand", - "hopkins", - "urgent", - "guarantees", - "infants", - "gothic", - "cylinder", - "witch", - "buck", - "indication", - "eh", - "congratulations", - "tba", - "cohen", - "sie", - "usgs", - "puppy", - "kathy", - "acre", - "graphs", - "surround", - "cigarettes", - "revenge", - "expires", - "enemies", - "lows", - "controllers", - "aqua", - "chen", - "emma", - "consultancy", - "finances", - "accepts", - "enjoying", - "conventions", - "eva", - "patrol", - "smell", - "pest", - "hc", - "italiano", - "coordinates", - "rca", - "fp", - "carnival", - "roughly", - "sticker", - "promises", - "responding", - "reef", - "physically", - "divide", - "stakeholders", - "hydrocodone", - "gst", - "consecutive", - "cornell", - "satin", - "bon", - "deserve", - "attempting", - "mailto", - "promo", - "jj", - "representations", - "chan", - "worried", - "tunes", - "garbage", - "competing", - "combines", - "mas", - "beth", - "bradford", - "len", - "phrases", - "kai", - "peninsula", - "chelsea", - "boring", - "reynolds", - "dom", - "jill", - "accurately", - "speeches", - "reaches", - "schema", - "considers", - "sofa", - "catalogs", - "ministries", - "vacancies", - "quizzes", - "parliamentary", - "obj", - "prefix", - "lucia", - "savannah", - "barrel", - "typing", - "nerve", - "dans", - "planets", - "deficit", - "boulder", - "pointing", - "renew", - "coupled", - "viii", - "myanmar", - "metadata", - "harold", - "circuits", - "floppy", - "texture", - "handbags", - "jar", - "ev", - "somerset", - "incurred", - "acknowledge", - "thoroughly", - "antigua", - "nottingham", - "thunder", - "tent", - "caution", - "identifies", - "questionnaire", - "qualification", - "locks", - "modelling", - "namely", - "miniature", - "dept", - "hack", - "dare", - "euros", - "interstate", - "pirates", - "aerial", - "hawk", - "consequence", - "rebel", - "systematic", - "perceived", - "origins", - "hired", - "makeup", - "textile", - "lamb", - "madagascar", - "nathan", - "tobago", - "presenting", - "cos", - "troubleshooting", - "uzbekistan", - "indexes", - "pac", - "rl", - "erp", - "centuries", - "gl", - "magnitude", - "ui", - "richardson", - "hindu", - "dh", - "fragrances", - "vocabulary", - "licking", - "earthquake", - "vpn", - "fundraising", - "fcc", - "markers", - "weights", - "albania", - "geological", - "assessing", - "lasting", - "wicked", - "eds", - "introduces", - "kills", - "roommate", - "webcams", - "pushed", - "webmasters", - "ro", - "df", - "computational", - "acdbentity", - "participated", - "junk", - "handhelds", - "wax", - "lucy", - "answering", - "hans", - "impressed", - "slope", - "reggae", - "failures", - "poet", - "conspiracy", - "surname", - "theology", - "nails", - "evident", - "whats", - "rides", - "rehab", - "epic", - "saturn", - "organizer", - "nut", - "allergy", - "sake", - "twisted", - "combinations", - "preceding", - "merit", - "enzyme", - "cumulative", - "zshops", - "planes", - "edmonton", - "tackle", - "disks", - "condo", - "pokemon", - "amplifier", - "ambien", - "arbitrary", - "prominent", - "retrieve", - "lexington", - "vernon", - "sans", - "worldcat", - "titanium", - "irs", - "fairy", - "builds", - "contacted", - "shaft", - "lean", - "bye", - "cdt", - "recorders", - "occasional", - "leslie", - "casio", - "deutsche", - "ana", - "postings", - "innovations", - "kitty", - "postcards", - "dude", - "drain", - "monte", - "fires", - "algeria", - "blessed", - "luis", - "reviewing", - "cardiff", - "cornwall", - "favors", - "potato", - "panic", - "explicitly", - "sticks", - "leone", - "transsexual", - "ez", - "citizenship", - "excuse", - "reforms", - "basement", - "onion", - "strand", - "pf", - "sandwich", - "uw", - "lawsuit", - "alto", - "informative", - "girlfriend", - "bloomberg", - "cheque", - "hierarchy", - "influenced", - "banners", - "reject", - "eau", - "abandoned", - "bd", - "circles", - "italic", - "beats", - "merry", - "mil", - "scuba", - "gore", - "complement", - "cult", - "dash", - "passive", - "mauritius", - "valued", - "cage", - "checklist", - "requesting", - "courage", - "verde", - "lauderdale", - "scenarios", - "gazette", - "hitachi", - "divx", - "extraction", - "batman", - "elevation", - "hearings", - "coleman", - "hugh", - "lap", - "utilization", - "beverages", - "calibration", - "jake", - "eval", - "efficiently", - "anaheim", - "ping", - "textbook", - "dried", - "entertaining", - "prerequisite", - "luther", - "frontier", - "settle", - "stopping", - "refugees", - "knights", - "hypothesis", - "palmer", - "medicines", - "flux", - "derby", - "sao", - "peaceful", - "altered", - "pontiac", - "regression", - "doctrine", - "scenic", - "trainers", - "muze", - "enhancements", - "renewable", - "intersection", - "passwords", - "sewing", - "consistency", - "collectors", - "conclude", - "recognised", - "munich", - "oman", - "celebs", - "gmc", - "propose", - "hh", - "azerbaijan", - "lighter", - "rage", - "adsl", - "uh", - "prix", - "astrology", - "advisors", - "pavilion", - "tactics", - "trusts", - "occurring", - "supplemental", - "travelling", - "talented", - "annie", - "pillow", - "induction", - "derek", - "precisely", - "shorter", - "harley", - "spreading", - "provinces", - "relying", - "finals", - "paraguay", - "steal", - "parcel", - "refined", - "fd", - "bo", - "fifteen", - "widespread", - "incidence", - "fears", - "predict", - "boutique", - "acrylic", - "rolled", - "tuner", - "avon", - "incidents", - "peterson", - "rays", - "asn", - "shannon", - "toddler", - "enhancing", - "flavor", - "alike", - "walt", - "homeless", - "horrible", - "hungry", - "metallic", - "acne", - "blocked", - "interference", - "warriors", - "palestine", - "listprice", - "libs", - "undo", - "cadillac", - "atmospheric", - "malawi", - "wm", - "pk", - "sagem", - "knowledgestorm", - "dana", - "halo", - "ppm", - "curtis", - "parental", - "referenced", - "strikes", - "lesser", - "publicity", - "marathon", - "ant", - "proposition", - "gays", - "pressing", - "gasoline", - "apt", - "dressed", - "scout", - "belfast", - "exec", - "dealt", - "niagara", - "inf", - "eos", - "warcraft", - "charms", - "catalyst", - "trader", - "bucks", - "allowance", - "vcr", - "denial", - "uri", - "designation", - "thrown", - "prepaid", - "raises", - "gem", - "duplicate", - "electro", - "criterion", - "badge", - "wrist", - "civilization", - "analyzed", - "vietnamese", - "heath", - "tremendous", - "ballot", - "lexus", - "varying", - "remedies", - "validity", - "trustee", - "maui", - "weighted", - "angola", - "performs", - "plastics", - "realm", - "corrected", - "jenny", - "helmet", - "salaries", - "postcard", - "elephant", - "yemen", - "encountered", - "tsunami", - "scholar", - "nickel", - "internationally", - "surrounded", - "psi", - "buses", - "expedia", - "geology", - "pct", - "wb", - "creatures", - "coating", - "commented", - "wallet", - "cleared", - "smilies", - "vids", - "accomplish", - "boating", - "drainage", - "shakira", - "corners", - "broader", - "vegetarian", - "rouge", - "yeast", - "yale", - "newfoundland", - "sn", - "qld", - "pas", - "clearing", - "investigated", - "dk", - "ambassador", - "coated", - "intend", - "stephanie", - "contacting", - "vegetation", - "doom", - "findarticles", - "louise", - "kenny", - "specially", - "owen", - "routines", - "hitting", - "yukon", - "beings", - "bite", - "issn", - "aquatic", - "reliance", - "habits", - "striking", - "myth", - "infectious", - "podcasts", - "singh", - "gig", - "gilbert", - "sas", - "ferrari", - "continuity", - "brook", - "fu", - "outputs", - "phenomenon", - "ensemble", - "insulin", - "assured", - "biblical", - "weed", - "conscious", - "accent", - "mysimon", - "eleven", - "wives", - "ambient", - "utilize", - "mileage", - "oecd", - "prostate", - "adaptor", - "auburn", - "unlock", - "hyundai", - "pledge", - "vampire", - "angela", - "relates", - "nitrogen", - "xerox", - "dice", - "merger", - "softball", - "referrals", - "quad", - "dock", - "differently", - "firewire", - "mods", - "nextel", - "framing", - "organised", - "musician", - "blocking", - "rwanda", - "sorts", - "integrating", - "vsnet", - "limiting", - "dispatch", - "revisions", - "papua", - "restored", - "hint", - "armor", - "riders", - "chargers", - "remark", - "dozens", - "varies", - "msie", - "reasoning", - "wn", - "liz", - "rendered", - "picking", - "charitable", - "guards", - "annotated", - "ccd", - "sv", - "convinced", - "openings", - "buys", - "burlington", - "replacing", - "researcher", - "watershed", - "councils", - "occupations", - "acknowledged", - "kruger", - "pockets", - "granny", - "pork", - "zu", - "equilibrium", - "viral", - "inquire", - "pipes", - "characterized", - "laden", - "aruba", - "cottages", - "realtor", - "merge", - "privilege", - "edgar", - "develops", - "qualifying", - "chassis", - "dubai", - "estimation", - "barn", - "pushing", - "llp", - "fleece", - "pediatric", - "boc", - "fare", - "dg", - "asus", - "pierce", - "allan", - "dressing", - "techrepublic", - "sperm", - "vg", - "bald", - "filme", - "craps", - "fuji", - "frost", - "leon", - "institutes", - "mold", - "dame", - "fo", - "sally", - "yacht", - "tracy", - "prefers", - "drilling", - "brochures", - "herb", - "tmp", - "alot", - "ate", - "breach", - "whale", - "traveller", - "appropriations", - "suspected", - "tomatoes", - "benchmark", - "beginners", - "instructors", - "highlighted", - "bedford", - "stationery", - "idle", - "mustang", - "unauthorized", - "clusters", - "antibody", - "competent", - "momentum", - "fin", - "wiring", - "io", - "pastor", - "mud", - "calvin", - "uni", - "shark", - "contributor", - "demonstrates", - "phases", - "grateful", - "emerald", - "gradually", - "laughing", - "grows", - "cliff", - "desirable", - "tract", - "ul", - "ballet", - "ol", - "journalist", - "abraham", - "js", - "bumper", - "afterwards", - "webpage", - "religions", - "garlic", - "hostels", - "shine", - "senegal", - "explosion", - "pn", - "banned", - "wendy", - "briefs", - "signatures", - "diffs", - "cove", - "mumbai", - "ozone", - "disciplines", - "casa", - "mu", - "daughters", - "conversations", - "radios", - "tariff", - "nvidia", - "opponent", - "pasta", - "simplified", - "muscles", - "serum", - "wrapped", - "swift", - "motherboard", - "runtime", - "inbox", - "focal", - "bibliographic", - "eden", - "distant", - "incl", - "champagne", - "ala", - "decimal", - "hq", - "deviation", - "superintendent", - "propecia", - "dip", - "nbc", - "samba", - "hostel", - "housewives", - "employ", - "mongolia", - "penguin", - "magical", - "influences", - "inspections", - "irrigation", - "miracle", - "manually", - "reprint", - "reid", - "wt", - "hydraulic", - "centered", - "robertson", - "flex", - "yearly", - "penetration", - "wound", - "belle", - "rosa", - "conviction", - "hash", - "omissions", - "writings", - "hamburg", - "lazy", - "mv", - "mpg", - "retrieval", - "qualities", - "cindy", - "fathers", - "carb", - "charging", - "cas", - "marvel", - "lined", - "cio", - "dow", - "prototype", - "importantly", - "rb", - "petite", - "apparatus", - "upc", - "terrain", - "dui", - "pens", - "explaining", - "yen", - "strips", - "gossip", - "rangers", - "nomination", - "empirical", - "mh", - "rotary", - "worm", - "dependence", - "discrete", - "beginner", - "boxed", - "lid", - "sexuality", - "polyester", - "cubic", - "deaf", - "commitments", - "suggesting", - "sapphire", - "kinase", - "skirts", - "mats", - "remainder", - "crawford", - "labeled", - "privileges", - "televisions", - "specializing", - "marking", - "commodities", - "pvc", - "serbia", - "sheriff", - "griffin", - "declined", - "guyana", - "spies", - "blah", - "mime", - "neighbor", - "motorcycles", - "elect", - "highways", - "thinkpad", - "concentrate", - "intimate", - "reproductive", - "preston", - "deadly", - "feof", - "bunny", - "chevy", - "molecules", - "rounds", - "longest", - "refrigerator", - "tions", - "intervals", - "sentences", - "dentists", - "usda", - "exclusion", - "workstation", - "holocaust", - "keen", - "flyer", - "peas", - "dosage", - "receivers", - "urls", - "customise", - "disposition", - "variance", - "navigator", - "investigators", - "cameroon", - "baking", - "marijuana", - "adaptive", - "computed", - "needle", - "baths", - "enb", - "gg", - "cathedral", - "brakes", - "og", - "nirvana", - "ko", - "fairfield", - "owns", - "til", - "invision", - "sticky", - "destiny", - "generous", - "madness", - "emacs", - "climb", - "blowing", - "fascinating", - "landscapes", - "heated", - "lafayette", - "jackie", - "wto", - "computation", - "hay", - "cardiovascular", - "ww", - "sparc", - "cardiac", - "salvation", - "dover", - "adrian", - "predictions", - "accompanying", - "vatican", - "brutal", - "learners", - "gd", - "selective", - "arbitration", - "configuring", - "token", - "editorials", - "zinc", - "sacrifice", - "seekers", - "guru", - "isa", - "removable", - "convergence", - "yields", - "gibraltar", - "levy", - "suited", - "numeric", - "anthropology", - "skating", - "kinda", - "aberdeen", - "emperor", - "grad", - "malpractice", - "dylan", - "bras", - "belts", - "blacks", - "educated", - "rebates", - "reporters", - "burke", - "proudly", - "pix", - "necessity", - "rendering", - "mic", - "inserted", - "pulling", - "basename", - "kyle", - "obesity", - "curves", - "suburban", - "touring", - "clara", - "vertex", - "bw", - "hepatitis", - "nationally", - "tomato", - "andorra", - "waterproof", - "expired", - "mj", - "travels", - "flush", - "waiver", - "pale", - "specialties", - "hayes", - "humanitarian", - "invitations", - "functioning", - "delight", - "survivor", - "garcia", - "cingular", - "economies", - "alexandria", - "bacterial", - "moses", - "counted", - "undertake", - "declare", - "continuously", - "johns", - "valves", - "gaps", - "impaired", - "achievements", - "donors", - "tear", - "jewel", - "teddy", - "lf", - "convertible", - "ata", - "teaches", - "ventures", - "nil", - "bufing", - "stranger", - "tragedy", - "julian", - "nest", - "pam", - "dryer", - "painful", - "velvet", - "tribunal", - "ruled", - "nato", - "pensions", - "prayers", - "funky", - "secretariat", - "nowhere", - "cop", - "paragraphs", - "gale", - "joins", - "adolescent", - "nominations", - "wesley", - "dim", - "lately", - "cancelled", - "scary", - "mattress", - "mpegs", - "brunei", - "likewise", - "banana", - "introductory", - "slovak", - "cakes", - "stan", - "reservoir", - "occurrence", - "idol", - "mixer", - "remind", - "wc", - "worcester", - "sbjct", - "demographic", - "charming", - "mai", - "tooth", - "disciplinary", - "annoying", - "respected", - "stays", - "disclose", - "affair", - "drove", - "washer", - "upset", - "restrict", - "springer", - "beside", - "mines", - "portraits", - "rebound", - "logan", - "mentor", - "interpreted", - "evaluations", - "fought", - "baghdad", - "elimination", - "metres", - "hypothetical", - "immigrants", - "complimentary", - "helicopter", - "pencil", - "freeze", - "hk", - "performer", - "abu", - "titled", - "commissions", - "sphere", - "powerseller", - "moss", - "ratios", - "concord", - "graduated", - "endorsed", - "ty", - "surprising", - "walnut", - "lance", - "ladder", - "italia", - "unnecessary", - "dramatically", - "liberia", - "sherman", - "cork", - "maximize", - "cj", - "hansen", - "senators", - "workout", - "mali", - "yugoslavia", - "bleeding", - "characterization", - "colon", - "likelihood", - "lanes", - "purse", - "fundamentals", - "contamination", - "mtv", - "endangered", - "compromise", - "optimize", - "stating", - "dome", - "caroline", - "leu", - "expiration", - "namespace", - "align", - "peripheral", - "bless", - "engaging", - "negotiation", - "crest", - "opponents", - "triumph", - "nominated", - "confidentiality", - "electoral", - "changelog", - "welding", - "deferred", - "alternatively", - "heel", - "alloy", - "condos", - "plots", - "polished", - "yang", - "gently", - "greensboro", - "tulsa", - "locking", - "casey", - "controversial", - "draws", - "fridge", - "blanket", - "bloom", - "qc", - "simpsons", - "lou", - "elliott", - "recovered", - "fraser", - "justify", - "upgrading", - "blades", - "pgp", - "loops", - "surge", - "frontpage", - "trauma", - "aw", - "tahoe", - "advert", - "possess", - "demanding", - "defensive", - "sip", - "flashers", - "subaru", - "forbidden", - "tf", - "vanilla", - "programmers", - "pj", - "monitored", - "installations", - "deutschland", - "picnic", - "souls", - "arrivals", - "spank", - "cw", - "practitioner", - "motivated", - "wr", - "dumb", - "smithsonian", - "hollow", - "vault", - "securely", - "examining", - "fioricet", - "groove", - "revelation", - "rg", - "pursuit", - "delegation", - "wires", - "bl", - "dictionaries", - "mails", - "backing", - "greenhouse", - "sleeps", - "vc", - "blake", - "transparency", - "dee", - "travis", - "wx", - "endless", - "figured", - "orbit", - "currencies", - "niger", - "bacon", - "survivors", - "positioning", - "heater", - "colony", - "cannon", - "circus", - "promoted", - "forbes", - "mae", - "moldova", - "mel", - "descending", - "paxil", - "spine", - "trout", - "enclosed", - "feat", - "temporarily", - "ntsc", - "cooked", - "thriller", - "transmit", - "apnic", - "fatty", - "gerald", - "pressed", - "frequencies", - "scanned", - "reflections", - "hunger", - "mariah", - "sic", - "municipality", - "usps", - "joyce", - "detective", - "surgeon", - "cement", - "experiencing", - "fireplace", - "endorsement", - "bg", - "planners", - "disputes", - "textiles", - "missile", - "intranet", - "closes", - "seq", - "psychiatry", - "persistent", - "deborah", - "conf", - "marco", - "assists", - "summaries", - "glow", - "gabriel", - "auditor", - "wma", - "aquarium", - "violin", - "prophet", - "cir", - "bracket", - "looksmart", - "isaac", - "oxide", - "oaks", - "magnificent", - "erik", - "colleague", - "naples", - "promptly", - "modems", - "adaptation", - "hu", - "harmful", - "paintball", - "prozac", - "sexually", - "enclosure", - "acm", - "dividend", - "newark", - "kw", - "paso", - "glucose", - "phantom", - "norm", - "playback", - "supervisors", - "westminster", - "turtle", - "ips", - "distances", - "absorption", - "treasures", - "dsc", - "warned", - "neural", - "ware", - "fossil", - "mia", - "hometown", - "badly", - "transcripts", - "apollo", - "wan", - "disappointed", - "persian", - "continually", - "communist", - "collectible", - "handmade", - "greene", - "entrepreneurs", - "robots", - "grenada", - "creations", - "jade", - "scoop", - "acquisitions", - "foul", - "keno", - "gtk", - "earning", - "mailman", - "sanyo", - "nested", - "biodiversity", - "excitement", - "somalia", - "movers", - "verbal", - "blink", - "presently", - "seas", - "carlo", - "workflow", - "mysterious", - "novelty", - "bryant", - "tiles", - "voyuer", - "librarian", - "subsidiaries", - "switched", - "stockholm", - "tamil", - "garmin", - "ru", - "pose", - "fuzzy", - "indonesian", - "grams", - "therapist", - "richards", - "mrna", - "budgets", - "toolkit", - "promising", - "relaxation", - "goat", - "render", - "carmen", - "ira", - "sen", - "thereafter", - "hardwood", - "erotica", - "temporal", - "sail", - "forge", - "commissioners", - "dense", - "dts", - "brave", - "forwarding", - "qt", - "awful", - "nightmare", - "airplane", - "reductions", - "southampton", - "istanbul", - "impose", - "organisms", - "sega", - "telescope", - "viewers", - "asbestos", - "portsmouth", - "cdna", - "meyer", - "enters", - "pod", - "savage", - "advancement", - "wu", - "harassment", - "willow", - "resumes", - "bolt", - "gage", - "throwing", - "existed", - "generators", - "lu", - "wagon", - "barbie", - "dat", - "favour", - "soa", - "knock", - "urge", - "smtp", - "generates", - "potatoes", - "thorough", - "replication", - "inexpensive", - "kurt", - "receptors", - "peers", - "roland", - "optimum", - "neon", - "interventions", - "quilt", - "huntington", - "creature", - "ours", - "mounts", - "syracuse", - "internship", - "lone", - "refresh", - "aluminium", - "snowboard", - "beastality", - "webcast", - "michel", - "evanescence", - "subtle", - "coordinated", - "notre", - "shipments", - "maldives", - "stripes", - "firmware", - "antarctica", - "cope", - "shepherd", - "lm", - "canberra", - "cradle", - "chancellor", - "mambo", - "lime", - "kirk", - "flour", - "controversy", - "legendary", - "bool", - "sympathy", - "choir", - "avoiding", - "beautifully", - "blond", - "expects", - "cho", - "jumping", - "fabrics", - "antibodies", - "polymer", - "hygiene", - "wit", - "poultry", - "virtue", - "burst", - "examinations", - "surgeons", - "bouquet", - "immunology", - "promotes", - "mandate", - "wiley", - "departmental", - "bbs", - "spas", - "ind", - "corpus", - "johnston", - "terminology", - "gentleman", - "fibre", - "reproduce", - "convicted", - "shades", - "jets", - "indices", - "roommates", - "adware", - "qui", - "intl", - "threatening", - "spokesman", - "zoloft", - "activists", - "frankfurt", - "prisoner", - "daisy", - "halifax", - "encourages", - "ultram", - "cursor", - "assembled", - "earliest", - "donated", - "stuffed", - "restructuring", - "insects", - "terminals", - "crude", - "morrison", - "maiden", - "simulations", - "cz", - "sufficiently", - "examines", - "viking", - "myrtle", - "bored", - "cleanup", - "yarn", - "knit", - "conditional", - "mug", - "crossword", - "bother", - "budapest", - "conceptual", - "knitting", - "attacked", - "hl", - "bhutan", - "liechtenstein", - "mating", - "compute", - "redhead", - "arrives", - "translator", - "automobiles", - "tractor", - "allah", - "continent", - "ob", - "unwrap", - "fares", - "longitude", - "resist", - "challenged", - "telecharger", - "hoped", - "pike", - "safer", - "insertion", - "instrumentation", - "ids", - "hugo", - "wagner", - "constraint", - "groundwater", - "touched", - "strengthening", - "cologne", - "gzip", - "wishing", - "ranger", - "smallest", - "insulation", - "newman", - "marsh", - "ricky", - "ctrl", - "scared", - "theta", - "infringement", - "bent", - "laos", - "subjective", - "monsters", - "asylum", - "lightbox", - "robbie", - "stake", - "cocktail", - "outlets", - "swaziland", - "varieties", - "arbor", - "mediawiki", - "configurations", - "poison", -]; diff --git a/src/modules/trading/README.md b/src/modules/trading/README.md deleted file mode 100644 index 5e9c4a2..0000000 --- a/src/modules/trading/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# Trading Module - -Paper-trading system where each Telegram user manages a virtual portfolio. Currently supports **VN stocks only** — crypto, gold, and currency exchange coming later. - -## Commands - -| Command | Action | -|---------|--------| -| `/trade_topup ` | Add VND to account. Tracks cumulative invested in `meta.invested`. | -| `/trade_buy ` | Buy VN stock at market price, deducting VND. Integer quantities only. | -| `/trade_sell ` | Sell stock holdings back to VND at market price. | -| `/trade_convert` | Currency exchange (coming soon). | -| `/trade_stats` | Portfolio breakdown with all assets valued in VND, plus P&L vs invested. | - -## Symbol Resolution - -Symbols are **resolved dynamically** — no hardcoded registry. When a user buys a ticker: - -1. Check KV cache (`sym:`) → if cached, use it -2. Query KBS API to verify the ticker exists and has price data -3. Cache the resolution permanently in KV - -Any valid VN stock ticker listed on KBS "just works" without code changes. - -## Database - -KV namespace prefix: `trading:` - -| Key | Type | Description | -|-----|------|-------------| -| `user:` | JSON | Per-user portfolio | -| `sym:` | JSON | Cached symbol resolution | -| `forex:latest` | JSON | Cached BIDV forex rates | - -### Schema: `user:` - -```json -{ - "currency": { "VND": 5000000 }, - "assets": { "TCB": 10, "FPT": 5, "VNM": 100 }, - "meta": { "invested": 10000000 } -} -``` - -- `currency` — fiat balances (VND only for now) -- `assets` — flat map of stock quantities keyed by ticker -- `meta.invested` — cumulative VND value of all top-ups (cost basis for P&L) -- Migrates old formats automatically on load (`totalvnd` → `meta.invested`, `stock`/`crypto`/`others` → `assets`) - -### Schema: `sym:` - -```json -{ "symbol": "TCB", "category": "stock", "label": "TCB" } -``` - -Cached permanently after first successful KBS lookup. - -## Price Source - -| API | Purpose | Auth | -|-----|---------|------| -| KBS `/iis-server/investment/stocks/{TICKER}/data_day` | VN stock daily close (VND, unscaled) | None | - -Prices are fetched on demand per symbol (not batch-cached), since any ticker can be queried dynamically. KBS returns a multi-day OHLCV window; we take the latest bar's close. - -## File Layout - -``` -src/modules/trading/ -├── index.js — module entry, wires handlers to commands -├── symbols.js — dynamic symbol resolution via KBS + KV cache -├── format.js — VND/stock number formatters -├── portfolio.js — per-user KV read/write, flat assets map -├── prices.js — KBS stock price fetch + BIDV forex (for future use) -├── handlers.js — topup/buy/sell/convert handlers -└── stats-handler.js — stats/P&L breakdown handler -``` - -## Future - -- Crypto (CoinGecko), gold (PAX Gold), currency exchange (BIDV bid/ask rates) -- Dynamic symbol resolution will extend to CoinGecko search for crypto diff --git a/src/modules/trading/format.js b/src/modules/trading/format.js deleted file mode 100644 index de92570..0000000 --- a/src/modules/trading/format.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @file Number formatters for trading module display. - * Manual VND formatter avoids locale-dependent toLocaleString in CF Workers. - */ - -/** - * Format number as Vietnamese Dong — dot thousands separator, no decimals. - * @param {number} n - * @returns {string} e.g. "15.000.000 VND" - */ -export function formatVND(n) { - const rounded = Math.round(n); - const abs = Math.abs(rounded).toString(); - // insert dots every 3 digits from right - let result = ""; - for (let i = 0; i < abs.length; i++) { - if (i > 0 && (abs.length - i) % 3 === 0) result += "."; - result += abs[i]; - } - return `${rounded < 0 ? "-" : ""}${result} VND`; -} - -/** - * Format number as USD — 2 decimals, comma thousands. - * @param {number} n - * @returns {string} e.g. "$1,234.56" - */ -export function formatUSD(n) { - const fixed = Math.abs(n).toFixed(2); - const [intPart, decPart] = fixed.split("."); - let result = ""; - for (let i = 0; i < intPart.length; i++) { - if (i > 0 && (intPart.length - i) % 3 === 0) result += ","; - result += intPart[i]; - } - return `${n < 0 ? "-" : ""}$${result}.${decPart}`; -} - -/** - * Format crypto quantity — up to 8 decimals, trailing zeros stripped. - * @param {number} n - * @returns {string} e.g. "0.00125" - */ -export function formatCrypto(n) { - return Number.parseFloat(n.toFixed(8)).toString(); -} - -/** - * Format stock quantity — integer only. - * @param {number} n - * @returns {string} - */ -export function formatStock(n) { - return Math.floor(n).toString(); -} - -/** - * Format amount based on currency type. - * @param {number} n - * @param {string} currency — "VND" or "USD" - * @returns {string} - */ -export function formatCurrency(n, currency) { - if (currency === "VND") return formatVND(n); - if (currency === "USD") return formatUSD(n); - return `${n} ${currency}`; -} - -/** - * Format P&L line with absolute and percentage. - * @param {number} currentValue - * @param {number} invested - * @returns {string} - */ -export function formatPnL(currentValue, invested) { - const diff = currentValue - invested; - const pct = invested > 0 ? ((diff / invested) * 100).toFixed(2) : "0.00"; - const sign = diff >= 0 ? "+" : ""; - return `${sign}${formatVND(diff)} (${sign}${pct}%)`; -} diff --git a/src/modules/trading/handlers.js b/src/modules/trading/handlers.js deleted file mode 100644 index 680f515..0000000 --- a/src/modules/trading/handlers.js +++ /dev/null @@ -1,148 +0,0 @@ -/** - * @file Command handler implementations for the trading module. - * Each handler receives (ctx, db) — the grammY context and KV store. - * Currently only VN stocks are supported. Crypto/gold/convert coming later. - */ - -import { formatStock, formatVND } from "./format.js"; -import { - addAsset, - addCurrency, - deductAsset, - deductCurrency, - getPortfolio, - savePortfolio, -} from "./portfolio.js"; -import { getStockPrice } from "./prices.js"; -import { comingSoonMessage, resolveSymbol } from "./symbols.js"; - -function uid(ctx) { - return ctx.from?.id; -} - -function parseArgs(ctx) { - return (ctx.match || "").trim().split(/\s+/).filter(Boolean); -} - -function usageReply(ctx, usage) { - return ctx.reply(`Usage: ${usage}`); -} - -// channel posts / inline queries may lack ctx.from — per-user state would collide under key "user:undefined". -function requireUid(ctx) { - const id = uid(ctx); - if (id == null) { - ctx.reply("Cannot identify user — trading only works in private or group chats with a sender."); - return null; - } - return id; -} - -/** /trade_topup — add VND to account */ -export async function handleTopup(ctx, db) { - const id = requireUid(ctx); - if (id == null) return; - const args = parseArgs(ctx); - if (args.length < 1) - return usageReply(ctx, "/trade_topup \nExample: /trade_topup 5000000"); - const amount = Number(args[0]); - if (!Number.isFinite(amount) || amount <= 0) - return ctx.reply("Amount must be a positive number."); - - const p = await getPortfolio(db, id); - addCurrency(p, "VND", amount); - p.meta.invested += amount; - await savePortfolio(db, id, p); - await ctx.reply(`Topped up ${formatVND(amount)}.\nBalance: ${formatVND(p.currency.VND)}`); -} - -/** - * /trade_buy — buy VN stock at market price. - * - * @param {any} ctx — grammY context. - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {((trade: {symbol:string, side:"buy"|"sell", qty:number, priceVnd:number}) => Promise) | null} [onTrade] - */ -export async function handleBuy(ctx, db, onTrade = null) { - const id = requireUid(ctx); - if (id == null) return; - const args = parseArgs(ctx); - if (args.length < 2) - return usageReply(ctx, "/trade_buy \nExample: /trade_buy 100 TCB"); - const amount = Number(args[0]); - if (!Number.isFinite(amount) || amount <= 0) - return ctx.reply("Amount must be a positive number."); - if (!Number.isInteger(amount)) return ctx.reply("Stock quantities must be whole numbers."); - - const info = await resolveSymbol(db, args[1]); - if (!info) - return ctx.reply(`Unknown stock ticker "${args[1].toUpperCase()}".\n${comingSoonMessage()}`); - - let price; - try { - price = await getStockPrice(info.symbol); - } catch { - return ctx.reply("Could not fetch price. Try again later."); - } - if (price == null) return ctx.reply(`No price available for ${info.symbol}.`); - - const cost = amount * price; - const p = await getPortfolio(db, id); - const result = deductCurrency(p, "VND", cost); - if (!result.ok) { - return ctx.reply( - `Insufficient VND. Need ${formatVND(cost)}, have ${formatVND(result.balance)}.`, - ); - } - addAsset(p, info.symbol, amount); - await savePortfolio(db, id, p); - if (onTrade) await onTrade({ symbol: info.symbol, side: "buy", qty: amount, priceVnd: price }); - await ctx.reply( - `Bought ${formatStock(amount)} ${info.symbol} @ ${formatVND(price)}\nCost: ${formatVND(cost)}`, - ); -} - -/** - * /trade_sell — sell VN stock back to VND. - * - * @param {any} ctx — grammY context. - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {((trade: {symbol:string, side:"buy"|"sell", qty:number, priceVnd:number}) => Promise) | null} [onTrade] - */ -export async function handleSell(ctx, db, onTrade = null) { - const id = requireUid(ctx); - if (id == null) return; - const args = parseArgs(ctx); - if (args.length < 2) - return usageReply(ctx, "/trade_sell \nExample: /trade_sell 100 TCB"); - const amount = Number(args[0]); - if (!Number.isFinite(amount) || amount <= 0) - return ctx.reply("Amount must be a positive number."); - if (!Number.isInteger(amount)) return ctx.reply("Stock quantities must be whole numbers."); - - const symbol = args[1].toUpperCase(); - const p = await getPortfolio(db, id); - const result = deductAsset(p, symbol, amount); - if (!result.ok) return ctx.reply(`Insufficient ${symbol}. You have: ${formatStock(result.held)}`); - - let price; - try { - price = await getStockPrice(symbol); - } catch { - return ctx.reply("Could not fetch price. Try again later."); - } - if (price == null) return ctx.reply(`No price available for ${symbol}.`); - - const revenue = amount * price; - addCurrency(p, "VND", revenue); - await savePortfolio(db, id, p); - if (onTrade) await onTrade({ symbol, side: "sell", qty: amount, priceVnd: price }); - await ctx.reply( - `Sold ${formatStock(amount)} ${symbol} @ ${formatVND(price)}\nRevenue: ${formatVND(revenue)}`, - ); -} - -/** /trade_convert — disabled, coming soon */ -export async function handleConvert(ctx) { - await ctx.reply(`Currency exchange is not available yet.\n${comingSoonMessage()}`); -} diff --git a/src/modules/trading/history.js b/src/modules/trading/history.js deleted file mode 100644 index 41c80be..0000000 --- a/src/modules/trading/history.js +++ /dev/null @@ -1,121 +0,0 @@ -/** - * @file history — D1-backed trade record + /history command for the trading module. - * - * Exports: - * recordTrade(sql, opts) — fire-and-forget insert; logs + swallows on failure. - * listTrades(sql, userId, limit) — newest-first query; returns [] when sql null. - * formatTradesHtml(trades) — HTML-escaped compact list for Telegram HTML mode. - * createHistoryHandler(sql) — grammY command handler factory. - */ - -import { escapeHtml } from "../../util/escape-html.js"; - -/** @typedef {import("../../types.js").Trade} Trade */ -/** @typedef {import("../../db/sql-store-interface.js").SqlStore} SqlStore */ - -const TABLE = "trading_trades"; -const DEFAULT_LIMIT = 10; -const MAX_LIMIT = 50; - -/** - * Insert a trade row. Silently skips when sql is null (no D1 binding). - * Failure is logged but never re-thrown — portfolio KV is source of truth. - * - * @param {SqlStore | null} sql - * @param {{ userId: number, symbol: string, side: "buy"|"sell", qty: number, priceVnd: number }} opts - * @returns {Promise} - */ -export async function recordTrade(sql, { userId, symbol, side, qty, priceVnd }) { - if (sql === null) { - console.warn("[trading/history] recordTrade skipped — no D1 binding"); - return; - } - try { - await sql.run( - `INSERT INTO ${TABLE} (user_id, symbol, side, qty, price_vnd, ts) VALUES (?, ?, ?, ?, ?, ?)`, - userId, - symbol, - side, - qty, - priceVnd, - Date.now(), - ); - } catch (err) { - console.error("[trading/history] recordTrade failed:", err); - } -} - -/** - * Fetch the most recent trades for a user, newest first. - * Returns [] when sql is null or the table is empty. - * - * @param {SqlStore | null} sql - * @param {number} userId - * @param {number} limit — clamped to [1, 50]. - * @returns {Promise} - */ -export async function listTrades(sql, userId, limit) { - if (sql === null) return []; - const n = Math.max(1, Math.min(MAX_LIMIT, limit)); - const rows = await sql.all( - `SELECT id, user_id, symbol, side, qty, price_vnd, ts FROM ${TABLE} WHERE user_id = ? ORDER BY ts DESC LIMIT ?`, - userId, - n, - ); - // Map snake_case DB columns → camelCase Trade objects. - return rows.map((r) => ({ - id: r.id, - userId: r.user_id, - symbol: r.symbol, - side: r.side, - qty: r.qty, - priceVnd: r.price_vnd, - ts: r.ts, - })); -} - -/** - * Render a trade list as Telegram HTML. - * Symbols are HTML-escaped to prevent injection. - * - * @param {Trade[]} trades - * @returns {string} - */ -export function formatTradesHtml(trades) { - if (trades.length === 0) return "No trades recorded yet."; - - const header = "Trade History\n"; - const lines = trades.map((t) => { - const side = t.side === "buy" ? "BUY " : "SELL"; - const sym = escapeHtml(t.symbol); - const price = t.priceVnd.toLocaleString("vi-VN"); - const date = new Date(t.ts).toISOString().slice(0, 16).replace("T", " "); - return `${side} ${t.qty} ${sym} @ ${price} VND ${date}`; - }); - - return header + lines.join("\n"); -} - -/** - * Factory that returns a grammY command handler for /history [n]. - * - * Parses optional N from ctx.match (default 10, clamped 1–50). - * Replies with HTML trade list. - * - * @param {SqlStore | null} sql - * @returns {(ctx: any) => Promise} - */ -export function createHistoryHandler(sql) { - return async (ctx) => { - const userId = ctx.from?.id; - if (!userId) return ctx.reply("Could not identify user."); - - const raw = Number.parseInt((ctx.match || "").trim(), 10); - // Invalid / zero / negative → default; > MAX_LIMIT → clamp inside listTrades. - const n = Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LIMIT; - - const trades = await listTrades(sql, userId, n); - const html = formatTradesHtml(trades); - await ctx.reply(html, { parse_mode: "HTML" }); - }; -} diff --git a/src/modules/trading/index.js b/src/modules/trading/index.js deleted file mode 100644 index fa0ff20..0000000 --- a/src/modules/trading/index.js +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @file Trading module entry — fake/paper trading with crypto, VN stocks, forex, gold. - * Handlers live in handlers.js; this file wires them into the module system. - */ - -import { handleBuy, handleConvert, handleSell, handleTopup } from "./handlers.js"; -import { createHistoryHandler, recordTrade } from "./history.js"; -import { trimTradesHandler } from "./retention.js"; -import { handleStats } from "./stats-handler.js"; - -/** @type {import("../../db/kv-store-interface.js").KVStore | null} */ -let db = null; - -/** @type {import("../../db/sql-store-interface.js").SqlStore | null} */ -let sql = null; - -/** - * Build an onTrade callback bound to the current sql store and userId. - * - * @param {number} userId - * @returns {(trade: {symbol:string, side:"buy"|"sell", qty:number, priceVnd:number}) => Promise} - */ -function makeOnTrade(userId) { - return ({ symbol, side, qty, priceVnd }) => - recordTrade(sql, { userId, symbol, side, qty, priceVnd }); -} - -/** @type {import("../registry.js").BotModule} */ -const tradingModule = { - name: "trading", - init: async ({ db: store, sql: sqlStore }) => { - db = store; - sql = sqlStore ?? null; - }, - commands: [ - { - name: "trade_topup", - visibility: "public", - description: "Top up VND to your trading account", - handler: (ctx) => handleTopup(ctx, db), - }, - { - name: "trade_buy", - visibility: "public", - description: "Buy VN stock at market price", - handler: (ctx) => handleBuy(ctx, db, makeOnTrade(ctx.from?.id)), - }, - { - name: "trade_sell", - visibility: "public", - description: "Sell VN stock back to VND", - handler: (ctx) => handleSell(ctx, db, makeOnTrade(ctx.from?.id)), - }, - { - name: "trade_convert", - visibility: "public", - description: "Currency exchange (coming soon)", - handler: (ctx) => handleConvert(ctx), - }, - { - name: "trade_stats", - visibility: "public", - description: "Show portfolio summary with P&L", - handler: (ctx) => handleStats(ctx, db), - }, - { - name: "history", - visibility: "public", - description: "Show your last N trades (default 10, max 50)", - // handler is created lazily so it picks up the sql value set in init(). - handler: (ctx) => createHistoryHandler(sql)(ctx), - }, - ], - crons: [ - { - schedule: "0 17 * * *", - name: "trim-trades", - handler: (event, ctx) => trimTradesHandler(event, ctx), - }, - ], -}; - -export default tradingModule; diff --git a/src/modules/trading/migrations/0001_trades.sql b/src/modules/trading/migrations/0001_trades.sql deleted file mode 100644 index 64f4a63..0000000 --- a/src/modules/trading/migrations/0001_trades.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE trading_trades ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - symbol TEXT NOT NULL, - side TEXT NOT NULL CHECK (side IN ('buy','sell')), - qty INTEGER NOT NULL, - price_vnd INTEGER NOT NULL, - ts INTEGER NOT NULL -); -CREATE INDEX idx_trading_trades_user_ts ON trading_trades(user_id, ts DESC); -CREATE INDEX idx_trading_trades_ts ON trading_trades(ts); diff --git a/src/modules/trading/portfolio.js b/src/modules/trading/portfolio.js deleted file mode 100644 index df2f9bc..0000000 --- a/src/modules/trading/portfolio.js +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @file Portfolio CRUD — per-user KV read/write and balance operations. - * All mutations are in-memory; caller must savePortfolio() to persist. - * - * Schema: { currency: { VND }, assets: { SYMBOL: qty }, meta: { invested } } - * Assets are stored in a flat map — category is derived from symbol resolution. - */ - -/** - * @typedef {object} PortfolioMeta - * @property {number} invested — cumulative VND value of all top-ups (cost basis for P&L) - * - * @typedef {object} Portfolio - * @property {{ [currency: string]: number }} currency - * @property {{ [symbol: string]: number }} assets - * @property {PortfolioMeta} meta - */ - -/** @returns {Portfolio} */ -export function emptyPortfolio() { - return { currency: { VND: 0 }, assets: {}, meta: { invested: 0, createdAt: Date.now() } }; -} - -/** - * Load user portfolio from KV, or return empty if first-time user. - * Migrates old 4-category format to flat assets map. - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} userId - * @returns {Promise} - */ -export async function getPortfolio(db, userId) { - const raw = await db.getJSON(`user:${userId}`); - if (!raw) return emptyPortfolio(); - - // migrate: merge old stock/crypto/others into flat assets - const assets = - raw.stock || raw.crypto || raw.others - ? { ...raw.stock, ...raw.crypto, ...raw.others, ...raw.assets } - : (raw.assets ?? {}); - - // migrate: totalvnd → meta.invested - const invested = raw.meta?.invested ?? raw.totalvnd ?? 0; - const createdAt = raw.meta?.createdAt ?? Date.now(); - - return { - currency: { VND: 0, ...raw.currency }, - assets, - meta: { invested, createdAt }, - }; -} - -/** - * Persist portfolio to KV. - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} userId - * @param {Portfolio} portfolio - */ -export async function savePortfolio(db, userId, portfolio) { - await db.putJSON(`user:${userId}`, portfolio); -} - -/** - * Add fiat to portfolio. Mutates in place. - * @param {Portfolio} p - * @param {string} currency - * @param {number} amount - */ -export function addCurrency(p, currency, amount) { - p.currency[currency] = (p.currency[currency] || 0) + amount; -} - -/** - * Deduct fiat. Returns { ok, balance } — ok=false if insufficient. - * @param {Portfolio} p - * @param {string} currency - * @param {number} amount - * @returns {{ ok: boolean, balance: number }} - */ -export function deductCurrency(p, currency, amount) { - const balance = p.currency[currency] || 0; - if (balance < amount) return { ok: false, balance }; - p.currency[currency] = balance - amount; - return { ok: true, balance: balance - amount }; -} - -/** - * Add asset to flat assets map. - * @param {Portfolio} p - * @param {string} symbol - * @param {number} qty - */ -export function addAsset(p, symbol, qty) { - p.assets[symbol] = (p.assets[symbol] || 0) + qty; -} - -/** - * Deduct asset. Returns { ok, held } — ok=false if insufficient. - * Removes key if balance reaches 0. - * @param {Portfolio} p - * @param {string} symbol - * @param {number} qty - * @returns {{ ok: boolean, held: number }} - */ -export function deductAsset(p, symbol, qty) { - const held = p.assets[symbol] || 0; - if (held < qty) return { ok: false, held }; - const remaining = held - qty; - if (remaining === 0) delete p.assets[symbol]; - else p.assets[symbol] = remaining; - return { ok: true, held: remaining }; -} diff --git a/src/modules/trading/prices.js b/src/modules/trading/prices.js deleted file mode 100644 index 1248f31..0000000 --- a/src/modules/trading/prices.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * @file Price fetching — KBS (VN stocks) + BIDV (forex). - * Single-stock price fetch on demand. Forex rates cached for 60s. - */ - -const FOREX_CACHE_KEY = "forex:latest"; -const CACHE_TTL_MS = 60_000; -const STALE_LIMIT_MS = 300_000; -const KBS_LOOKBACK_DAYS = 14; // window wide enough to cover weekends & holidays - -function kbsDate(d) { - const dd = String(d.getUTCDate()).padStart(2, "0"); - const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); - const yyyy = d.getUTCFullYear(); - return `${dd}-${mm}-${yyyy}`; -} - -/** - * Fetch current VND price for a VN stock ticker via KBS. - * Returns the most recent daily close (already in VND — no scaling). - * Also doubles as ticker validation: null means the symbol has no KBS data. - * @param {string} ticker — uppercase, e.g. "TCB" - * @returns {Promise} - */ -export async function fetchStockPrice(ticker) { - const now = new Date(); - const edate = kbsDate(now); - const sdate = kbsDate(new Date(now.getTime() - KBS_LOOKBACK_DAYS * 86_400_000)); - const url = `https://kbbuddywts.kbsec.com.vn/iis-server/investment/stocks/${encodeURIComponent(ticker)}/data_day?sdate=${sdate}&edate=${edate}`; - const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0" } }); - if (!res.ok) return null; - const json = await res.json(); - const bars = json?.data_day; - if (!Array.isArray(bars) || bars.length === 0) return null; - const close = bars[0]?.c; - return typeof close === "number" && Number.isFinite(close) ? close : null; -} - -/** Forex rates via BIDV public API — returns real buy/sell rates */ -async function fetchForex() { - const res = await fetch("https://www.bidv.com.vn/ServicesBIDV/ExchangeDetailServlet"); - if (!res.ok) throw new Error(`BIDV API ${res.status}`); - const json = await res.json(); - const usd = json?.data?.find((r) => r.currency === "USD"); - if (!usd) throw new Error("BIDV missing USD rate"); - const parse = (s) => Number.parseFloat(String(s).replace(/,/g, "")); - const buy = parse(usd.muaCk); - const sell = parse(usd.ban); - if (!Number.isFinite(buy) || !Number.isFinite(sell)) { - throw new Error("BIDV invalid USD rate"); - } - return { USD: { mid: (buy + sell) / 2, buy, sell } }; -} - -/** - * Get VND price for a stock symbol. - * @param {string} symbol — uppercase ticker - * @returns {Promise} - */ -export async function getStockPrice(symbol) { - return fetchStockPrice(symbol); -} - -/** - * Cache-first forex rate retrieval. - * @param {import("../../db/kv-store-interface.js").KVStore} db - */ -async function getForexRates(db) { - const cached = await db.getJSON(FOREX_CACHE_KEY); - if (cached?.ts && Date.now() - cached.ts < CACHE_TTL_MS) return cached; - try { - const forex = await fetchForex(); - const data = { ts: Date.now(), ...forex }; - try { - await db.putJSON(FOREX_CACHE_KEY, data); - } catch { - /* best effort */ - } - return data; - } catch { - if (cached?.ts && Date.now() - cached.ts < STALE_LIMIT_MS) return cached; - throw new Error("Could not fetch forex rates. Try again later."); - } -} - -/** - * Mid-rate VND equivalent of 1 unit of currency (for stats display). - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {string} currency - * @returns {Promise} - */ -export async function getForexRate(db, currency) { - if (currency === "VND") return 1; - const rates = await getForexRates(db); - return rates[currency]?.mid ?? null; -} - -/** - * Buy/sell forex rates for a currency. - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {string} currency - * @returns {Promise<{ buy: number, sell: number }|null>} - */ -export async function getForexBidAsk(db, currency) { - if (currency === "VND") return null; - const rates = await getForexRates(db); - const rate = rates[currency]; - if (!rate?.buy || !rate?.sell) return null; - return { buy: rate.buy, sell: rate.sell }; -} diff --git a/src/modules/trading/retention.js b/src/modules/trading/retention.js deleted file mode 100644 index 12191e7..0000000 --- a/src/modules/trading/retention.js +++ /dev/null @@ -1,94 +0,0 @@ -/** - * @file retention — daily cron handler that trims trading_trades to enforce row caps. - * - * Strategy (two-pass): - * 1. Per-user pass: for each distinct user_id, delete rows beyond PER_USER_CAP - * (keeps the newest N rows per user). - * 2. Global FIFO pass: delete any rows beyond GLOBAL_CAP across all users - * (keeps the newest N rows globally). - * - * Uses a hybrid SELECT-then-DELETE approach so it works with both the real - * Cloudflare D1 binding and the in-memory fake-d1 used in unit tests. - * - * @typedef {import("../../db/sql-store-interface.js").SqlStore} SqlStore - */ - -const TABLE = "trading_trades"; - -/** Default per-user row cap. Exported for testability. */ -export const PER_USER_CAP = 1000; - -/** Default global row cap across all users. Exported for testability. */ -export const GLOBAL_CAP = 10000; - -/** - * Build a dynamic `DELETE FROM WHERE id IN (?, ?, ...)` query. - * Returns [query, ids] tuple — ids passed as spread binds. - * - * @param {number[]} ids - * @returns {[string, number[]]} - */ -function buildDeleteByIds(ids) { - const placeholders = ids.map(() => "?").join(", "); - return [`DELETE FROM ${TABLE} WHERE id IN (${placeholders})`, ids]; -} - -/** - * Daily cron handler — trims trading_trades to enforce per-user and global caps. - * - * @param {any} _event — Cloudflare ScheduledEvent (unused; present for handler contract). - * @param {{ sql: SqlStore | null }} ctx - * @param {{ perUserCap?: number, globalCap?: number }} [caps] — override caps (for tests). - * @returns {Promise} - */ -export async function trimTradesHandler(_event, { sql }, caps = {}) { - if (sql === null) { - console.log("[trim-trades] no D1 binding — skipping"); - return; - } - - const perUserCap = caps.perUserCap ?? PER_USER_CAP; - const globalCap = caps.globalCap ?? GLOBAL_CAP; - - let perUserDeleted = 0; - - // ── Pass 1: per-user trim ────────────────────────────────────────────────── - const userRows = await sql.all(`SELECT DISTINCT user_id FROM ${TABLE}`); - - for (const row of userRows) { - const userId = row.user_id; - - // Fetch IDs of rows that exceed the per-user cap (oldest rows = beyond OFFSET perUserCap). - const excessRows = await sql.all( - `SELECT id FROM ${TABLE} WHERE user_id = ? ORDER BY ts DESC LIMIT -1 OFFSET ?`, - userId, - perUserCap, - ); - - if (excessRows.length === 0) continue; - - const ids = excessRows.map((r) => r.id); - const [query, binds] = buildDeleteByIds(ids); - const result = await sql.run(query, ...binds); - perUserDeleted += result.changes ?? ids.length; - } - - console.log(`[trim-trades] per-user pass: deleted ${perUserDeleted} rows`); - - // ── Pass 2: global FIFO trim ─────────────────────────────────────────────── - const globalExcess = await sql.all( - `SELECT id FROM ${TABLE} ORDER BY ts DESC LIMIT -1 OFFSET ?`, - globalCap, - ); - - let globalDeleted = 0; - if (globalExcess.length > 0) { - const ids = globalExcess.map((r) => r.id); - const [query, binds] = buildDeleteByIds(ids); - const result = await sql.run(query, ...binds); - globalDeleted = result.changes ?? ids.length; - } - - console.log(`[trim-trades] global pass: deleted ${globalDeleted} rows`); - console.log(`[trim-trades] total deleted: ${perUserDeleted + globalDeleted} rows`); -} diff --git a/src/modules/trading/stats-handler.js b/src/modules/trading/stats-handler.js deleted file mode 100644 index b93b658..0000000 --- a/src/modules/trading/stats-handler.js +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @file /trade_stats handler — portfolio summary with P&L breakdown. - * - * Price fetches are issued in parallel with Promise.allSettled so a portfolio - * holding N stocks only waits for the slowest fetch, not the sum. Without this - * 10+ symbols would serially stack KBS latency and can blow Cloudflare's - * subrequest budget. - */ - -import { formatPnL, formatStock, formatVND } from "./format.js"; -import { getPortfolio } from "./portfolio.js"; -import { getStockPrice } from "./prices.js"; - -/** /trade_stats — show full portfolio valued in VND with P&L */ -export async function handleStats(ctx, db) { - const id = ctx.from?.id; - if (id == null) { - return ctx.reply("Cannot identify user — /trade_stats needs a sender."); - } - const p = await getPortfolio(db, id); - - const lines = ["📊 Portfolio Summary\n"]; - let totalValue = 0; - - const vnd = p.currency.VND || 0; - if (vnd > 0) { - totalValue += vnd; - lines.push(`VND: ${formatVND(vnd)}`); - } - - const held = Object.entries(p.assets).filter(([, qty]) => qty !== 0); - if (held.length > 0) { - lines.push("\nStocks:"); - const prices = await Promise.allSettled(held.map(([sym]) => getStockPrice(sym))); - for (let i = 0; i < held.length; i++) { - const [sym, qty] = held[i]; - const settled = prices[i]; - const price = settled.status === "fulfilled" ? settled.value : null; - if (price == null) { - lines.push(` ${sym} x${formatStock(qty)} (no price)`); - continue; - } - const val = qty * price; - totalValue += val; - lines.push(` ${sym} x${formatStock(qty)} @ ${formatVND(price)} = ${formatVND(val)}`); - } - } - - lines.push(`\nTotal value: ${formatVND(totalValue)}`); - lines.push(`Invested: ${formatVND(p.meta.invested)}`); - lines.push(`P&L: ${formatPnL(totalValue, p.meta.invested)}`); - await ctx.reply(lines.join("\n")); -} diff --git a/src/modules/trading/symbols.js b/src/modules/trading/symbols.js deleted file mode 100644 index 4f32702..0000000 --- a/src/modules/trading/symbols.js +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @file Symbol resolution — dynamically resolves stock tickers via KBS. - * Resolved symbols are cached in KV permanently to avoid repeated lookups. - * Currently only supports VN stocks. Crypto, gold, forex coming later. - */ - -import { fetchStockPrice } from "./prices.js"; - -const COMING_SOON = "Crypto, gold & currency exchange coming soon!"; - -/** - * @typedef {object} ResolvedSymbol - * @property {string} symbol — uppercase ticker - * @property {string} category — "stock" (only supported category for now) - * @property {string} label — company name - */ - -/** - * Resolve a ticker to a symbol entry. Checks KV cache first, then queries KBS. - * Validation reuses the KBS price endpoint — if it returns a bar, the ticker is real. - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {string} ticker — user input, case-insensitive - * @returns {Promise} null if KBS has no data for this ticker - */ -export async function resolveSymbol(db, ticker) { - if (!ticker) return null; - const symbol = ticker.toUpperCase(); - const cacheKey = `sym:${symbol}`; - - const cached = await db.getJSON(cacheKey); - if (cached) return cached; - - const price = await fetchStockPrice(symbol); - if (price == null) return null; - - const entry = { symbol, category: "stock", label: symbol }; - // cache permanently — stock tickers don't change - await db.putJSON(cacheKey, entry); - return entry; -} - -/** - * Error message for unsupported asset types. - * @returns {string} - */ -export function comingSoonMessage() { - return COMING_SOON; -} diff --git a/src/modules/twentyq/README.md b/src/modules/twentyq/README.md deleted file mode 100644 index 5f1d64a..0000000 --- a/src/modules/twentyq/README.md +++ /dev/null @@ -1,106 +0,0 @@ -# Twentyq Module - -A reverse-Akinator yes/no guessing game. The bot picks a secret keyword from -a flat seed list. At round-start, a Workers AI LLM -(`@cf/google/gemma-4-26b-a4b-it`) generates a category + cryptic opening -hint. Each turn the same model judges the user's input and returns -`{ is_guess, answer, hint }` as one-line JSON (parsed out of response text — -no function calling, no tools array). Round ends on a correct guess or -`/twentyq_giveup`. - -**Visibility: `public`** — commands appear in both `/help` and Telegram's -native `/` autocomplete menu. - -## Commands - -| Command | Visibility | Description | -|---------|-----------|-------------| -| `/twentyq` | public | Show current board, or auto-start a round if none | -| `/twentyq ` | public | Submit a yes/no question OR a final guess (`is it ...?`) | -| `/twentyq_giveup` | public | Reveal the answer and end the round (next `/twentyq` starts fresh) | -| `/twentyq_stats` | public | Show per-subject stats | - -## Example flow - -``` -/twentyq -🎯 I'm thinking of an instrument. -Hint: it uses wind through pipes to create sound. - -/twentyq does it require hands to play? -✅ Yes. Hint: most players use both hands at once. - -/twentyq is it made of wood? -❌ No. Hint: its body is mostly metal pipes. - -/twentyq is it an organ? -🎉 Correct! It was an organ. Solved in 3 questions. -``` - -## Rules at a glance - -- Yes/no questions only — open-ended forms (`what`, `how`, `why`, ...) are - rejected client-side and don't count toward the turn tally. -- Repeat questions are deduped — same exact text replies `🔁 already asked` - and skips the AI call. -- Unlimited turns. End with a correct guess or `/twentyq_giveup`. - -## Data source - -- **Secret pool:** `seeds.js` — flat array of keyword strings (~50+ nouns). - No hand-curated categories or hints — drop in any noun, the LLM figures out - the right category and produces a cryptic opening clue at round-start. -- **Round-start + per-turn responses:** Workers AI binding `env.AI` calling - `@cf/google/gemma-4-26b-a4b-it`. Prompts instruct the model to emit one-line - JSON that `parseJudgementJson` extracts even if wrapped in prose/fences. - - Round start: `generateRoundStart(env, target)` → `{category, initialHint}` - - Each turn: `judge(env, state, userInput)` → `{is_guess, answer, hint}` - -The Gemma 4 26B A4B model runs on the Workers Free plan (10k Neurons/day). -Pricing: $0.10/M input + $0.30/M output. Each round is one start call (~1 -AI request) + one request per turn, well under the cap for normal play volume. - -## Architecture - -- `seeds.js` — flat `SEEDS` string array of target keywords + `getRandomSeed(rng)`. -- `state.js` — KV persistence for game + stats. Subject = user id (DM) or - chat id (group). 7-day TTL on the active round. -- `prompts.js` — `buildStartRoundPrompt(target)` (opens a round) and - `buildSystemPrompt(state)` (per-turn judging). Both include HINT STYLE - rules to keep hints cryptic. -- `validate-input.js` — pre-AI regex check; rejects open-ended starters, - empty/oversized input. Saves Neurons. -- `ai-client.js` — wraps `env.AI.run`. `generateRoundStart(env, target)` - produces `{category, initialHint}`; `judge(env, state, userInput)` produces - `{is_guess, answer, hint}`. Both parse one-line JSON from response text, - redact the secret word from any generated hint, and throw `UpstreamError` - on upstream failure. -- `render.js` — five Telegram-HTML formatters; all user-derived text - HTML-escaped. -- `handlers.js` — three command entry points + subject resolver + repeat - dedup + round lifecycle. - -## Storage - -KV namespace prefix: `twentyq:` - -| Key | Value | -|-----|-------| -| `game:` | `{ category, target, initialHint, startedAt, solved, turns[] }` (TTL 7 days) | -| `stats:` | `{ played, solved, totalTurns, bestTurnCount, lastResultAt }` | - -Each `turns[]` entry is `{ text, isGuess, answer, hint, ts }`. - -## Config - -| Env var | Default | Purpose | -|---------|---------|---------| -| `MODULES` | (must include `twentyq`) | Activate the module at deploy time. | - -No additional env vars. The module reads `env.AI` (Workers AI binding, -declared in `wrangler.toml [ai]`). - -## Credits - -- Game concept: classic *20 Questions* / Akinator-reversed. -- LLM judging: Cloudflare Workers AI `@cf/google/gemma-4-26b-a4b-it`. diff --git a/src/modules/twentyq/ai-client.js b/src/modules/twentyq/ai-client.js deleted file mode 100644 index 5627c42..0000000 --- a/src/modules/twentyq/ai-client.js +++ /dev/null @@ -1,234 +0,0 @@ -/** - * @file Workers AI client — wraps env.AI.run for the twentyq judge. - * - * Plain JSON-in-content approach: the system prompt instructs the model to - * emit one-line JSON, we grep it out of the response. No function calling, - * no tools array — maximum compatibility across Workers AI models. - * - * On any AI failure or unparseable output, throws UpstreamError / returns - * defensive defaults so handlers can show a friendly retry message. - * - * Rich logging on failure paths so wrangler tail surfaces the actual cause. - */ - -import { buildStartRoundPrompt, buildSystemPrompt } from "./prompts.js"; - -export const MODEL_ID = "@cf/google/gemma-4-26b-a4b-it"; - -const DEFAULT_FALLBACK = { - is_guess: false, - answer: "no", - hint: "I couldn't fully parse that — try a clear yes/no question.", -}; - -export class UpstreamError extends Error { - /** @param {string} message @param {{ cause?: unknown, status?: number }} [opts] */ - constructor(message, { cause, status } = {}) { - super(message); - this.name = "UpstreamError"; - this.cause = cause; - this.status = status; - } -} - -/** - * Pull the response text out of whatever shape env.AI.run returned. - * Handles { response: "..." } (traditional Workers AI), OpenAI-compatible - * { choices: [{ message: { content } }] }, and plain strings. - * - * @param {any} response - * @returns {string} - */ -export function extractText(response) { - if (typeof response === "string") return response; - if (!response || typeof response !== "object") return ""; - if (typeof response.response === "string") return response.response; - const choice = response.choices?.[0]; - const content = choice?.message?.content; - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content.map((part) => (typeof part === "string" ? part : (part?.text ?? ""))).join(""); - } - return ""; -} - -/** - * Extract the first `{...}` JSON object from a text blob. Tolerates - * leading/trailing prose, backtick fences, explanations — even though the - * system prompt forbids them. - * - * @param {string} text - * @returns {object | null} - */ -export function parseJudgementJson(text) { - if (!text || typeof text !== "string") return null; - // Strip code fences if the model disobeyed and wrapped the JSON. - const unfenced = text.replace(/```(?:json)?\s*/gi, "").replace(/```/g, ""); - const start = unfenced.indexOf("{"); - if (start === -1) return null; - // Walk brace depth to find the matching close. - let depth = 0; - let inString = false; - let isEscaped = false; - for (let i = start; i < unfenced.length; i++) { - const ch = unfenced[i]; - if (inString) { - if (isEscaped) isEscaped = false; - else if (ch === "\\") isEscaped = true; - else if (ch === '"') inString = false; - continue; - } - if (ch === '"') inString = true; - else if (ch === "{") depth++; - else if (ch === "}") { - depth--; - if (depth === 0) { - const slice = unfenced.slice(start, i + 1); - try { - return JSON.parse(slice); - } catch { - return null; - } - } - } - } - return null; -} - -/** - * Coerce any parsed payload into the canonical shape with defaults. - * - * @param {any} payload - * @returns {{ is_guess: boolean, answer: "yes"|"no", hint: string }} - */ -export function normalizeJudgement(payload) { - if (!payload || typeof payload !== "object") return { ...DEFAULT_FALLBACK }; - const is_guess = payload.is_guess === true; - const answerLower = String(payload.answer ?? "").toLowerCase(); - const answer = answerLower === "yes" ? "yes" : "no"; - const hint = - typeof payload.hint === "string" && payload.hint.trim().length > 0 - ? payload.hint.trim() - : DEFAULT_FALLBACK.hint; - return { is_guess, answer, hint }; -} - -/** - * Strip any case-insensitive whole-word occurrence of the secret from the - * hint. Defense-in-depth — the system prompt forbids it but we don't trust - * the model. - * - * @param {string} hint - * @param {string} target - * @returns {string} - */ -export function redactSecret(hint, target) { - if (!target) return hint; - const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const re = new RegExp(`\\b${escaped}\\b`, "ig"); - return hint.replace(re, "(redacted)"); -} - -/** - * Judge a single user turn. Throws UpstreamError on AI failure. - * - * @param {{ AI: { run: (model: string, body: object) => Promise } }} env - * @param {import("./state.js").TwentyqGameState} state - * @param {string} userInput — validated/normalized - * @returns {Promise<{ is_guess: boolean, answer: "yes"|"no", hint: string }>} - */ -export async function judge(env, state, userInput) { - if (!env?.AI?.run) { - throw new UpstreamError("Workers AI binding not available"); - } - const messages = [ - { role: "system", content: buildSystemPrompt(state) }, - { role: "user", content: userInput }, - ]; - let response; - try { - response = await env.AI.run(MODEL_ID, { messages }); - } catch (err) { - console.log( - JSON.stringify({ - msg: "twentyq_ai_throw", - model: MODEL_ID, - err: err instanceof Error ? { name: err.name, message: err.message } : String(err), - }), - ); - throw new UpstreamError("env.AI.run threw", { cause: err }); - } - - const text = extractText(response); - const payload = parseJudgementJson(text); - if (!payload) { - console.log( - JSON.stringify({ - msg: "twentyq_ai_unparseable", - model: MODEL_ID, - stage: "judge", - preview: text.slice(0, 200), - }), - ); - } - const judgement = normalizeJudgement(payload); - judgement.hint = redactSecret(judgement.hint, state.target); - return judgement; -} - -/** - * Generate { category, initialHint } for a fresh round's target keyword. - * Uses the same JSON-in-content approach as judge(). On parse failure, - * returns a safe generic fallback so the round can still start. - * - * @param {{ AI: { run: (model: string, body: object) => Promise } }} env - * @param {string} target - * @returns {Promise<{ category: string, initialHint: string }>} - */ -export async function generateRoundStart(env, target) { - if (!env?.AI?.run) { - throw new UpstreamError("Workers AI binding not available"); - } - const messages = [ - { role: "system", content: buildStartRoundPrompt(target) }, - { role: "user", content: "begin" }, - ]; - let response; - try { - response = await env.AI.run(MODEL_ID, { messages }); - } catch (err) { - console.log( - JSON.stringify({ - msg: "twentyq_ai_throw", - model: MODEL_ID, - stage: "roundstart", - err: err instanceof Error ? { name: err.name, message: err.message } : String(err), - }), - ); - throw new UpstreamError("env.AI.run threw (roundstart)", { cause: err }); - } - - const text = extractText(response); - const payload = parseJudgementJson(text); - if (!payload || typeof payload.category !== "string" || typeof payload.initialHint !== "string") { - console.log( - JSON.stringify({ - msg: "twentyq_ai_unparseable", - model: MODEL_ID, - stage: "roundstart", - preview: text.slice(0, 200), - }), - ); - return { - category: "object", - initialHint: "it is something you might encounter in everyday life", - }; - } - const category = payload.category.trim() || "object"; - const rawHint = - payload.initialHint.trim() || "it is something you might encounter in everyday life"; - return { - category, - initialHint: redactSecret(rawHint, target), - }; -} diff --git a/src/modules/twentyq/handlers.js b/src/modules/twentyq/handlers.js deleted file mode 100644 index 1f7fd71..0000000 --- a/src/modules/twentyq/handlers.js +++ /dev/null @@ -1,151 +0,0 @@ -/** - * @file Command handlers for the twentyq module. - * - * Subject resolution mirrors loldle/doantu: - * private chat → user id (per-user game) - * group/supergroup chat → chat id (shared game) - * - * Commands: - * /twentyq → show board (or auto-start a fresh round) - * /twentyq → ask a yes/no question OR submit a guess - * /twentyq_giveup → reveal target + end round - * /twentyq_stats → show per-subject stats - */ - -import { UpstreamError, generateRoundStart, judge } from "./ai-client.js"; -import { formatBoard, formatGiveup, formatIntro, formatStats, formatTurnReply } from "./render.js"; -import { getRandomSeed } from "./seeds.js"; -import { clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js"; -import { validateQuestion } from "./validate-input.js"; - -const UPSTREAM_FAIL = "⚠️ AI service hiccup — try again in a few seconds."; -const NO_ROUND = "No active round. Send /twentyq to start one."; - -function getSubject(ctx) { - const type = ctx.chat?.type; - if (type === "group" || type === "supergroup") return ctx.chat.id; - return ctx.from?.id ?? null; -} - -function argAfterCommand(text) { - if (!text) return ""; - const idx = text.indexOf(" "); - return idx === -1 ? "" : text.slice(idx + 1).trim(); -} - -async function startFreshGame(env) { - const target = getRandomSeed(); - const { category, initialHint } = await generateRoundStart(env, target); - return { - category, - target, - initialHint, - startedAt: Date.now(), - solved: false, - turns: [], - }; -} - -function logFail(stage, err) { - console.log( - JSON.stringify({ - msg: "twentyq_upstream_fail", - stage, - err: - err instanceof UpstreamError - ? { name: err.name, status: err.status, cause: String(err.cause) } - : String(err), - }), - ); -} - -export async function handleTwentyq(ctx, { db, env }) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const arg = argAfterCommand(ctx.message?.text ?? ""); - - let game = await loadGame(db, subject); - // Solved games linger until next /twentyq → start a fresh round transparently. - if (game?.solved) { - await clearGame(db, subject); - game = null; - } - - if (!game) { - try { - game = await startFreshGame(env); - } catch (err) { - logFail("roundstart", err); - return ctx.reply(UPSTREAM_FAIL); - } - await saveGame(db, subject, game); - if (!arg) return ctx.reply(formatIntro(game), { parse_mode: "HTML" }); - // Fresh round + immediate question — show intro then process turn. - await ctx.reply(formatIntro(game), { parse_mode: "HTML" }); - return submitTurn(ctx, { db, env }, subject, game, arg); - } - - if (!arg) return ctx.reply(formatBoard(game), { parse_mode: "HTML" }); - return submitTurn(ctx, { db, env }, subject, game, arg); -} - -async function submitTurn(ctx, { db, env }, subject, game, raw) { - const v = validateQuestion(raw); - if (!v.ok) return ctx.reply(v.reason, { parse_mode: "HTML" }); - - const lower = v.normalized.toLowerCase(); - if (game.turns.some((t) => t.text.toLowerCase() === lower)) { - return ctx.reply("🔁 You already asked that exact question — try a new angle."); - } - - let result; - try { - result = await judge(env, game, v.normalized); - } catch (err) { - logFail("judge", err); - return ctx.reply(UPSTREAM_FAIL); - } - - const turn = { - text: v.normalized, - isGuess: result.is_guess, - answer: result.answer, - hint: result.hint, - ts: Date.now(), - }; - game.turns.push(turn); - - const won = turn.isGuess && turn.answer === "yes"; - if (won) { - game.solved = true; - const turnCount = game.turns.length; - await recordResult(db, subject, { solved: true, turnCount }); - await clearGame(db, subject); - return ctx.reply(formatTurnReply({ turn, solved: true, target: game.target, turnCount }), { - parse_mode: "HTML", - }); - } - - await saveGame(db, subject, game); - return ctx.reply( - formatTurnReply({ turn, solved: false, target: game.target, turnCount: game.turns.length }), - { parse_mode: "HTML" }, - ); -} - -export async function handleGiveup(ctx, { db }) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const game = await loadGame(db, subject); - if (!game) return ctx.reply(NO_ROUND, { parse_mode: "HTML" }); - await recordResult(db, subject, { solved: false, turnCount: game.turns.length }); - await clearGame(db, subject); - return ctx.reply(formatGiveup(game), { parse_mode: "HTML" }); -} - -export async function handleStats(ctx, { db }) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const stats = await loadStats(db, subject); - return ctx.reply(formatStats(stats), { parse_mode: "HTML" }); -} diff --git a/src/modules/twentyq/index.js b/src/modules/twentyq/index.js deleted file mode 100644 index c56645b..0000000 --- a/src/modules/twentyq/index.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @file Twentyq module — reverse-Akinator yes/no guessing game. - * - * Bot picks a secret keyword from ./seeds.js. Workers AI - * (@cf/google/gemma-4-26b-a4b-it) generates {category, initialHint} at - * round start and emits a one-line JSON {is_guess, answer, hint} per turn. - * Round ends on a correct guess or /twentyq_giveup. Unlimited turns. - * Per-subject state in KV (user id in DMs, chat id in groups). - * - * `init` captures both the prefixed KV store AND the raw env so handlers can - * reach env.AI per request without changing the dispatcher contract. - */ - -import { handleGiveup, handleStats, handleTwentyq } from "./handlers.js"; - -/** @type {import("../../db/kv-store-interface.js").KVStore | null} */ -let db = null; -/** @type {any} */ -let aiEnv = null; - -/** @type {import("../registry.js").BotModule} */ -const twentyqModule = { - name: "twentyq", - init: async ({ db: store, env }) => { - db = store; - aiEnv = env; - }, - commands: [ - { - name: "twentyq", - visibility: "public", - description: "20 questions — bot picks an object, you ask yes/no questions", - handler: (ctx) => handleTwentyq(ctx, { db, env: aiEnv }), - }, - { - name: "twentyq_giveup", - visibility: "public", - description: "Reveal the current twentyq answer (auto-starts a fresh round)", - handler: (ctx) => handleGiveup(ctx, { db }), - }, - { - name: "twentyq_stats", - visibility: "public", - description: "Show your twentyq stats (played, solved, best round)", - handler: (ctx) => handleStats(ctx, { db }), - }, - ], -}; - -export default twentyqModule; diff --git a/src/modules/twentyq/prompts.js b/src/modules/twentyq/prompts.js deleted file mode 100644 index 8e2432b..0000000 --- a/src/modules/twentyq/prompts.js +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @file System prompt + JSON response schema for the twentyq judge. - * - * We ask the model to emit a SINGLE-LINE JSON object with the keys - * {is_guess, answer, hint} — no function calling, no tools array. - * Function calling shape differs between models and Workers AI models - * sometimes reject unknown params; plain JSON instruction is universal - * and battle-tested. ai-client.parseJudgementJson does the parse. - */ - -/** @typedef {import("./state.js").TwentyqGameState} TwentyqGameState */ - -const HISTORY_WINDOW = 5; - -/** - * Build the system prompt. Includes the secret + last N turns so the model - * stays consistent with its prior answers and varies the hint. - * - * @param {TwentyqGameState} state - * @returns {string} - */ -export function buildSystemPrompt(state) { - const recent = state.turns.slice(-HISTORY_WINDOW); - const historyText = recent.length - ? recent.map((t, i) => `${i + 1}. Q: ${t.text}\n A: ${t.answer}. Hint: ${t.hint}`).join("\n") - : "(no questions yet)"; - - return `You are the judge for a "20 questions" reverse-Akinator game. -The user is trying to guess a secret object. You must answer truthfully based on what the secret actually is. - -Secret object: "${state.target}" -Category: ${state.category} -Initial hint already given: ${state.initialHint} - -Question history so far: -${historyText} - -The user will send a single message — either a yes/no question (e.g. "is it big?", "does it have wheels?") or a final guess of a specific noun (e.g. "is it an organ?", "is it a piano?"). - -You MUST reply with exactly ONE line of JSON and NOTHING else — no prose, no backticks, no code fences, no explanation. - -Schema: -{"is_guess": boolean, "answer": "yes" | "no", "hint": string} - -Field meanings: -- is_guess: true ONLY when the user is naming a specific concrete object equal to, a synonym of, or extremely close to the secret. Vague descriptors ("is it big?", "is it round?") are NOT guesses. Saying "is it a string instrument?" when the secret is "guitar" is NOT a guess (too broad). Saying "is it a guitar?" IS a guess. -- answer: truthful "yes" or "no" about the secret. - * If is_guess is true: "yes" only if the named object matches the secret (allowing for synonyms / minor wording). Otherwise "no". - * If is_guess is false: "yes" or "no" based on whether the property holds for the secret. -- hint: a cryptic clue in plain text, max 120 characters. Must be TRUE about the secret but phrased indirectly. Vary from prior hints. Never include the secret word, its plural, its base form, or any obvious category word. - -HINT STYLE — the point of a good hint: -- Be INDIRECT. Think riddle, metaphor, oblique association — not a definition. -- Use partial, lateral, or sideways facts. Hint at ONE small property at a time. -- Prefer "it is often found near X", "people tend to associate it with Y", "a famous one lives in Z" over "it is used for X". -- Avoid giving a second clear category word. If the user has narrowed it down with questions, DO NOT hand them the final word. -- Aim for: player thinks "interesting, but I still need another question." NOT: "oh it's obviously X." - -Rules: -- Output ONLY the JSON line. No markdown fences. No prose before or after. -- If the user input is not a valid yes/no question and not a guess, still return JSON with answer="no", is_guess=false, and a cryptic hint nudging them to rephrase as yes/no. - -Example outputs (secret = "organ", category = "instrument"): -GOOD: {"is_guess": false, "answer": "yes", "hint": "it was once the largest mechanical object humans built"} -GOOD: {"is_guess": false, "answer": "no", "hint": "in its natural habitat you might hear echoes of stone"} -BAD (too direct): {"is_guess": false, "answer": "yes", "hint": "it uses pipes to produce sound"} -BAD (names category synonym): {"is_guess": false, "answer": "yes", "hint": "it is a keyboard instrument"}`; -} - -/** - * Build the round-start prompt. Given just a target keyword, we ask the - * model to emit { category, initialHint } so seeds don't need hand-curated - * metadata — drop in any noun, the model fills in the rest. - * - * @param {string} target - * @returns {string} - */ -export function buildStartRoundPrompt(target) { - return `You are opening a new round of the "20 questions" reverse-Akinator game. - -Secret object: "${target}" - -Your job: emit ONE line of JSON with these two fields: -{"category": "", "initialHint": ""} - -Category rules: -- A SHORT, COMMON category word a player can start narrowing from (e.g. "instrument", "animal", "food", "vehicle", "sport", "household item", "tool", "clothing", "plant"). Prefer single words. -- Broad enough that many objects fall under it — NOT a narrow sub-category (don't say "brass instrument", say "instrument"). -- Do not include the secret word in the category. - -Initial hint rules (same HINT STYLE as the main game): -- Max 120 characters. TRUE about the secret. Indirect, oblique, riddle-like. -- Never include the secret word, its plural, its base form, or an obvious category synonym. -- Hint at ONE lateral property — a cultural association, a habitat, a use context, a historical fact. -- Player should think "ok that narrows it a bit" NOT "oh that's obviously X". - -Examples (secret → output): -secret="organ" → {"category": "instrument", "initialHint": "it was once the largest mechanical object humans built"} -secret="elephant" → {"category": "animal", "initialHint": "the ancient Greeks believed it was terrified of mice"} -secret="pizza" → {"category": "food", "initialHint": "a popular version bears the colors of a national flag"} -secret="submarine" → {"category": "vehicle", "initialHint": "the first successful one was made of wood and powered by hand"} - -BAD examples (do NOT emit these): -secret="organ" → {"category": "keyboard instrument", "initialHint": "it has pipes"} // category too specific + hint too direct -secret="elephant" → {"category": "animal", "initialHint": "it has a long trunk"} // hint too direct - -Output ONLY the JSON line. No fences, no prose.`; -} diff --git a/src/modules/twentyq/render.js b/src/modules/twentyq/render.js deleted file mode 100644 index bbd4a22..0000000 --- a/src/modules/twentyq/render.js +++ /dev/null @@ -1,97 +0,0 @@ -/** - * @file Telegram-HTML renderers for the twentyq game. - * All target/hint/text values are HTML-escaped before splicing. - */ - -import { escapeHtml } from "../../util/escape-html.js"; - -const MAX_TURN_ROWS = 10; -const ANSWER_EMOJI = { yes: "✅", no: "❌" }; - -/** @typedef {import("./state.js").TwentyqGameState} TwentyqGameState */ -/** @typedef {import("./state.js").TwentyqStats} TwentyqStats */ -/** @typedef {import("./state.js").TwentyqTurn} TwentyqTurn */ - -/** - * Initial game-start message: category + initial hint. - * @param {TwentyqGameState} state - */ -export function formatIntro(state) { - return [ - `🎯 I'm thinking of a ${escapeHtml(state.category)}.`, - `Hint: ${escapeHtml(state.initialHint)}`, - "", - "Ask yes/no questions with /twentyq is it ...?", - ].join("\n"); -} - -/** - * Reply for one answered turn. - * @param {{ turn: TwentyqTurn, solved: boolean, target: string, turnCount: number }} args - */ -export function formatTurnReply({ turn, solved, target, turnCount }) { - if (solved) { - return [ - `🎉 Correct! It was ${escapeHtml(target)}.`, - `Solved in ${turnCount} question${turnCount === 1 ? "" : "s"}.`, - ].join("\n"); - } - const emoji = ANSWER_EMOJI[turn.answer] ?? "❓"; - const head = turn.isGuess - ? `${emoji} Not quite. Hint: ${escapeHtml(turn.hint)}` - : `${emoji} ${turn.answer === "yes" ? "Yes" : "No"}. Hint: ${escapeHtml(turn.hint)}`; - return head; -} - -/** - * Board view: initial hint + numbered Q/A list. - * @param {TwentyqGameState} state - */ -export function formatBoard(state) { - const header = `🎯 Category: ${escapeHtml(state.category)}`; - const intro = `Initial hint: ${escapeHtml(state.initialHint)}`; - if (state.turns.length === 0) { - return [header, intro, "", "No questions yet — go ahead and ask one."].join("\n"); - } - const recent = state.turns.slice(-MAX_TURN_ROWS); - const startNo = state.turns.length - recent.length + 1; - const lines = recent.map((t, i) => { - const num = String(startNo + i).padStart(2); - const ans = ANSWER_EMOJI[t.answer] ?? "❓"; - const q = escapeHtml(t.text); - const h = escapeHtml(t.hint); - return `${num}. ${ans} ${q}\n ${h}`; - }); - const hidden = state.turns.length - recent.length; - const footer = hidden > 0 ? `\n…${hidden} earlier turn${hidden === 1 ? "" : "s"} hidden.` : ""; - return [header, intro, "", lines.join("\n")].join("\n") + footer; -} - -/** - * Reveal-on-giveup message. - * @param {TwentyqGameState} state - */ -export function formatGiveup(state) { - return [ - `🏳️ Gave up. The answer was ${escapeHtml(state.target)}.`, - "Send /twentyq to start a fresh round.", - ].join("\n"); -} - -/** - * Stats summary. - * @param {TwentyqStats} stats - */ -export function formatStats(stats) { - if (stats.played === 0) return "No twentyq games played yet."; - const solveRate = Math.round((stats.solved / stats.played) * 100); - const avg = stats.played > 0 ? Math.round(stats.totalTurns / stats.played) : "—"; - return [ - "🎯 Twentyq stats", - `Played: ${stats.played}`, - `Solved: ${stats.solved} (${solveRate}%)`, - `Total questions: ${stats.totalTurns}`, - `Fewest to solve: ${stats.bestTurnCount ?? "—"}`, - `Avg per round: ${avg}`, - ].join("\n"); -} diff --git a/src/modules/twentyq/seeds.js b/src/modules/twentyq/seeds.js deleted file mode 100644 index 0185d10..0000000 --- a/src/modules/twentyq/seeds.js +++ /dev/null @@ -1,81 +0,0 @@ -/** - * @file Seed keywords for the twentyq game. - * - * Just the target word. Category + initial hint are generated by the LLM at - * round start (see ai-client.generateRoundStart). Add any noun here — the - * model figures out the right category and produces a cryptic opening clue. - */ - -/** @type {string[]} */ -export const SEEDS = [ - // instruments - "guitar", - "piano", - "drum", - "violin", - "flute", - "trumpet", - "organ", - "harmonica", - // animals - "elephant", - "dolphin", - "eagle", - "kangaroo", - "octopus", - "penguin", - "tiger", - "horse", - "snake", - "owl", - // food - "pizza", - "sushi", - "burger", - "ramen", - "taco", - "pho", - "curry", - "salad", - "chocolate", - "cheese", - // vehicles - "bicycle", - "car", - "airplane", - "boat", - "train", - "motorcycle", - "helicopter", - "submarine", - // sports - "soccer", - "basketball", - "tennis", - "swimming", - "boxing", - "golf", - "chess", - "skiing", - // household items - "refrigerator", - "microwave", - "vacuum", - "toaster", - "kettle", - "blender", - "lamp", - "sofa", - "mirror", - "broom", -]; - -/** - * Pick one target at random. - * @param {() => number} [rng] — defaults to Math.random; tests inject deterministic rng. - * @returns {string} - */ -export function getRandomSeed(rng = Math.random) { - const i = Math.floor(rng() * SEEDS.length); - return SEEDS[Math.min(i, SEEDS.length - 1)]; -} diff --git a/src/modules/twentyq/state.js b/src/modules/twentyq/state.js deleted file mode 100644 index 5ef8a75..0000000 --- a/src/modules/twentyq/state.js +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @file Game + stats persistence in KV, keyed by "subject" - * (user id in DMs, chat id in groups). Mirrors doantu/state.js shape; - * `turns[]` instead of `guesses[]` because each entry holds Q + A + hint. - * - * Key layout (inside the module-prefixed store): - * game: -> { category, target, initialHint, startedAt, solved, turns[] } - * stats: -> { played, solved, totalTurns, bestTurnCount, lastResultAt } - */ - -const GAME_TTL_SECONDS = 60 * 60 * 24 * 7; - -const gameKey = (subject) => `game:${subject}`; -const statsKey = (subject) => `stats:${subject}`; - -/** - * @typedef {object} TwentyqTurn - * @property {string} text — raw user input (trimmed) - * @property {boolean} isGuess — model classified as a final-guess attempt - * @property {"yes"|"no"} answer - * @property {string} hint - * @property {number} ts — Date.now() when recorded - */ - -/** - * @typedef {object} TwentyqGameState - * @property {string} category - * @property {string} target — lowercased - * @property {string} initialHint - * @property {number|null} startedAt - * @property {boolean} solved - * @property {TwentyqTurn[]} turns - */ - -/** - * @typedef {object} TwentyqStats - * @property {number} played - * @property {number} solved - * @property {number} totalTurns - * @property {number|null} bestTurnCount - * @property {number|null} lastResultAt - */ - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - * @returns {Promise} - */ -export async function loadGame(db, subject) { - return db.getJSON(gameKey(subject)); -} - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - * @param {TwentyqGameState} state - */ -export async function saveGame(db, subject, state) { - await db.putJSON(gameKey(subject), state, { expirationTtl: GAME_TTL_SECONDS }); -} - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - */ -export async function clearGame(db, subject) { - await db.delete(gameKey(subject)); -} - -/** - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - * @returns {Promise} - */ -export async function loadStats(db, subject) { - return ( - (await db.getJSON(statsKey(subject))) ?? { - played: 0, - solved: 0, - totalTurns: 0, - bestTurnCount: null, - lastResultAt: null, - } - ); -} - -/** - * Record a finished round. `turnCount` counts scored Q&A turns only — - * validator-rejected inputs and repeat-question dedups never reach state. - * - * @param {import("../../db/kv-store-interface.js").KVStore} db - * @param {number|string} subject - * @param {{ solved: boolean, turnCount: number }} outcome - */ -export async function recordResult(db, subject, { solved, turnCount }) { - const s = await loadStats(db, subject); - s.played += 1; - s.totalTurns += turnCount; - if (solved) { - s.solved += 1; - if (s.bestTurnCount === null || turnCount < s.bestTurnCount) { - s.bestTurnCount = turnCount; - } - } - s.lastResultAt = Date.now(); - await db.putJSON(statsKey(subject), s); - return s; -} diff --git a/src/modules/twentyq/validate-input.js b/src/modules/twentyq/validate-input.js deleted file mode 100644 index f20c589..0000000 --- a/src/modules/twentyq/validate-input.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @file Pre-AI input validator. Reject open-ended questions client-side - * to save Workers AI Neurons. Accept only yes/no question shapes. - * - * Returns { ok: true, normalized } or { ok: false, reason }. - * `normalized` strips extra whitespace + collapses internal runs to single - * spaces. Original casing preserved (model handles capitalization fine). - */ - -const MIN_LEN = 3; -const MAX_LEN = 200; - -const OPEN_ENDED = /^\s*(what|how|why|which|who|where|when|tell me|describe|explain)\b/i; - -/** - * @typedef {{ ok: true, normalized: string } | { ok: false, reason: string }} ValidateResult - */ - -/** - * @param {string} raw - * @returns {ValidateResult} - */ -export function validateQuestion(raw) { - if (typeof raw !== "string") { - return { ok: false, reason: "Please send a yes/no question after the command." }; - } - const collapsed = raw.replace(/\s+/g, " ").trim(); - if (collapsed.length < MIN_LEN) { - return { - ok: false, - reason: "Question too short — try something like is it big?.", - }; - } - if (collapsed.length > MAX_LEN) { - return { ok: false, reason: `Question too long — keep it under ${MAX_LEN} characters.` }; - } - if (OPEN_ENDED.test(collapsed)) { - return { - ok: false, - reason: "Yes/no questions only — try is it ...? or does it ...?.", - }; - } - return { ok: true, normalized: collapsed }; -} diff --git a/src/modules/util/README.md b/src/modules/util/README.md deleted file mode 100644 index 0ea13f9..0000000 --- a/src/modules/util/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Util Module - -Core bot utilities — informational commands that read framework metadata. - -## Commands - -| Command | Visibility | Description | -|---------|-----------|-------------| -| `/info` | public | Echoes chat id, thread id, sender id (debug helper) | -| `/help` | public | Renders all public + protected commands grouped by module | -| `/stickerid` | private | Reply to a sticker to get its bot-scoped `file_id` (used to collect sticker pools for other modules) | - -## Architecture - -- `/help` is a pure renderer over `getCurrentRegistry()` — it reads the registry's command maps and formats them as Telegram HTML. Modules with zero visible commands are omitted. Private commands are always excluded. -- `/info` reads grammY context fields (`ctx.chat.id`, `ctx.message.message_thread_id`, `ctx.from.id`). No external state. -- `/stickerid` reads `ctx.message.reply_to_message.sticker` and echoes `file_id` + `file_unique_id`. Private visibility keeps it out of `/help` and the Telegram menu. Used to collect sticker IDs for hard-coded pools in other modules (e.g., loldle win/lose/giveup stickers). -- HTML injection is prevented via `escapeHtml()` on all user-influenced strings. - -## Database - -**No KV usage.** This module has no `init()` hook and does not store any data. diff --git a/src/modules/util/help-command.js b/src/modules/util/help-command.js deleted file mode 100644 index 36b0495..0000000 --- a/src/modules/util/help-command.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @file /help — renders public + protected commands grouped by module. - * - * /help is a pure renderer over the registry; it holds no command metadata of - * its own. Modules with zero visible (public or protected) commands are - * omitted entirely. Private commands are always skipped — that's the point. - * - * Output uses Telegram HTML parse mode. Every user-influenced substring - * (module names, command descriptions) is HTML-escaped so a rogue `