From e03d2a937c6bec4565fb09826c95ec5a66f81b8d Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 7 May 2026 13:52:09 +0700 Subject: [PATCH] feat(loldle): tunable max guesses per subject + retuned defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop classic loldle from 8 โ†’ 6 (7-axis grid leaks too much per guess for 8 to feel earned) and emoji from 5 โ†’ 4 (3 emojis are usually unmistakable). Add a hidden /_setmax command per loldle module so a chat can override its own round length (1-10). Override stored at config: in each module's KV; getMaxGuesses() falls back to the default when unset. --- src/modules/loldle-ability/handlers.js | 48 ++++++++++++++++++++------ src/modules/loldle-ability/index.js | 8 ++++- src/modules/loldle-ability/state.js | 18 +++++++++- src/modules/loldle-emoji/handlers.js | 46 +++++++++++++++++++----- src/modules/loldle-emoji/index.js | 8 ++++- src/modules/loldle-emoji/state.js | 23 ++++++++++-- src/modules/loldle-quote/handlers.js | 46 +++++++++++++++++++----- src/modules/loldle-quote/index.js | 8 ++++- src/modules/loldle-quote/state.js | 18 +++++++++- src/modules/loldle-splash/handlers.js | 48 ++++++++++++++++++++------ src/modules/loldle-splash/index.js | 8 ++++- src/modules/loldle-splash/state.js | 18 +++++++++- src/modules/loldle/README.md | 12 ++++--- src/modules/loldle/handlers.js | 47 ++++++++++++++++++++----- src/modules/loldle/index.js | 8 ++++- src/modules/loldle/state.js | 36 +++++++++++++++++-- tests/modules/dispatcher.test.js | 7 ++-- 17 files changed, 341 insertions(+), 66 deletions(-) diff --git a/src/modules/loldle-ability/handlers.js b/src/modules/loldle-ability/handlers.js index 0cb8284..2cb3df0 100644 --- a/src/modules/loldle-ability/handlers.js +++ b/src/modules/loldle-ability/handlers.js @@ -9,7 +9,16 @@ 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"; +import { + MAX_GUESSES_CAP, + clearGame, + getMaxGuesses, + loadGame, + loadStats, + recordResult, + saveGame, + setMaxGuesses, +} from "./state.js"; const POOL = abilitiesData.filter((c) => c.abilities && c.abilities.length > 0); @@ -57,14 +66,14 @@ async function startFreshGame(db, subject) { return fresh; } -async function getOrInitGame(db, subject) { +async function getOrInitGame(db, subject, maxGuesses) { const existing = await loadGame(db, subject); - if (existing && existing.guesses.length < MAX_GUESSES) return existing; + if (existing && existing.guesses.length < maxGuesses) return existing; return startFreshGame(db, subject); } -function caption(guesses) { - return `๐Ÿ”ฎ Guess the champion from this ability. ${guesses.length}/${MAX_GUESSES} guesses so far.`; +function caption(guesses, maxGuesses) { + return `๐Ÿ”ฎ Guess the champion from this ability. ${guesses.length}/${maxGuesses} guesses so far.`; } export async function handleAbility(ctx, db) { @@ -72,7 +81,8 @@ export async function handleAbility(ctx, db) { if (subject == null) return ctx.reply("Cannot identify chat."); const arg = argAfterCommand(ctx.message?.text ?? ""); - const game = await getOrInitGame(db, subject); + const maxGuesses = await getMaxGuesses(db, subject); + const game = await getOrInitGame(db, subject, maxGuesses); const target = findByName(game.target); const ability = target && getAbilityBySlot(target, game.slot); if (!target || !ability) { @@ -83,7 +93,7 @@ export async function handleAbility(ctx, db) { } if (!arg) { - return ctx.replyWithPhoto(ability.icon, { caption: caption(game.guesses) }); + return ctx.replyWithPhoto(ability.icon, { caption: caption(game.guesses, maxGuesses) }); } const guess = findChampion(POOL, arg); @@ -106,12 +116,12 @@ export async function handleAbility(ctx, db) { const s = await recordResult(db, subject, true); await clearGame(db, subject); return ctx.reply( - `๐ŸŽ‰ Got it! That was ${answer} โ€” ${abilityLabel}. Solved in ${game.guesses.length}/${MAX_GUESSES}\n๐Ÿ”ฅ Streak: ${s.streak}\n${NEW_ROUND_HINT}`, + `๐ŸŽ‰ Got it! That was ${answer} โ€” ${abilityLabel}. Solved in ${game.guesses.length}/${maxGuesses}\n๐Ÿ”ฅ Streak: ${s.streak}\n${NEW_ROUND_HINT}`, { parse_mode: "HTML" }, ); } - if (game.guesses.length >= MAX_GUESSES) { + if (game.guesses.length >= maxGuesses) { await recordResult(db, subject, false); await clearGame(db, subject); return ctx.reply( @@ -122,11 +132,29 @@ export async function handleAbility(ctx, db) { await saveGame(db, subject, game); return ctx.reply( - `โŒ Not ${escapeHtml(guess.championName)}. Guess ${game.guesses.length}/${MAX_GUESSES}.`, + `โŒ Not ${escapeHtml(guess.championName)}. Guess ${game.guesses.length}/${maxGuesses}.`, { parse_mode: "HTML" }, ); } +/** + * Hidden โ€” set the per-subject MAX_GUESSES override (1..MAX_GUESSES_CAP). + * + * @param {import("grammy").Context} ctx + * @param {import("../../db/kv-store-interface.js").KVStore} db + */ +export async function handleSetMax(ctx, db) { + const subject = getSubject(ctx); + if (subject == null) return ctx.reply("Cannot identify chat."); + const arg = argAfterCommand(ctx.message?.text ?? ""); + const n = Number.parseInt(arg, 10); + if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { + return ctx.reply(`Usage: /loldle_ability_setmax <1-${MAX_GUESSES_CAP}>`); + } + await setMaxGuesses(db, subject, n); + return ctx.reply(`โœ… Loldle ability max guesses set to ${n} (applies to the next round).`); +} + export async function handleGiveup(ctx, db) { const subject = getSubject(ctx); if (subject == null) return ctx.reply("Cannot identify chat."); diff --git a/src/modules/loldle-ability/index.js b/src/modules/loldle-ability/index.js index fa10152..4f8c96a 100644 --- a/src/modules/loldle-ability/index.js +++ b/src/modules/loldle-ability/index.js @@ -3,7 +3,7 @@ * Pool seeded from Data Dragon (same source loldle.net uses at runtime). */ -import { handleAbility, handleGiveup, handleStats } from "./handlers.js"; +import { handleAbility, handleGiveup, handleSetMax, handleStats } from "./handlers.js"; /** @type {import("../../db/kv-store-interface.js").KVStore | null} */ let db = null; @@ -33,6 +33,12 @@ const loldleAbilityModule = { description: "Show your ability loldle stats (wins, streak)", handler: (ctx) => handleStats(ctx, db), }, + { + name: "loldle_ability_setmax", + visibility: "private", + description: "Override ability loldle max guesses per round (1-10)", + handler: (ctx) => handleSetMax(ctx, db), + }, ], }; diff --git a/src/modules/loldle-ability/state.js b/src/modules/loldle-ability/state.js index 91996d9..4af3552 100644 --- a/src/modules/loldle-ability/state.js +++ b/src/modules/loldle-ability/state.js @@ -4,12 +4,28 @@ */ const MAX_GUESSES = 5; +const MAX_GUESSES_CAP = 10; const GAME_TTL_SECONDS = 60 * 60 * 24 * 7; const gameKey = (subject) => `game:${subject}`; const statsKey = (subject) => `stats:${subject}`; +const configKey = (subject) => `config:${subject}`; -export { MAX_GUESSES }; +export { MAX_GUESSES, MAX_GUESSES_CAP }; + +export async function getMaxGuesses(db, subject) { + const cfg = await db.getJSON(configKey(subject)); + const n = cfg?.maxGuesses; + if (Number.isInteger(n) && n >= 1 && n <= MAX_GUESSES_CAP) return n; + return MAX_GUESSES; +} + +export async function setMaxGuesses(db, subject, n) { + if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { + throw new RangeError(`maxGuesses must be an integer in [1, ${MAX_GUESSES_CAP}]`); + } + await db.putJSON(configKey(subject), { maxGuesses: n }); +} export async function loadGame(db, subject) { return db.getJSON(gameKey(subject)); diff --git a/src/modules/loldle-emoji/handlers.js b/src/modules/loldle-emoji/handlers.js index a6126a0..25926de 100644 --- a/src/modules/loldle-emoji/handlers.js +++ b/src/modules/loldle-emoji/handlers.js @@ -15,7 +15,16 @@ import { escapeHtml } from "../../util/escape-html.js"; import emojisData from "./emojis.json" with { type: "json" }; import { findChampion } from "./lookup.js"; import { renderBoard } from "./render.js"; -import { MAX_GUESSES, clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js"; +import { + MAX_GUESSES_CAP, + clearGame, + getMaxGuesses, + loadGame, + loadStats, + recordResult, + saveGame, + setMaxGuesses, +} from "./state.js"; const POOL = emojisData.filter((c) => c.emojis && c.emojis.trim().length > 0); @@ -49,9 +58,9 @@ async function startFreshGame(db, subject) { return fresh; } -async function getOrInitGame(db, subject) { +async function getOrInitGame(db, subject, maxGuesses) { const existing = await loadGame(db, subject); - if (existing && existing.guesses.length < MAX_GUESSES) return existing; + if (existing && existing.guesses.length < maxGuesses) return existing; return startFreshGame(db, subject); } @@ -60,7 +69,8 @@ export async function handleEmoji(ctx, db) { if (subject == null) return ctx.reply("Cannot identify chat."); const arg = argAfterCommand(ctx.message?.text ?? ""); - const game = await getOrInitGame(db, subject); + const maxGuesses = await getMaxGuesses(db, subject); + const game = await getOrInitGame(db, subject, maxGuesses); const target = findByName(game.target); if (!target) { // Pool refreshed mid-round and the target is gone โ€” start over. @@ -71,7 +81,7 @@ export async function handleEmoji(ctx, db) { } if (!arg) { - return ctx.reply(renderBoard(target.emojis, game.guesses, MAX_GUESSES), { + return ctx.reply(renderBoard(target.emojis, game.guesses, maxGuesses), { parse_mode: "HTML", }); } @@ -95,27 +105,45 @@ export async function handleEmoji(ctx, db) { const s = await recordResult(db, subject, true); await clearGame(db, subject); return ctx.reply( - `๐ŸŽ‰ Got it! ${answer} โ€” solved in ${game.guesses.length}/${MAX_GUESSES}\n๐Ÿ”ฅ Streak: ${s.streak}\n${NEW_ROUND_HINT}`, + `๐ŸŽ‰ Got it! ${answer} โ€” solved in ${game.guesses.length}/${maxGuesses}\n๐Ÿ”ฅ Streak: ${s.streak}\n${NEW_ROUND_HINT}`, { parse_mode: "HTML" }, ); } - if (game.guesses.length >= MAX_GUESSES) { + if (game.guesses.length >= maxGuesses) { await recordResult(db, subject, false); await clearGame(db, subject); return ctx.reply( - `${renderBoard(target.emojis, game.guesses, MAX_GUESSES)}\n\nโŒ Out of guesses. Answer was ${answer}.\n${NEW_ROUND_HINT}`, + `${renderBoard(target.emojis, game.guesses, maxGuesses)}\n\nโŒ Out of guesses. Answer was ${answer}.\n${NEW_ROUND_HINT}`, { parse_mode: "HTML" }, ); } await saveGame(db, subject, game); return ctx.reply( - `${renderBoard(target.emojis, game.guesses, MAX_GUESSES)}\n\nโŒ Not ${escapeHtml(guess.championName)}. Guess ${game.guesses.length}/${MAX_GUESSES}.`, + `${renderBoard(target.emojis, game.guesses, maxGuesses)}\n\nโŒ Not ${escapeHtml(guess.championName)}. Guess ${game.guesses.length}/${maxGuesses}.`, { parse_mode: "HTML" }, ); } +/** + * Hidden โ€” set the per-subject MAX_GUESSES override (1..MAX_GUESSES_CAP). + * + * @param {import("grammy").Context} ctx + * @param {import("../../db/kv-store-interface.js").KVStore} db + */ +export async function handleSetMax(ctx, db) { + const subject = getSubject(ctx); + if (subject == null) return ctx.reply("Cannot identify chat."); + const arg = argAfterCommand(ctx.message?.text ?? ""); + const n = Number.parseInt(arg, 10); + if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { + return ctx.reply(`Usage: /loldle_emoji_setmax <1-${MAX_GUESSES_CAP}>`); + } + await setMaxGuesses(db, subject, n); + return ctx.reply(`โœ… Loldle emoji max guesses set to ${n} (applies to the next round).`); +} + export async function handleGiveup(ctx, db) { const subject = getSubject(ctx); if (subject == null) return ctx.reply("Cannot identify chat."); diff --git a/src/modules/loldle-emoji/index.js b/src/modules/loldle-emoji/index.js index efb52b5..d45e335 100644 --- a/src/modules/loldle-emoji/index.js +++ b/src/modules/loldle-emoji/index.js @@ -4,7 +4,7 @@ * scripts/fetch-ddragon-data.js). */ -import { handleEmoji, handleGiveup, handleStats } from "./handlers.js"; +import { handleEmoji, handleGiveup, handleSetMax, handleStats } from "./handlers.js"; /** @type {import("../../db/kv-store-interface.js").KVStore | null} */ let db = null; @@ -34,6 +34,12 @@ const loldleEmojiModule = { description: "Show your emoji loldle stats (wins, streak)", handler: (ctx) => handleStats(ctx, db), }, + { + name: "loldle_emoji_setmax", + visibility: "private", + description: "Override emoji loldle max guesses per round (1-10)", + handler: (ctx) => handleSetMax(ctx, db), + }, ], }; diff --git a/src/modules/loldle-emoji/state.js b/src/modules/loldle-emoji/state.js index beb0ab6..52adb5c 100644 --- a/src/modules/loldle-emoji/state.js +++ b/src/modules/loldle-emoji/state.js @@ -10,13 +10,32 @@ * keeps stats isolated per mode. */ -const MAX_GUESSES = 5; +// Default tightened from 5 โ†’ 4: three emojis are a strong signal, so 4 keeps +// tension without being unfair. Admins can override per-subject via the +// hidden /loldle_emoji_setmax command (bounded by MAX_GUESSES_CAP). +const MAX_GUESSES = 4; +const MAX_GUESSES_CAP = 10; const GAME_TTL_SECONDS = 60 * 60 * 24 * 7; const gameKey = (subject) => `game:${subject}`; const statsKey = (subject) => `stats:${subject}`; +const configKey = (subject) => `config:${subject}`; -export { MAX_GUESSES }; +export { MAX_GUESSES, MAX_GUESSES_CAP }; + +export async function getMaxGuesses(db, subject) { + const cfg = await db.getJSON(configKey(subject)); + const n = cfg?.maxGuesses; + if (Number.isInteger(n) && n >= 1 && n <= MAX_GUESSES_CAP) return n; + return MAX_GUESSES; +} + +export async function setMaxGuesses(db, subject, n) { + if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { + throw new RangeError(`maxGuesses must be an integer in [1, ${MAX_GUESSES_CAP}]`); + } + await db.putJSON(configKey(subject), { maxGuesses: n }); +} export async function loadGame(db, subject) { return db.getJSON(gameKey(subject)); diff --git a/src/modules/loldle-quote/handlers.js b/src/modules/loldle-quote/handlers.js index 18ddfbb..51447b9 100644 --- a/src/modules/loldle-quote/handlers.js +++ b/src/modules/loldle-quote/handlers.js @@ -7,7 +7,16 @@ import { escapeHtml } from "../../util/escape-html.js"; import { findChampion } from "./lookup.js"; import quotesData from "./quotes.json" with { type: "json" }; import { renderBoard } from "./render.js"; -import { MAX_GUESSES, clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js"; +import { + MAX_GUESSES_CAP, + clearGame, + getMaxGuesses, + loadGame, + loadStats, + recordResult, + saveGame, + setMaxGuesses, +} from "./state.js"; const POOL = quotesData.filter((c) => c.quote && c.quote.trim().length > 0); @@ -41,9 +50,9 @@ async function startFreshGame(db, subject) { return fresh; } -async function getOrInitGame(db, subject) { +async function getOrInitGame(db, subject, maxGuesses) { const existing = await loadGame(db, subject); - if (existing && existing.guesses.length < MAX_GUESSES) return existing; + if (existing && existing.guesses.length < maxGuesses) return existing; return startFreshGame(db, subject); } @@ -52,7 +61,8 @@ export async function handleQuote(ctx, db) { if (subject == null) return ctx.reply("Cannot identify chat."); const arg = argAfterCommand(ctx.message?.text ?? ""); - const game = await getOrInitGame(db, subject); + const maxGuesses = await getMaxGuesses(db, subject); + const game = await getOrInitGame(db, subject, maxGuesses); const target = findByName(game.target); if (!target) { await clearGame(db, subject); @@ -62,7 +72,7 @@ export async function handleQuote(ctx, db) { } if (!arg) { - return ctx.reply(renderBoard(target.quote, game.guesses, MAX_GUESSES), { + return ctx.reply(renderBoard(target.quote, game.guesses, maxGuesses), { parse_mode: "HTML", }); } @@ -86,27 +96,45 @@ export async function handleQuote(ctx, db) { const s = await recordResult(db, subject, true); await clearGame(db, subject); return ctx.reply( - `๐ŸŽ‰ Nailed it! ${answer} โ€” solved in ${game.guesses.length}/${MAX_GUESSES}\n๐Ÿ”ฅ Streak: ${s.streak}\n${NEW_ROUND_HINT}`, + `๐ŸŽ‰ Nailed it! ${answer} โ€” solved in ${game.guesses.length}/${maxGuesses}\n๐Ÿ”ฅ Streak: ${s.streak}\n${NEW_ROUND_HINT}`, { parse_mode: "HTML" }, ); } - if (game.guesses.length >= MAX_GUESSES) { + if (game.guesses.length >= maxGuesses) { await recordResult(db, subject, false); await clearGame(db, subject); return ctx.reply( - `${renderBoard(target.quote, game.guesses, MAX_GUESSES)}\n\nโŒ Out of guesses. Answer: ${answer}.\n${NEW_ROUND_HINT}`, + `${renderBoard(target.quote, game.guesses, maxGuesses)}\n\nโŒ Out of guesses. Answer: ${answer}.\n${NEW_ROUND_HINT}`, { parse_mode: "HTML" }, ); } await saveGame(db, subject, game); return ctx.reply( - `${renderBoard(target.quote, game.guesses, MAX_GUESSES)}\n\nโŒ Not ${escapeHtml(guess.championName)}. Guess ${game.guesses.length}/${MAX_GUESSES}.`, + `${renderBoard(target.quote, game.guesses, maxGuesses)}\n\nโŒ Not ${escapeHtml(guess.championName)}. Guess ${game.guesses.length}/${maxGuesses}.`, { parse_mode: "HTML" }, ); } +/** + * Hidden โ€” set the per-subject MAX_GUESSES override (1..MAX_GUESSES_CAP). + * + * @param {import("grammy").Context} ctx + * @param {import("../../db/kv-store-interface.js").KVStore} db + */ +export async function handleSetMax(ctx, db) { + const subject = getSubject(ctx); + if (subject == null) return ctx.reply("Cannot identify chat."); + const arg = argAfterCommand(ctx.message?.text ?? ""); + const n = Number.parseInt(arg, 10); + if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { + return ctx.reply(`Usage: /loldle_quote_setmax <1-${MAX_GUESSES_CAP}>`); + } + await setMaxGuesses(db, subject, n); + return ctx.reply(`โœ… Loldle quote max guesses set to ${n} (applies to the next round).`); +} + export async function handleGiveup(ctx, db) { const subject = getSubject(ctx); if (subject == null) return ctx.reply("Cannot identify chat."); diff --git a/src/modules/loldle-quote/index.js b/src/modules/loldle-quote/index.js index c831ae5..ed9f283 100644 --- a/src/modules/loldle-quote/index.js +++ b/src/modules/loldle-quote/index.js @@ -4,7 +4,7 @@ * (see scripts/fetch-ddragon-data.js). */ -import { handleGiveup, handleQuote, handleStats } from "./handlers.js"; +import { handleGiveup, handleQuote, handleSetMax, handleStats } from "./handlers.js"; /** @type {import("../../db/kv-store-interface.js").KVStore | null} */ let db = null; @@ -34,6 +34,12 @@ const loldleQuoteModule = { description: "Show your quote loldle stats (wins, streak)", handler: (ctx) => handleStats(ctx, db), }, + { + name: "loldle_quote_setmax", + visibility: "private", + description: "Override quote loldle max guesses per round (1-10)", + handler: (ctx) => handleSetMax(ctx, db), + }, ], }; diff --git a/src/modules/loldle-quote/state.js b/src/modules/loldle-quote/state.js index 412eb72..cf24182 100644 --- a/src/modules/loldle-quote/state.js +++ b/src/modules/loldle-quote/state.js @@ -4,12 +4,28 @@ */ const MAX_GUESSES = 6; +const MAX_GUESSES_CAP = 10; const GAME_TTL_SECONDS = 60 * 60 * 24 * 7; const gameKey = (subject) => `game:${subject}`; const statsKey = (subject) => `stats:${subject}`; +const configKey = (subject) => `config:${subject}`; -export { MAX_GUESSES }; +export { MAX_GUESSES, MAX_GUESSES_CAP }; + +export async function getMaxGuesses(db, subject) { + const cfg = await db.getJSON(configKey(subject)); + const n = cfg?.maxGuesses; + if (Number.isInteger(n) && n >= 1 && n <= MAX_GUESSES_CAP) return n; + return MAX_GUESSES; +} + +export async function setMaxGuesses(db, subject, n) { + if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { + throw new RangeError(`maxGuesses must be an integer in [1, ${MAX_GUESSES_CAP}]`); + } + await db.putJSON(configKey(subject), { maxGuesses: n }); +} export async function loadGame(db, subject) { return db.getJSON(gameKey(subject)); diff --git a/src/modules/loldle-splash/handlers.js b/src/modules/loldle-splash/handlers.js index b75488f..5adbae1 100644 --- a/src/modules/loldle-splash/handlers.js +++ b/src/modules/loldle-splash/handlers.js @@ -9,7 +9,16 @@ 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"; +import { + MAX_GUESSES_CAP, + clearGame, + getMaxGuesses, + loadGame, + loadStats, + recordResult, + saveGame, + setMaxGuesses, +} from "./state.js"; const POOL = splashesData.filter((c) => c.skins && c.skins.length > 0); @@ -57,14 +66,14 @@ async function startFreshGame(db, subject) { return fresh; } -async function getOrInitGame(db, subject) { +async function getOrInitGame(db, subject, maxGuesses) { const existing = await loadGame(db, subject); - if (existing && existing.guesses.length < MAX_GUESSES) return existing; + if (existing && existing.guesses.length < maxGuesses) return existing; return startFreshGame(db, subject); } -function caption(guesses) { - return `๐ŸŽจ Guess the champion from this splash art. ${guesses.length}/${MAX_GUESSES} guesses so far.`; +function caption(guesses, maxGuesses) { + return `๐ŸŽจ Guess the champion from this splash art. ${guesses.length}/${maxGuesses} guesses so far.`; } export async function handleSplash(ctx, db) { @@ -72,7 +81,8 @@ export async function handleSplash(ctx, db) { if (subject == null) return ctx.reply("Cannot identify chat."); const arg = argAfterCommand(ctx.message?.text ?? ""); - const game = await getOrInitGame(db, subject); + const maxGuesses = await getMaxGuesses(db, subject); + const game = await getOrInitGame(db, subject, maxGuesses); const target = findByName(game.target); const skin = target && getSkinById(target, game.skinId); if (!target || !skin) { @@ -83,7 +93,7 @@ export async function handleSplash(ctx, db) { } if (!arg) { - return ctx.replyWithPhoto(skin.url, { caption: caption(game.guesses) }); + return ctx.replyWithPhoto(skin.url, { caption: caption(game.guesses, maxGuesses) }); } const guess = findChampion(POOL, arg); @@ -106,12 +116,12 @@ export async function handleSplash(ctx, db) { const s = await recordResult(db, subject, true); await clearGame(db, subject); return ctx.reply( - `๐ŸŽ‰ Got it! That was ${answer} in ${skinLabel}. Solved in ${game.guesses.length}/${MAX_GUESSES}\n๐Ÿ”ฅ Streak: ${s.streak}\n${NEW_ROUND_HINT}`, + `๐ŸŽ‰ Got it! That was ${answer} in ${skinLabel}. Solved in ${game.guesses.length}/${maxGuesses}\n๐Ÿ”ฅ Streak: ${s.streak}\n${NEW_ROUND_HINT}`, { parse_mode: "HTML" }, ); } - if (game.guesses.length >= MAX_GUESSES) { + if (game.guesses.length >= maxGuesses) { await recordResult(db, subject, false); await clearGame(db, subject); return ctx.reply( @@ -122,11 +132,29 @@ export async function handleSplash(ctx, db) { await saveGame(db, subject, game); return ctx.reply( - `โŒ Not ${escapeHtml(guess.championName)}. Guess ${game.guesses.length}/${MAX_GUESSES}.`, + `โŒ Not ${escapeHtml(guess.championName)}. Guess ${game.guesses.length}/${maxGuesses}.`, { parse_mode: "HTML" }, ); } +/** + * Hidden โ€” set the per-subject MAX_GUESSES override (1..MAX_GUESSES_CAP). + * + * @param {import("grammy").Context} ctx + * @param {import("../../db/kv-store-interface.js").KVStore} db + */ +export async function handleSetMax(ctx, db) { + const subject = getSubject(ctx); + if (subject == null) return ctx.reply("Cannot identify chat."); + const arg = argAfterCommand(ctx.message?.text ?? ""); + const n = Number.parseInt(arg, 10); + if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { + return ctx.reply(`Usage: /loldle_splash_setmax <1-${MAX_GUESSES_CAP}>`); + } + await setMaxGuesses(db, subject, n); + return ctx.reply(`โœ… Loldle splash max guesses set to ${n} (applies to the next round).`); +} + export async function handleGiveup(ctx, db) { const subject = getSubject(ctx); if (subject == null) return ctx.reply("Cannot identify chat."); diff --git a/src/modules/loldle-splash/index.js b/src/modules/loldle-splash/index.js index 00f1e14..ee3928b 100644 --- a/src/modules/loldle-splash/index.js +++ b/src/modules/loldle-splash/index.js @@ -3,7 +3,7 @@ * Skin pool scraped from loldle.net, splash URLs from Data Dragon CDN. */ -import { handleGiveup, handleSplash, handleStats } from "./handlers.js"; +import { handleGiveup, handleSetMax, handleSplash, handleStats } from "./handlers.js"; /** @type {import("../../db/kv-store-interface.js").KVStore | null} */ let db = null; @@ -33,6 +33,12 @@ const loldleSplashModule = { description: "Show your splash loldle stats (wins, streak)", handler: (ctx) => handleStats(ctx, db), }, + { + name: "loldle_splash_setmax", + visibility: "private", + description: "Override splash loldle max guesses per round (1-10)", + handler: (ctx) => handleSetMax(ctx, db), + }, ], }; diff --git a/src/modules/loldle-splash/state.js b/src/modules/loldle-splash/state.js index 682fca6..9787952 100644 --- a/src/modules/loldle-splash/state.js +++ b/src/modules/loldle-splash/state.js @@ -4,12 +4,28 @@ */ const MAX_GUESSES = 4; +const MAX_GUESSES_CAP = 10; const GAME_TTL_SECONDS = 60 * 60 * 24 * 7; const gameKey = (subject) => `game:${subject}`; const statsKey = (subject) => `stats:${subject}`; +const configKey = (subject) => `config:${subject}`; -export { MAX_GUESSES }; +export { MAX_GUESSES, MAX_GUESSES_CAP }; + +export async function getMaxGuesses(db, subject) { + const cfg = await db.getJSON(configKey(subject)); + const n = cfg?.maxGuesses; + if (Number.isInteger(n) && n >= 1 && n <= MAX_GUESSES_CAP) return n; + return MAX_GUESSES; +} + +export async function setMaxGuesses(db, subject, n) { + if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) { + throw new RangeError(`maxGuesses must be an integer in [1, ${MAX_GUESSES_CAP}]`); + } + await db.putJSON(configKey(subject), { maxGuesses: n }); +} export async function loadGame(db, subject) { return db.getJSON(gameKey(subject)); diff --git a/src/modules/loldle/README.md b/src/modules/loldle/README.md index e5cf233..e3e470a 100644 --- a/src/modules/loldle/README.md +++ b/src/modules/loldle/README.md @@ -1,8 +1,9 @@ # Loldle Module -Classic-mode League of Legends champion guessing game. Players get 8 guesses -to identify a hidden champion; each guess is compared across 7 attributes -and the board is rendered as a monospace Telegram table. +Classic-mode League of Legends champion guessing game. Players get 6 guesses +(default; per-subject override via the hidden `/loldle_setmax` command, capped +at 10) to identify a hidden champion; each guess is compared across 7 +attributes and the board is rendered as a monospace Telegram table. ## Data source @@ -28,6 +29,7 @@ opens a PR whenever the data changes. | `/loldle` | public | Show current board, or submit a champion guess | | `/loldle_giveup` | public | Reveal the answer and end the round | | `/loldle_stats` | public | Show your wins / streak | +| `/loldle_setmax ` | private | Override max guesses (1-10) for this subject; applies to next round | Submit a guess with `/loldle ` (e.g. `/loldle Ahri`). Names match case/space/punctuation-insensitive with a unique-prefix fallback. A round @@ -45,7 +47,8 @@ empty board gives no hints, so it shouldn't count against the clock. - `lookup.js` โ€” normalizes user input to a champion record. - `render.js` โ€” Telegram HTML `
` monospace table with auto-widthed
   label column.
