Files
miti99bot/src/modules/loldle-ability/handlers.js
T
tiennm99 3ac06bffaa 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.
2026-04-24 23:58:42 +07:00

162 lines
5.3 KiB
JavaScript

/**
* @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}`,
);
}