diff --git a/src/modules/loldle/README.md b/src/modules/loldle/README.md index 37c97b0..e5cf233 100644 --- a/src/modules/loldle/README.md +++ b/src/modules/loldle/README.md @@ -26,13 +26,15 @@ opens a PR whenever the data changes. | Command | Visibility | Description | |---------|-----------|-------------| | `/loldle` | public | Show current board, or submit a champion guess | -| `/loldle_giveup` | public | Reveal the answer and auto-start a fresh round | +| `/loldle_giveup` | public | Reveal the answer and end the round | | `/loldle_stats` | public | Show your wins / streak | Submit a guess with `/loldle ` (e.g. `/loldle Ahri`). Names match case/space/punctuation-insensitive with a unique-prefix fallback. A round -that ends (solved, gave up, or out of guesses) is immediately replaced by a -fresh one — no manual "new round" command. +that ends (solved, gave up, or out of guesses) is cleared; the next round +is created lazily on the first `/loldle` call, and the timer (`startedAt`) +only starts when the player submits their first actual guess — viewing an +empty board gives no hints, so it shouldn't count against the clock. ## Architecture diff --git a/src/modules/loldle/handlers.js b/src/modules/loldle/handlers.js index c36a064..e49d16a 100644 --- a/src/modules/loldle/handlers.js +++ b/src/modules/loldle/handlers.js @@ -5,10 +5,15 @@ * private chat → user id (per-user game) * group/supergroup chat → chat id (shared game — everyone plays together) * + * Round lifecycle: a round is created lazily on the first /loldle call after + * the previous round ended, and the timer (`startedAt`) only starts when the + * player submits their first actual guess — viewing an empty board gives no + * hints, so it shouldn't count against the clock. + * * Commands: * /loldle → show the current board (or start a round) * /loldle → submit a guess - * /loldle_giveup → reveal the answer and auto-start a fresh round + * /loldle_giveup → reveal the answer and end the round * /loldle_stats → show wins / streak for the current subject */ @@ -18,13 +23,14 @@ 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, loadGame, loadStats, recordResult, saveGame } from "./state.js"; +import { MAX_GUESSES, clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js"; import { GIVEUP_STICKERS, LOSE_STICKERS, WIN_STICKERS, pickSticker } from "./stickers.js"; /** @type {Array>} */ const champions = championsData; -const NEW_ROUND_HINT = "🆕 New round started. Use /loldle <champion> to guess."; +const NEW_ROUND_HINT = + "🆕 Send /loldle or /loldle <champion> to start a new round."; function getSubject(ctx) { const type = ctx.chat?.type; @@ -61,7 +67,9 @@ function rehydrateGuesses(game) { async function startFreshGame(db, subject) { const target = pickRandomChampion(); - const fresh = { target: target.championName, guesses: [], startedAt: Date.now() }; + // startedAt stays null until the player actually submits their first guess — + // seeing an empty board gives no hints, so the round hasn't really begun. + const fresh = { target: target.championName, guesses: [], startedAt: null }; await saveGame(db, subject, fresh); return fresh; } @@ -113,13 +121,15 @@ export async function handleLoldle(ctx, db) { const target = findByName(game.target); // champions.json can be refreshed between rounds — an active target may disappear. if (!target) { - await startFreshGame(db, subject); - return ctx.reply( - "Champion data was updated since this round started. Starting a fresh round — try again.", - ); + await clearGame(db, subject); + return ctx.reply(`Champion data was updated since this round started. ${NEW_ROUND_HINT}`, { + parse_mode: "HTML", + }); } const results = compareChampions(guess, target); + // Stamp the clock on the first guess — prior /loldle views don't count. + if (game.startedAt == null) game.startedAt = Date.now(); game.guesses.push(guess.championName); const won = guess.championName === target.championName; @@ -129,7 +139,7 @@ export async function handleLoldle(ctx, db) { if (won) { const s = await recordResult(db, subject, true); - await startFreshGame(db, subject); + await clearGame(db, subject); await trySendSticker(ctx, WIN_STICKERS); const flavor = attemptFlavor(game.guesses.length, MAX_GUESSES); return ctx.reply( @@ -140,7 +150,7 @@ export async function handleLoldle(ctx, db) { if (game.guesses.length >= MAX_GUESSES) { await recordResult(db, subject, false); - await startFreshGame(db, subject); + await clearGame(db, subject); await trySendSticker(ctx, LOSE_STICKERS); return ctx.reply(`${reply}\n\n❌ Out of guesses. Answer was ${champ}.\n${NEW_ROUND_HINT}`, { parse_mode: "HTML", @@ -160,12 +170,16 @@ export async function handleLoldle(ctx, db) { export async function handleGiveup(ctx, db) { const subject = getSubject(ctx); if (subject == null) return ctx.reply("Cannot identify chat."); - const game = await getOrInitGame(db, subject); + const existing = await loadGame(db, subject); + // No active round — nothing to give up on. + if (!existing) { + return ctx.reply(`No active round. ${NEW_ROUND_HINT}`, { parse_mode: "HTML" }); + } await recordResult(db, subject, false); - const target = findByName(game.target); - await startFreshGame(db, subject); + const target = findByName(existing.target); + await clearGame(db, subject); await trySendSticker(ctx, GIVEUP_STICKERS); - const answer = target ? escapeHtml(target.championName) : escapeHtml(game.target); + const answer = target ? escapeHtml(target.championName) : escapeHtml(existing.target); return ctx.reply(`🏳️ Answer was ${answer}.\n${NEW_ROUND_HINT}`, { parse_mode: "HTML" }); } diff --git a/src/modules/loldle/state.js b/src/modules/loldle/state.js index 4e1c426..add9a1a 100644 --- a/src/modules/loldle/state.js +++ b/src/modules/loldle/state.js @@ -22,7 +22,8 @@ const statsKey = (subject) => `stats:${subject}`; * @typedef {object} GameState * @property {string} target — hidden champion's championName * @property {string[]} guesses — championNames already tried this round - * @property {number} startedAt — epoch ms + * @property {number|null} startedAt — epoch ms; null until the first guess is + * submitted (viewing an empty board shouldn't start the clock) */ /** @@ -43,6 +44,18 @@ export async function saveGame(db, subject, state) { await db.putJSON(gameKey(subject), state, { expirationTtl: GAME_TTL_SECONDS }); } +/** + * Remove the stored game so the next /loldle call creates a fresh round. + * Used after win / loss / giveup — the new round's timer should start on the + * player's next interaction, not at the moment the previous round ended. + * + * @param {import("../../db/kv-store-interface.js").KVStore} db + * @param {number|string} subject + */ +export async function clearGame(db, subject) { + await db.delete(gameKey(subject)); +} + export { MAX_GUESSES }; /**