mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-15 02:17:35 +00:00
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:
1 parent
59a0b76f91
commit
e03d2a937c
17 files changed
+341
-66
No files matched your search
@@ -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.");
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <champion></code>.`;
|
||||
const header = `Guess ${game.guesses.length}/${maxGuesses}. Use <code>/loldle <champion></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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user