feat(loldle): tunable max guesses per subject + retuned defaults

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 /<module>_setmax <n> command per loldle module so a chat can
override its own round length (1-10). Override stored at config:<subject>
in each module's KV; getMaxGuesses() falls back to the default when unset.
This commit is contained in:
tiennm99 committed 2026-05-07 13:52:09 +07:00
1 parent 59a0b76f91
commit e03d2a937c
17 files changed
+341 -66

No files matched your search

+38 -10
View File
@@ -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 <b>${answer}</b> — ${abilityLabel}. Solved in ${game.guesses.length}/${MAX_GUESSES}\n🔥 Streak: ${s.streak}\n${NEW_ROUND_HINT}`,
`🎉 Got it! That was <b>${answer}</b> — ${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 <b>${escapeHtml(guess.championName)}</b>. Guess ${game.guesses.length}/${MAX_GUESSES}.`,
`❌ Not <b>${escapeHtml(guess.championName)}</b>. 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.");
+7 -1
View File
@@ -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),
},
],
};
+17 -1
View File
@@ -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));
+37 -9
View File
@@ -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! <b>${answer}</b> — solved in ${game.guesses.length}/${MAX_GUESSES}\n🔥 Streak: ${s.streak}\n${NEW_ROUND_HINT}`,
`🎉 Got it! <b>${answer}</b> — 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 <b>${answer}</b>.\n${NEW_ROUND_HINT}`,
`${renderBoard(target.emojis, game.guesses, maxGuesses)}\n\n❌ Out of guesses. Answer was <b>${answer}</b>.\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 <b>${escapeHtml(guess.championName)}</b>. Guess ${game.guesses.length}/${MAX_GUESSES}.`,
`${renderBoard(target.emojis, game.guesses, maxGuesses)}\n\n❌ Not <b>${escapeHtml(guess.championName)}</b>. 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.");
+7 -1
View File
@@ -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),
},
],
};
+21 -2
View File
@@ -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));
+37 -9
View File
@@ -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! <b>${answer}</b> — solved in ${game.guesses.length}/${MAX_GUESSES}\n🔥 Streak: ${s.streak}\n${NEW_ROUND_HINT}`,
`🎉 Nailed it! <b>${answer}</b> — 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: <b>${answer}</b>.\n${NEW_ROUND_HINT}`,
`${renderBoard(target.quote, game.guesses, maxGuesses)}\n\n❌ Out of guesses. Answer: <b>${answer}</b>.\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 <b>${escapeHtml(guess.championName)}</b>. Guess ${game.guesses.length}/${MAX_GUESSES}.`,
`${renderBoard(target.quote, game.guesses, maxGuesses)}\n\n❌ Not <b>${escapeHtml(guess.championName)}</b>. 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.");
+7 -1
View File
@@ -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),
},
],
};
+17 -1
View File
@@ -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));
+38 -10
View File
@@ -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 <b>${answer}</b> in ${skinLabel}. Solved in ${game.guesses.length}/${MAX_GUESSES}\n🔥 Streak: ${s.streak}\n${NEW_ROUND_HINT}`,
`🎉 Got it! That was <b>${answer}</b> 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 <b>${escapeHtml(guess.championName)}</b>. Guess ${game.guesses.length}/${MAX_GUESSES}.`,
`❌ Not <b>${escapeHtml(guess.championName)}</b>. 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.");
+7 -1
View File
@@ -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),
},
],
};
+17 -1
View File
@@ -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));
+8 -4
View File
@@ -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 <n>` | private | Override max guesses (1-10) for this subject; applies to next round |
Submit a guess with `/loldle <champion>` (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 `<pre>` 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:<subject>` | `{ target, guesses, startedAt }` — active round (TTL 7 days). `guesses` is a championName array; comparison rows are recomputed at render time. |
| `stats:<subject>` | `{ played, wins, streak, bestStreak }` |
| `config:<subject>` | `{ maxGuesses }` — optional per-subject round-length override (1-10). Absent ⇒ default `MAX_GUESSES`. |
## Credits
+38 -9
View File
@@ -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<Record<string, any>>} */
@@ -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 <code>/loldle &lt;champion&gt;</code>.`;
const header = `Guess ${game.guesses.length}/${maxGuesses}. Use <code>/loldle &lt;champion&gt;</code>.`;
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
+7 -1
View File
@@ -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),
},
],
};
+34 -2
View File
@@ -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<number>}
*/
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
+4 -3
View File
@@ -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();