From 7997c8eb5b8150a66626c73d745a2a6b6217bd14 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 21 Apr 2026 23:03:49 +0700 Subject: [PATCH] feat(loldle): monospace aligned board, auto-restart round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove /loldle_new; finished rounds (solve/giveup/out-of-guesses) immediately roll into a fresh round. - Render guesses as an HTML
 monospace table with auto-widthed
  label column and a ๐ŸŽฏ Name row (uppercase champion name).
- Year direction uses โฌ†๏ธ / โฌ‡๏ธ.
---
 docs/codebase-summary.md            |  2 +-
 docs/development-roadmap.md         |  5 +-
 src/modules/loldle/README.md        | 10 ++--
 src/modules/loldle/handlers.js      | 67 +++++++++---------------
 src/modules/loldle/index.js         | 10 +---
 src/modules/loldle/render.js        | 75 +++++++++++++++++++-------
 src/modules/loldle/state.js         |  2 +-
 tests/modules/dispatcher.test.js    |  6 +--
 tests/modules/loldle/render.test.js | 81 +++++++++++++++++++++++++++++
 9 files changed, 176 insertions(+), 82 deletions(-)
 create mode 100644 tests/modules/loldle/render.test.js

diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md
index fd33a35..c21efda 100644
--- a/docs/codebase-summary.md
+++ b/docs/codebase-summary.md
@@ -22,7 +22,7 @@ Telegram bot on Cloudflare Workers with a plug-n-play module system. grammY hand
 | `util` | Complete | `/info`, `/help` | โ€” | โ€” | Bot info and command help renderer |
 | `trading` | Complete | `/trade_topup`, `/trade_buy`, `/trade_sell`, `/trade_convert`, `/trade_stats`, `/history` | D1 (trades) + KV (portfolio, symbol cache) | Daily 5PM trim | Paper trading โ€” VN stocks with dynamic symbol resolution. Crypto/gold/forex coming soon. |
 | `wordle` | Complete | `/wordle`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` | KV (game, stats) | โ€” | Classic 5-letter word game. 14,855-word dict sourced from [dracos's gist](https://gist.github.com/dracos/dd0668f281e685bad51479e5acaadb93). |
-| `loldle` | Complete | `/loldle`, `/loldle_new`, `/loldle_giveup`, `/loldle_stats` | KV (game, stats) | โ€” | Classic-mode LoL champion guesser. Champion data synced from `tiennm99/loldle-data`. |
+| `loldle` | Complete | `/loldle`, `/loldle_giveup`, `/loldle_stats` | KV (game, stats) | โ€” | Classic-mode LoL champion guesser (auto-starts a new round after solve/giveup). Champion data synced from `tiennm99/loldle-data`. |
 | `misc` | Stub | `/ping`, `/mstats`, `/fortytwo` | KV | โ€” | Health check + DB demo |
 
 ## Key Data Flows
diff --git a/docs/development-roadmap.md b/docs/development-roadmap.md
index ddafb9e..f64f54a 100644
--- a/docs/development-roadmap.md
+++ b/docs/development-roadmap.md
@@ -58,5 +58,6 @@ tracks what's **next**, not what's done โ€” for completed work, see git log and
   per-chat daily (each group has its own seed)?
 - Does daily mode count toward the existing `stats:` record, or live
   in a separate `daily-stats::` namespace?
-- Do we keep `/wordle_new` and `/loldle_new` in daily mode, or hide them until
-  the next UTC rollover?
+- Do we keep `/wordle_new` in daily mode, or hide it until the next UTC
+  rollover? (Loldle auto-starts a fresh round after solve/giveup, so the
+  question only applies to wordle now.)
diff --git a/src/modules/loldle/README.md b/src/modules/loldle/README.md
index 565e69e..00d2c8b 100644
--- a/src/modules/loldle/README.md
+++ b/src/modules/loldle/README.md
@@ -11,13 +11,13 @@ GitHub Actions workflow that regenerates `champions-data.js`.
 | Command | Visibility | Description |
 |---------|-----------|-------------|
 | `/loldle` | public | Show current board, start a game, or submit a champion guess when an argument is provided |
-| `/loldle_new` | public | Start a new round (auto-gives-up any in-progress one) |
-| `/loldle_giveup` | public | Reveal the current loldle answer |
+| `/loldle_giveup` | public | Reveal the current loldle answer (auto-starts a fresh round) |
 | `/loldle_stats` | public | Show your loldle stats (wins, streak) |
 
 Submit a guess with `/loldle ` โ€” e.g. `/loldle Ahri`. Champion names
 are matched case/space/punctuation-insensitive with a unique-prefix fallback
-(see `lookup.js`).
+(see `lookup.js`). A round that ends (solved, gave up, or ran out of guesses)
+immediately rolls into a fresh round โ€” no manual "new round" command needed.
 
 ## Architecture
 
@@ -27,8 +27,8 @@ are matched case/space/punctuation-insensitive with a unique-prefix fallback
 - `lookup.js` โ€” normalizes user input and resolves it to a champion record.
 - `daily.js` โ€” `pickRandom` / `pickDaily` (djb2-hashed date seed for future
   daily-mode use).
-- `render.js` โ€” Telegram-friendly plain-text rendering (โœ…/๐ŸŸจ/โŒ markers and
-  โฌ†/โฌ‡ year direction hints).
+- `render.js` โ€” Telegram HTML `
` monospace table with auto-widthed label
+  column (โœ…/๐ŸŸจ/โŒ markers and โฌ†๏ธ/โฌ‡๏ธ year direction hints).
 - `state.js` โ€” KV persistence with `MAX_GUESSES = 8`, per-subject stats with
   streak tracking.
 - `handlers.js` โ€” wires subject resolution (user id in DMs, chat id in groups)
diff --git a/src/modules/loldle/handlers.js b/src/modules/loldle/handlers.js
index 253b0a6..534d7ee 100644
--- a/src/modules/loldle/handlers.js
+++ b/src/modules/loldle/handlers.js
@@ -8,9 +8,11 @@
  * Commands:
  *   /loldle              โ†’ show board / start puzzle
  *   /loldle    โ†’ submit a guess
- *   /loldle_new          โ†’ abandon current round (counts as giveup) + start fresh
- *   /loldle_giveup       โ†’ reveal answer, end current round
+ *   /loldle_giveup       โ†’ reveal answer, end round (a fresh round auto-starts)
  *   /loldle_stats        โ†’ show stats (per-user in DM, per-group in groups)
+ *
+ * A finished round (solved, gave up, or out of guesses) is immediately
+ * replaced by a fresh round, so the user can just keep playing.
  */
 
 import championsData from "./champions-data.js";
@@ -23,6 +25,8 @@ import { MAX_GUESSES, loadGame, loadStats, recordResult, saveGame } from "./stat
 /** @type {Array>} */
 const champions = championsData;
 
+const NEW_ROUND_HINT = "๐Ÿ†• New round started. Use `/loldle ` to guess.";
+
 /**
  * Returns the stable subject identifier for the current chat.
  * In private chat: user id. In groups: chat id (shared across all members).
@@ -48,12 +52,14 @@ function isFinished(game) {
 
 /**
  * Load existing round, or create + persist a fresh random one.
+ * A previously-finished round is discarded and replaced with a fresh one so
+ * the game auto-continues without needing a manual "new round" command.
  * @param {import("../../db/kv-store-interface.js").KVStore} db
  * @param {number} subject
  */
 async function getOrInitGame(db, subject) {
   const existing = await loadGame(db, subject);
-  if (existing) return existing;
+  if (existing && !isFinished(existing)) return existing;
   return startFreshGame(db, subject);
 }
 
@@ -82,18 +88,8 @@ export async function handleLoldle(ctx, db) {
   const game = await getOrInitGame(db, subject);
 
   if (!arg) {
-    const header = game.solved
-      ? `๐ŸŽ‰ Solved in ${game.guesses.length}/${MAX_GUESSES}. /loldle_new for another.`
-      : game.giveup
-        ? `๐Ÿณ๏ธ Gave up. Answer was ${game.target}. /loldle_new for another.`
-        : `Guess ${game.guesses.length}/${MAX_GUESSES}. Use \`/loldle \`.`;
-    return ctx.reply(`${header}\n\n${renderBoard(game.guesses)}`);
-  }
-
-  if (isFinished(game)) {
-    return ctx.reply(
-      `Current round is over. Use /loldle_new to start another. Answer was ${game.target}.`,
-    );
+    const header = `Guess ${game.guesses.length}/${MAX_GUESSES}. Use \`/loldle \`.`;
+    return ctx.reply(`${header}\n\n${renderBoard(game.guesses)}`, { parse_mode: "HTML" });
   }
 
   const guess = findChampion(champions, arg);
