feat(loldle): sticker reactions + per-attempt flavor + solve time

- Send a random sticker from WIN/LOSE/GIVEUP pools before the text reply
  (errors swallowed so a rotten file_id never blocks the message).
- Win message includes attempt-count flavor ("First try!" / "Sharp!" /
  "Close call!" / "Phew — last one!") and elapsed solve time.
- Lose and giveup messages now also include the champion's title.
- Extract stickers.js and flavor.js so handlers.js stays at ~200 LoC.
This commit is contained in:
tiennm99 committed 2026-04-22 00:00:50 +07:00
1 parent 9977c4d74e
commit 405a0391e2
4 files changed
+158 -5

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
/**
* @file Small text helpers for the loldle win message.
*
* Kept separate so handlers.js stays under the 200-LoC guideline and so these
* pure functions are easy to unit-test without a grammY context.
*/
/**
* One-word reaction keyed by how many guesses it took to solve.
*
* @param {number} attempt — 1-indexed count of the guess that won (1..max)
* @param {number} max — MAX_GUESSES (8 for loldle)
* @returns {string}
*/
export function attemptFlavor(attempt, max) {
if (attempt <= 1) return "First try!";
if (attempt === 2) return "Sharp!";
if (attempt >= max) return "Phew — last one!";
if (attempt >= max - 2) return "Close call!";
return "Nice.";
}
/**
* Format an elapsed duration in ms as a compact human string.
* < 60s → "42s"
* < 60min → "3m 14s"
* otherwise → "1h 12m"
*
* @param {number} ms
* @returns {string}
*/
export function formatDuration(ms) {
const total = Math.max(0, Math.round(ms / 1000));
if (total < 60) return `${total}s`;
const minutes = Math.floor(total / 60);
const seconds = total % 60;
if (minutes < 60) return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
const hours = Math.floor(minutes / 60);
const remMin = minutes % 60;
return remMin === 0 ? `${hours}h` : `${hours}h ${remMin}m`;
}
+31 -5
View File
@@ -19,9 +19,11 @@ import { escapeHtml } from "../../util/escape-html.js";
import championsData from "./champions-data.js";
import { compareChampions } from "./compare.js";
import { pickRandom } from "./daily.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 { GIVEUP_STICKERS, LOSE_STICKERS, WIN_STICKERS, pickSticker } from "./stickers.js";
/** @type {Array<Record<string, any>>} */
const champions = championsData;
@@ -79,6 +81,24 @@ async function startFreshGame(db, subject) {
return fresh;
}
/**
* Send a random sticker from the pool, swallowing errors so a rotten file_id
* (Telegram rejection) never blocks the follow-up text reply.
*
* @param {import("grammy").Context} ctx
* @param {readonly string[]} pool
*/
async function trySendSticker(ctx, pool) {
const sticker = pickSticker(pool);
if (!sticker) return;
try {
await ctx.replyWithSticker(sticker);
} catch {
// Ignore — the outcome text reply is what matters. An invalid file_id
// or transient Telegram error must not derail the game flow.
}
}
/**
* @param {import("grammy").Context} ctx
* @param {import("../../db/kv-store-interface.js").KVStore} db
@@ -113,21 +133,26 @@ export async function handleLoldle(ctx, db) {
await saveGame(db, subject, game);
const reply = renderGuess(guess.name, results);
const elapsed = formatDuration(Date.now() - (game.startedAt ?? Date.now()));
const champ = `${escapeHtml(target.name)} — ${escapeHtml(target.title)}`;
if (won) {
const s = await recordResult(db, subject, true);
await startFreshGame(db, subject);
await trySendSticker(ctx, WIN_STICKERS);
const flavor = attemptFlavor(game.guesses.length, MAX_GUESSES);
return ctx.reply(
`${reply}\n\n🎉 Solved in ${game.guesses.length}/${MAX_GUESSES}! Streak: ${s.streak}.\n${NEW_ROUND_HINT}`,
`${reply}\n\n🎉 ${flavor} ${champ}\n⏱ ${elapsed} · 🔥 Streak: ${s.streak} (${game.guesses.length}/${MAX_GUESSES})\n${NEW_ROUND_HINT}`,
{ parse_mode: "HTML" },
);
}
if (game.guesses.length >= MAX_GUESSES) {
await recordResult(db, subject, false);
await startFreshGame(db, subject);
return ctx.reply(
`${reply}\n\n❌ Out of guesses. Answer was ${escapeHtml(target.name)}.\n${NEW_ROUND_HINT}`,
{ parse_mode: "HTML" },
);
await trySendSticker(ctx, LOSE_STICKERS);
return ctx.reply(`${reply}\n\n❌ Out of guesses. Answer was ${champ}.\n${NEW_ROUND_HINT}`, {
parse_mode: "HTML",
});
}
return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${MAX_GUESSES}.`, {
parse_mode: "HTML",
@@ -148,6 +173,7 @@ export async function handleGiveup(ctx, db) {
await recordResult(db, subject, false);
const target = champions.find((c) => c.id === game.target);
await startFreshGame(db, subject);
await trySendSticker(ctx, GIVEUP_STICKERS);
const answer = target
? `${escapeHtml(target.name)} — ${escapeHtml(target.title)}`
: escapeHtml(game.target);
+38
View File
@@ -0,0 +1,38 @@
/**
* @file Loldle sticker pools — keyed by round outcome.
*
* Telegram `file_id`s are bot-scoped: these IDs are only valid for @miti99bot.
* To add/replace stickers, DM a sticker to the bot and use `/stickerid` (util
* module, private) to capture the bot-scoped file_id, then paste it here.
*
* Empty pools are safe — `pickSticker` returns null and the handler skips the
* replyWithSticker call entirely.
*/
/** Cheerful stickers shown before a win message. */
export const WIN_STICKERS = [
"CAACAgIAAxkBAAECGBVp56vuLw5TSWy8TiFGkQYAAWxi0wUAAh9EAAJTD8BJ2wQ6vyMilxs7BA",
"CAACAgIAAxkBAAECGBhp56wAATkzsBBcIxMSC_8mp2dBczsAAvBEAAL1vklJuCnu695nAvI7BA",
"CAACAgIAAxkBAAECGBlp56wBZ4rmluL3KNlOuWsyctN0FQACNEQAAue5OEv37J_IfMnpljsE",
"CAACAgIAAxkBAAECGCFp56w7WlD6vTIsHE2WUTs4C2IjXAAC9FsAAtgFMUu63usfH16ZpzsE",
];
/** Deflated stickers shown when the player runs out of guesses. */
export const LOSE_STICKERS = [
"CAACAgIAAxkBAAECGCBp56w3HUyYOHOeMwLfULT8p8SPtQACWkYAAiPesEjnptWk36YKZjsE",
];
/** Resigned stickers shown on /loldle_giveup. */
export const GIVEUP_STICKERS = [
"CAACAgIAAxkBAAECGCJp56xIk6B6McSfnYykLhgXVCSnmQACBlkAApSyWEo6G2rnqDvZxjsE",
];
/**
* Pick a random sticker from a pool, or null if the pool is empty.
* @param {readonly string[]} pool
* @returns {string|null}
*/
export function pickSticker(pool) {
if (!pool || pool.length === 0) return null;
return pool[Math.floor(Math.random() * pool.length)];
}
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { attemptFlavor, formatDuration } from "../../../src/modules/loldle/flavor.js";
describe("attemptFlavor", () => {
it("rewards a first-try solve", () => {
expect(attemptFlavor(1, 8)).toBe("First try!");
});
it("calls out a second-attempt solve as Sharp", () => {
expect(attemptFlavor(2, 8)).toBe("Sharp!");
});
it("flags the last guess as Phew", () => {
expect(attemptFlavor(8, 8)).toBe("Phew — last one!");
});
it("calls the second-to-last and third-to-last a close call", () => {
expect(attemptFlavor(6, 8)).toBe("Close call!");
expect(attemptFlavor(7, 8)).toBe("Close call!");
});
it("falls back to Nice for the middle range", () => {
expect(attemptFlavor(3, 8)).toBe("Nice.");
expect(attemptFlavor(5, 8)).toBe("Nice.");
});
});
describe("formatDuration", () => {
it("renders sub-minute as seconds only", () => {
expect(formatDuration(42_000)).toBe("42s");
expect(formatDuration(999)).toBe("1s");
expect(formatDuration(0)).toBe("0s");
});
it("renders minutes with seconds, dropping seconds when zero", () => {
expect(formatDuration(3 * 60_000 + 14_000)).toBe("3m 14s");
expect(formatDuration(5 * 60_000)).toBe("5m");
});
it("renders hours with minutes, dropping minutes when zero", () => {
expect(formatDuration(60 * 60_000 + 12 * 60_000)).toBe("1h 12m");
expect(formatDuration(2 * 60 * 60_000)).toBe("2h");
});
it("clamps negative input to 0s (clock-skew safety)", () => {
expect(formatDuration(-5_000)).toBe("0s");
});
});