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
+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;
}