mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-28 14:20:45 +00:00
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:
@@ -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).
|
||||
@@ -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 <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);
|
||||
}
|
||||
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user