diff --git a/src/modules/loldle/flavor.js b/src/modules/loldle/flavor.js new file mode 100644 index 0000000..7a3ea7f --- /dev/null +++ b/src/modules/loldle/flavor.js @@ -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`; +} diff --git a/src/modules/loldle/handlers.js b/src/modules/loldle/handlers.js index 22c8ac9..6b1f038 100644 --- a/src/modules/loldle/handlers.js +++ b/src/modules/loldle/handlers.js @@ -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>} */ 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); diff --git a/src/modules/loldle/stickers.js b/src/modules/loldle/stickers.js new file mode 100644 index 0000000..d51e602 --- /dev/null +++ b/src/modules/loldle/stickers.js @@ -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)]; +} diff --git a/tests/modules/loldle/flavor.test.js b/tests/modules/loldle/flavor.test.js new file mode 100644 index 0000000..ef053ce --- /dev/null +++ b/tests/modules/loldle/flavor.test.js @@ -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"); + }); +});