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