-- `state.js` โ€” KV persistence (`MAX_GUESSES = 8`, per-subject stats).
+- `state.js` โ€” KV persistence (`MAX_GUESSES = 6` default, `MAX_GUESSES_CAP = 10`,
+  per-subject stats and config override).
 - `handlers.js` โ€” subject resolution (user id in DMs, chat id in groups) +
   command flow.
 - `flavor.js` โ€” win-message text helpers.
@@ -63,6 +66,7 @@ KV namespace prefix: `loldle:`
 |-----|-------|
 | `game:` | `{ target, guesses, startedAt }` โ€” active round (TTL 7 days). `guesses` is a championName array; comparison rows are recomputed at render time. |
 | `stats:` | `{ played, wins, streak, bestStreak }` |
+| `config:` | `{ maxGuesses }` โ€” optional per-subject round-length override (1-10). Absent โ‡’ default `MAX_GUESSES`. |
 
 ## Credits
 
diff --git a/src/modules/loldle/handlers.js b/src/modules/loldle/handlers.js
index e49d16a..5c464a9 100644
--- a/src/modules/loldle/handlers.js
+++ b/src/modules/loldle/handlers.js
@@ -23,7 +23,16 @@ import { compareChampions } from "./compare.js";
 import { attemptFlavor, formatDuration } from "./flavor.js";
 import { findChampion } from "./lookup.js";
 import { renderBoard, renderGuess } from "./render.js";
