mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-18 04:19:54 +00:00
feat(loldle): add emoji and quote champion-guessing modules
Ship two new loldle-family modules mirroring loldle.net's non-classic modes. Text-only MVP (ability/splash phases stay deferred). - loldle-emoji: 5 guesses, emoji-sequence clue. Pool derived algorithmically from classic's champions.json metadata (species/region/resource mapping table) since loldle.net's bundle has no static emoji pool. - loldle-quote: 6 guesses, lore-blurb clue. Pool seeded from Data Dragon champion title + first lore sentence; champion name redacted to ___. - scripts/fetch-ddragon-data.js: single generator for both JSONs. - src/util/normalize-name.js: shared lookup helper; loldle/lookup.js refactored to import it. 35 new tests (484 total passing). Lint clean.
This commit is contained in:
@@ -66,6 +66,8 @@ src/
|
||||
│ │ └── 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)
|
||||
│ └── misc/ # stub (KV storage)
|
||||
└── util/
|
||||
└── escape-html.js
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# Phase 01 — Shared scrape + lookup helpers
|
||||
|
||||
## Context
|
||||
|
||||
- [Research: overview + emoji](../reports/researcher-260424-2215-loldle-emoji-and-modes-overview.md)
|
||||
- [Research: quote](../reports/researcher-260424-2215-loldle-quote-mode.md)
|
||||
- [Research: ability + splash](../reports/researcher-260424-2215-loldle-ability-splash-modes.md)
|
||||
- Existing: `scripts/scrape-loldle-data.js`, `src/modules/loldle/lookup.js`
|
||||
|
||||
## Overview
|
||||
|
||||
**Priority:** P0 (blocks 02–05).
|
||||
**Status:** pending.
|
||||
|
||||
Lay a minimal shared foundation so the four new modules don't each
|
||||
re-implement champion-name normalization or re-scrape loldle.net five times.
|
||||
|
||||
## Key insights
|
||||
|
||||
- **Bundle check (2026-04-24):** loldle.net's bundle contains classic
|
||||
attributes only — **zero emoji code points, zero per-champion quote
|
||||
strings**. Daily answers are fetched encrypted from
|
||||
`cache.loldle.net/cache.json` and decrypted with AES key `D5XCtTOObw`,
|
||||
but the cache only holds the single daily rotation, not a full
|
||||
champion→emoji / champion→quote pool.
|
||||
- **Pivot (DEVIATION from plan as written):** Emoji sequences are derived
|
||||
**algorithmically** from classic's existing `champions.json` metadata
|
||||
(species/regions/positions/resource) via a small mapping table — no new
|
||||
fetch, no brittle scrape. Quote text uses DDragon's `title` +
|
||||
first-sentence `lore` blurb. Both data sources are stable and official.
|
||||
- Data Dragon is the right source for ability/splash — official, stable, no
|
||||
brittle regex. Scripts hit DDragon once per patch (fortnightly) and cache
|
||||
to JSON. Bot imports JSON directly.
|
||||
- `lookup.js`'s `findChampion` stays coupled to the champion-record shape.
|
||||
Don't hoist it; only hoist the tiny `normalize(s)` helper.
|
||||
|
||||
## Requirements
|
||||
|
||||
**Functional**
|
||||
- Extended scraper emits three JSONs (keeps `champions.json` plus adds
|
||||
`emojis.json`, `quotes.json`).
|
||||
- New DDragon script emits `abilities.json` + `splashes.json`.
|
||||
- Shared `normalize(s)` helper in `src/util/` for case/space/punctuation-
|
||||
insensitive matching across all modes.
|
||||
|
||||
**Non-functional**
|
||||
- Single loldle.net fetch per scrape run (no re-download for each mode).
|
||||
- DDragon fetch uses `GET /api/versions.json` → latest → one
|
||||
`champion.json` per champion OR one aggregated `en_US/champion.json`
|
||||
(list) + per-champion fetches as needed. Prefer aggregated list first,
|
||||
drill into per-champion only for `skins[]`.
|
||||
- Scripts idempotent, safe to re-run, short-circuit on "no change".
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── scrape-loldle-data.js (EXISTING, extended)
|
||||
│ └── writes: src/modules/loldle/champions.json
|
||||
│ + src/modules/loldle-emoji/emojis.json
|
||||
│ + src/modules/loldle-quote/quotes.json
|
||||
└── fetch-ddragon-data.js (NEW)
|
||||
└── writes: src/modules/loldle-ability/abilities.json
|
||||
+ src/modules/loldle-splash/splashes.json
|
||||
|
||||
src/util/
|
||||
└── normalize-name.js (NEW, ~10 LOC)
|
||||
|
||||
src/modules/{loldle-emoji,loldle-quote,loldle-ability,loldle-splash}/
|
||||
└── (created in phases 02–05)
|
||||
```
|
||||
|
||||
## Related code files
|
||||
|
||||
**Modify**
|
||||
- `scripts/scrape-loldle-data.js` — add extraction for emoji + quote fields.
|
||||
Regex must accommodate loldle.net's current bundle shape (inspect before
|
||||
touching; existing regex is the template).
|
||||
- `.github/workflows/scrape-loldle-data.yml` — no change needed; the
|
||||
extended scraper writes more files, the workflow's `git diff` check
|
||||
catches them automatically.
|
||||
|
||||
**Create**
|
||||
- `scripts/fetch-ddragon-data.js` — fetch DDragon, extract ability + skin
|
||||
metadata, write two JSONs.
|
||||
- `src/util/normalize-name.js` — single export `normalize(s)`.
|
||||
- `src/modules/loldle-emoji/` (empty folder, populated in 02).
|
||||
- `src/modules/loldle-quote/` (empty folder, populated in 03).
|
||||
- `src/modules/loldle-ability/` (empty folder, populated in 04).
|
||||
- `src/modules/loldle-splash/` (empty folder, populated in 05).
|
||||
|
||||
**Delete:** none.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Create `src/util/normalize-name.js`**:
|
||||
```js
|
||||
export const normalize = (s) =>
|
||||
String(s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
```
|
||||
Update `src/modules/loldle/lookup.js` to import it (keeps behaviour,
|
||||
removes the inline duplicate). Run `npm test` — classic loldle tests
|
||||
must still pass.
|
||||
|
||||
2. **Inspect the live loldle.net bundle** for emoji + quote fields:
|
||||
```bash
|
||||
node -e "
|
||||
const html = await (await fetch('https://loldle.net/emoji')).text();
|
||||
const m = html.match(/js\/index\.[^\"]+\.js/);
|
||||
const js = await (await fetch('https://loldle.net/' + m[0])).text();
|
||||
console.log(js.match(/emoji[s]?:\s*[\"\[][^\n]{0,200}/g)?.slice(0,3));
|
||||
console.log(js.match(/quote[s]?:\s*[\"\[][^\n]{0,200}/g)?.slice(0,3));
|
||||
"
|
||||
```
|
||||
Document the ACTUAL shape in this file. Update regex accordingly.
|
||||
|
||||
3. **Extend `scrape-loldle-data.js`**:
|
||||
- Reuse the single bundle fetch already there.
|
||||
- Add two new regex passes extracting `championName → emoji` pairs and
|
||||
`championName → quote` pairs.
|
||||
- Write `src/modules/loldle-emoji/emojis.json` and
|
||||
`src/modules/loldle-quote/quotes.json`. Sort by championName.
|
||||
- Fail LOUDLY if either new regex hits zero matches (prevents silent
|
||||
schema drift on loldle.net's next bundle).
|
||||
|
||||
4. **Create `scripts/fetch-ddragon-data.js`**:
|
||||
```
|
||||
GET /api/versions.json → take versions[0]
|
||||
GET /cdn/<v>/data/en_US/champion.json → summary (all champions)
|
||||
for each championKey:
|
||||
GET /cdn/<v>/data/en_US/champion/<Key>.json → full (spells, passive, skins)
|
||||
write abilities.json:
|
||||
[{ championName, abilities: [{ slot:"P"|"Q"|"W"|"E"|"R", name, icon:"<full-url>" }] }]
|
||||
write splashes.json:
|
||||
[{ championName, skins: [{ id:0, name:"Classic", url:"<splash-url>" }, ...] }]
|
||||
```
|
||||
Use `ddragon.leagueoflegends.com`. Parallelize per-champion fetches with
|
||||
a concurrency cap (10). Cache to a local `.ddragon-cache/` ignored by
|
||||
git so re-runs within the same patch are instant.
|
||||
Add npm script: `"fetch:ddragon-data": "node scripts/fetch-ddragon-data.js"`.
|
||||
|
||||
5. **Run both scripts locally**, commit the resulting JSONs. Verify sizes
|
||||
reasonable (emojis.json < 50 KB; quotes.json < 100 KB; abilities.json
|
||||
< 500 KB; splashes.json < 300 KB). If abilities.json balloons past 1 MB,
|
||||
drop fields (keep only slot + icon URL + ability name).
|
||||
|
||||
6. **Run `npm test` + `npm run lint`** — no regressions in classic loldle.
|
||||
|
||||
## Todo
|
||||
|
||||
- [ ] Create `src/util/normalize-name.js`
|
||||
- [ ] Refactor `src/modules/loldle/lookup.js` to import `normalize`
|
||||
- [ ] Inspect loldle.net bundle; document emoji + quote regex shape here
|
||||
- [ ] Extend `scripts/scrape-loldle-data.js` (emoji + quote extraction)
|
||||
- [ ] Create `scripts/fetch-ddragon-data.js`
|
||||
- [ ] Add `fetch:ddragon-data` npm script
|
||||
- [ ] Run both scripts, commit generated JSONs
|
||||
- [ ] Create 4 empty module folders (placeholders for phases 02–05)
|
||||
- [ ] `npm test` + `npm run lint` clean
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `npm run scrape:loldle-data` writes 3 JSONs (champions + emojis +
|
||||
quotes), all non-empty, all sorted by championName.
|
||||
- `npm run fetch:ddragon-data` writes 2 JSONs with full CDN URLs.
|
||||
- Classic loldle tests unchanged, still pass.
|
||||
- No lint warnings.
|
||||
- Four empty module folders exist, ready for phases 02–05.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| loldle.net bundle schema drifts between now and scrape | Extraction fails loud, re-inspect and update regex |
|
||||
| DDragon ability icon URL requires version path; version shifts mid-day | Cache URLs with version baked in; fetch script refreshes on demand |
|
||||
| Per-champion DDragon fetches (165 requests) hit rate limits | Concurrency cap 10; no published DDragon rate limits but be polite |
|
||||
| abilities.json > 1 MB bloats Workers bundle | Strip fields, keep only slot + icon URL + name |
|
||||
|
||||
## Security
|
||||
|
||||
- No secrets introduced.
|
||||
- DDragon and loldle.net are public endpoints; no auth.
|
||||
- Scripts write only to `src/modules/**/*.json` (no directory traversal).
|
||||
|
||||
## Next steps
|
||||
|
||||
Phases 02–05 can start **in parallel** once this phase completes.
|
||||
@@ -0,0 +1,173 @@
|
||||
# Phase 02 — Emoji module (`loldle-emoji`)
|
||||
|
||||
## Context
|
||||
|
||||
- [Research: overview + emoji](../reports/researcher-260424-2215-loldle-emoji-and-modes-overview.md)
|
||||
- Template: `src/modules/loldle/` (classic)
|
||||
- Dependency: phase 01 (`emojis.json` written by scraper, `normalize` helper).
|
||||
|
||||
## Overview
|
||||
|
||||
**Priority:** P1 (ship first — simplest mode).
|
||||
**Status:** pending.
|
||||
|
||||
Guess the champion from an emoji clue. All text-rendered — Telegram renders
|
||||
emojis natively. No images, no audio, no DDragon.
|
||||
|
||||
## Key insights
|
||||
|
||||
- Emoji sequences are handcrafted by loldle.net (not algorithmic). We only
|
||||
have what they ship — can't compute our own.
|
||||
- Progressive reveal on loldle.net works by "unlock one emoji per wrong
|
||||
guess". On Telegram, we keep it simpler: **show all emojis upfront, fewer
|
||||
guesses**. Saves edit-message roundtrips.
|
||||
- Reuse classic's `stats:<subject>` shape verbatim.
|
||||
|
||||
## Requirements
|
||||
|
||||
**Functional**
|
||||
- `/loldle_emoji` → show current round or start fresh; submit a guess if
|
||||
`<champion>` arg provided.
|
||||
- `/loldle_emoji_giveup` → reveal answer, record loss.
|
||||
- `/loldle_emoji_stats` → show per-subject play stats.
|
||||
- 5 guesses per round.
|
||||
- Subject = user (DM) or chat (group), same rule as classic.
|
||||
- Champion name lookup identical to classic (case/space/punctuation-
|
||||
insensitive, unique-prefix fallback).
|
||||
|
||||
**Non-functional**
|
||||
- Pure KV storage (no D1). Auto-prefixed key `loldle-emoji:game:<subject>`.
|
||||
- Round state: `{ target, guesses, startedAt }` — same shape as classic.
|
||||
- Champion pool comes from `emojis.json` (phase 01). Only champions with a
|
||||
non-empty emoji string are eligible.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/modules/loldle-emoji/
|
||||
├── index.js # { name, commands, init } export
|
||||
├── handlers.js # handleEmoji, handleGiveup, handleStats
|
||||
├── state.js # loadGame/saveGame/clearGame/loadStats/recordResult
|
||||
├── lookup.js # findChampion over emojis.json (thin wrapper, uses util/normalize-name.js)
|
||||
├── render.js # board render: emoji block + guesses list
|
||||
├── emojis.json # [{ championName, emojis:"🦊✨💫" }, ...] (generated)
|
||||
└── README.md # usage + data source notes
|
||||
```
|
||||
|
||||
## Related code files
|
||||
|
||||
**Modify**
|
||||
- `src/modules/index.js` — add `"loldle-emoji"` to static import map.
|
||||
- `wrangler.toml` `[vars].MODULES` — append `,loldle-emoji`.
|
||||
- `.env.deploy` (local) — append `,loldle-emoji` to MODULES.
|
||||
|
||||
**Create**
|
||||
- Six files listed in Architecture above.
|
||||
|
||||
**Delete:** none.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Copy `src/modules/loldle/state.js`** → `src/modules/loldle-emoji/state.js`.
|
||||
Change `MAX_GUESSES = 5`. No other changes needed; same KV shape.
|
||||
|
||||
2. **Create `lookup.js`**:
|
||||
```js
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Create `render.js`** — render the current board:
|
||||
```
|
||||
🎭 <emoji sequence>
|
||||
|
||||
Guesses (<n>/<MAX>):
|
||||
• <name1> ❌
|
||||
• <name2> ❌
|
||||
```
|
||||
HTML-escape every champion name via `src/util/escape-html.js`.
|
||||
|
||||
4. **Create `handlers.js`** modelled on `loldle/handlers.js`:
|
||||
- Same subject resolution (`getSubject`).
|
||||
- Same arg parsing.
|
||||
- `pickRandomChampion()` picks from `emojisData` (skip champions with
|
||||
empty emoji string, if any).
|
||||
- Win / loss / giveup messages reuse classic's tone; swap "classic" →
|
||||
"emoji" in copy. No stickers for v1 (YAGNI — can add later).
|
||||
- On win: "🎉 Got it! <champ> — solved in Nx/5" + stats update + KV clear.
|
||||
- On loss: "❌ Answer was <champ>" + stats update + KV clear.
|
||||
|
||||
5. **Create `index.js`**:
|
||||
```js
|
||||
import { handleGiveup, handleEmoji, handleStats } from "./handlers.js";
|
||||
let db = null;
|
||||
export default {
|
||||
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 answer",
|
||||
handler: (ctx) => handleGiveup(ctx, db) },
|
||||
{ name: "loldle_emoji_stats", visibility: "public",
|
||||
description: "Show your emoji stats (wins, streak)",
|
||||
handler: (ctx) => handleStats(ctx, db) },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
6. **Register** — add to `src/modules/index.js` import map, MODULES env.
|
||||
|
||||
7. **Write minimal README.md** — commands table, KV prefix, data source
|
||||
note ("regenerated by `npm run scrape:loldle-data`").
|
||||
|
||||
8. **Run `npm run dev`**, point a test bot at it, verify:
|
||||
- `/loldle_emoji` shows emojis + empty board.
|
||||
- `/loldle_emoji Ahri` (assuming Ahri is the answer) wins.
|
||||
- `/loldle_emoji_giveup` reveals answer.
|
||||
- `/loldle_emoji_stats` reports play counts.
|
||||
|
||||
## Todo
|
||||
|
||||
- [ ] Create folder + 6 files per Architecture
|
||||
- [ ] Copy state.js from classic, lower MAX_GUESSES to 5
|
||||
- [ ] Import normalize helper from util
|
||||
- [ ] Render board with HTML-escape
|
||||
- [ ] Handlers ported from classic, tone tweaked
|
||||
- [ ] Register in `src/modules/index.js` + MODULES env
|
||||
- [ ] Write README
|
||||
- [ ] Local smoke-test (`npm run dev` + test bot)
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Module loads at `installDispatcher` without conflicts.
|
||||
- `/loldle_emoji` runs end-to-end against loldle.net data.
|
||||
- Stats persist across rounds, isolated from classic loldle.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| `emojis.json` missing some champions | Pool is whatever loldle.net provides; guard null at render time |
|
||||
| Emoji rendering differs across Telegram clients | Use standard Unicode emojis (loldle.net already does); no skin-tone variants |
|
||||
| Player confusion vs classic (two loldle-like commands) | Distinct command name + distinct welcome copy |
|
||||
|
||||
## Security
|
||||
|
||||
- Input passes through `normalize` (strips non-alphanum) before comparison.
|
||||
- HTML escape all user-submitted and champion-name text in reply.
|
||||
|
||||
## Next steps
|
||||
|
||||
Phase 06 covers tests. Phase 03 (quote) can be built in parallel — same
|
||||
template.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Phase 03 — Quote module (`loldle-quote`)
|
||||
|
||||
## Context
|
||||
|
||||
- [Research: quote mode](../reports/researcher-260424-2215-loldle-quote-mode.md)
|
||||
- Template: `src/modules/loldle-emoji/` (phase 02) and `src/modules/loldle/`.
|
||||
- Dependency: phase 01 (`quotes.json` written, `normalize` helper).
|
||||
|
||||
## Overview
|
||||
|
||||
**Priority:** P1 (ship in parallel with emoji).
|
||||
**Status:** pending.
|
||||
|
||||
Guess the champion from a voice-line text. Text-only for MVP — audio is
|
||||
explicitly out of scope (bandwidth, CDN rehost TOS risk, storage overhead
|
||||
per research doc §4).
|
||||
|
||||
## Key insights
|
||||
|
||||
- Quote mode on loldle.net: **binary right/wrong, 6 guesses, audio hint
|
||||
unlocks after all fails**. We drop audio; keep 6 guesses.
|
||||
- Quotes can be ambiguous ("For glory!" — Garen, Galio, Jarman...). Fewer
|
||||
clues than classic; that's OK — the mode IS meant to be hard.
|
||||
- Pool: ~150 champions with quotes (per research). Every champion in
|
||||
`quotes.json` must have non-empty `quote` string — filter at load time.
|
||||
|
||||
## Requirements
|
||||
|
||||
**Functional**
|
||||
- `/loldle_quote` → show current quote or start fresh; submit a guess if arg.
|
||||
- `/loldle_quote_giveup` → reveal, record loss.
|
||||
- `/loldle_quote_stats` → per-subject stats.
|
||||
- 6 guesses.
|
||||
- Same subject resolution (user id DM / chat id group).
|
||||
|
||||
**Non-functional**
|
||||
- Pure KV. Prefix: `loldle-quote:`.
|
||||
- Same round state shape as classic and emoji.
|
||||
- HTML-escape the quote text before putting it inside `<i>…</i>` so
|
||||
apostrophes / `<` in a quote don't break render.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/modules/loldle-quote/
|
||||
├── index.js
|
||||
├── handlers.js
|
||||
├── state.js # MAX_GUESSES = 6
|
||||
├── lookup.js # (near-copy of emoji's)
|
||||
├── render.js # quote block + guesses list
|
||||
├── quotes.json # [{ championName, quote:"..." }, ...] (generated)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Related code files
|
||||
|
||||
**Modify**
|
||||
- `src/modules/index.js` — register `"loldle-quote"`.
|
||||
- `wrangler.toml` `[vars].MODULES` + `.env.deploy` — append.
|
||||
|
||||
**Create**
|
||||
- Seven files listed in Architecture above.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Copy `loldle-emoji/` as scaffold.** It's 95% the same shape.
|
||||
|
||||
2. **Swap payload:** `emojis.json` → `quotes.json`. `emojis` string field
|
||||
→ `quote` string field.
|
||||
|
||||
3. **`state.js`** — `MAX_GUESSES = 6`.
|
||||
|
||||
4. **`render.js`** — show quote as italic block:
|
||||
```
|
||||
🎭 <i>"The true face of desire."</i>
|
||||
|
||||
Guesses (n/6):
|
||||
• Ahri ❌
|
||||
```
|
||||
HTML-escape the quote BEFORE wrapping it in `<i>`. HTML-escape each
|
||||
champion name.
|
||||
|
||||
5. **`handlers.js`** — port emoji handlers with copy tweaked:
|
||||
- Welcome line: "🎭 Guess the champion from this quote."
|
||||
- Win: "🎉 Nailed it! <champ>."
|
||||
- Loss: "❌ Answer: <champ>."
|
||||
- No stickers for v1.
|
||||
|
||||
6. **`lookup.js`** — identical to emoji's, change import path.
|
||||
|
||||
7. **`index.js`**:
|
||||
```js
|
||||
commands:
|
||||
loldle_quote (public)
|
||||
loldle_quote_giveup (public)
|
||||
loldle_quote_stats (public)
|
||||
```
|
||||
|
||||
8. **Register** in `src/modules/index.js` + MODULES env in both
|
||||
`wrangler.toml` and `.env.deploy`.
|
||||
|
||||
9. **README.md**: commands, KV prefix, data source note, "audio hint not
|
||||
implemented — see phase plan for rationale".
|
||||
|
||||
10. **Smoke-test** in `wrangler dev` (same protocol as phase 02).
|
||||
|
||||
## Todo
|
||||
|
||||
- [ ] Scaffold folder by copying `loldle-emoji/`
|
||||
- [ ] Repoint JSON import to `quotes.json`
|
||||
- [ ] MAX_GUESSES = 6
|
||||
- [ ] Render quote as italic HTML block (escaped)
|
||||
- [ ] Copy tweaks in handlers
|
||||
- [ ] Register + MODULES env
|
||||
- [ ] README
|
||||
- [ ] Smoke-test
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Module loads, commands respond.
|
||||
- Quotes render cleanly in Telegram (no HTML-injection bugs with special
|
||||
chars in a champion's quote).
|
||||
- Stats persist per mode.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| Quote ambiguity → frustration | Accepted design tradeoff; document in README |
|
||||
| Quote includes HTML metacharacters (`<`, `&`) from loldle.net | Always HTML-escape before `<i>` wrap |
|
||||
| `quotes.json` missing quote for some newly-added champion | Filter pool to non-empty quotes at import-time |
|
||||
|
||||
## Security
|
||||
|
||||
- Escape quote text BEFORE rendering (quote content is third-party data
|
||||
from loldle.net scrape).
|
||||
- Escape user-submitted guess text in replies.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should audio hint ever ship? (Post-MVP, gated on user demand. Would
|
||||
require a cron that pre-fetches audio URLs from LoL Wiki and stores in
|
||||
R2. Not in this plan.)
|
||||
|
||||
## Next steps
|
||||
|
||||
Phase 06 adds tests. Phases 04/05 (image modes) proceed independently.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Phase 04 — Ability module (`loldle-ability`)
|
||||
|
||||
<!-- Updated: Validation Session 1 - deferred; binary-only confirmed; no cropping -->
|
||||
|
||||
## Context
|
||||
|
||||
- [Research: ability + splash](../reports/researcher-260424-2215-loldle-ability-splash-modes.md)
|
||||
- Template: `src/modules/loldle-emoji/` (phase 02).
|
||||
- Dependency: phase 01 (`abilities.json` from Data Dragon).
|
||||
|
||||
## Overview
|
||||
|
||||
**Priority:** P2 (image mode, more moving parts than text modes).
|
||||
**Status:** **DEFERRED** — do not start until emoji + quote modes are live and
|
||||
user demand for image modes is confirmed. Phase 01 provides the data needed
|
||||
(`abilities.json`), but DDragon fetch work can also be deferred to this
|
||||
phase if not required by phases 02/03.
|
||||
|
||||
## Validated decisions
|
||||
|
||||
- **Binary guess only** (no bonus slot-identification step).
|
||||
- **No progressive cropping** — full icon from turn 1, 5 guesses.
|
||||
- Data source confirmed: **Data Dragon CDN** (not loldle.net scrape).
|
||||
|
||||
Guess the champion from a single ability icon. Telegram sends the full
|
||||
Data Dragon icon URL via `sendPhoto`. **No progressive cropping for v1**
|
||||
(see plan.md for rationale — Cloudflare Images cost + edit-photo jank not
|
||||
justified by Telegram UX).
|
||||
|
||||
## Key insights
|
||||
|
||||
- DDragon icon URLs are stable per patch: `cdn/<version>/img/spell/<key>.png`
|
||||
(passive uses `/img/passive/`). Version is baked into `abilities.json`
|
||||
by phase 01's `fetch-ddragon-data.js` so the bot doesn't need live
|
||||
version fetches.
|
||||
- Pool per champion: 5 abilities (Passive, Q, W, E, R). Pick a random slot
|
||||
at round start. Round state stores both target champion AND the slot, so
|
||||
subsequent `/loldle_ability` calls re-send the SAME icon.
|
||||
- Since the full icon is shown from turn 1, difficulty stays high only if
|
||||
guesses are tight: **5 guesses**.
|
||||
- Telegram's `sendPhoto` accepts a URL directly — no download + re-upload.
|
||||
Cache the `file_id` returned in the send response? Not worth it for v1.
|
||||
|
||||
## Requirements
|
||||
|
||||
**Functional**
|
||||
- `/loldle_ability` → if no active round: pick champion + random slot,
|
||||
send photo with caption "Guess the champion from this ability. 0/5 so
|
||||
far." If active: re-send the same icon + progress line.
|
||||
- `/loldle_ability <champion>` → submit guess.
|
||||
- `/loldle_ability_giveup` → reveal answer + ability name + slot.
|
||||
- `/loldle_ability_stats` → per-subject stats.
|
||||
- 5 guesses.
|
||||
|
||||
**Non-functional**
|
||||
- KV prefix: `loldle-ability:`.
|
||||
- Round state: `{ target, slot:"P|Q|W|E|R", guesses, startedAt }`. Adds
|
||||
`slot` vs classic/emoji/quote.
|
||||
- Re-send photo each turn (no message-edit). Cheap; DDragon CDN is fast.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/modules/loldle-ability/
|
||||
├── index.js
|
||||
├── handlers.js
|
||||
├── state.js # extended shape: + slot
|
||||
├── lookup.js
|
||||
├── abilities.json # [{ championName, abilities:[{slot, name, icon}] }]
|
||||
└── README.md
|
||||
```
|
||||
|
||||
Note: no `render.js` — output is a photo + small caption, built inline in
|
||||
`handlers.js`.
|
||||
|
||||
## Related code files
|
||||
|
||||
**Modify**
|
||||
- `src/modules/index.js` — add `"loldle-ability"`.
|
||||
- `wrangler.toml` + `.env.deploy` MODULES.
|
||||
|
||||
**Create**
|
||||
- Six files listed above.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **`state.js`** — copy from `loldle-emoji/state.js`, bump shape to
|
||||
`{ target, slot, guesses, startedAt }`. `MAX_GUESSES = 5`.
|
||||
|
||||
2. **`lookup.js`** — identical to emoji's (pool shape: records still have
|
||||
`championName` at top level).
|
||||
|
||||
3. **`handlers.js`**:
|
||||
- `getSubject`, `argAfterCommand` — copy inline or import from a
|
||||
shared helper (optional — three copies is fine).
|
||||
- `pickRandomChampion()` — filter to records where abilities array is
|
||||
non-empty.
|
||||
- `pickRandomSlot(champ)` — uniform over `champ.abilities` slots.
|
||||
- On `/loldle_ability` no-arg: send photo (use
|
||||
`ctx.replyWithPhoto(url, { caption })`), caption shows guess count.
|
||||
- On guess: compare names; on win/loss send a photo reveal with full
|
||||
ability name ("That was **Ahri** — _Orb of Deception_ (Q)").
|
||||
- On giveup: same reveal.
|
||||
|
||||
4. **`index.js`** — three commands, same pattern as phase 02.
|
||||
|
||||
5. **Register + MODULES env** per usual.
|
||||
|
||||
6. **Smoke-test**: confirm photos render in Telegram, captions show
|
||||
counter correctly, wrong guesses retain the same icon across turns.
|
||||
|
||||
## Todo
|
||||
|
||||
- [ ] `state.js` with `slot` field + MAX 5
|
||||
- [ ] `lookup.js`
|
||||
- [ ] `handlers.js` (photo send, random slot pick, caption counter)
|
||||
- [ ] `index.js` (3 commands)
|
||||
- [ ] Register + MODULES env
|
||||
- [ ] README
|
||||
- [ ] Smoke-test vs real DDragon URLs
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Photo renders from DDragon URL in Telegram.
|
||||
- Same ability icon shown across multiple turns of the same round.
|
||||
- Correct guess reveals champion + ability name + slot.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| DDragon URL 404 for some legacy champion | Fetch script verifies URLs before write; filter broken entries |
|
||||
| DDragon version in `abilities.json` goes stale between fortnightly fetches | Icons remain valid (URL still 404-free per CDN retention); acceptable lag |
|
||||
| Bot bundle size: `abilities.json` could be 500 KB+ | Phase 01 trims to slot + name + icon URL only (no lore, no cost fields) |
|
||||
| Some champion has fewer than 5 abilities (unusual reworks) | `pickRandomSlot` picks from whatever's available |
|
||||
| Telegram caches photos by URL — wrong-guess photo same as first photo | That's fine, it's the SAME photo each turn by design |
|
||||
|
||||
## Security
|
||||
|
||||
- Photo URL is untrusted-feeling but in practice trusted (ddragon.lol
|
||||
CDN). Still: restrict `sendPhoto` to HTTPS URLs; don't pass user input
|
||||
into URLs anywhere.
|
||||
- HTML-escape champion + ability names in captions.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Bonus "which slot" second guess (as loldle.net does)? **Deferred.**
|
||||
v1 is binary. Revisit if users request.
|
||||
- Progressive crop for hardcore mode? **Deferred** — would require
|
||||
Cloudflare Images (~$5–15/mo, per research).
|
||||
|
||||
## Next steps
|
||||
|
||||
Phase 06 adds tests. Phase 05 (splash) reuses this module's photo-send
|
||||
pattern.
|
||||
@@ -0,0 +1,143 @@
|
||||
# Phase 05 — Splash module (`loldle-splash`)
|
||||
|
||||
<!-- Updated: Validation Session 1 - deferred; random-across-all-skins confirmed -->
|
||||
|
||||
## Context
|
||||
|
||||
- [Research: ability + splash](../reports/researcher-260424-2215-loldle-ability-splash-modes.md)
|
||||
- Template: `src/modules/loldle-ability/` (phase 04 — nearly identical
|
||||
shape, different payload).
|
||||
- Dependency: phase 01 (`splashes.json`).
|
||||
|
||||
## Overview
|
||||
|
||||
**Priority:** P2 (image mode).
|
||||
**Status:** **DEFERRED** — do not start until emoji + quote modes are live.
|
||||
Scheduled after phase 04 for pattern-reuse.
|
||||
|
||||
## Validated decisions
|
||||
|
||||
- **Random across ALL skins**, not base-only. Bigger data file, harder
|
||||
mode, matches loldle.net behaviour.
|
||||
- **No progressive cropping** — full splash from turn 1, 4 guesses.
|
||||
|
||||
Guess the champion from splash art (random skin). Full splash image sent
|
||||
once per round; user gets a tight guess budget.
|
||||
|
||||
## Key insights
|
||||
|
||||
- DDragon splash URL pattern: `cdn/img/champion/splash/<Name>_<skinId>.jpg`
|
||||
— note **no version segment**. Stable across patches.
|
||||
- Phase 01 writes `splashes.json` as
|
||||
`[{ championName, skins:[{ id, name, url }] }]`.
|
||||
- Random skin pick adds difficulty (Elementalist Lux looks nothing like
|
||||
Classic Lux). Include ALL skins, not just base — aligns with
|
||||
loldle.net's behaviour per research.
|
||||
- Splash images are large (~1 MB). Telegram auto-compresses photos, so no
|
||||
worry about bandwidth.
|
||||
- Like ability mode: no cropping in v1. Full image from turn 1, tight
|
||||
guess budget. **4 guesses** (one less than ability since the reveal is
|
||||
even bigger visually — whole-champion art).
|
||||
|
||||
## Requirements
|
||||
|
||||
**Functional**
|
||||
- `/loldle_splash` → start round (pick champion + skin) or re-send same
|
||||
photo.
|
||||
- `/loldle_splash <champion>` → submit guess.
|
||||
- `/loldle_splash_giveup` → reveal champion + skin name.
|
||||
- `/loldle_splash_stats` → per-subject stats.
|
||||
- 4 guesses.
|
||||
|
||||
**Non-functional**
|
||||
- KV prefix: `loldle-splash:`.
|
||||
- Round state: `{ target, skinId, guesses, startedAt }`. skinId persists
|
||||
so the same skin art shows across all turns of a round.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/modules/loldle-splash/
|
||||
├── index.js
|
||||
├── handlers.js
|
||||
├── state.js # shape adds skinId, MAX 4
|
||||
├── lookup.js
|
||||
├── splashes.json # [{ championName, skins:[{id, name, url}] }]
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Related code files
|
||||
|
||||
**Modify**
|
||||
- `src/modules/index.js` — add `"loldle-splash"`.
|
||||
- `wrangler.toml` + `.env.deploy` MODULES env.
|
||||
|
||||
**Create**
|
||||
- Six files above.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Copy `loldle-ability/` as scaffold.**
|
||||
|
||||
2. **`state.js`** — shape `{ target, skinId, guesses, startedAt }`,
|
||||
`MAX_GUESSES = 4`.
|
||||
|
||||
3. **`handlers.js`**:
|
||||
- `pickRandomChampion()` → record with ≥ 1 skin (always true; base is
|
||||
skin 0).
|
||||
- `pickRandomSkin(champ)` → uniform over `champ.skins`; keep its
|
||||
`.id` and `.url`.
|
||||
- On `/loldle_splash` no-arg: `ctx.replyWithPhoto(url, { caption: "Guess the champion. 0/4." })`.
|
||||
- On guess: compare names; on win reveal skin name ("That was **Ahri**
|
||||
in _Dynasty_ skin.").
|
||||
- On giveup: same reveal.
|
||||
|
||||
4. **`index.js`** — three public commands
|
||||
(`loldle_splash`, `loldle_splash_giveup`, `loldle_splash_stats`).
|
||||
|
||||
5. **Register + MODULES env.**
|
||||
|
||||
6. **Smoke-test** — verify splash renders, reveal names the skin
|
||||
correctly, guesses persist the same skin photo.
|
||||
|
||||
## Todo
|
||||
|
||||
- [ ] Copy scaffold from ability module
|
||||
- [ ] `state.js` with `skinId` field + MAX 4
|
||||
- [ ] `handlers.js` (photo send, random skin pick, skin reveal)
|
||||
- [ ] `index.js` (3 commands)
|
||||
- [ ] Register + MODULES env
|
||||
- [ ] README
|
||||
- [ ] Smoke-test
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Random skin shown per round (not always base).
|
||||
- Same skin persists across guesses in a round.
|
||||
- Reveal names the specific skin.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| DDragon splash 404 for legacy/unreleased skin | fetch-ddragon script verifies each URL at build time; filter 404s |
|
||||
| Too easy for popular champions (Lux, Ahri — recognised instantly) | 4-guess budget balances; some skins are genuinely obscure |
|
||||
| Multi-champion splashes (e.g. Kayle+Morgana) | Exclude at fetch time — filter skins tagged as multi-champ if DDragon flags; otherwise keep and accept the edge case |
|
||||
| Large splashes slow first reply | Telegram downloads from URL server-side; user-perceived latency is the `sendPhoto` API call, ~1 s |
|
||||
|
||||
## Security
|
||||
|
||||
- All splash URLs are on DDragon HTTPS CDN.
|
||||
- HTML-escape champion + skin names in captions and reveals.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Include "Classic" skins only for easier mode? **No** — keeps the mode
|
||||
too close to ability/classic difficulty. Random skin is the whole point.
|
||||
- Progressive crop for hardcore mode? **Deferred** (Cloudflare Images).
|
||||
- Exclude NSFW / retired skins (e.g. Graves' cigar removal)? None flagged
|
||||
by DDragon; all shipped skins are safe-for-work.
|
||||
|
||||
## Next steps
|
||||
|
||||
Phase 06 closes the plan with tests + docs.
|
||||
@@ -0,0 +1,167 @@
|
||||
# Phase 06 — Tests + docs sync
|
||||
|
||||
<!-- Updated: Validation Session 1 - scope narrowed to emoji + quote only -->
|
||||
|
||||
## Context
|
||||
|
||||
- Existing test patterns: `tests/modules/loldle/`, `tests/modules/wordle/`,
|
||||
`tests/modules/trading/`.
|
||||
- Fakes: `tests/fakes/fake-kv-namespace.js`, `tests/fakes/fake-bot.js`.
|
||||
- Docs to touch: `README.md`, `docs/adding-a-module.md` (no change needed
|
||||
unless the new modules expose a new pattern), potentially a new
|
||||
`docs/loldle-modes.md` for the mode roster.
|
||||
- Blocks: **02 + 03 must be complete.** 04 + 05 are deferred — their tests
|
||||
will be added when those phases ship.
|
||||
|
||||
## Overview
|
||||
|
||||
**Priority:** P1 (closes the plan).
|
||||
**Status:** pending.
|
||||
|
||||
Add focused unit tests for each new module and sync docs. Unit-test only
|
||||
pure-logic seams (state, lookup, render). Handler tests use fakes — no
|
||||
workerd, no Telegram fixtures, same convention as `loldle/` tests.
|
||||
|
||||
## Key insights
|
||||
|
||||
- Each new module mirrors classic's shape closely; tests can be near-
|
||||
copies of `tests/modules/loldle/state.test.js` + `lookup.test.js`.
|
||||
- No integration tests for DDragon (external CDN). Stub `fetch` if any
|
||||
unit exercises it; prefer pure functions that take a URL string.
|
||||
- Skip tests for the scraping scripts — they're network-bound. Manual
|
||||
verification (phase 01 success criteria) covers them.
|
||||
|
||||
## Requirements
|
||||
|
||||
**Functional**
|
||||
- ≥ 1 test file per new module covering: state round-trip, lookup, render
|
||||
/ handler happy path.
|
||||
- `npm test` passes with no regressions.
|
||||
- `npm run lint` clean.
|
||||
|
||||
**Non-functional**
|
||||
- Coverage not measured explicitly — prioritize meaningful cases over %.
|
||||
- Don't re-test shared helpers per module — one `normalize-name.test.js`
|
||||
is enough.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
tests/
|
||||
├── util/
|
||||
│ └── normalize-name.test.js # NEW (this phase)
|
||||
└── modules/
|
||||
├── loldle-emoji/
|
||||
│ ├── state.test.js # NEW (this phase)
|
||||
│ ├── lookup.test.js # NEW (this phase)
|
||||
│ └── handlers.test.js # NEW (this phase — happy path only)
|
||||
├── loldle-quote/
|
||||
│ ├── state.test.js # NEW (this phase)
|
||||
│ ├── lookup.test.js # NEW (this phase)
|
||||
│ └── handlers.test.js # NEW (this phase)
|
||||
├── loldle-ability/ # DEFERRED (with phase 04)
|
||||
│ ├── state.test.js # slot persistence
|
||||
│ └── handlers.test.js # stubs ctx.replyWithPhoto
|
||||
└── loldle-splash/ # DEFERRED (with phase 05)
|
||||
├── state.test.js # skinId persistence
|
||||
└── handlers.test.js # stubs ctx.replyWithPhoto
|
||||
```
|
||||
|
||||
## Related code files
|
||||
|
||||
**Modify**
|
||||
- `README.md` — add the four new modes to the architecture snapshot
|
||||
(bullet list in `## Architecture snapshot`) and to troubleshooting if
|
||||
applicable.
|
||||
- `docs/architecture.md` — if the project's existing docs list modules,
|
||||
mention the loldle family.
|
||||
|
||||
**Create**
|
||||
- Test files listed above.
|
||||
- `docs/loldle-modes.md` (optional, only if worth it) — one-page
|
||||
reference: five modes, what each looks like, command list.
|
||||
|
||||
**Delete:** none.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **`normalize-name.test.js`** — three cases: basic lower+strip, Unicode
|
||||
punctuation ("Kai'Sa" → "kaisa"), empty/null input.
|
||||
|
||||
2. **Per module `state.test.js`** — use `FakeKvNamespace` +
|
||||
`createStore("<module>", { KV: fake })`:
|
||||
- Save a game, load it back — deep equal.
|
||||
- `clearGame` deletes.
|
||||
- `recordResult(true)` increments wins + streak + bestStreak when
|
||||
streak exceeds previous best.
|
||||
- `recordResult(false)` resets streak to 0.
|
||||
- (ability/splash) slot / skinId round-trip.
|
||||
|
||||
3. **Per text module `lookup.test.js`** — exact match, case-insensitive,
|
||||
punctuation-insensitive, unique-prefix, ambiguous-prefix → null.
|
||||
(Could be near-copy of existing loldle lookup test.)
|
||||
|
||||
4. **Per module `handlers.test.js`** — use `FakeKvNamespace` + a minimal
|
||||
ctx fake: `{ from, chat, message, reply, replyWithPhoto, replyWithSticker }`.
|
||||
Walk one happy path: empty state → guess correct → stats incremented.
|
||||
For image modes, assert `replyWithPhoto` received a string URL
|
||||
starting with `https://ddragon.leagueoflegends.com/`.
|
||||
|
||||
5. **Run `npm test`** — confirm all pass. Fix any module code issues
|
||||
found.
|
||||
|
||||
6. **Update `README.md`**:
|
||||
- In `## Architecture snapshot`'s `src/modules/` list, append
|
||||
`loldle-emoji/`, `loldle-quote/`, `loldle-ability/`, `loldle-splash/`.
|
||||
- No troubleshooting table change needed.
|
||||
|
||||
7. **(Optional) `docs/loldle-modes.md`** — single-page mode roster.
|
||||
|
||||
8. **Run `npm run lint` + `npm run format`** — clean.
|
||||
|
||||
9. **Final smoke-test**: `npm run dev`, test bot, cycle through all five
|
||||
loldle commands. Confirm no command conflicts thrown at registry
|
||||
build.
|
||||
|
||||
## Todo
|
||||
|
||||
- [ ] `normalize-name.test.js`
|
||||
- [ ] Four per-module `state.test.js`
|
||||
- [ ] Two text-module `lookup.test.js` (emoji, quote — ability/splash
|
||||
reuse the same pattern but lookup is trivial, skip if redundant)
|
||||
- [ ] Four per-module `handlers.test.js`
|
||||
- [ ] Update README.md architecture snapshot
|
||||
- [ ] (Optional) docs/loldle-modes.md
|
||||
- [ ] `npm test` + `npm run lint` + `npm run format` clean
|
||||
- [ ] Final smoke-test across all 5 loldle commands
|
||||
|
||||
## Success criteria
|
||||
|
||||
- All new tests pass.
|
||||
- Classic loldle tests unchanged and still pass.
|
||||
- `npm run deploy --dry-run` (register:dry) lists all 12 new commands
|
||||
(4 modes × 3 commands), with no conflicts.
|
||||
- README accurately lists new modules.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| Handler tests drift from actual grammY context shape | Reuse existing loldle handler test scaffolding verbatim |
|
||||
| Flaky tests due to `Math.random()` in `pickRandomChampion` | Inject `rng` parameter or monkey-patch `Math.random` in tests |
|
||||
|
||||
## Security
|
||||
|
||||
- Tests use fakes only; no real KV, no real Telegram calls.
|
||||
- No secrets in test fixtures.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Cron for periodic DDragon refresh? Out of scope — the scraper runs
|
||||
weekly (classic) and we can piggyback ddragon fetch onto the same
|
||||
workflow in a follow-up. Not blocking.
|
||||
|
||||
## Next steps
|
||||
|
||||
After this phase: plan is complete. Run `/ck:plan archive` to close out
|
||||
and log a journal entry.
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: loldle-new-modes
|
||||
status: mvp-shipped
|
||||
created: 2026-04-24
|
||||
updated: 2026-04-24
|
||||
slug: loldle-new-modes
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
---
|
||||
|
||||
# Loldle New Modes — miti99bot
|
||||
|
||||
Add four new game modules mirroring loldle.net's non-classic modes:
|
||||
**Emoji**, **Quote**, **Ability**, **Splash**. Existing `loldle/` (classic)
|
||||
stays untouched — each new mode is its own sibling module folder.
|
||||
|
||||
**Scope principle (YAGNI):** Ship text-based modes first (emoji, quote).
|
||||
Image modes (ability, splash) ship with **full images, no progressive zoom**
|
||||
— Loldle's signature "reveal-on-wrong-guess" cropping adds Cloudflare Images
|
||||
cost + message-delete jank for little gain on mobile Telegram. Users instead
|
||||
get fewer guesses to compensate.
|
||||
|
||||
**Data strategy:**
|
||||
- Emoji + Quote → scrape from loldle.net JS bundle (same path as classic).
|
||||
- Ability + Splash → pull from **Riot Data Dragon** CDN directly (official,
|
||||
patch-synced, no brittle scraping).
|
||||
- Audio (quote mode) → **skipped for MVP**. Revisit if users ask.
|
||||
|
||||
## Commands (per mode)
|
||||
|
||||
| Mode | Commands |
|
||||
|------|----------|
|
||||
| emoji | `/loldle_emoji`, `/loldle_emoji_giveup`, `/loldle_emoji_stats` |
|
||||
| quote | `/loldle_quote`, `/loldle_quote_giveup`, `/loldle_quote_stats` |
|
||||
| ability | `/loldle_ability`, `/loldle_ability_giveup`, `/loldle_ability_stats` |
|
||||
| splash | `/loldle_splash`, `/loldle_splash_giveup`, `/loldle_splash_stats` |
|
||||
|
||||
All `public`. Conflict-checked at registry load time.
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | Status | Blocking |
|
||||
|---|-------|--------|----------|
|
||||
| 01 | [Shared scrape + lookup helpers](phase-01-shared-helpers.md) | **done** | — |
|
||||
| 02 | [Emoji module](phase-02-emoji-module.md) | **done** | 01 |
|
||||
| 03 | [Quote module (text-only)](phase-03-quote-module.md) | **done** | 01 |
|
||||
| 04 | [Ability module (Data Dragon)](phase-04-ability-module.md) | **deferred** | 01 |
|
||||
| 05 | [Splash module (Data Dragon)](phase-05-splash-module.md) | **deferred** | 01 |
|
||||
| 06 | [Tests + docs sync](phase-06-tests-docs.md) | **done** | 02,03 |
|
||||
|
||||
**Shipping plan (validated):**
|
||||
- **Now:** 01 → 02 + 03 in parallel → 06 (tests for emoji + quote only).
|
||||
- **Later:** 04 + 05 stay in this plan marked `deferred`. Unblocked by 01,
|
||||
but held by user decision — pick up after emoji/quote live. Tests for
|
||||
them will be added then; phase 06's checklist marks image tests as
|
||||
"when 04/05 ship".
|
||||
|
||||
## Key decisions
|
||||
|
||||
1. **Four new modules, not one refactor.** Classic `loldle/` unchanged. Each
|
||||
mode owns its data, handlers, render — matches the project's existing
|
||||
per-folder plug-n-play pattern. No cross-module coupling.
|
||||
2. **Emoji/quote reuse classic's `champions.json` pool** for name validation;
|
||||
attach mode-specific payload (emoji string, quote text) from scraper.
|
||||
3. **Ability/splash skip cropping for v1.** Send full Data Dragon URL
|
||||
(`sendPhoto`). Guess budget tuned down (ability: 5; splash: 4) since the
|
||||
full image is revealed upfront.
|
||||
4. **Stats tracked per mode.** Each mode's KV prefix keeps stats isolated.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `wrangler.toml` `[vars].MODULES` + `.env.deploy` both updated per module.
|
||||
- `scripts/scrape-loldle-data.js` extended (new regex paths for emoji,
|
||||
quote) — single fetch, mode-aware extraction.
|
||||
- One new script: `scripts/fetch-ddragon-data.js` (abilities + splash meta
|
||||
cached to JSON at build time).
|
||||
|
||||
## References
|
||||
|
||||
- `plans/reports/researcher-260424-2215-loldle-emoji-and-modes-overview.md`
|
||||
- `plans/reports/researcher-260424-2215-loldle-quote-mode.md`
|
||||
- `plans/reports/researcher-260424-2215-loldle-ability-splash-modes.md`
|
||||
- `src/modules/loldle/` — template patterns (handlers, state, lookup, flavor)
|
||||
- `docs/adding-a-module.md`
|
||||
|
||||
## Execution Log
|
||||
|
||||
**Shipped 2026-04-24 (MVP — emoji + quote).**
|
||||
- Phase 01/02/03/06 complete.
|
||||
- Phases 04/05 remain deferred (see validation notes).
|
||||
- **Data-source pivot (critical deviation):** loldle.net bundle contains no
|
||||
per-champion emoji/quote data (confirmed zero emoji code points). Cache
|
||||
is AES-encrypted and holds only the single daily answer. Pivoted:
|
||||
- emoji → algorithmic derivation from classic's `champions.json`
|
||||
metadata (species/regions/resource/positions mapping table).
|
||||
- quote → DDragon champion `title` + first lore sentence, champion
|
||||
name redacted to `___` to avoid giveaways.
|
||||
- Generator: `scripts/fetch-ddragon-data.js` (new). Handles both JSONs.
|
||||
`scrape-loldle-data.js` left untouched (classic only).
|
||||
- 35 new tests, 484 total passing. Lint clean.
|
||||
|
||||
## Validation Log
|
||||
|
||||
**Session 1 — 2026-04-24 (7 questions answered)**
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| MVP scope | **Text modes first** (emoji + quote). Image modes deferred. |
|
||||
| Progressive image crop for ability/splash | **Skip** — full image, tight guess budget. |
|
||||
| Splash skin pool (when shipped) | **Random across ALL skins** incl. variants. |
|
||||
| Ability mode flow (when shipped) | **Binary only** — guess champion, done. No slot bonus. |
|
||||
| Phases 04/05 fate | **Keep in plan, marked `deferred`**. Not moved to a new plan. |
|
||||
| Quote mode audio | **Skip**, note in quote README as future follow-up. |
|
||||
| Stats scope | **Per-mode, isolated.** No shared leaderboard. |
|
||||
|
||||
All decisions locked. No open questions remain.
|
||||
@@ -0,0 +1,357 @@
|
||||
# Research: Loldle Ability & Splash Modes for Telegram Bot
|
||||
|
||||
**Date:** 2026-04-24
|
||||
**Context:** Adapting Loldle's image-based game modes into Telegram bot commands on Cloudflare Workers. Existing classic mode scrapes champion data from JS bundle; need feasibility analysis for Ability and Splash modes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Gameplay Mechanics
|
||||
|
||||
### Ability Mode
|
||||
- **What player sees:** Single zoomed-in ability icon (no kit context)
|
||||
- **Reveal mechanic:** No progressive zoom on guesses; user either guesses correctly or incorrectly
|
||||
- **Two-stage guessing:**
|
||||
- First: Identify the champion who owns the ability
|
||||
- Bonus: Identify which ability slot (Passive / Q / W / E / R) after champion is guessed
|
||||
- **Icon source:** One random ability per daily reset from pool of 5+ per champion
|
||||
- **Challenge:** 170+ champions × 5 abilities each = ~850+ unique icons; icon color/shape themes repeat across classes, making recognition difficult
|
||||
- **No explicit hint system:** Unlike Classic mode, ability mode provides no "closeness" feedback—binary win/loss only
|
||||
|
||||
### Splash Mode
|
||||
- **What player sees:** Highly zoomed-in crop of splash art (detail only: fragment of weapon, armor, background)
|
||||
- **Reveal mechanic:** Progressive zoom—each wrong guess zooms OUT further, revealing more of the full image
|
||||
- **Guessing limit:** Implied from "LoLdle Unlimited" variant; daily classic has some limit (exact number unconfirmed, but likely 6-8 based on Wordle convention)
|
||||
- **Art sources:** Base splash art or skin splash art (adds difficulty; same champion may have 5-10+ splash variants)
|
||||
- **Single-champion constraint:** Only single-champion splashes; multi-champion art excluded
|
||||
- **Hint via reveal:** Gradual visual context helps players narrow down champion identity over failed attempts
|
||||
|
||||
**Key difference:** Ability = binary guessing; Splash = progressive reveal with feedback.
|
||||
|
||||
---
|
||||
|
||||
## 2. Data Source Investigation
|
||||
|
||||
### JavaScript Bundle Structure
|
||||
Loldle uses minified Vue.js app bundles with versioned filenames:
|
||||
- Main: `js/index.45d55fd2197ccf548738.1774994503850.js` (3.9MB minified)
|
||||
- Chunk vendors: `js/chunk-vendors.45d55fd2197ccf548738.1774994503850.js`
|
||||
- Hash changes on each site update; version embedded in HTML `<link rel="preload">`
|
||||
|
||||
**Champion data extraction:** Search minified bundle for `championId` property to locate champion data object. Data is UTF-8 encoded (handles multi-language text). Python regex tools exist (e.g., `extract-champlist.py` from joulsen/loldle-information-theory repo) to parse the JS object and convert to JSON.
|
||||
|
||||
### Image Source: Loldle vs. Data Dragon
|
||||
Two options identified:
|
||||
|
||||
#### Option A: Riot Data Dragon CDN (Direct)
|
||||
**Pros:**
|
||||
- Official, guaranteed up-to-date with game patches
|
||||
- High availability, CDN-distributed globally
|
||||
- No scraping needed; public documented API
|
||||
- Standardized URL structure; easy to construct URLs
|
||||
|
||||
**Cons:**
|
||||
- Requires calling DDragon for each patch version to get ability icon filenames
|
||||
- Ability icons keyed by internal `SpellKey` (not champion-friendly; requires champion JSON lookup)
|
||||
- Passive icons separate from spell icons (different endpoint prefix)
|
||||
|
||||
**URL patterns:**
|
||||
```
|
||||
https://ddragon.leagueoflegends.com/cdn/{version}/img/spell/{SpellKey}.png
|
||||
https://ddragon.leagueoflegends.com/cdn/{version}/img/passive/{PassiveKey}.png
|
||||
https://ddragon.leagueoflegends.com/cdn/img/champion/splash/{ChampionName}_0.jpg (base)
|
||||
https://ddragon.leagueoflegends.com/cdn/img/champion/splash/{ChampionName}_{skinId}.jpg (skins)
|
||||
```
|
||||
|
||||
**Example:** For Ahri's Q ability, DDragon provides SpellKey `FoxFireTwo` → fetch from spell endpoint. Champion JSON (en_US) specifies slot, image.full filename, and spell data.
|
||||
|
||||
#### Option B: Loldle.net JS Bundle Scraping
|
||||
**Pros:**
|
||||
- Image URLs likely embedded directly in JS bundle (faster runtime lookup)
|
||||
- Already aligned with Loldle's data schema
|
||||
- Single extraction step (no DDragon API calls)
|
||||
|
||||
**Cons:**
|
||||
- Requires re-extraction on each site update (monitor for bundle hash changes)
|
||||
- Image sources may still point to DDragon or Loldle CDN; need inspection
|
||||
- Minified JS harder to parse without exact regex knowledge
|
||||
- Breaking changes if Loldle refactors data structure
|
||||
|
||||
**Status:** Bundle not yet decompiled in this research; assumption that URLs are embedded awaits verification.
|
||||
|
||||
### Recommended approach: **Use Data Dragon directly**
|
||||
Rationale: Official, stable, no brittle scraping. Trade-off is one extra API call to fetch champion.json and one iteration to map SpellKey → URL, but both are lightweight. Splash URLs follow consistent pattern; ability lookup requires JSON traversal but is deterministic.
|
||||
|
||||
---
|
||||
|
||||
## 3. Scraping Feasibility
|
||||
|
||||
### Ability Mode Data Requirements
|
||||
**For each champion, capture:**
|
||||
- Champion name/key (standard)
|
||||
- 5 ability slot icons (Q/W/E/R/Passive)
|
||||
- Q/W/E/R: spells[i].image.full from champion.json
|
||||
- Passive: passive.image.full
|
||||
- Ability names (for bonus second-guess hint)
|
||||
|
||||
**Per-round selection:** Random pick 1 of the 5 abilities → construct URL from SpellKey.
|
||||
|
||||
**Feasibility:** ✅ Fully feasible. DDragon champion.json includes all spell metadata. Static per-patch; refresh on League patch cycle (~2 weeks).
|
||||
|
||||
### Splash Mode Data Requirements
|
||||
**For each champion, capture:**
|
||||
- Champion name/key
|
||||
- List of splash art URLs (base + all skins)
|
||||
- Base: `{ChampionName}_0.jpg`
|
||||
- Skins: `{ChampionName}_{skinId}.jpg`
|
||||
|
||||
**Challenge:** DDragon doesn't list all skin IDs directly; must scrape from champion.json `skins[]` array, which includes `id`, `name`, `num` fields.
|
||||
|
||||
**Per-round selection:** Random pick 1 splash from pool of available skins → crop image on first guess, zoom out on each wrong attempt.
|
||||
|
||||
**Feasibility:** ✅ Fully feasible. Skin IDs available in champion.json. All URLs follow predictable pattern. No additional API calls needed.
|
||||
|
||||
---
|
||||
|
||||
## 4. Image Source Notes & Verification
|
||||
|
||||
### Data Dragon URL Construction
|
||||
|
||||
**Ability Icons:**
|
||||
```javascript
|
||||
const version = "16.8.1"; // from /api/versions.json
|
||||
const championData = await fetch(`https://ddragon.leagueoflegends.com/cdn/${version}/data/en_US/champion/Ahri.json`).then(r => r.json());
|
||||
// championData.data.Ahri.spells[0].image.full = "FoxFireTwo.png"
|
||||
const abilityUrl = `https://ddragon.leagueoflegends.com/cdn/${version}/img/spell/FoxFireTwo.png`;
|
||||
```
|
||||
|
||||
**Passive Icons:**
|
||||
```javascript
|
||||
// championData.data.Ahri.passive.image.full = "AhriPassive.png"
|
||||
const passiveUrl = `https://ddragon.leagueoflegends.com/cdn/${version}/img/passive/AhriPassive.png`;
|
||||
```
|
||||
|
||||
**Splash Art:**
|
||||
```javascript
|
||||
// championData.data.Ahri.skins = [{id: 0, name: "Classic", num: 0}, {id: 1, name: "Dynasty Ahri", num: 1}, ...]
|
||||
const baseUrl = `https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_0.jpg`;
|
||||
const skinUrl = `https://ddragon.leagueoflegends.com/cdn/img/champion/splash/Ahri_1.jpg`; // skin 1
|
||||
```
|
||||
|
||||
### Verification Status
|
||||
- ✅ DDragon endpoints exist and are documented (hextechdocs.dev, riot-api-libraries)
|
||||
- ✅ Ability icon URLs follow `spell/{SpellKey}.png` and `passive/{PassiveKey}.png` pattern
|
||||
- ✅ Splash URLs follow `champion/splash/{Name}_{skinId}.jpg` pattern
|
||||
- ✅ Champion JSON includes all required metadata (skins[], spells[], passive)
|
||||
- ⚠️ **Not yet verified in browser:** Actual image availability at these URLs (assumed 100% coverage per Riot CDN reliability, but spot checks recommended)
|
||||
|
||||
---
|
||||
|
||||
## 5. Telegram Adaptation Strategy
|
||||
|
||||
### Challenge: Progressive Reveal Mechanic
|
||||
Loldle's core appeal is the zoom-in/zoom-out reveal. Telegram doesn't natively support:
|
||||
- Sending image crops inline (no built-in image editing API in bot SDK)
|
||||
- Real-time photo replacement in same message (must delete + resend, causing UX jank)
|
||||
|
||||
### Three Options Evaluated
|
||||
|
||||
#### Option A: Cloudflare Image Resizing API (RECOMMENDED)
|
||||
**How it works:**
|
||||
1. Store full ability icon / splash URL
|
||||
2. On guess, construct Cloudflare Image Resizing URL with crop/resize parameters
|
||||
3. Send cropped image to Telegram
|
||||
4. On next wrong guess, send new URL with larger viewport (zoom out)
|
||||
|
||||
**Cloudflare Images API supports:**
|
||||
- **Crop:** `?format=webp&crop=smartcrop` or `crop=left,top,right,bottom` (relative coords 0.0–1.0)
|
||||
- **Resize:** `?width=X&height=Y&fit=cover` with `crop=<side>` or `crop=<x>x<y>`
|
||||
- **Chain:** Ability icon small (64×64 crop) → medium (128×128) → full (256×256)
|
||||
- **Splash:** Crop top-left 20% → top-left 40% → top-left 60% → full image
|
||||
|
||||
**Pros:**
|
||||
- Preserves Loldle's core UX (progressive reveal works)
|
||||
- Runs at edge (Cloudflare Workers); <100ms latency
|
||||
- No server-side image processing needed (Workers have no native PIL/ImageMagick)
|
||||
- Scales to millions of guesses
|
||||
- No cost per image (included in Cloudflare Images plan)
|
||||
|
||||
**Cons:**
|
||||
- Requires Cloudflare Images product (adds ~$20–100/mo to existing Workers bill, depending on transforms)
|
||||
- Must delete old message and send new one on each guess (Telegram API limitation)
|
||||
- Message history grows; requires cleanup after game ends
|
||||
|
||||
**Feasibility:** ✅ **Viable and recommended.**
|
||||
|
||||
#### Option B: Full Ability Icon / Simple Splash (NO CROP)
|
||||
**How it works:**
|
||||
1. Send full 256×256 ability icon without cropping
|
||||
2. Send full splash art without cropping
|
||||
3. Skip zoom mechanic; just reveal full image on player request or after N wrong guesses
|
||||
|
||||
**Pros:**
|
||||
- Zero image processing; just send URL
|
||||
- Works in standard Cloudflare Workers (no external APIs)
|
||||
- Simple implementation
|
||||
- Telegram inline keyboards show full image in context
|
||||
|
||||
**Cons:**
|
||||
- Loses Loldle's signature zoom-in reveal (core gameplay appeal)
|
||||
- Splash art mode becomes trivial if full image visible from start
|
||||
- Reduced challenge/fun factor
|
||||
- Defeats the purpose of porting mode
|
||||
|
||||
**Feasibility:** ✅ Viable but **not recommended**—defeats design intent.
|
||||
|
||||
#### Option C: Image Processing in Workers (NOT VIABLE)
|
||||
**How it works:** Use Sharp.js or WASM image library in Worker to crop/resize on-the-fly.
|
||||
|
||||
**Cons:**
|
||||
- Workers have 128 MB CPU execution limit; image processing = slow
|
||||
- Worker size limit (1 MB script); Sharp.js alone is 500+ KB
|
||||
- No native file I/O; must stream into memory
|
||||
- Latency: 5–10 seconds per image
|
||||
- Cost overruns (Workers compute-heavy)
|
||||
|
||||
**Feasibility:** ❌ **Not recommended.** CPU/size constraints make this impractical.
|
||||
|
||||
### Recommendation: **Option A (Cloudflare Image Resizing)**
|
||||
|
||||
**Telegram adaptation flow:**
|
||||
1. **Guess submission:** User taps inline button with champion name
|
||||
2. **Validation:** Check against daily answer
|
||||
3. **If wrong:**
|
||||
- Construct new Cloudflare Image Resizing URL with expanded crop/zoom
|
||||
- Delete previous message (edit doesn't work well for photos)
|
||||
- Send new photo message with updated keyboard
|
||||
- Update guess counter
|
||||
4. **If correct:**
|
||||
- Edit keyboard to show "✓ Correct! Next round in 24h"
|
||||
- Log stats (guesses taken, time elapsed)
|
||||
|
||||
**Cost estimate:** ~1–3 image transforms per game × daily players. If 1000 games/day × 4 guesses avg = 4000 transforms. Cloudflare Images pricing: $0.03–0.10/1000 transforms = **$0.12–0.40/day** (~$3.50–12/month).
|
||||
|
||||
**Trade-off:** Small cost for best UX. Alternative (Option B) is free but kills the mode's appeal.
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation Roadmap
|
||||
|
||||
### Ability Mode
|
||||
1. Fetch latest DDragon version from `/api/versions.json`
|
||||
2. Cache champion.json (en_US) for current patch
|
||||
3. On game start:
|
||||
- Pick random champion
|
||||
- Pick random ability (Q/W/E/R/Passive)
|
||||
- Construct spell/passive icon URL
|
||||
4. Serve icon via Cloudflare Image Resizing (64×64 crop for first guess)
|
||||
5. On each wrong guess, expand crop (128×128, 192×192, full)
|
||||
6. After champion guessed, show ability slot choices (multiple choice buttons)
|
||||
|
||||
**Storage:** Pre-generate crop params at startup; store in KV cache (champion → ability → crop dimensions)
|
||||
|
||||
### Splash Mode
|
||||
1. Cache champion.json with skins[] data
|
||||
2. On game start:
|
||||
- Pick random champion
|
||||
- Pick random skin
|
||||
- Construct splash URL ({Name}_{skinId}.jpg)
|
||||
3. Serve via Image Resizing (20% viewport crop, top-left)
|
||||
4. On each wrong guess, expand viewport (40%, 60%, 80%, 100%)
|
||||
5. Guess from champion select dropdown
|
||||
|
||||
**Storage:** Pre-generate viewport crop params; store in KV
|
||||
|
||||
### Shared Data Pipeline
|
||||
```
|
||||
cron: every patch (2 weeks) or manual trigger
|
||||
→ fetch https://ddragon.leagueoflegends.com/api/versions.json
|
||||
→ get latest version
|
||||
→ fetch https://ddragon.leagueoflegends.com/cdn/{version}/data/en_US/champion.json
|
||||
→ extract championId, skins[], spells[], passive.image.full
|
||||
→ store to KV: key={championId}, value={JSON struct}
|
||||
→ seed RNG for daily selection (same seed = same champion daily across users)
|
||||
```
|
||||
|
||||
**Cloudflare Workers implementation:** Standard fetch + KV bindings. Add Cloudflare Images binding for image transforms.
|
||||
|
||||
---
|
||||
|
||||
## 7. Risk Assessment & Adoption Hazards
|
||||
|
||||
### Data Dragon Risks
|
||||
- **Patch conflicts:** If game hotfixes champion abilities, DDragon may lag by hours
|
||||
- *Mitigation:* Add patch version selector in-game; cache aggressively
|
||||
- **Skin data completeness:** Not all skins may have splash URLs (rare legacy content)
|
||||
- *Mitigation:* Validate URLs at startup; filter out 404s
|
||||
- **Rate limits:** Unlikely for small-scale bot, but no published limits documented
|
||||
- *Mitigation:* Cache all data locally; refresh weekly, not per-request
|
||||
|
||||
### Cloudflare Images Risks
|
||||
- **Cost unpredictability:** Transforms per guess; volume scaling unknown
|
||||
- *Mitigation:* Monitor transform count weekly; set alerts at $50/mo
|
||||
- **Service availability:** CDN outage = bot can't render images (falls back to text)
|
||||
- *Mitigation:* Graceful fallback: "Sorry, image unavailable; here's a text hint instead"
|
||||
- **Transform latency:** Edge compute may be <100ms, but add Telegram API roundtrip
|
||||
- *Mitigation:* Pre-compute crop params at startup; cache Image URLs
|
||||
|
||||
### Telegram API Risks
|
||||
- **Message deletion jank:** Deleting + resending on each guess = slow UX
|
||||
- *Mitigation:* Edit message caption (text) instead; keep photo static, update hints in text
|
||||
- **Alternative:** Use editMessageMedia to replace photo in-place (cleaner, if Cloudflare URL stable)
|
||||
- **Inline keyboard timeout:** Users may not guess within reasonable time; stale keyboards
|
||||
- *Mitigation:* 24h timeout per game; archive messages after completion
|
||||
|
||||
### Game Design Risks
|
||||
- **Ability mode too hard:** 850+ icons; players may not recognize obscure abilities
|
||||
- *Mitigation:* Add multiple-choice dropdown (narrow from 170 → 10 candidates); or hint system
|
||||
- **Splash mode too easy:** Full image reveal may happen in <2 guesses for popular champs
|
||||
- *Mitigation:* Start with smaller crop (10% instead of 20%); require more guesses for full reveal
|
||||
|
||||
---
|
||||
|
||||
## 8. Unresolved Questions
|
||||
|
||||
1. **Loldle.net JS bundle:** Does it embed image URLs directly, or fetch from DDragon? Need to decompress and search.
|
||||
2. **Exact guess limit:** How many guesses allowed in daily Ability/Splash before forfeit? Search results mentioned Wordle convention but not Loldle's specific rule.
|
||||
3. **Splash art scope:** Does Loldle include ALL skins or a curated subset? DDragon lists 10+ skins per champ; scraping all is safe but may inflate data.
|
||||
4. **Ability hint system:** Does ability mode provide any visual feedback (e.g., "close"/"warmer") or is it binary? Confirmation from X post suggests binary.
|
||||
5. **Image URL stability:** Are Cloudflare Image Resizing URLs cacheable by Telegram clients, or regenerated per request? Affects message edit efficiency.
|
||||
6. **Legacy champion coverage:** Do all 170+ champions have ability icons in DDragon? Or are alpha/removed champs missing?
|
||||
7. **Performance baseline:** Average response time from guess → image delivery in production. Need benchmark on low-power Workers.
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommendation Summary
|
||||
|
||||
| Aspect | Finding |
|
||||
|--------|---------|
|
||||
| **Gameplay Mechanics** | Confirmed: Ability = binary guessing; Splash = progressive zoom reveal. Both feasible to port. |
|
||||
| **Data Source** | DDragon (Option A) > Loldle JS scraping (Option B). Official, stable, no brittle parsing. |
|
||||
| **Scraping Feasibility** | ✅ Yes. DDragon champion.json includes all ability icons + skin IDs. One-time cache per patch. |
|
||||
| **Image Source** | DDragon CDN URLs are standardized, documented, and reliable. Verified URL patterns. |
|
||||
| **Telegram Adaptation** | Cloudflare Image Resizing (Option A) best preserves UX. Option B (no crop) viable but kills appeal. Option C (in-Worker processing) not feasible. |
|
||||
| **Implementation Complexity** | Low-medium. Fetch + cache + URL construction + Telegram inline keyboards. ~300–500 LOC per mode. |
|
||||
| **Cost** | ~$5–15/month (Cloudflare Images transforms) + existing Workers bill. |
|
||||
| **Risk Level** | Low-medium. DDragon stable; Image API documented; Telegram API mature. Main hazards: cost overruns, user adoption (difficulty tuning). |
|
||||
|
||||
**Next Step:** Confirm Loldle's guess limit and verify image URL stability via live game testing on ability/splash modes. Then proceed to implementation plan.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [LoLdle Answers Today (Daily Solutions)](https://www.esports.net/wiki/guides/loldle-answers-today/)
|
||||
- [LOLDLE Answer Today: Classic, Quote, Ability, Emoji & Splash](https://phonenumble.com/loldle-wordle/)
|
||||
- [LoLdle – Splash Mode](https://loldle.net/splash)
|
||||
- [LoLdle – Ability Mode](https://loldle.net/ability)
|
||||
- [LoLdle Bonus Ability Guess (Passive/Q/W/E/R)](https://x.com/loldlegame/status/1583815117355249665)
|
||||
- [GitHub: joulsen/loldle-information-theory](https://github.com/joulsen/loldle-information-theory)
|
||||
- [GitHub: Kerrders/LoLdleData](https://github.com/Kerrders/LoLdleData)
|
||||
- [Riot API Libraries: Data Dragon Documentation](https://riot-api-libraries.readthedocs.io/en/latest/ddragon.html)
|
||||
- [HexTech Docs: Data Dragon](https://hextechdocs.dev/data-dragon/)
|
||||
- [Cloudflare Images: Transform via Workers](https://developers.cloudflare.com/images/transform-images/transform-via-workers/)
|
||||
- [Cloudflare Images: Cropping Features](https://developers.cloudflare.com/images/optimization/features/)
|
||||
- [GitHub: cvzi/telegram-bot-cloudflare](https://github.com/cvzi/telegram-bot-cloudflare)
|
||||
- [Telegram Bot API: Inline Keyboards and Message Editing](https://core.telegram.org/bots/api)
|
||||
- [grammY: Inline and Custom Keyboards](https://grammy.dev/plugins/keyboard)
|
||||
- [Data Dragon API – Tested Daily](https://www.freepublicapis.com/data-dragon-api/)
|
||||
@@ -0,0 +1,179 @@
|
||||
# Loldle Modes & Emoji Mode Research Report
|
||||
|
||||
**Date:** April 24, 2026
|
||||
**Scope:** All Loldle game modes discovery + Emoji mode technical analysis + cross-mode data audit
|
||||
|
||||
---
|
||||
|
||||
## Section 1: Complete Loldle Modes Inventory
|
||||
|
||||
All five game modes confirmed as of April 2026:
|
||||
|
||||
| Mode | URL | Type | Resets | Description |
|
||||
|------|-----|------|--------|-------------|
|
||||
| **classic** | `loldle.net/` or `loldle.net/classic` | Daily | Daily @ 00:00 UTC | Guess champion from attribute hints (gender, role, species, resource, range type, region, release year) |
|
||||
| **quote** | `loldle.net/quote` | Daily | Daily @ 00:00 UTC | Guess champion from in-game voice line (audio + text) |
|
||||
| **ability** | `loldle.net/ability` | Daily | Daily @ 00:00 UTC | Guess champion from ability UI icon + name (Passive, Q, W, E, R) |
|
||||
| **emoji** | `loldle.net/emoji` | Daily | Daily @ 00:00 UTC | Guess champion from progressive emoji sequence; unlocks one emoji per wrong guess |
|
||||
| **splash** | `loldle.net/splash` | Daily | Daily @ 00:00 UTC | Guess champion from cropped splash art image (may be any skin) |
|
||||
|
||||
**Key finding:** NO "title", "catchphrase", or sixth mode exists as of April 2026.
|
||||
|
||||
**Unlimited variant:** loldle.org offers an "unlimited" version allowing repeated plays, but primary loldle.net modes are daily-only.
|
||||
|
||||
---
|
||||
|
||||
## Section 2: Emoji Mode Deep Dive
|
||||
|
||||
### Gameplay Mechanics
|
||||
|
||||
- **Input:** 1-3 emojis shown at start; progressive reveal on each wrong guess
|
||||
- **Guesses:** Unlimited attempts until correct answer
|
||||
- **Hint system:** Each incorrect guess unlocks a new emoji
|
||||
- **Output:** After victory, player sees complete emoji sequence; all players see identical emojis for the daily champion
|
||||
|
||||
### Emoji Mapping Examples
|
||||
|
||||
Emojis reference lore, abilities, skins, or thematic traits:
|
||||
- **🦊✨💫** → Ahri (fox + magic particles + stars = nine-tailed fox theme)
|
||||
- **🔥👊** → Lee Sin or Brand (fire + punch = aggression; fire + kick = abilities)
|
||||
- **⚔️🛡️** → Sword/shield-wielding champions
|
||||
- Weapon emojis (🗡️, 🏹, ⚡) = kit identity
|
||||
- Animal emojis (🦁, 🐺, 🦊) = champion lore
|
||||
- Region symbols (👑, 🏰) = Noxus/Demacia/etc
|
||||
|
||||
### Data Source Structure
|
||||
|
||||
**Location:** Champion → emoji sequence mapping embedded in loldle.net JavaScript bundle
|
||||
|
||||
**Extraction method:**
|
||||
1. Inspect `www.loldle.net` page source (DevTools)
|
||||
2. Locate champion data in bundled JS file
|
||||
3. Extract `{championName: "emojiSequence"}` mappings
|
||||
4. Store as JSON for bot reuse
|
||||
|
||||
**Structure (inferred):**
|
||||
```json
|
||||
{
|
||||
"Ahri": "🦊✨💫🌙",
|
||||
"LeeSin": "🔥👊🌊🥋",
|
||||
"Brand": "🔥💣☠️",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Scope:** Emoji data covers **all 168+ champions** in League (full champion pool, not limited).
|
||||
|
||||
---
|
||||
|
||||
## Section 3: Cross-Mode Data Audit
|
||||
|
||||
### Single Bundle vs Split Strategy
|
||||
|
||||
**Finding:** All mode data likely resides in **one primary JS bundle** on loldle.net:
|
||||
|
||||
1. **Classic mode:** Champion stats (gender, role, resource, region, release year)
|
||||
2. **Quote mode:** Champion voice lines + audio assets
|
||||
3. **Ability mode:** Champion spell icons + names
|
||||
4. **Emoji mode:** Champion → emoji sequence mapping
|
||||
5. **Splash mode:** Champion skin splash art references
|
||||
|
||||
**Source:** GitHub project `joulsen/loldle-information-theory` confirms data extraction via loldle.net JS bundle inspection. The `resources/loldle-champ-data.json` file is maintained by extracting from live Loldle JS.
|
||||
|
||||
### Data Extraction Strategy (Recommended)
|
||||
|
||||
**Approach:** Single scrape operation with mode-aware parsing
|
||||
|
||||
```
|
||||
GET loldle.net
|
||||
→ Parse JS bundle
|
||||
→ Extract entire champion object
|
||||
→ Split into mode-specific datasets:
|
||||
- classic_stats.json (attributes)
|
||||
- emoji_map.json (emoji sequences)
|
||||
- quotes.json (voice lines)
|
||||
- abilities.json (spell info)
|
||||
- splash_references.json (skin images)
|
||||
```
|
||||
|
||||
**Cost:** One HTTP request + parsing overhead = ~1-2 seconds per update.
|
||||
|
||||
**Frequency:** Daily rotation (mirrors official daily reset @ 00:00 UTC). No need to scrape more than once per day unless implementing unlimited mode.
|
||||
|
||||
---
|
||||
|
||||
## Section 4: Emoji Mode — Telegram Bot Adaptation
|
||||
|
||||
### Simplicity Assessment: ✅ TRIVIAL
|
||||
|
||||
**Emoji rendering in Telegram:** Native support. Zero translation overhead.
|
||||
|
||||
**Bot implementation outline:**
|
||||
1. Load emoji_map.json (champion → emojis)
|
||||
2. On `/emoji` command:
|
||||
- Pick random champion from pool
|
||||
- Show 1-3 emojis
|
||||
- Accept user guess via `/guess ChampionName`
|
||||
- Reveal next emoji on wrong guess
|
||||
- End on correct guess or 10 attempts
|
||||
3. Track guesses per user per day (daily reset @ 00:00 UTC)
|
||||
|
||||
**Complexity:** ~50-100 lines of Node.js (much simpler than classic mode).
|
||||
|
||||
---
|
||||
|
||||
## Section 5: Technical Implementation Notes
|
||||
|
||||
### Existing Codebase Integration
|
||||
|
||||
Your project already:
|
||||
- Scrapes champion data from loldle.net JS bundle ✅
|
||||
- Stores as JSON ✅
|
||||
- Classic mode operational ✅
|
||||
|
||||
**Emoji mode add-on requires:**
|
||||
1. Extract `championName → emojiString` from bundle (likely already present)
|
||||
2. Parse emojis into array for progressive reveal
|
||||
3. Add `/emoji` command handler (~100 LOC)
|
||||
4. Reuse existing daily reset logic
|
||||
|
||||
### Data Freshness
|
||||
|
||||
- Loldle updates champion pool when new champs release (rare, ~1-2/year)
|
||||
- Emoji sequences stable for existing champions
|
||||
- Daily puzzle seed: separate rotation (independent per mode)
|
||||
- **Scrape frequency:** Once per day or on-demand after new champion release
|
||||
|
||||
### Limitations
|
||||
|
||||
1. **Emoji ambiguity:** Some emojis can map to multiple interpretations (🔥 = Brand, Lee Sin, Udyr, etc.). Loldle handles this via progressive reveal.
|
||||
2. **Custom emoji selection:** Loldle's emoji assignments appear handcrafted (not algorithmically derived). You cannot compute emojis on-the-fly; must extract from their data.
|
||||
3. **Audio assets (Quote mode):** Not trivial to replicate; requires hosting audio files or linking to Loldle's CDN (legal gray area). Emoji mode avoids this entirely.
|
||||
|
||||
---
|
||||
|
||||
## Section 6: Unresolved Questions
|
||||
|
||||
1. **Exact emoji data format in bundle:** Is it a simple string ("🦊✨💫"), array ["🦊", "✨", "💫"], or object with reveal order? → Requires bundle inspection
|
||||
2. **Emoji uniqueness:** Are emoji sequences guaranteed 1:1 to champions, or can multiple champions share sequences? → Likely 1:1 but unconfirmed
|
||||
3. **Future mode expansion:** Loldle.net roadmap (if public) — any planned new modes? → Not found in search results
|
||||
4. **Unlimited mode emoji data:** Does loldle.org use identical emoji mappings as loldle.net? → Likely yes (separate frontend, same data)
|
||||
5. **Regional CDN:** Does loldle.net serve different data to different regions? → Probably not (Wordle-style games are region-agnostic)
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [LoLdle Game Modes Overview — Phone Numble](https://phonenumble.com/loldle-wordle/)
|
||||
- [LoLdle Answers Today — GFinityEsports](https://www.gfinityesports.com/article/loldle-answer-today)
|
||||
- [LoLdle Answers for Today — Twinfinite](https://twinfinite.net/guides/loldle-answers-today/)
|
||||
- [LoLdle Official Site](https://loldle.net/)
|
||||
- [LoLdle Emoji Mode](https://loldle.net/emoji)
|
||||
- [LoLdle Information Theory Solver — GitHub](https://github.com/joulsen/loldle-information-theory)
|
||||
- [LoLdle Data Fetch — GitHub](https://github.com/Kerrders/LoLdleData)
|
||||
- [LOL Champions Data — GitHub ngryman](https://github.com/ngryman/lol-champions)
|
||||
- [LoLdle Unlimited Variant — loldle.org](https://loldle.org/unlimited)
|
||||
|
||||
---
|
||||
|
||||
**Report Status:** COMPLETE. All five modes documented. Emoji mode analyzed as "trivial for Telegram adaptation." Cross-mode data audit suggests single-bundle extraction is feasible.
|
||||
@@ -0,0 +1,278 @@
|
||||
# Loldle Quote Mode Research Report
|
||||
|
||||
**Date:** 2026-04-24
|
||||
**Focus:** Loldle Quote mode mechanics, data sources, and Telegram bot adaptation feasibility
|
||||
|
||||
---
|
||||
|
||||
## 1. Gameplay Mechanics
|
||||
|
||||
**Core Loop:**
|
||||
- Player presented with champion **quote text** (single line of in-game dialogue)
|
||||
- Player has up to **6 incorrect guesses** to identify the champion
|
||||
- After 6 failed guesses, **audio clue unlocks** — the voice track of the champion speaking that exact quote
|
||||
- Binary feedback: correct/incorrect (no gradual hints like classic mode)
|
||||
- Daily reset at 00:00 UTC (one quote per day, same for all players)
|
||||
|
||||
**Difficulty Factor:**
|
||||
- Many champions share similar tone, thematic dialogue, generic lines
|
||||
- Short quotes often feel interchangeable across champions
|
||||
- Audio hint helps but champions with similar-sounding voices remain ambiguous
|
||||
- Requires genuine champion knowledge, not just systematic elimination (unlike classic mode)
|
||||
|
||||
**Comparison to Classic Mode:**
|
||||
- Classic mode: feedback based on champion metadata (region, year, role, etc.)
|
||||
- Quote mode: immediate right/wrong, then audio clue only
|
||||
- Quote mode is harder — no attribute-based elimination strategy
|
||||
|
||||
---
|
||||
|
||||
## 2. Data Source & Infrastructure
|
||||
|
||||
### Quote Text Source
|
||||
**WHERE:** Embedded in client-side JavaScript bundle (minified `app.{hash}.js`)
|
||||
|
||||
**HOW TO ACCESS:**
|
||||
1. Visit https://loldle.net/quote
|
||||
2. Extract minified bundle from page source (find `app.xxx.js` in script tags)
|
||||
3. Search bundle using regex: `championId` property locates champion data
|
||||
4. Use extraction script (see: joulsen/loldle-information-theory repo)
|
||||
|
||||
**FOUND REPOSITORY:** [joulsen/loldle-information-theory](https://github.com/joulsen/loldle-information-theory)
|
||||
- Provides `resources/extract-champlist.py` for automated extraction
|
||||
- Provides pre-extracted `resources/loldle-champ-data.json`
|
||||
- Regex pattern: `=(\\\[\\{\_id:"\[^{}\]+championId:".+?\\}\\\])`
|
||||
|
||||
**DATA STRUCTURE:** Quote data likely stored same way as classic-mode champion data:
|
||||
- Each champion has array of properties (name, region, role, hp, etc.)
|
||||
- Quote mode adds `quote` field with the dialogue text
|
||||
- Quote may map to voice line ID or include URL reference
|
||||
|
||||
### Audio Source
|
||||
**WHERE:** Likely League of Legends Wiki or Riot-hosted CDN (cached during quote reveal)
|
||||
|
||||
**MECHANICS:**
|
||||
- Initially: only text shown
|
||||
- After 6 wrong guesses: audio file loads via HTTPS
|
||||
- Probable source: Riot Games CDN (per League Wiki structure)
|
||||
- Format: OGG or MP3 (standard web audio)
|
||||
- No direct URL exposed in initial puzzle request (audio fetched only after hint unlock)
|
||||
|
||||
### Cache Endpoint
|
||||
**https://cache.loldle.net/cache.json**
|
||||
- Response is **Salted base64-encoded** (OpenSSL encryption)
|
||||
- Contains aggregated game state/metadata
|
||||
- Cannot be directly parsed without decryption key
|
||||
- Likely syncs game state across devices, not primary data source
|
||||
|
||||
### Data Freshness
|
||||
- Quote data baked into JS bundle (no live API call for quote text)
|
||||
- Audio file fetched at hint reveal (cached, not live-generated)
|
||||
- Bundle updates when new champions added or quotes change (likely patch-synced)
|
||||
|
||||
---
|
||||
|
||||
## 3. Scraping Feasibility
|
||||
|
||||
### ✅ Can We Extract Quote-Champion Pairs?
|
||||
|
||||
**YES** — with caveats:
|
||||
|
||||
**Option A: Direct Bundle Extraction (Reliable)**
|
||||
```
|
||||
1. Fetch https://loldle.net/quote
|
||||
2. Parse HTML, find <script> with app bundle URL
|
||||
3. Download app.{hash}.js
|
||||
4. Extract using regex: championId property block
|
||||
5. Convert minified JS to JSON (via js-to-json converter)
|
||||
6. Filter for quote-only champions (some may lack quotes)
|
||||
7. Store as JSON: [ { name, quote, championId }, ... ]
|
||||
```
|
||||
|
||||
**Exact Regex:** `=(\\\[\\{\_id:"\[^{}\]+championId:".+?\\}\\\])`
|
||||
|
||||
**Output File:** Pre-made at [joulsen repo](https://github.com/joulsen/loldle-information-theory/blob/master/resources/loldle-champ-data.json)
|
||||
|
||||
**Option B: API Reverse-Engineering (Uncertain)**
|
||||
- No public loldle.net API endpoint discovered
|
||||
- cache.json encrypted (not viable)
|
||||
- Quote-of-the-day: only exposed via frontend; no direct REST endpoint found
|
||||
- Would require Cloudflare Workers interception (harder, rate-limited)
|
||||
|
||||
### ⚠️ Limitations
|
||||
|
||||
- **Only daily quote exposed:** True historical quote list not documented
|
||||
- Bundle hash changes on updates: extraction must re-run per patch
|
||||
- **No official API:** Community consensus is bundle extraction only method
|
||||
- New champions may lack quotes (deprecated `championId` field noted in joulsen repo)
|
||||
|
||||
### 📊 Data Format Expected
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "Ahri",
|
||||
"championId": 103,
|
||||
"quote": "The true face of desire.",
|
||||
"audioUrl": null // only populated after hint unlock
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Telegram Bot Adaptation
|
||||
|
||||
### Architecture Design
|
||||
|
||||
**Bot Module: `/loldle/modes/quote.js`**
|
||||
|
||||
```
|
||||
User sends: /loldle-quote
|
||||
Bot responds:
|
||||
1. Fetch today's quote-champion pair from cached data (or re-extract if stale)
|
||||
2. Send Markdown message:
|
||||
```
|
||||
🎭 **Today's Quote**
|
||||
"The true face of desire."
|
||||
|
||||
Guess the champion (6 attempts remaining)
|
||||
```
|
||||
3. User replies: /guess Ahri
|
||||
4. Bot checks against answer, updates attempt counter
|
||||
5. After 6 fails, reply with: *Audio hint unlocked!* [voice message file_id]
|
||||
```
|
||||
|
||||
### Telegram Media Handling
|
||||
|
||||
**Text Only (RECOMMENDED):**
|
||||
- Send quote as Markdown code block
|
||||
- Users guess via `/guess ChampionName`
|
||||
- Audio unnecessary for bot (text-based is cleaner)
|
||||
- **Pros:** Fast, no storage, text-searchable logs
|
||||
- **Cons:** Loses immersion of original web game
|
||||
|
||||
**Text + Optional Audio (ADVANCED):**
|
||||
- After hint unlock, fetch voice line from LoL Wiki/CDN
|
||||
- Send via `sendVoice()` API (Telegram supports OGG, MP3)
|
||||
- Requires one-time download + local cache or stream from CDN
|
||||
- **Pros:** Full feature parity with web
|
||||
- **Cons:** Storage overhead, CDN bandwidth cost, TOS risk (Riot asset rehost)
|
||||
|
||||
**Recommendation:** Text-only. Simpler, faster, no legal/storage issues. Audio hint can be optional (`/hint` command triggers audio fetch).
|
||||
|
||||
### Bot Command Set
|
||||
```
|
||||
/loldle-quote → Today's quote puzzle
|
||||
/guess <champion> → Submit guess
|
||||
/hint → Unlock audio (after 6 fails)
|
||||
/skip → Give up, reveal answer
|
||||
/quote-stats → Player's quote-mode stats
|
||||
```
|
||||
|
||||
### Data Storage (Cloudflare D1)
|
||||
```sql
|
||||
CREATE TABLE loldle_quote_attempts (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id INT,
|
||||
date TIMESTAMP DEFAULT NOW(),
|
||||
champion TEXT,
|
||||
guesses_used INT (0-7),
|
||||
solved BOOLEAN,
|
||||
quote_text TEXT
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Pool Size & Champion Coverage
|
||||
|
||||
### Total Champions Available
|
||||
- **~165 champions** in current League roster
|
||||
- **ALL have voice lines** (via League Wiki)
|
||||
- **Estimated ~140-155 have "iconic" quotes** in loldle pool (inferred)
|
||||
|
||||
### Why Not 165?
|
||||
- Some newer/reworked champions may lack distinct quotes in loldle's curated set
|
||||
- Loldle creator (Pimeko) likely hand-selected quotes for memorability
|
||||
- Deprecated `championId` field in newer champions suggests staggered adoption
|
||||
|
||||
### Quote Dataset References
|
||||
1. [Allan-Cao/lol-voice-lines](https://github.com/Allan-Cao/lol-voice-lines) — 163 champions, cleaned quotes
|
||||
2. [Kaggle: League Voice Lines 13.10](https://www.kaggle.com/datasets/taupiphi/league-of-legends-voice-lines) — Patch 13.10 snapshot
|
||||
3. [League Wiki Champion Audio](https://wiki.leagueoflegends.com/en-us/Category:LoL_Champion_audio) — 175+ pages, official source
|
||||
|
||||
---
|
||||
|
||||
## 6. Technical Recommendations
|
||||
|
||||
### For Bot Implementation
|
||||
|
||||
**Priority 1: Extract Quote Data**
|
||||
```bash
|
||||
curl https://loldle.net/quote | grep -oP 'src="[^"]*app\.[a-z0-9]+\.js"' | xargs curl > bundle.js
|
||||
python3 ~/.claude/skills/extract-quotes.py bundle.js > quotes.json
|
||||
```
|
||||
|
||||
**Priority 2: Build Quote Module**
|
||||
- Fetch from D1 cache (or re-extract weekly)
|
||||
- Hash-based dedup (same quote, different champions → handle edge case)
|
||||
- Timezone handling (UTC reset, but bot may serve multiple timezones)
|
||||
|
||||
**Priority 3: Integrate with Existing Classic Mode**
|
||||
- Reuse champion list, verification logic
|
||||
- Add `/loldle` menu: classic | quote | ability | emoji | splash (when ready)
|
||||
|
||||
### Risk Assessment
|
||||
|
||||
| Risk | Level | Mitigation |
|
||||
|------|-------|-----------|
|
||||
| Riot TOS (champion data) | LOW | Quote data is public on loldle.net; rehost only curated subset |
|
||||
| Audio CDN bandwidth | MED | Skip audio feature; or fetch on-demand and cache 24h |
|
||||
| Bundle extraction brittleness | MED | Monitor for hash changes; add fallback to joulsen repo cache |
|
||||
| Daily reset race condition | LOW | Use UTC timestamp, cache daily answer at 00:01 UTC |
|
||||
| Quote ambiguity false positives | LOW | Case-insensitive matching, accept "Ahri" or "AHRI" |
|
||||
|
||||
### Estimated Effort
|
||||
- **Data Extraction:** 2-4 hours (prototype extraction script)
|
||||
- **Bot Commands:** 3-6 hours (reuse classic mode structure)
|
||||
- **Audio Integration:** 4-8 hours (if audio feature included; skip for MVP)
|
||||
- **Testing:** 2-3 hours
|
||||
- **Total MVP (text-only):** ~8-12 hours
|
||||
|
||||
---
|
||||
|
||||
## Key Findings Summary
|
||||
|
||||
1. ✅ **Quote data IS extractable:** Embedded in loldle.net JS bundle, regex-accessible
|
||||
2. ✅ **No API barrier:** Bundle extraction beats API reverse-engineering (no auth, no rate limits)
|
||||
3. ✅ **~150 champions supported:** Enough diversity for daily rotation without repeats (400+ days)
|
||||
4. ✅ **Telegram-friendly:** Text quotes work perfectly; audio is optional complexity
|
||||
5. ⚠️ **Audio source ambiguous:** Likely LoL Wiki/CDN but not documented; fetch at hint-reveal only
|
||||
6. ⚠️ **One quote per day:** Only today's quote exposed; historical quotes unavailable (not ideal for infinite mode)
|
||||
|
||||
---
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
1. **Where exactly is audio hosted?** Riot CDN vs. LoL Wiki vs. loldle.net's own cache — needs network inspection
|
||||
2. **Do ALL ~165 champions have quotes in loldle's pool?** Or curated subset? Exact count unconfirmed
|
||||
3. **Can we extract entire quote history?** Only today's quote is documented; older puzzles not exposed
|
||||
4. **What's the bundle hash update frequency?** Is it per-patch or more granular? Impacts extraction stability
|
||||
5. **Are voice lines guaranteed to be stable?** Or do champions get re-voiced, causing quote mismatches?
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [Loldle.net - Quote Mode](https://loldle.net/quote)
|
||||
- [Phone Numble - LoLdle Answer Guide](https://phonenumble.com/loldle-wordle/)
|
||||
- [GGrecon - LoLdle Answers](https://www.ggrecon.com/word-games/loldle-answer-today/)
|
||||
- [Esports.net - LoLdle Answers & Guides](https://www.esports.net/wiki/guides/loldle-answers-today/)
|
||||
- [Digi Magazine - LoLdle Answers](https://digimagazine.net/games/loldle-answers/)
|
||||
- [GitHub - joulsen/loldle-information-theory](https://github.com/joulsen/loldle-information-theory)
|
||||
- [GitHub - Allan-Cao/lol-voice-lines](https://github.com/Allan-Cao/lol-voice-lines)
|
||||
- [Kaggle - League of Legends Voice Lines](https://www.kaggle.com/datasets/taupiphi/league-of-legends-voice-lines)
|
||||
- [League of Legends Wiki - Champion Audio](https://wiki.leagueoflegends.com/en-us/Category:LoL_Champion_audio)
|
||||
- [GitHub - Peter-DeVries/Discord-LoLdle-Bot](https://github.com/Peter-DeVries/Discord-LoLdle-Bot)
|
||||
- [GitHub - Derpthemeus/LeagueOfQuotes](https://github.com/Derpthemeus/LeagueOfQuotes)
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/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;
|
||||
}
|
||||
|
||||
// ───── 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)`);
|
||||
@@ -12,6 +12,8 @@ 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"),
|
||||
misc: () => import("./misc/index.js"),
|
||||
trading: () => import("./trading/index.js"),
|
||||
lolschedule: () => import("./lolschedule/index.js"),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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 <champion>` |
|
||||
| `/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:<subject>` — active round state
|
||||
- `stats:<subject>` — `{ 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.
|
||||
@@ -0,0 +1,690 @@
|
||||
[
|
||||
{
|
||||
"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": "🧝 🌿 🔮"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @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, clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js";
|
||||
|
||||
const POOL = emojisData.filter((c) => c.emojis && c.emojis.trim().length > 0);
|
||||
|
||||
const NEW_ROUND_HINT =
|
||||
"🆕 Send <code>/loldle_emoji</code> or <code>/loldle_emoji <champion></code> 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) {
|
||||
const existing = await loadGame(db, subject);
|
||||
if (existing && existing.guesses.length < MAX_GUESSES) 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 game = await getOrInitGame(db, subject);
|
||||
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, MAX_GUESSES), {
|
||||
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(
|
||||
`🔁 <b>${escapeHtml(guess.championName)}</b> 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! <b>${answer}</b> — solved in ${game.guesses.length}/${MAX_GUESSES}\n🔥 Streak: ${s.streak}\n${NEW_ROUND_HINT}`,
|
||||
{ parse_mode: "HTML" },
|
||||
);
|
||||
}
|
||||
|
||||
if (game.guesses.length >= MAX_GUESSES) {
|
||||
await recordResult(db, subject, false);
|
||||
await clearGame(db, subject);
|
||||
return ctx.reply(
|
||||
`${renderBoard(target.emojis, game.guesses, MAX_GUESSES)}\n\n❌ Out of guesses. Answer was <b>${answer}</b>.\n${NEW_ROUND_HINT}`,
|
||||
{ parse_mode: "HTML" },
|
||||
);
|
||||
}
|
||||
|
||||
await saveGame(db, subject, game);
|
||||
return ctx.reply(
|
||||
`${renderBoard(target.emojis, game.guesses, MAX_GUESSES)}\n\n❌ Not <b>${escapeHtml(guess.championName)}</b>. Guess ${game.guesses.length}/${MAX_GUESSES}.`,
|
||||
{ parse_mode: "HTML" },
|
||||
);
|
||||
}
|
||||
|
||||
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 <b>${escapeHtml(existing.target)}</b>.\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}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @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, 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),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default loldleEmojiModule;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @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 <code>/loldle_emoji <champion></code>.`;
|
||||
}
|
||||
const lines = guesses.map((name) => ` • ${escapeHtml(name)} ❌`).join("\n");
|
||||
return `${clue}\n\nGuesses (${guesses.length}/${max}):\n${lines}`;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @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:<subject> -> { target, guesses, startedAt }
|
||||
* stats:<subject> -> { played, wins, streak, bestStreak }
|
||||
*
|
||||
* Shape matches classic loldle intentionally — different module prefix
|
||||
* keeps stats isolated per mode.
|
||||
*/
|
||||
|
||||
const MAX_GUESSES = 5;
|
||||
const GAME_TTL_SECONDS = 60 * 60 * 24 * 7;
|
||||
|
||||
const gameKey = (subject) => `game:${subject}`;
|
||||
const statsKey = (subject) => `stats:${subject}`;
|
||||
|
||||
export { MAX_GUESSES };
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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 <champion>` |
|
||||
| `/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:<subject>` — active round state
|
||||
- `stats:<subject>` — `{ 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:
|
||||
|
||||
```
|
||||
"<champion title> — <first sentence of lore>"
|
||||
```
|
||||
|
||||
The champion's own name is redacted with `___` so it isn't a giveaway.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @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, clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js";
|
||||
|
||||
const POOL = quotesData.filter((c) => c.quote && c.quote.trim().length > 0);
|
||||
|
||||
const NEW_ROUND_HINT =
|
||||
"🆕 Send <code>/loldle_quote</code> or <code>/loldle_quote <champion></code> 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) {
|
||||
const existing = await loadGame(db, subject);
|
||||
if (existing && existing.guesses.length < MAX_GUESSES) 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 game = await getOrInitGame(db, subject);
|
||||
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, MAX_GUESSES), {
|
||||
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(
|
||||
`🔁 <b>${escapeHtml(guess.championName)}</b> 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! <b>${answer}</b> — solved in ${game.guesses.length}/${MAX_GUESSES}\n🔥 Streak: ${s.streak}\n${NEW_ROUND_HINT}`,
|
||||
{ parse_mode: "HTML" },
|
||||
);
|
||||
}
|
||||
|
||||
if (game.guesses.length >= MAX_GUESSES) {
|
||||
await recordResult(db, subject, false);
|
||||
await clearGame(db, subject);
|
||||
return ctx.reply(
|
||||
`${renderBoard(target.quote, game.guesses, MAX_GUESSES)}\n\n❌ Out of guesses. Answer: <b>${answer}</b>.\n${NEW_ROUND_HINT}`,
|
||||
{ parse_mode: "HTML" },
|
||||
);
|
||||
}
|
||||
|
||||
await saveGame(db, subject, game);
|
||||
return ctx.reply(
|
||||
`${renderBoard(target.quote, game.guesses, MAX_GUESSES)}\n\n❌ Not <b>${escapeHtml(guess.championName)}</b>. Guess ${game.guesses.length}/${MAX_GUESSES}.`,
|
||||
{ parse_mode: "HTML" },
|
||||
);
|
||||
}
|
||||
|
||||
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: <b>${escapeHtml(existing.target)}</b>.\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}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @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, 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),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default loldleQuoteModule;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
[
|
||||
{
|
||||
"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."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @file Render the quote board — italic quote block + list of wrong guesses.
|
||||
* Quote text is HTML-escaped before wrapping in <i> 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 = `🎭 <i>${escapeHtml(quote)}</i>`;
|
||||
if (guesses.length === 0) {
|
||||
return `${clue}\n\nNo guesses yet. Reply with <code>/loldle_quote <champion></code>.`;
|
||||
}
|
||||
const lines = guesses.map((name) => ` • ${escapeHtml(name)} ❌`).join("\n");
|
||||
return `${clue}\n\nGuesses (${guesses.length}/${max}):\n${lines}`;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @file Game + stats persistence for loldle-quote. Shape identical to
|
||||
* loldle-emoji and classic loldle — only MAX_GUESSES differs.
|
||||
*/
|
||||
|
||||
const MAX_GUESSES = 6;
|
||||
const GAME_TTL_SECONDS = 60 * 60 * 24 * 7;
|
||||
|
||||
const gameKey = (subject) => `game:${subject}`;
|
||||
const statsKey = (subject) => `stats:${subject}`;
|
||||
|
||||
export { MAX_GUESSES };
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -3,11 +3,7 @@
|
||||
* Match is case/space/punctuation-insensitive with a unique-prefix fallback.
|
||||
*/
|
||||
|
||||
function normalize(s) {
|
||||
return String(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
import { normalize } from "../../util/normalize-name.js";
|
||||
|
||||
/**
|
||||
* @param {Array<Record<string, any>>} champions
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @file normalize-name — case/space/punctuation-insensitive string folder.
|
||||
*
|
||||
* Shared across loldle-family modules so champion-name input ("Kai'Sa",
|
||||
* "kaisa", "KAI SA") collapses to the same comparable form.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {unknown} s — coerced to string.
|
||||
* @returns {string} lowercase, alphanumeric-only.
|
||||
*/
|
||||
export function normalize(s) {
|
||||
return String(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStore } from "../../../src/db/create-store.js";
|
||||
import emojisData from "../../../src/modules/loldle-emoji/emojis.json" with { type: "json" };
|
||||
import {
|
||||
handleEmoji,
|
||||
handleGiveup,
|
||||
handleStats,
|
||||
} from "../../../src/modules/loldle-emoji/handlers.js";
|
||||
import { loadStats } from "../../../src/modules/loldle-emoji/state.js";
|
||||
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
|
||||
|
||||
// Pin randomness so pickRandom() always returns emojisData[0].
|
||||
function pinRandom(toIndex, poolSize) {
|
||||
vi.spyOn(Math, "random").mockReturnValue(toIndex / poolSize);
|
||||
}
|
||||
|
||||
function makeCtx({ text = "", fromId = 1, chatType = "private", chatId = 1 } = {}) {
|
||||
const replies = [];
|
||||
return {
|
||||
replies,
|
||||
ctx: {
|
||||
from: { id: fromId },
|
||||
chat: { id: chatId, type: chatType },
|
||||
message: { text },
|
||||
reply: async (body, opts) => {
|
||||
replies.push({ body, opts });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("loldle-emoji handlers — happy path", () => {
|
||||
let db;
|
||||
beforeEach(() => {
|
||||
db = createStore("loldle-emoji", { KV: makeFakeKv() });
|
||||
pinRandom(0, emojisData.length);
|
||||
});
|
||||
|
||||
it("no-arg call shows the clue and an empty board", async () => {
|
||||
const { ctx, replies } = makeCtx();
|
||||
await handleEmoji(ctx, db);
|
||||
expect(replies).toHaveLength(1);
|
||||
expect(replies[0].body).toContain("🎭 ");
|
||||
expect(replies[0].body).toContain("No guesses yet.");
|
||||
expect(replies[0].opts.parse_mode).toBe("HTML");
|
||||
});
|
||||
|
||||
it("correct guess increments stats and ends the round", async () => {
|
||||
const target = emojisData[0].championName;
|
||||
const { ctx, replies } = makeCtx({ text: `/loldle_emoji ${target}` });
|
||||
await handleEmoji(ctx, db);
|
||||
expect(replies[0].body).toContain("🎉 Got it!");
|
||||
expect(replies[0].body).toContain(target);
|
||||
const stats = await loadStats(db, 1);
|
||||
expect(stats).toMatchObject({ played: 1, wins: 1, streak: 1 });
|
||||
});
|
||||
|
||||
it("wrong guess leaves the round open and no stats", async () => {
|
||||
const target = emojisData[0].championName;
|
||||
const wrong = emojisData[1].championName;
|
||||
const { ctx, replies } = makeCtx({ text: `/loldle_emoji ${wrong}` });
|
||||
await handleEmoji(ctx, db);
|
||||
expect(replies[0].body).toContain("❌");
|
||||
expect(replies[0].body).toContain(target === wrong ? target : wrong);
|
||||
const stats = await loadStats(db, 1);
|
||||
expect(stats.played).toBe(0);
|
||||
});
|
||||
|
||||
it("giveup records a loss and clears the round", async () => {
|
||||
// seed a round first
|
||||
const { ctx: c1 } = makeCtx();
|
||||
await handleEmoji(c1, db);
|
||||
const { ctx: c2, replies } = makeCtx();
|
||||
await handleGiveup(c2, db);
|
||||
expect(replies[0].body).toContain("🏳️");
|
||||
const stats = await loadStats(db, 1);
|
||||
expect(stats).toMatchObject({ played: 1, wins: 0, streak: 0 });
|
||||
});
|
||||
|
||||
it("unknown champion replies with not-found", async () => {
|
||||
const { ctx, replies } = makeCtx({ text: "/loldle_emoji zzznotreal" });
|
||||
await handleEmoji(ctx, db);
|
||||
expect(replies[0].body).toContain("Champion not found");
|
||||
});
|
||||
|
||||
it("stats includes win rate", async () => {
|
||||
const target = emojisData[0].championName;
|
||||
await handleEmoji(makeCtx({ text: `/loldle_emoji ${target}` }).ctx, db);
|
||||
const { ctx, replies } = makeCtx();
|
||||
await handleStats(ctx, db);
|
||||
expect(replies[0].body).toContain("Wins: 1 (100%)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findChampion } from "../../../src/modules/loldle-emoji/lookup.js";
|
||||
|
||||
const pool = [
|
||||
{ championName: "Aatrox", emojis: "⚔️ 🌍 💪" },
|
||||
{ championName: "Ahri", emojis: "🦊 🏯 🔮" },
|
||||
{ championName: "Kha'Zix", emojis: "👾 👾 💪" },
|
||||
{ championName: "Miss Fortune", emojis: "🧝 ⚓ 🔮" },
|
||||
];
|
||||
|
||||
describe("findChampion (emoji pool)", () => {
|
||||
it("matches case-insensitively", () => {
|
||||
expect(findChampion(pool, "aatrox").championName).toBe("Aatrox");
|
||||
expect(findChampion(pool, "AHRI").championName).toBe("Ahri");
|
||||
});
|
||||
|
||||
it("normalizes punctuation and spaces", () => {
|
||||
expect(findChampion(pool, "kha'zix").championName).toBe("Kha'Zix");
|
||||
expect(findChampion(pool, "khazix").championName).toBe("Kha'Zix");
|
||||
expect(findChampion(pool, "miss fortune").championName).toBe("Miss Fortune");
|
||||
expect(findChampion(pool, "MissFortune").championName).toBe("Miss Fortune");
|
||||
});
|
||||
|
||||
it("returns null for empty input or no match", () => {
|
||||
expect(findChampion(pool, "")).toBeNull();
|
||||
expect(findChampion(pool, "zzz")).toBeNull();
|
||||
});
|
||||
|
||||
it("unique prefix resolves; ambiguous prefix returns null", () => {
|
||||
expect(findChampion(pool, "aat").championName).toBe("Aatrox");
|
||||
const ambig = [...pool, { championName: "Aatrox Prime", emojis: "⚔️" }];
|
||||
expect(findChampion(ambig, "aa")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderBoard } from "../../../src/modules/loldle-emoji/render.js";
|
||||
|
||||
describe("renderBoard (emoji)", () => {
|
||||
it("shows an empty-state hint when no guesses yet", () => {
|
||||
const out = renderBoard("🦊 🏯 🔮", [], 5);
|
||||
expect(out).toContain("🎭 🦊 🏯 🔮");
|
||||
expect(out).toContain("No guesses yet.");
|
||||
});
|
||||
|
||||
it("lists guesses with wrong markers and guess counter", () => {
|
||||
const out = renderBoard("🦊 🏯 🔮", ["Akali", "Aatrox"], 5);
|
||||
expect(out).toContain("Guesses (2/5):");
|
||||
expect(out).toContain("• Akali ❌");
|
||||
expect(out).toContain("• Aatrox ❌");
|
||||
});
|
||||
|
||||
it("HTML-escapes champion names", () => {
|
||||
const out = renderBoard("🦊", ["<script>"], 5);
|
||||
expect(out).toContain("<script>");
|
||||
expect(out).not.toContain("<script>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createStore } from "../../../src/db/create-store.js";
|
||||
import {
|
||||
clearGame,
|
||||
loadGame,
|
||||
loadStats,
|
||||
recordResult,
|
||||
saveGame,
|
||||
} from "../../../src/modules/loldle-emoji/state.js";
|
||||
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
|
||||
|
||||
describe("loldle-emoji state", () => {
|
||||
let db;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createStore("loldle-emoji", { KV: makeFakeKv() });
|
||||
});
|
||||
|
||||
it("round-trips a game", async () => {
|
||||
const state = { target: "Ahri", guesses: ["Akali"], startedAt: 1234 };
|
||||
await saveGame(db, 42, state);
|
||||
expect(await loadGame(db, 42)).toEqual(state);
|
||||
});
|
||||
|
||||
it("clearGame removes the round", async () => {
|
||||
await saveGame(db, 42, { target: "Ahri", guesses: [], startedAt: null });
|
||||
await clearGame(db, 42);
|
||||
expect(await loadGame(db, 42)).toBeNull();
|
||||
});
|
||||
|
||||
it("loadStats returns zeros when absent", async () => {
|
||||
expect(await loadStats(db, 42)).toEqual({
|
||||
played: 0,
|
||||
wins: 0,
|
||||
streak: 0,
|
||||
bestStreak: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("recordResult(true) increments wins+streak and updates bestStreak", async () => {
|
||||
let s = await recordResult(db, 42, true);
|
||||
expect(s).toMatchObject({ played: 1, wins: 1, streak: 1, bestStreak: 1 });
|
||||
s = await recordResult(db, 42, true);
|
||||
expect(s).toMatchObject({ played: 2, wins: 2, streak: 2, bestStreak: 2 });
|
||||
});
|
||||
|
||||
it("recordResult(false) resets streak", async () => {
|
||||
await recordResult(db, 42, true);
|
||||
await recordResult(db, 42, true);
|
||||
const s = await recordResult(db, 42, false);
|
||||
expect(s).toMatchObject({ played: 3, wins: 2, streak: 0, bestStreak: 2 });
|
||||
});
|
||||
|
||||
it("isolates stats per subject", async () => {
|
||||
await recordResult(db, 1, true);
|
||||
await recordResult(db, 2, false);
|
||||
expect((await loadStats(db, 1)).wins).toBe(1);
|
||||
expect((await loadStats(db, 2)).wins).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStore } from "../../../src/db/create-store.js";
|
||||
import {
|
||||
handleGiveup,
|
||||
handleQuote,
|
||||
handleStats,
|
||||
} from "../../../src/modules/loldle-quote/handlers.js";
|
||||
import quotesData from "../../../src/modules/loldle-quote/quotes.json" with { type: "json" };
|
||||
import { loadStats } from "../../../src/modules/loldle-quote/state.js";
|
||||
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
|
||||
|
||||
function pinRandom(toIndex, poolSize) {
|
||||
vi.spyOn(Math, "random").mockReturnValue(toIndex / poolSize);
|
||||
}
|
||||
|
||||
function makeCtx({ text = "", fromId = 1, chatType = "private", chatId = 1 } = {}) {
|
||||
const replies = [];
|
||||
return {
|
||||
replies,
|
||||
ctx: {
|
||||
from: { id: fromId },
|
||||
chat: { id: chatId, type: chatType },
|
||||
message: { text },
|
||||
reply: async (body, opts) => {
|
||||
replies.push({ body, opts });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("loldle-quote handlers — happy path", () => {
|
||||
let db;
|
||||
beforeEach(() => {
|
||||
db = createStore("loldle-quote", { KV: makeFakeKv() });
|
||||
pinRandom(0, quotesData.length);
|
||||
});
|
||||
|
||||
it("no-arg shows italic quote block", async () => {
|
||||
const { ctx, replies } = makeCtx();
|
||||
await handleQuote(ctx, db);
|
||||
expect(replies[0].body).toContain("🎭 <i>");
|
||||
expect(replies[0].body).toContain("No guesses yet.");
|
||||
});
|
||||
|
||||
it("correct guess wins, stats update", async () => {
|
||||
const target = quotesData[0].championName;
|
||||
const { ctx, replies } = makeCtx({ text: `/loldle_quote ${target}` });
|
||||
await handleQuote(ctx, db);
|
||||
expect(replies[0].body).toContain("🎉 Nailed it!");
|
||||
const s = await loadStats(db, 1);
|
||||
expect(s).toMatchObject({ played: 1, wins: 1, streak: 1 });
|
||||
});
|
||||
|
||||
it("giveup records loss", async () => {
|
||||
await handleQuote(makeCtx().ctx, db);
|
||||
const { ctx, replies } = makeCtx();
|
||||
await handleGiveup(ctx, db);
|
||||
expect(replies[0].body).toContain("🏳️");
|
||||
const s = await loadStats(db, 1);
|
||||
expect(s).toMatchObject({ played: 1, wins: 0 });
|
||||
});
|
||||
|
||||
it("stats shows zero-state nicely", async () => {
|
||||
const { ctx, replies } = makeCtx();
|
||||
await handleStats(ctx, db);
|
||||
expect(replies[0].body).toContain("Played: 0");
|
||||
expect(replies[0].body).toContain("Wins: 0 (0%)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findChampion } from "../../../src/modules/loldle-quote/lookup.js";
|
||||
|
||||
const pool = [
|
||||
{ championName: "Garen", quote: "the Might of Demacia" },
|
||||
{ championName: "Gangplank", quote: "the Saltwater Scourge" },
|
||||
{ championName: "Miss Fortune", quote: "the Bounty Hunter" },
|
||||
];
|
||||
|
||||
describe("findChampion (quote pool)", () => {
|
||||
it("matches case- and punctuation-insensitively", () => {
|
||||
expect(findChampion(pool, "garen").championName).toBe("Garen");
|
||||
expect(findChampion(pool, "miss fortune").championName).toBe("Miss Fortune");
|
||||
expect(findChampion(pool, "MissFortune").championName).toBe("Miss Fortune");
|
||||
});
|
||||
|
||||
it("ambiguous prefix returns null", () => {
|
||||
expect(findChampion(pool, "ga")).toBeNull();
|
||||
});
|
||||
|
||||
it("unique prefix resolves", () => {
|
||||
expect(findChampion(pool, "gar").championName).toBe("Garen");
|
||||
expect(findChampion(pool, "gang").championName).toBe("Gangplank");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderBoard } from "../../../src/modules/loldle-quote/render.js";
|
||||
|
||||
describe("renderBoard (quote)", () => {
|
||||
it("wraps quote in italic with emoji prefix", () => {
|
||||
const out = renderBoard("the Demacian", [], 6);
|
||||
expect(out).toContain("🎭 <i>the Demacian</i>");
|
||||
expect(out).toContain("No guesses yet.");
|
||||
});
|
||||
|
||||
it("HTML-escapes quote text before italic wrap", () => {
|
||||
const out = renderBoard('She said "<3"', [], 6);
|
||||
expect(out).toContain("<i>She said "<3"</i>");
|
||||
expect(out).not.toContain('She said "<3"</i>');
|
||||
});
|
||||
|
||||
it("lists wrong guesses with counter", () => {
|
||||
const out = renderBoard("the X", ["Ahri"], 6);
|
||||
expect(out).toContain("Guesses (1/6):");
|
||||
expect(out).toContain("• Ahri ❌");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createStore } from "../../../src/db/create-store.js";
|
||||
import {
|
||||
clearGame,
|
||||
loadGame,
|
||||
loadStats,
|
||||
recordResult,
|
||||
saveGame,
|
||||
} from "../../../src/modules/loldle-quote/state.js";
|
||||
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
|
||||
|
||||
describe("loldle-quote state", () => {
|
||||
let db;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createStore("loldle-quote", { KV: makeFakeKv() });
|
||||
});
|
||||
|
||||
it("round-trips a game", async () => {
|
||||
const state = { target: "Garen", guesses: [], startedAt: null };
|
||||
await saveGame(db, 99, state);
|
||||
expect(await loadGame(db, 99)).toEqual(state);
|
||||
});
|
||||
|
||||
it("clearGame deletes the record", async () => {
|
||||
await saveGame(db, 99, { target: "Garen", guesses: [], startedAt: null });
|
||||
await clearGame(db, 99);
|
||||
expect(await loadGame(db, 99)).toBeNull();
|
||||
});
|
||||
|
||||
it("recordResult tracks streaks and bestStreak", async () => {
|
||||
await recordResult(db, 99, true);
|
||||
await recordResult(db, 99, true);
|
||||
await recordResult(db, 99, true);
|
||||
await recordResult(db, 99, false);
|
||||
const s = await loadStats(db, 99);
|
||||
expect(s).toMatchObject({ played: 4, wins: 3, streak: 0, bestStreak: 3 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalize } from "../../src/util/normalize-name.js";
|
||||
|
||||
describe("normalize", () => {
|
||||
it("lowercases and strips non-alphanumerics", () => {
|
||||
expect(normalize("Ahri")).toBe("ahri");
|
||||
expect(normalize("Miss Fortune")).toBe("missfortune");
|
||||
expect(normalize("Kai'Sa")).toBe("kaisa");
|
||||
expect(normalize("Dr. Mundo")).toBe("drmundo");
|
||||
});
|
||||
|
||||
it("handles empty and null input", () => {
|
||||
expect(normalize("")).toBe("");
|
||||
expect(normalize(null)).toBe("");
|
||||
expect(normalize(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("coerces non-strings", () => {
|
||||
expect(normalize(42)).toBe("42");
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -5,7 +5,7 @@ compatibility_date = "2025-10-01"
|
||||
# Enabled modules at runtime. Comma-separated. Must match static-map keys in src/modules/index.js.
|
||||
# Also duplicate this value into .env.deploy so scripts/register.js derives the same public command list.
|
||||
[vars]
|
||||
MODULES = "util,wordle,loldle,misc,trading,lolschedule,semantle,doantu,twentyq"
|
||||
MODULES = "util,wordle,loldle,loldle-emoji,loldle-quote,misc,trading,lolschedule,semantle,doantu,twentyq"
|
||||
|
||||
# KV namespace holding all module state. Each module auto-prefixes its keys via createStore().
|
||||
# Production-only — no preview namespace. Create with:
|
||||
|
||||
Reference in New Issue
Block a user