` (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();