-import { MAX_GUESSES, clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js";
+import {
+  MAX_GUESSES_CAP,
+  clearGame,
+  getMaxGuesses,
+  loadGame,
+  loadStats,
+  recordResult,
+  saveGame,
+  setMaxGuesses,
+} from "./state.js";
 import { GIVEUP_STICKERS, LOSE_STICKERS, WIN_STICKERS, pickSticker } from "./stickers.js";
 
 /** @type {Array>} */
@@ -74,9 +83,9 @@ async function startFreshGame(db, subject) {
   return fresh;
 }
 
-async function getOrInitGame(db, subject) {
+async function getOrInitGame(db, subject, maxGuesses) {
   const existing = await loadGame(db, subject);
-  if (existing && existing.guesses.length < MAX_GUESSES) return existing;
+  if (existing && existing.guesses.length < maxGuesses) return existing;
   return startFreshGame(db, subject);
 }
 
@@ -100,10 +109,11 @@ export async function handleLoldle(ctx, db) {
   if (subject == null) return ctx.reply("Cannot identify chat.");
   const arg = argAfterCommand(ctx.message?.text ?? "");
 
-  const game = await getOrInitGame(db, subject);
+  const maxGuesses = await getMaxGuesses(db, subject);
+  const game = await getOrInitGame(db, subject, maxGuesses);
 
   if (!arg) {
-    const header = `Guess ${game.guesses.length}/${MAX_GUESSES}. Use /loldle <champion>.`;
+    const header = `Guess ${game.guesses.length}/${maxGuesses}. Use /loldle <champion>.`;
     const board = renderBoard(rehydrateGuesses(game));
     return ctx.reply(`${header}\n\n${board}`, { parse_mode: "HTML" });
   }
@@ -141,14 +151,14 @@ export async function handleLoldle(ctx, db) {
     const s = await recordResult(db, subject, true);
     await clearGame(db, subject);
     await trySendSticker(ctx, WIN_STICKERS);
-    const flavor = attemptFlavor(game.guesses.length, MAX_GUESSES);
+    const flavor = attemptFlavor(game.guesses.length, maxGuesses);
     return ctx.reply(
-      `${reply}\n\n๐ŸŽ‰ ${flavor} ${champ}\nโฑ ${elapsed} ยท ๐Ÿ”ฅ Streak: ${s.streak} (${game.guesses.length}/${MAX_GUESSES})\n${NEW_ROUND_HINT}`,
+      `${reply}\n\n๐ŸŽ‰ ${flavor} ${champ}\nโฑ ${elapsed} ยท ๐Ÿ”ฅ Streak: ${s.streak} (${game.guesses.length}/${maxGuesses})\n${NEW_ROUND_HINT}`,
       { parse_mode: "HTML" },
     );
   }
 
-  if (game.guesses.length >= MAX_GUESSES) {
+  if (game.guesses.length >= maxGuesses) {
     await recordResult(db, subject, false);
     await clearGame(db, subject);
     await trySendSticker(ctx, LOSE_STICKERS);
@@ -158,11 +168,30 @@ export async function handleLoldle(ctx, db) {
   }
 
   await saveGame(db, subject, game);
-  return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${MAX_GUESSES}.`, {
+  return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${maxGuesses}.`, {
     parse_mode: "HTML",
   });
 }
 
+/**
+ * Hidden command โ€” set the per-subject MAX_GUESSES override (1..MAX_GUESSES_CAP).
+ * Takes effect on the next round; in-progress rounds keep the old value.
+ *
+ * @param {import("grammy").Context} ctx
+ * @param {import("../../db/kv-store-interface.js").KVStore} db
+ */
+export async function handleSetMax(ctx, db) {
+  const subject = getSubject(ctx);
+  if (subject == null) return ctx.reply("Cannot identify chat.");
+  const arg = argAfterCommand(ctx.message?.text ?? "");
+  const n = Number.parseInt(arg, 10);
+  if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) {
+    return ctx.reply(`Usage: /loldle_setmax <1-${MAX_GUESSES_CAP}>`);
+  }
+  await setMaxGuesses(db, subject, n);
+  return ctx.reply(`โœ… Loldle max guesses set to ${n} (applies to the next round).`);
+}
+
 /**
  * @param {import("grammy").Context} ctx
  * @param {import("../../db/kv-store-interface.js").KVStore} db
diff --git a/src/modules/loldle/index.js b/src/modules/loldle/index.js
index ebbff8e..e4dcea2 100644
--- a/src/modules/loldle/index.js
+++ b/src/modules/loldle/index.js
@@ -3,7 +3,7 @@
  * Champion data is scraped weekly from loldle.net.
  */
 
-import { handleGiveup, handleLoldle, handleStats } from "./handlers.js";
+import { handleGiveup, handleLoldle, handleSetMax, handleStats } from "./handlers.js";
 
 /** @type {import("../../db/kv-store-interface.js").KVStore | null} */
 let db = null;
@@ -33,6 +33,12 @@ const loldleModule = {
       description: "Show your loldle stats (wins, streak)",
       handler: (ctx) => handleStats(ctx, db),
     },
+    {
+      name: "loldle_setmax",
+      visibility: "private",
+      description: "Override max guesses per round (1-10) for this chat",
+      handler: (ctx) => handleSetMax(ctx, db),
+    },
   ],
 };
 
