feat(loldle): add ability and splash champion-guessing modules

Closes deferred phases 04 + 05 of loldle-new-modes plan.

- loldle-ability: 5 guesses, DDragon ability icon as photo. State pins
  slot (P/Q/W/E/R) so the same icon shows every turn. Abilities pulled
  from DDragon per-champion — same source loldle.net uses at runtime.
- loldle-splash: 4 guesses, random skin splash as photo. Skin pool
  scraped from loldle.net bundle (var Ad=[…] — 172 champs × 1939 skins,
  non-chroma, matches their splash mode exactly). URLs from Riot
  DDragon CDN (no version segment, stable across patches).
- fetch-ddragon-data.js: extended to write all four JSONs in one run.
  Shares a single DDragon per-champion fetch cycle (concurrency 10).
- Credits loldle.net + Riot Games in all loldle-family READMEs.

19 new tests (503 total). Lint clean. register:dry reports 12 loldle_*
commands with no conflicts.
This commit is contained in:
2026-04-24 23:58:42 +07:00
parent bd5626534b
commit 3ac06bffaa
24 changed files with 16998 additions and 5 deletions
+10
View File
@@ -68,6 +68,8 @@ src/
│ ├── loldle/ # classic-mode LoL champion guessing (KV storage)
│ ├── loldle-emoji/ # emoji-clue LoL champion guessing (KV storage)
│ ├── loldle-quote/ # lore-blurb LoL champion guessing (KV storage)
│ ├── loldle-ability/ # ability-icon LoL champion guessing (KV storage)
│ ├── loldle-splash/ # splash-art LoL champion guessing (KV storage)
│ └── misc/ # stub (KV storage)
└── util/
└── escape-html.js
@@ -198,6 +200,14 @@ TL;DR:
| `npm run register` exits `missing env: X` | Add `X` to `.env.deploy`. |
| `--env-file` flag not recognized | Node < 20.6. Upgrade Node. |
## Credits
The `loldle` module family (classic, emoji, quote, ability, splash) is
inspired by [**loldle.net**](https://loldle.net/). Classic's champion
metadata and splash mode's skin pool are scraped from their JS bundle;
other modes derive or generate data from [Riot Data Dragon](https://ddragon.leagueoflegends.com/).
League of Legends, champion art, and ability icons are © Riot Games.
## Further reading
- [`docs/architecture.md`](docs/architecture.md) — deeper dive: cold-start, module lifecycle, KV + D1 storage, cron dispatch, deploy flow, design tradeoffs.
+21 -4
View File
@@ -1,6 +1,6 @@
---
name: loldle-new-modes
status: mvp-shipped
status: completed
created: 2026-04-24
updated: 2026-04-24
slug: loldle-new-modes
@@ -44,9 +44,9 @@ All `public`. Conflict-checked at registry load time.
| 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 |
| 04 | [Ability module (Data Dragon)](phase-04-ability-module.md) | **done** | 01 |
| 05 | [Splash module (Data Dragon)](phase-05-splash-module.md) | **done** | 01 |
| 06 | [Tests + docs sync](phase-06-tests-docs.md) | **done** | 02,03,04,05 |
**Shipping plan (validated):**
- **Now:** 01 → 02 + 03 in parallel → 06 (tests for emoji + quote only).
@@ -99,6 +99,23 @@ All `public`. Conflict-checked at registry load time.
`scrape-loldle-data.js` left untouched (classic only).
- 35 new tests, 484 total passing. Lint clean.
**Shipped 2026-04-24 (deferred phases — ability + splash).**
- Phase 04/05 complete. Plan now fully shipped.
- **Bundle re-probe:** loldle.net's bundle DOES ship the full splash pool
(var `Ad=[...]` — 172 champs × skin-name lists with translations).
Scraped it (regex-split on `championName:"…"` markers to handle the
nested translations arrays). Ability pool still not in bundle — pulled
from DDragon per-champion (172 parallel fetches, concurrency 10).
- `fetch-ddragon-data.js` extended: now writes all four JSONs in one run
(emojis, quotes, abilities, splashes). Single DDragon per-champion
fetch cycle shared between abilities + splash skin IDs.
- Splash pool mirrors loldle.net exactly (non-chroma skins, 1939 total
skins across 172 champions). URLs from Riot Data Dragon CDN (no
version segment — stable across patches).
- Credits added to all four loldle-family READMEs + main README.
- 19 more tests (503 total). Lint clean. register:dry shows 12 new
public commands across the 4 modes with no conflicts.
## Validation Log
**Session 1 — 2026-04-24 (7 questions answered)**
+186
View File
@@ -218,6 +218,174 @@ async function buildQuotes() {
return out;
}
// ───── shared DDragon per-champion cache ─────
// Ability and splash builders both need per-champion details. One fetch
// cycle populates a shared cache to avoid 340 redundant HTTP requests.
async function mapWithConcurrency(items, limit, fn) {
const out = new Array(items.length);
let next = 0;
const workers = Array.from({ length: limit }, async () => {
while (true) {
const i = next++;
if (i >= items.length) return;
out[i] = await fn(items[i], i);
}
});
await Promise.all(workers);
return out;
}
async function fetchDdragonDetails() {
const versions = await fetchJson("https://ddragon.leagueoflegends.com/api/versions.json");
const v = versions[0];
console.log(` DDragon version: ${v}`);
const summary = await fetchJson(
`https://ddragon.leagueoflegends.com/cdn/${v}/data/en_US/champion.json`,
);
const summaryByKey = summary.data;
const normMap = new Map();
for (const k of Object.keys(summaryByKey)) {
normMap.set(k.toLowerCase().replace(/[^a-z0-9]/g, ""), k);
}
function resolveKey(championName) {
const attempted = ddragonKey(championName);
if (summaryByKey[attempted]) return attempted;
const norm = attempted.toLowerCase().replace(/[^a-z0-9]/g, "");
return normMap.get(norm) ?? null;
}
// Fetch per-champion in parallel with a polite concurrency cap. ~172
// requests to a CDN — finishes in ~15-25s.
const tasks = championsData.map((c) => ({ champ: c, key: resolveKey(c.championName) }));
console.log(` fetching ${tasks.length} champion details (concurrency 10)…`);
const details = await mapWithConcurrency(tasks, 10, async ({ champ, key }) => {
if (!key) return { champ, key: null, data: null };
const full = await fetchJson(
`https://ddragon.leagueoflegends.com/cdn/${v}/data/en_US/champion/${key}.json`,
);
return { champ, key, data: full.data[key] };
});
return { version: v, details };
}
// ───── loldle.net splash-pool scrape ─────
async function scrapeLoldleSplashPool() {
const pageRes = await fetch("https://loldle.net/splash");
const page = await pageRes.text();
const bundleMatch = page.match(/js\/index\.[^"]+\.js/);
if (!bundleMatch) throw new Error("loldle.net: could not find index.js bundle");
const bundleRes = await fetch(`https://loldle.net/${bundleMatch[0]}`);
const bundle = await bundleRes.text();
// `Ad=[{championName:"…",splashes:[{name:"…",translations:[…]}…]}…]` —
// the bundle's splash pool used by loldle.net's guess validator. We pull
// names only; translations and champion order can be ignored.
const championStart = bundle.indexOf('Ad=[{championName:"Aatrox"');
if (championStart < 0) {
throw new Error("loldle.net: splash pool variable `Ad` not found — bundle format drifted");
}
// Each champion block has nested translations arrays, so a naïve
// non-greedy `[…]` match terminates too early. Instead, slice the
// splash-pool region and split on `championName:"` — each segment is
// one champion's block; skin names inside are `{name:"…",translations:`.
const endMarker = bundle.indexOf("];", championStart);
if (endMarker < 0) throw new Error("loldle.net: could not find end of splash pool");
const region = bundle.slice(championStart + 3, endMarker); // skip `Ad=` prefix
const championRx = /championName:"([^"]+)"/g;
const pool = new Map();
const championMatches = [...region.matchAll(championRx)];
for (let i = 0; i < championMatches.length; i++) {
const name = championMatches[i][1];
const start = championMatches[i].index;
const end = i + 1 < championMatches.length ? championMatches[i + 1].index : region.length;
const slice = region.slice(start, end);
const skinNames = [...slice.matchAll(/\{name:"([^"]+)",translations:/g)].map((x) => x[1]);
pool.set(name, skinNames);
}
if (pool.size === 0) {
throw new Error("loldle.net: parsed zero splash entries — regex drifted");
}
console.log(` scraped ${pool.size} champions from loldle.net splash pool`);
return pool;
}
// ───── abilities.json builder ─────
function abilityIconUrl(version, imageFull, isPassive) {
const segment = isPassive ? "passive" : "spell";
return `https://ddragon.leagueoflegends.com/cdn/${version}/img/${segment}/${imageFull}`;
}
function buildAbilities(version, details) {
const out = [];
const missing = [];
const SLOTS = ["Q", "W", "E", "R"];
for (const { champ, key, data } of details) {
if (!data) {
missing.push(champ.championName);
continue;
}
const abilities = [
{
slot: "P",
name: data.passive.name,
icon: abilityIconUrl(version, data.passive.image.full, true),
},
];
data.spells.slice(0, 4).forEach((s, i) => {
abilities.push({
slot: SLOTS[i],
name: s.name,
icon: abilityIconUrl(version, s.image.full, false),
});
});
out.push({ championName: champ.championName, key, abilities });
}
if (missing.length) console.warn(` WARN: abilities missing for ${missing.join(", ")}`);
return out.sort((a, b) => a.championName.localeCompare(b.championName));
}
// ───── splashes.json builder ─────
function splashUrl(championKey, skinNum) {
// DDragon splash URLs have NO version segment. Stable across patches.
return `https://ddragon.leagueoflegends.com/cdn/img/champion/splash/${championKey}_${skinNum}.jpg`;
}
function buildSplashes(details, loldlePool) {
const out = [];
const missing = [];
for (const { champ, key, data } of details) {
if (!data) {
missing.push(champ.championName);
continue;
}
// Loldle's pool defines WHICH skins are in play (names). DDragon gives
// URL num + "default" skin normalization. Normalize both to match.
const allowedNames = loldlePool.get(champ.championName);
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
const allowedSet = allowedNames ? new Set(allowedNames.map(norm)) : null;
const skins = [];
for (const s of data.skins) {
// DDragon labels base skin "default" — loldle.net calls it "Default".
const displayName = s.name === "default" ? "Default" : s.name;
if (allowedSet && !allowedSet.has(norm(displayName))) continue;
skins.push({
id: Number(s.num),
name: displayName,
url: splashUrl(key, s.num),
});
}
if (skins.length === 0) {
missing.push(`${champ.championName} (no skins matched pool)`);
continue;
}
out.push({ championName: champ.championName, skins });
}
if (missing.length) console.warn(` WARN: splashes missing for ${missing.join(", ")}`);
return out.sort((a, b) => a.championName.localeCompare(b.championName));
}
// ───── main ─────
const root = resolve(import.meta.dirname, "..");
@@ -237,3 +405,21 @@ const quotesPath = resolve(root, "src/modules/loldle-quote/quotes.json");
mkdirSync(resolve(quotesPath, ".."), { recursive: true });
writeFileSync(quotesPath, `${JSON.stringify(quotes, null, 4)}\n`);
console.log(`wrote ${quotesPath} (${quotes.length} champions)`);
console.log("fetching DDragon champion details (spells, passives, skins)…");
const { version: ddragonVersion, details } = await fetchDdragonDetails();
const abilities = buildAbilities(ddragonVersion, details);
const abilitiesPath = resolve(root, "src/modules/loldle-ability/abilities.json");
mkdirSync(resolve(abilitiesPath, ".."), { recursive: true });
writeFileSync(abilitiesPath, `${JSON.stringify(abilities, null, 4)}\n`);
console.log(`wrote ${abilitiesPath} (${abilities.length} champions)`);
console.log("scraping loldle.net splash pool…");
const loldleSplashPool = await scrapeLoldleSplashPool();
const splashes = buildSplashes(details, loldleSplashPool);
const splashesPath = resolve(root, "src/modules/loldle-splash/splashes.json");
mkdirSync(resolve(splashesPath, ".."), { recursive: true });
writeFileSync(splashesPath, `${JSON.stringify(splashes, null, 4)}\n`);
console.log(`wrote ${splashesPath} (${splashes.length} champions)`);
+2
View File
@@ -14,6 +14,8 @@ export const moduleRegistry = {
loldle: () => import("./loldle/index.js"),
"loldle-emoji": () => import("./loldle-emoji/index.js"),
"loldle-quote": () => import("./loldle-quote/index.js"),
"loldle-ability": () => import("./loldle-ability/index.js"),
"loldle-splash": () => import("./loldle-splash/index.js"),
misc: () => import("./misc/index.js"),
trading: () => import("./trading/index.js"),
lolschedule: () => import("./lolschedule/index.js"),
+37
View File
@@ -0,0 +1,37 @@
# loldle-ability
Guess the champion from a single ability icon (passive, Q, W, E, or R).
Binary right/wrong. 5 guesses per round.
## Commands
| Command | Visibility | Description |
|---|---|---|
| `/loldle_ability` | public | Show current ability icon / submit a guess with `/loldle_ability <champion>` |
| `/loldle_ability_giveup` | public | Reveal the answer, record loss |
| `/loldle_ability_stats` | public | Show per-subject play stats |
## Storage
KV prefix: `loldle-ability:`
- `game:<subject>``{target, slot, guesses, startedAt}`. `slot` persists so
the same icon shows across every guess in a round.
- `stats:<subject>``{played, wins, streak, bestStreak}`
## Data source
`abilities.json` is generated by `npm run fetch:ddragon-data` directly from
[Riot Data Dragon](https://ddragon.leagueoflegends.com/) — the same CDN
that [loldle.net](https://loldle.net/) uses at runtime. Version is baked
into each icon URL, so re-run after patch bumps to refresh.
Credits:
- Game concept inspired by [loldle.net](https://loldle.net/)'s ability mode.
- Icons © Riot Games, served from their public Data Dragon CDN.
## Design notes
- **No progressive crop** — full icon from turn 1. Shorter guess budget (5)
balances difficulty vs. loldle.net's unlimited-guess progressive-reveal.
- **No slot-ID bonus** — the mode ends on correct champion guess. Slot is
named in the reveal, not asked.
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
/**
* @file loldle-ability command handlers.
*
* Binary right/wrong — 5 guesses. Subject resolution mirrors other loldle
* modes. Round state persists `{target, slot, guesses, startedAt}` so the
* SAME ability icon shows across all turns until the round ends.
*/
import { escapeHtml } from "../../util/escape-html.js";
import abilitiesData from "./abilities.json" with { type: "json" };
import { findChampion } from "./lookup.js";
import { MAX_GUESSES, clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js";
const POOL = abilitiesData.filter((c) => c.abilities && c.abilities.length > 0);
const NEW_ROUND_HINT =
"🆕 Send <code>/loldle_ability</code> or <code>/loldle_ability &lt;champion&gt;</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);
}
function pickAbility(champ) {
return champ.abilities[Math.floor(Math.random() * champ.abilities.length)];
}
function getAbilityBySlot(champ, slot) {
return champ.abilities.find((a) => a.slot === slot);
}
async function startFreshGame(db, subject) {
const target = pickRandom();
const ability = pickAbility(target);
const fresh = {
target: target.championName,
slot: ability.slot,
guesses: [],
startedAt: null,
};
await saveGame(db, subject, fresh);
return fresh;
}
async function getOrInitGame(db, subject) {
const existing = await loadGame(db, subject);
if (existing && existing.guesses.length < MAX_GUESSES) return existing;
return startFreshGame(db, subject);
}
function caption(guesses) {
return `🔮 Guess the champion from this ability. ${guesses.length}/${MAX_GUESSES} guesses so far.`;
}
export async function handleAbility(ctx, db) {
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
const arg = argAfterCommand(ctx.message?.text ?? "");
const game = await getOrInitGame(db, subject);
const target = findByName(game.target);
const ability = target && getAbilityBySlot(target, game.slot);
if (!target || !ability) {
await clearGame(db, subject);
return ctx.reply(`Ability data was updated since this round started. ${NEW_ROUND_HINT}`, {
parse_mode: "HTML",
});
}
if (!arg) {
return ctx.replyWithPhoto(ability.icon, { caption: caption(game.guesses) });
}
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);
const abilityLabel = `<i>${escapeHtml(ability.name)}</i> (${ability.slot})`;
if (won) {
const s = await recordResult(db, subject, true);
await clearGame(db, subject);
return ctx.reply(
`🎉 Got it! That was <b>${answer}</b> — ${abilityLabel}. 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(
`❌ Out of guesses. Answer was <b>${answer}</b> — ${abilityLabel}.\n${NEW_ROUND_HINT}`,
{ parse_mode: "HTML" },
);
}
await saveGame(db, subject, game);
return ctx.reply(
`❌ 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);
const target = findByName(existing.target);
const ability = target && getAbilityBySlot(target, existing.slot);
await clearGame(db, subject);
const abilityLabel = ability ? ` — <i>${escapeHtml(ability.name)}</i> (${ability.slot})` : "";
return ctx.reply(
`🏳️ Answer was <b>${escapeHtml(existing.target)}</b>${abilityLabel}.\n${NEW_ROUND_HINT}`,
{ parse_mode: "HTML" },
);
}
export async function handleStats(ctx, db) {
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
const s = await loadStats(db, subject);
const winRate = s.played ? Math.round((s.wins / s.played) * 100) : 0;
const scope = ctx.chat?.type === "private" ? "your" : "group";
return ctx.reply(
`📊 Loldle Ability ${scope} stats\n` +
`Played: ${s.played}\n` +
`Wins: ${s.wins} (${winRate}%)\n` +
`Current streak: ${s.streak}\n` +
`Best streak: ${s.bestStreak}`,
);
}
+39
View File
@@ -0,0 +1,39 @@
/**
* @file loldle-ability — guess the champion from a single ability icon.
* Pool seeded from Data Dragon (same source loldle.net uses at runtime).
*/
import { handleAbility, handleGiveup, handleStats } from "./handlers.js";
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
let db = null;
/** @type {import("../registry.js").BotModule} */
const loldleAbilityModule = {
name: "loldle-ability",
init: async ({ db: store }) => {
db = store;
},
commands: [
{
name: "loldle_ability",
visibility: "public",
description: "Ability loldle — guess the champion from an ability icon",
handler: (ctx) => handleAbility(ctx, db),
},
{
name: "loldle_ability_giveup",
visibility: "public",
description: "Reveal the current ability loldle answer",
handler: (ctx) => handleGiveup(ctx, db),
},
{
name: "loldle_ability_stats",
visibility: "public",
description: "Show your ability loldle stats (wins, streak)",
handler: (ctx) => handleStats(ctx, db),
},
],
};
export default loldleAbilityModule;
+17
View File
@@ -0,0 +1,17 @@
/**
* @file Champion lookup over the ability pool — case/space/punctuation-
* insensitive with unique-prefix fallback.
*/
import { normalize } from "../../util/normalize-name.js";
export function findChampion(pool, input) {
const q = normalize(input);
if (!q) return null;
const exact = pool.find((c) => normalize(c.championName) === q);
if (exact) return exact;
const prefix = pool.filter((c) => normalize(c.championName).startsWith(q));
return prefix.length === 1 ? prefix[0] : null;
}
+49
View File
@@ -0,0 +1,49 @@
/**
* @file Game + stats persistence for loldle-ability. Adds `slot` field vs
* emoji/quote so the SAME ability icon persists across guesses in a round.
*/
const MAX_GUESSES = 5;
const 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;
}
+5
View File
@@ -29,3 +29,8 @@ emoji mapping table. Re-runs are idempotent.
emoji code points — emoji sequences live encrypted in a daily-rotating
cache, not a full pool. Deriving from metadata gives us all 170+ champions
deterministically with no brittle scrape.
Credits:
- Game concept inspired by [loldle.net](https://loldle.net/)'s emoji mode.
- Emoji mappings are ours (not scraped from loldle.net); any similarity to
their per-champion sequences is coincidental.
+4
View File
@@ -30,6 +30,10 @@ Dragon's champion endpoint. Each entry is:
The champion's own name is redacted with `___` so it isn't a giveaway.
Credits:
- Game concept inspired by [loldle.net](https://loldle.net/)'s quote mode.
- Lore text © Riot Games, served from their public Data Dragon CDN.
## Design notes
- **No audio hint** — serving MP3s from Workers would eat the bundle budget,
+39
View File
@@ -0,0 +1,39 @@
# loldle-splash
Guess the champion from splash art (random skin from loldle.net's full
non-chroma pool). Binary right/wrong. 4 guesses per round.
## Commands
| Command | Visibility | Description |
|---|---|---|
| `/loldle_splash` | public | Show current splash / submit a guess with `/loldle_splash <champion>` |
| `/loldle_splash_giveup` | public | Reveal the answer, record loss |
| `/loldle_splash_stats` | public | Show per-subject play stats |
## Storage
KV prefix: `loldle-splash:`
- `game:<subject>``{target, skinId, guesses, startedAt}`. `skinId`
persists so the same splash shows across every guess.
- `stats:<subject>``{played, wins, streak, bestStreak}`
## Data source
`splashes.json` is generated by `npm run fetch:ddragon-data`:
- **Skin pool** scraped from [loldle.net](https://loldle.net/)'s JS bundle
(same approach as classic loldle). Guarantees pool parity with their
splash mode — no chromas, same inclusion rules.
- **Splash URLs** built against [Riot Data Dragon](https://ddragon.leagueoflegends.com/)'s
stable-across-patches pattern: `cdn/img/champion/splash/<key>_<skinId>.jpg`.
Credits:
- Game concept and skin pool curation from [loldle.net](https://loldle.net/).
- Splash art © Riot Games, served from their public Data Dragon CDN.
## Design notes
- **No progressive crop** — full splash from turn 1. Tightest guess budget
in the family (4) since the full image is shown.
- **Random across ALL listed skins** (Default + all named skins, chromas
excluded by loldle.net's pool).
+161
View File
@@ -0,0 +1,161 @@
/**
* @file loldle-splash command handlers.
*
* Binary right/wrong — 4 guesses. Random skin (including all non-Default
* skins) drawn per round. `skinId` pinned in round state so the same
* splash persists across every guess.
*/
import { escapeHtml } from "../../util/escape-html.js";
import { findChampion } from "./lookup.js";
import splashesData from "./splashes.json" with { type: "json" };
import { MAX_GUESSES, clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js";
const POOL = splashesData.filter((c) => c.skins && c.skins.length > 0);
const NEW_ROUND_HINT =
"🆕 Send <code>/loldle_splash</code> or <code>/loldle_splash &lt;champion&gt;</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);
}
function pickSkin(champ) {
return champ.skins[Math.floor(Math.random() * champ.skins.length)];
}
function getSkinById(champ, id) {
return champ.skins.find((s) => s.id === id);
}
async function startFreshGame(db, subject) {
const target = pickRandom();
const skin = pickSkin(target);
const fresh = {
target: target.championName,
skinId: skin.id,
guesses: [],
startedAt: null,
};
await saveGame(db, subject, fresh);
return fresh;
}
async function getOrInitGame(db, subject) {
const existing = await loadGame(db, subject);
if (existing && existing.guesses.length < MAX_GUESSES) return existing;
return startFreshGame(db, subject);
}
function caption(guesses) {
return `🎨 Guess the champion from this splash art. ${guesses.length}/${MAX_GUESSES} guesses so far.`;
}
export async function handleSplash(ctx, db) {
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
const arg = argAfterCommand(ctx.message?.text ?? "");
const game = await getOrInitGame(db, subject);
const target = findByName(game.target);
const skin = target && getSkinById(target, game.skinId);
if (!target || !skin) {
await clearGame(db, subject);
return ctx.reply(`Splash data was updated since this round started. ${NEW_ROUND_HINT}`, {
parse_mode: "HTML",
});
}
if (!arg) {
return ctx.replyWithPhoto(skin.url, { caption: caption(game.guesses) });
}
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);
const skinLabel = `<i>${escapeHtml(skin.name)}</i> skin`;
if (won) {
const s = await recordResult(db, subject, true);
await clearGame(db, subject);
return ctx.reply(
`🎉 Got it! That was <b>${answer}</b> in ${skinLabel}. 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(
`❌ Out of guesses. Answer was <b>${answer}</b> in ${skinLabel}.\n${NEW_ROUND_HINT}`,
{ parse_mode: "HTML" },
);
}
await saveGame(db, subject, game);
return ctx.reply(
`❌ 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);
const target = findByName(existing.target);
const skin = target && getSkinById(target, existing.skinId);
await clearGame(db, subject);
const skinLabel = skin ? ` in <i>${escapeHtml(skin.name)}</i> skin` : "";
return ctx.reply(
`🏳️ Answer was <b>${escapeHtml(existing.target)}</b>${skinLabel}.\n${NEW_ROUND_HINT}`,
{ parse_mode: "HTML" },
);
}
export async function handleStats(ctx, db) {
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
const s = await loadStats(db, subject);
const winRate = s.played ? Math.round((s.wins / s.played) * 100) : 0;
const scope = ctx.chat?.type === "private" ? "your" : "group";
return ctx.reply(
`📊 Loldle Splash ${scope} stats\n` +
`Played: ${s.played}\n` +
`Wins: ${s.wins} (${winRate}%)\n` +
`Current streak: ${s.streak}\n` +
`Best streak: ${s.bestStreak}`,
);
}
+39
View File
@@ -0,0 +1,39 @@
/**
* @file loldle-splash — guess the champion from splash art (random skin).
* Skin pool scraped from loldle.net, splash URLs from Data Dragon CDN.
*/
import { handleGiveup, handleSplash, handleStats } from "./handlers.js";
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
let db = null;
/** @type {import("../registry.js").BotModule} */
const loldleSplashModule = {
name: "loldle-splash",
init: async ({ db: store }) => {
db = store;
},
commands: [
{
name: "loldle_splash",
visibility: "public",
description: "Splash loldle — guess the champion from splash art",
handler: (ctx) => handleSplash(ctx, db),
},
{
name: "loldle_splash_giveup",
visibility: "public",
description: "Reveal the current splash loldle answer",
handler: (ctx) => handleGiveup(ctx, db),
},
{
name: "loldle_splash_stats",
visibility: "public",
description: "Show your splash loldle stats (wins, streak)",
handler: (ctx) => handleStats(ctx, db),
},
],
};
export default loldleSplashModule;
+17
View File
@@ -0,0 +1,17 @@
/**
* @file Champion lookup over the splash pool — case/space/punctuation-
* insensitive with unique-prefix fallback.
*/
import { normalize } from "../../util/normalize-name.js";
export function findChampion(pool, input) {
const q = normalize(input);
if (!q) return null;
const exact = pool.find((c) => normalize(c.championName) === q);
if (exact) return exact;
const prefix = pool.filter((c) => normalize(c.championName).startsWith(q));
return prefix.length === 1 ? prefix[0] : null;
}
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
/**
* @file Game + stats persistence for loldle-splash. Adds `skinId` field so
* the SAME splash art persists across all guesses in a round.
*/
const MAX_GUESSES = 4;
const 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,89 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStore } from "../../../src/db/create-store.js";
import abilitiesData from "../../../src/modules/loldle-ability/abilities.json" with {
type: "json",
};
import {
handleAbility,
handleGiveup,
handleStats,
} from "../../../src/modules/loldle-ability/handlers.js";
import { loadStats } from "../../../src/modules/loldle-ability/state.js";
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
function pinRandom(value) {
// deterministic: force Math.random() to the floor of the first entry
vi.spyOn(Math, "random").mockReturnValue(value);
}
function makeCtx({ text = "", fromId = 1, chatType = "private", chatId = 1 } = {}) {
const replies = [];
const photos = [];
return {
replies,
photos,
ctx: {
from: { id: fromId },
chat: { id: chatId, type: chatType },
message: { text },
reply: async (body, opts) => {
replies.push({ body, opts });
},
replyWithPhoto: async (url, opts) => {
photos.push({ url, opts });
},
},
};
}
describe("loldle-ability handlers — happy path", () => {
let db;
beforeEach(() => {
db = createStore("loldle-ability", { KV: makeFakeKv() });
pinRandom(0); // picks abilitiesData[0] + abilities[0]
});
it("no-arg sends a photo with DDragon icon URL and caption", async () => {
const { ctx, photos } = makeCtx();
await handleAbility(ctx, db);
expect(photos).toHaveLength(1);
expect(photos[0].url).toMatch(/^https:\/\/ddragon\.leagueoflegends\.com\//);
expect(photos[0].opts.caption).toMatch(/0\/5 guesses so far/);
});
it("correct guess wins and cites slot + ability name", async () => {
const target = abilitiesData[0];
const { ctx, replies } = makeCtx({ text: `/loldle_ability ${target.championName}` });
await handleAbility(ctx, db);
expect(replies[0].body).toContain("🎉 Got it!");
expect(replies[0].body).toContain(target.championName);
expect(replies[0].body).toMatch(/\([PQWER]\)/);
const s = await loadStats(db, 1);
expect(s).toMatchObject({ played: 1, wins: 1, streak: 1 });
});
it("wrong guess does not end the round", async () => {
const wrong = abilitiesData[1];
const { ctx, replies } = makeCtx({ text: `/loldle_ability ${wrong.championName}` });
await handleAbility(ctx, db);
expect(replies[0].body).toContain("❌");
const s = await loadStats(db, 1);
expect(s.played).toBe(0);
});
it("giveup records loss and names the ability", async () => {
await handleAbility(makeCtx().ctx, db);
const { ctx, replies } = makeCtx();
await handleGiveup(ctx, db);
expect(replies[0].body).toContain("🏳️");
expect(replies[0].body).toMatch(/\([PQWER]\)/);
const s = await loadStats(db, 1);
expect(s).toMatchObject({ played: 1, wins: 0 });
});
it("stats renders zero-state", async () => {
const { ctx, replies } = makeCtx();
await handleStats(ctx, db);
expect(replies[0].body).toContain("Played: 0");
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { findChampion } from "../../../src/modules/loldle-ability/lookup.js";
const pool = [
{ championName: "Ahri", abilities: [{ slot: "Q", name: "Orb of Deception", icon: "x" }] },
{ championName: "Akali", abilities: [{ slot: "R", name: "Perfect Execution", icon: "x" }] },
{ championName: "Miss Fortune", abilities: [{ slot: "P", name: "Love Tap", icon: "x" }] },
];
describe("findChampion (ability pool)", () => {
it("matches case-insensitively", () => {
expect(findChampion(pool, "ahri").championName).toBe("Ahri");
});
it("normalizes punctuation and spaces", () => {
expect(findChampion(pool, "MissFortune").championName).toBe("Miss Fortune");
expect(findChampion(pool, "miss fortune").championName).toBe("Miss Fortune");
});
it("unique prefix resolves", () => {
expect(findChampion(pool, "mi").championName).toBe("Miss Fortune");
expect(findChampion(pool, "ak").championName).toBe("Akali");
});
it("returns null on empty / no-match / ambiguous", () => {
expect(findChampion(pool, "")).toBeNull();
expect(findChampion(pool, "zzz")).toBeNull();
expect(findChampion(pool, "a")).toBeNull();
});
});
@@ -0,0 +1,38 @@
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-ability/state.js";
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
describe("loldle-ability state", () => {
let db;
beforeEach(() => {
db = createStore("loldle-ability", { KV: makeFakeKv() });
});
it("round-trips a game with slot field", async () => {
const state = { target: "Ahri", slot: "Q", guesses: ["Akali"], startedAt: 1234 };
await saveGame(db, 42, state);
expect(await loadGame(db, 42)).toEqual(state);
});
it("clearGame removes the record", async () => {
await saveGame(db, 42, { target: "Ahri", slot: "P", guesses: [], startedAt: null });
await clearGame(db, 42);
expect(await loadGame(db, 42)).toBeNull();
});
it("recordResult tracks streaks + bestStreak", async () => {
await recordResult(db, 42, true);
await recordResult(db, 42, true);
await recordResult(db, 42, false);
const s = await loadStats(db, 42);
expect(s).toMatchObject({ played: 3, wins: 2, streak: 0, bestStreak: 2 });
});
});
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStore } from "../../../src/db/create-store.js";
import {
handleGiveup,
handleSplash,
handleStats,
} from "../../../src/modules/loldle-splash/handlers.js";
import splashesData from "../../../src/modules/loldle-splash/splashes.json" with { type: "json" };
import { loadStats } from "../../../src/modules/loldle-splash/state.js";
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
function pinRandom(value) {
vi.spyOn(Math, "random").mockReturnValue(value);
}
function makeCtx({ text = "", fromId = 1, chatType = "private", chatId = 1 } = {}) {
const replies = [];
const photos = [];
return {
replies,
photos,
ctx: {
from: { id: fromId },
chat: { id: chatId, type: chatType },
message: { text },
reply: async (body, opts) => {
replies.push({ body, opts });
},
replyWithPhoto: async (url, opts) => {
photos.push({ url, opts });
},
},
};
}
describe("loldle-splash handlers — happy path", () => {
let db;
beforeEach(() => {
db = createStore("loldle-splash", { KV: makeFakeKv() });
pinRandom(0); // picks first champion + first skin
});
it("no-arg sends a splash photo with 0/4 caption", async () => {
const { ctx, photos } = makeCtx();
await handleSplash(ctx, db);
expect(photos).toHaveLength(1);
expect(photos[0].url).toMatch(
/^https:\/\/ddragon\.leagueoflegends\.com\/cdn\/img\/champion\/splash\//,
);
expect(photos[0].opts.caption).toMatch(/0\/4 guesses so far/);
});
it("correct guess wins and names the skin", async () => {
const target = splashesData[0];
const { ctx, replies } = makeCtx({ text: `/loldle_splash ${target.championName}` });
await handleSplash(ctx, db);
expect(replies[0].body).toContain("🎉 Got it!");
expect(replies[0].body).toContain("skin");
const s = await loadStats(db, 1);
expect(s).toMatchObject({ played: 1, wins: 1 });
});
it("giveup records loss and names skin", async () => {
await handleSplash(makeCtx().ctx, db);
const { ctx, replies } = makeCtx();
await handleGiveup(ctx, db);
expect(replies[0].body).toContain("🏳️");
expect(replies[0].body).toContain("skin");
});
it("stats renders zero-state", async () => {
const { ctx, replies } = makeCtx();
await handleStats(ctx, db);
expect(replies[0].body).toContain("Played: 0");
});
});
+37
View File
@@ -0,0 +1,37 @@
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-splash/state.js";
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
describe("loldle-splash state", () => {
let db;
beforeEach(() => {
db = createStore("loldle-splash", { KV: makeFakeKv() });
});
it("round-trips a game with skinId field", async () => {
const state = { target: "Ahri", skinId: 9, guesses: [], startedAt: null };
await saveGame(db, 99, state);
expect(await loadGame(db, 99)).toEqual(state);
});
it("clearGame removes the record", async () => {
await saveGame(db, 99, { target: "Ahri", skinId: 0, guesses: [], startedAt: null });
await clearGame(db, 99);
expect(await loadGame(db, 99)).toBeNull();
});
it("recordResult tracks streaks + bestStreak", async () => {
await recordResult(db, 99, true);
await recordResult(db, 99, true);
const s = await loadStats(db, 99);
expect(s).toMatchObject({ played: 2, wins: 2, streak: 2, bestStreak: 2 });
});
});
+1 -1
View File
@@ -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,loldle-emoji,loldle-quote,misc,trading,lolschedule,semantle,doantu,twentyq"
MODULES = "util,wordle,loldle,loldle-emoji,loldle-quote,loldle-ability,loldle-splash,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: