feat(loldle): monospace aligned board, auto-restart round

- Remove /loldle_new; finished rounds (solve/giveup/out-of-guesses)
  immediately roll into a fresh round.
- Render guesses as an HTML <pre> monospace table with auto-widthed
  label column and a 🎯 Name row (uppercase champion name).
- Year direction uses ⬆️ / ⬇️.
This commit is contained in:
tiennm99 committed 2026-04-21 23:03:49 +07:00
1 parent bd266c0385
commit 7997c8eb5b
9 files changed
+176 -82

No files matched your search

+1 -1
View File
@@ -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
+3 -2
View File
@@ -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:<subject>` record, or live
in a separate `daily-stats:<subject>:<date>` 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.)
+5 -5
View File
@@ -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 <champion>` — 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 `<pre>` 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)
+24 -43
View File
@@ -8,9 +8,11 @@
* Commands:
* /loldle → show board / start puzzle
* /loldle <champion> → 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<Record<string, any>>} */
const champions = championsData;
const NEW_ROUND_HINT = "🆕 New round started. Use `/loldle <champion>` 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 <champion>\`.`;
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 <champion>\`.`;
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 <champion>\` 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}`);
}
/**
+2 -8
View File
@@ -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),
},
{
+56 -19
View File
@@ -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 <pre> 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<import("./compare.js").compareChampions>} 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<Array<{marker: string, label: string, value: string}>>} 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 `<pre>${blocks.join("\n\n")}</pre>`;
}
/**
* Render a single guess row-group.
* @param {string} championName
* @param {ReturnType<import("./compare.js").compareChampions>} 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 <champion>`.";
return guesses.map((g) => renderGuess(g.champion, g.results)).join("\n\n");
if (guesses.length === 0) {
return "No guesses yet. Reply with <code>/loldle &lt;champion&gt;</code>.";
}
return formatRowGroups(guesses.map((g) => buildRows(g.champion, g.results)));
}
+1 -1
View File
@@ -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:<subject> -> { target, guesses[], solved, giveup, startedAt }
+3 -3
View File
@@ -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();
+81
View File
@@ -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 <pre> for Telegram HTML monospace", () => {
const out = renderGuess("Brand", sampleResults);
expect(out.startsWith("<pre>")).toBe(true);
expect(out.endsWith("</pre>")).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: "<script>" }];
const out = renderGuess("Foo", evil);
expect(out).toContain("&lt;script&gt;");
expect(out).not.toContain("<script>");
});
});
describe("renderBoard", () => {
it("returns an empty-state prompt when there are no guesses", () => {
expect(renderBoard([])).toContain("No guesses yet");
});
it("stacks multiple guesses in one <pre> with aligned labels", () => {
const out = renderBoard([
{ champion: "Ahri", results: sampleResults },
{ champion: "Brand", results: sampleResults },
]);
expect(out.startsWith("<pre>")).toBe(true);
expect(out.endsWith("</pre>")).toBe(true);
expect(out).toContain("🎯 Name AHRI");
expect(out).toContain("🎯 Name BRAND");
});
});