diff --git a/src/modules/loldle/state.js b/src/modules/loldle/state.js
index add9a1a..ad5cdc9 100644
--- a/src/modules/loldle/state.js
+++ b/src/modules/loldle/state.js
@@ -10,13 +10,18 @@
  * at render time, so the board always reflects the live champions.json.
  */
 
-const MAX_GUESSES = 8;
+// Default round length. The 7-axis grid leaks enough info per guess that 6
+// is a fair target for typical play; admins can override per-subject via the
+// hidden /loldle_setmax command (bounded by MAX_GUESSES_CAP).
+const MAX_GUESSES = 6;
+const MAX_GUESSES_CAP = 10;
 // Upper bound for a round โ€” long enough for any real session, short enough
 // that stale KV entries get reclaimed automatically.
 const GAME_TTL_SECONDS = 60 * 60 * 24 * 7;
 
 const gameKey = (subject) => `game:${subject}`;
 const statsKey = (subject) => `stats:${subject}`;
+const configKey = (subject) => `config:${subject}`;
 
 /**
  * @typedef {object} GameState
@@ -56,7 +61,34 @@ export async function clearGame(db, subject) {
   await db.delete(gameKey(subject));
 }
 
-export { MAX_GUESSES };
+export { MAX_GUESSES, MAX_GUESSES_CAP };
+
+/**
+ * Per-subject override for the round length, falling back to MAX_GUESSES.
+ * Values outside [1, MAX_GUESSES_CAP] are ignored as if unset.
+ *
+ * @param {import("../../db/kv-store-interface.js").KVStore} db
+ * @param {number|string} subject
+ * @returns {Promise}
+ */
+export async function getMaxGuesses(db, subject) {
+  const cfg = await db.getJSON(configKey(subject));
+  const n = cfg?.maxGuesses;
+  if (Number.isInteger(n) && n >= 1 && n <= MAX_GUESSES_CAP) return n;
+  return MAX_GUESSES;
+}
+
+/**
+ * @param {import("../../db/kv-store-interface.js").KVStore} db
+ * @param {number|string} subject
+ * @param {number} n
+ */
+export async function setMaxGuesses(db, subject, n) {
+  if (!Number.isInteger(n) || n < 1 || n > MAX_GUESSES_CAP) {
+    throw new RangeError(`maxGuesses must be an integer in [1, ${MAX_GUESSES_CAP}]`);
+  }
+  await db.putJSON(configKey(subject), { maxGuesses: n });
+}
 
 /**
  * @param {import("../../db/kv-store-interface.js").KVStore} db
diff --git a/tests/modules/dispatcher.test.js b/tests/modules/dispatcher.test.js
index cb24210..b880318 100644
--- a/tests/modules/dispatcher.test.js
+++ b/tests/modules/dispatcher.test.js
@@ -24,9 +24,10 @@ describe("installDispatcher", () => {
     const env = { MODULES: "util,wordle,loldle,misc", KV: makeFakeKv() };
     const reg = await installDispatcher(bot, env);
 
-    // Expect 13 total commands (10 public + 1 protected + 2 private).
-    expect(bot.commandCalls).toHaveLength(13);
-    expect(reg.allCommands.size).toBe(13);
+    // Expect 14 total commands (10 public + 1 protected + 3 private โ€”
+    // the third private is loldle's /loldle_setmax override command).
+    expect(bot.commandCalls).toHaveLength(14);
+    expect(reg.allCommands.size).toBe(14);
 
     const registeredNames = bot.commandCalls.map((c) => c.name).sort();
     const expected = [...reg.allCommands.keys()].sort();