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: "