fix(loldle): start round timer on first guess, not round end

Previously startFreshGame was called at the tail of every win/lose/giveup
path, stamping startedAt to that moment — so the clock accrued while the
player was away between rounds. Now:

- round-ending paths call clearGame (new helper in state.js), deleting the
  KV record instead of pre-creating the next round
- getOrInitGame lazily creates the next round on the player's next /loldle
  call, with startedAt: null
- the first actual guess inside handleLoldle stamps startedAt = Date.now()

Viewing an empty board gives no hints, so it shouldn't count against the
clock. handleGiveup no longer auto-creates a fresh round and now reports
"No active round" when called with nothing in progress.
This commit is contained in:
tiennm99 committed 2026-04-22 17:00:31 +07:00
1 parent cedd9da4ac
commit ba829d2906
3 files changed
+47 -18

No files matched your search

+5 -3
View File
@@ -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 <champion>` (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
+28 -14
View File
@@ -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 <champion> → 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<Record<string, any>>} */
const champions = championsData;
const NEW_ROUND_HINT = "🆕 New round started. Use <code>/loldle &lt;champion&gt;</code> to guess.";
const NEW_ROUND_HINT =
"🆕 Send <code>/loldle</code> or <code>/loldle &lt;champion&gt;</code> 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" });
}
+14 -1
View File
@@ -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 };
/**