@@ -116,38 +112,23 @@ export async function handleLoldle(ctx, db) {
   const reply = renderGuess(guess.name, results);
   if (won) {
     const s = await recordResult(db, subject, true);
+    await startFreshGame(db, subject);
     return ctx.reply(
-      `${reply}\n\n๐ŸŽ‰ Solved in ${game.guesses.length}/${MAX_GUESSES}! Streak: ${s.streak}. /loldle_new for another.`,
+      `${reply}\n\n๐ŸŽ‰ Solved in ${game.guesses.length}/${MAX_GUESSES}! Streak: ${s.streak}.\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 ${target.name}. /loldle_new to retry.`,
+      `${reply}\n\nโŒ Out of guesses. Answer was ${target.name}.\n${NEW_ROUND_HINT}`,
+      { parse_mode: "HTML" },
     );
   }
-  return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${MAX_GUESSES}.`);
-}
-
-/**
- * @param {import("grammy").Context} ctx
- * @param {import("../../db/kv-store-interface.js").KVStore} db
- */
-export async function handleNew(ctx, db) {
-  const subject = getSubject(ctx);
-  if (subject == null) return ctx.reply("Cannot identify chat.");
-
-  const prior = await loadGame(db, subject);
-  let prelude = "";
-  if (prior && !isFinished(prior)) {
-    await recordResult(db, subject, false);
-    const prev = champions.find((c) => c.id === prior.target);
-    // prev may be undefined if champion data was refreshed โ€” fall back to the id we stored.
-    prelude = `๐Ÿณ๏ธ Previous round abandoned (auto-giveup). Answer was ${prev?.name ?? prior.target}.\n\n`;
-  }
-
-  await startFreshGame(db, subject);
-  return ctx.reply(`${prelude}๐Ÿ†• New round started. Use \`/loldle \` to guess.`);
+  return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${MAX_GUESSES}.`, {
+    parse_mode: "HTML",
+  });
 }
 
 /**
@@ -158,16 +139,16 @@ 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);
-  if (game.solved) return ctx.reply(`Already solved โ€” ${game.target}.`);
-  if (game.giveup) return ctx.reply(`Already gave up โ€” ${game.target}.`);
+  // getOrInitGame guarantees the returned game is unfinished, so mark + record.
   game.giveup = true;
   await saveGame(db, subject, game);
   await recordResult(db, subject, false);
   const target = champions.find((c) => c.id === game.target);
+  await startFreshGame(db, subject);
   if (!target) {
-    return ctx.reply(`๐Ÿณ๏ธ Answer was ${game.target}. /loldle_new for another.`);
+    return ctx.reply(`๐Ÿณ๏ธ Answer was ${game.target}.\n${NEW_ROUND_HINT}`);
   }
-  return ctx.reply(`๐Ÿณ๏ธ Answer was ${target.name} โ€” ${target.title}. /loldle_new for another.`);
+  return ctx.reply(`๐Ÿณ๏ธ Answer was ${target.name} โ€” ${target.title}.\n${NEW_ROUND_HINT}`);
 }
 
 /**
diff --git a/src/modules/loldle/index.js b/src/modules/loldle/index.js
index 3e5c21a..617e3ca 100644
--- a/src/modules/loldle/index.js
+++ b/src/modules/loldle/index.js
@@ -5,7 +5,7 @@
  * tiennm99/loldle-data's champions.json (synced via GH Actions).
  */
 
-import { handleGiveup, handleLoldle, handleNew, handleStats } from "./handlers.js";
+import { handleGiveup, handleLoldle, handleStats } from "./handlers.js";
 
 /** @type {import("../../db/kv-store-interface.js").KVStore | null} */
 let db = null;
@@ -23,16 +23,10 @@ const loldleModule = {
       description: "Classic loldle โ€” guess the current champion",
       handler: (ctx) => handleLoldle(ctx, db),
     },
-    {
-      name: "loldle_new",
-      visibility: "public",
-      description: "Start a new round (auto-gives-up any in-progress one)",
-      handler: (ctx) => handleNew(ctx, db),
-    },
     {
       name: "loldle_giveup",
       visibility: "public",
-      description: "Reveal the current loldle answer",
+      description: "Reveal the current loldle answer (auto-starts a fresh round)",
       handler: (ctx) => handleGiveup(ctx, db),
     },
     {
diff --git a/src/modules/loldle/render.js b/src/modules/loldle/render.js
index 8dde4ba..dced6b5 100644
--- a/src/modules/loldle/render.js
+++ b/src/modules/loldle/render.js
@@ -1,35 +1,72 @@
 /**
- * @file Render comparison results as a Telegram-friendly plain-text grid.
- * Avoids HTML to keep escaping trivial; uses emoji markers:
- *   โœ… correct ยท ๐ŸŸจ partial ยท โŒ wrong ยท โฌ† direction-up ยท โฌ‡ direction-down
+ * @file Render comparison results as a monospace-aligned table.
+ *
+ * Output uses Telegram HTML parse mode wrapped in 
 so columns line up
+ * in Telegram's fixed-width font. The label column auto-widths based on the
+ * longest label in the block, so new attributes drop in without re-tuning.
+ *
+ * Markers:
+ *   ๐ŸŽฏ the guessed champion (name row header)
+ *   โœ… correct ยท ๐ŸŸจ partial ยท โŒ wrong ยท โฌ†๏ธ / โฌ‡๏ธ direction hint for year
  */
 
+import { escapeHtml } from "../../util/escape-html.js";
+
 const MARKER = { correct: "โœ…", partial: "๐ŸŸจ", wrong: "โŒ" };
+const ARROW = { up: "โฌ†๏ธ", down: "โฌ‡๏ธ" };
+const NAME_LABEL = "Name";
+const NAME_MARKER = "๐ŸŽฏ";
 
 /**
- * Render a single guess row.
+ * Build the label/value rows for a single guess (name row + one row per attribute).
+ * @param {string} championName
+ * @param {ReturnType} results
+ * @returns {Array<{marker: string, label: string, value: string}>}
+ */
+function buildRows(championName, results) {
+  const rows = [{ marker: NAME_MARKER, label: NAME_LABEL, value: championName.toUpperCase() }];
+  for (const r of results) {
+    const marker = MARKER[r.result] ?? MARKER.wrong;
+    let value = String(r.guessValue ?? "");
+    if (r.key === "releaseDate" && r.result !== "correct" && r.direction) {
+      const arrow = ARROW[r.direction];
+      if (arrow) value = `${value} ${arrow}`;
+    }
+    rows.push({ marker, label: r.label, value });
+  }
+  return rows;
+}
+
+/**
+ * Render one or more row-groups as a single aligned monospace block. Label
+ * column width = max label length across ALL groups, so stacked guesses on a
+ * board line up with each other.
+ * @param {Array>} rowGroups
+ */
+function formatRowGroups(rowGroups) {
+  const width = Math.max(...rowGroups.flat().map((r) => r.label.length));
+  const blocks = rowGroups.map((rows) =>
+    rows.map((r) => `${r.marker} ${r.label.padEnd(width)} ${escapeHtml(r.value)}`).join("\n"),
+  );
+  return `
${blocks.join("\n\n")}
`; +} + +/** + * Render a single guess row-group. * @param {string} championName * @param {ReturnType} results */ export function renderGuess(championName, results) { - const header = `๐ŸŽฏ ${championName}`; - const lines = results.map((r) => { - const mark = MARKER[r.result] ?? "โŒ"; - let arrow = ""; - if (r.key === "releaseDate" && r.result !== "correct") { - if (r.direction === "up") arrow = " โฌ†"; - else if (r.direction === "down") arrow = " โฌ‡"; - } - return `${mark} ${r.label}: ${r.guessValue}${arrow}`; - }); - return [header, ...lines].join("\n"); + return formatRowGroups([buildRows(championName, results)]); } /** - * Render the current board = all prior guesses stacked. - * @param {Array<{champion:string, results: any[]}>} guesses + * Render the current board โ€” all prior guesses stacked in one aligned block. + * @param {Array<{champion: string, results: any[]}>} guesses */ export function renderBoard(guesses) { - if (guesses.length === 0) return "No guesses yet. Reply with `/loldle `."; - return guesses.map((g) => renderGuess(g.champion, g.results)).join("\n\n"); + if (guesses.length === 0) { + return "No guesses yet. Reply with /loldle <champion>."; + } + return formatRowGroups(guesses.map((g) => buildRows(g.champion, g.results))); } diff --git a/src/modules/loldle/state.js b/src/modules/loldle/state.js index 4a7808f..be39e9d 100644 --- a/src/modules/loldle/state.js +++ b/src/modules/loldle/state.js @@ -2,7 +2,7 @@ * @file Game state in KV, keyed by "subject" (user in DM, chat in groups). * * One active round per subject at a time. Rounds are self-paced: players - * can /loldle_new to abandon and reroll. Streak = consecutive wins. + * can /loldle_giveup to reveal (a fresh round auto-starts). Streak = consecutive wins. * * Key layout (inside module-prefixed store): * game: -> { target, guesses[], solved, giveup, startedAt } diff --git a/tests/modules/dispatcher.test.js b/tests/modules/dispatcher.test.js index da7e7a3..9c09247 100644 --- a/tests/modules/dispatcher.test.js +++ b/tests/modules/dispatcher.test.js @@ -24,9 +24,9 @@ describe("installDispatcher", () => { const env = { MODULES: "util,wordle,loldle,misc", KV: makeFakeKv() }; const reg = await installDispatcher(bot, env); - // Expect 13 total commands (11 public + 1 protected + 1 private). - expect(bot.commandCalls).toHaveLength(13); - expect(reg.allCommands.size).toBe(13); + // Expect 12 total commands (10 public + 1 protected + 1 private). + expect(bot.commandCalls).toHaveLength(12); + expect(reg.allCommands.size).toBe(12); const registeredNames = bot.commandCalls.map((c) => c.name).sort(); const expected = [...reg.allCommands.keys()].sort(); diff --git a/tests/modules/loldle/render.test.js b/tests/modules/loldle/render.test.js new file mode 100644 index 0000000..6001b33 --- /dev/null +++ b/tests/modules/loldle/render.test.js @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { renderBoard, renderGuess } from "../../../src/modules/loldle/render.js"; + +/** Mirror the shape compareChampions returns โ€” only the fields render.js reads. */ +const sampleResults = [ + { key: "gender", label: "Gender", result: "correct", guessValue: "Male" }, + { key: "genre", label: "Genre", result: "correct", guessValue: "Mage, Support" }, + { key: "attackType", label: "Range", result: "correct", guessValue: "Ranged" }, + { key: "resource", label: "Resource", result: "correct", guessValue: "Mana" }, + { key: "region", label: "Region", result: "correct", guessValue: "Runeterra" }, + { key: "lane", label: "Lane", result: "partial", guessValue: "Jungle, Support" }, + { + key: "releaseDate", + label: "Year", + result: "wrong", + direction: "up", + guessValue: "2011", + }, +]; + +describe("renderGuess", () => { + it("wraps output in
 for Telegram HTML monospace", () => {
+    const out = renderGuess("Brand", sampleResults);
+    expect(out.startsWith("
")).toBe(true);
+    expect(out.endsWith("
")).toBe(true); + }); + + it("uppercases champion name on the ๐ŸŽฏ row", () => { + const out = renderGuess("Rakan", sampleResults); + expect(out).toContain("๐ŸŽฏ Name"); + expect(out).toContain("RAKAN"); + expect(out).not.toContain(" Rakan"); + }); + + it("auto-widths label column to the longest label (Resource = 8)", () => { + const out = renderGuess("Brand", sampleResults); + // "Name" (4) padded to 8 โ†’ 4 trailing spaces + the single separator space. + expect(out).toContain("๐ŸŽฏ Name BRAND"); + // "Resource" (8) = exact width, single separator space before value. + expect(out).toContain("โœ… Resource Mana"); + // "Gender" (6) padded to 8 โ†’ 2 trailing + 1 separator = 3 spaces before value. + expect(out).toContain("โœ… Gender Male"); + }); + + it("appends โฌ†๏ธ / โฌ‡๏ธ year direction hints only when wrong", () => { + const up = renderGuess("Brand", sampleResults); + expect(up).toContain("โŒ Year"); + expect(up).toContain("2011 โฌ†๏ธ"); + + const correctYear = sampleResults.map((r) => + r.key === "releaseDate" ? { ...r, result: "correct", direction: undefined } : r, + ); + const out = renderGuess("Brand", correctYear); + expect(out).not.toContain("โฌ†๏ธ"); + expect(out).not.toContain("โฌ‡๏ธ"); + }); + + it("HTML-escapes values so < and > render literally", () => { + const evil = [{ key: "region", label: "Region", result: "wrong", guessValue: "