feat(wordle): render board in monospace so markers align with letters

Wrap renderGuess / renderBoard output in <pre> and send replies with
parse_mode: HTML. In Telegram's monospace font each " X " letter cell
is 3 characters wide, which is roughly the width of a single emoji
marker, so colored squares stack cleanly over the letter they score.

No user-controlled content lands inside the <pre> (guesses are
validated [a-z]{5}), so no HTML escaping is needed in the grid. The
inline <code>/wordle &lt;word&gt;</code> placeholder is properly
entity-encoded for HTML parse mode.
This commit is contained in:
2026-04-20 22:30:17 +07:00
parent a2f67a7758
commit 78de7e1cd3
2 changed files with 36 additions and 15 deletions
+13 -4
View File
@@ -24,6 +24,10 @@ import wordsData from "./words-data.js";
const words = wordsData;
const wordSet = makeWordSet(words);
// All replies use HTML parse mode so renderGuess/renderBoard <pre> blocks
// render as a monospace code box; emoji markers then align with letter columns.
const HTML = { parse_mode: "HTML" };
/**
* @param {import("grammy").Context} ctx
* @returns {number|null}
@@ -86,8 +90,8 @@ export async function handleWordle(ctx, db) {
? `🎉 Solved in ${game.guesses.length}/${MAX_GUESSES}. /wordle_new for another.`
: game.giveup
? `🏳️ Gave up. Answer was ${game.target.toUpperCase()}. /wordle_new for another.`
: `Guess ${game.guesses.length}/${MAX_GUESSES}. Use \`/wordle <word>\`.`;
return ctx.reply(`${header}\n\n${renderBoard(game.guesses)}`);
: `Guess ${game.guesses.length}/${MAX_GUESSES}. Use <code>/wordle &lt;word&gt;</code>.`;
return ctx.reply(`${header}\n\n${renderBoard(game.guesses)}`, HTML);
}
if (isFinished(game)) {
@@ -110,15 +114,17 @@ export async function handleWordle(ctx, db) {
const s = await recordResult(db, subject, true);
return ctx.reply(
`${reply}\n\n🎉 Solved in ${game.guesses.length}/${MAX_GUESSES}! Streak: ${s.streak}. /wordle_new for another.`,
HTML,
);
}
if (game.guesses.length >= MAX_GUESSES) {
await recordResult(db, subject, false);
return ctx.reply(
`${reply}\n\n❌ Out of guesses. Answer was ${game.target.toUpperCase()}. /wordle_new to retry.`,
HTML,
);
}
return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${MAX_GUESSES}.`);
return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${MAX_GUESSES}.`, HTML);
}
/**
@@ -137,7 +143,10 @@ export async function handleNew(ctx, db) {
}
await startFreshGame(db, subject);
return ctx.reply(`${prelude}🆕 New round started. Use \`/wordle <word>\` to guess.`);
return ctx.reply(
`${prelude}🆕 New round started. Use <code>/wordle &lt;word&gt;</code> to guess.`,
HTML,
);
}
/**
+23 -11
View File
@@ -1,28 +1,40 @@
/**
* @file Render wordle comparison results as a Telegram-friendly grid.
* @file Render wordle comparison results as a Telegram monospace grid.
*
* Each guess renders as two lines — colored markers over the upper-case
* letters — wrapped in an HTML <pre> block so Telegram renders it in
* monospace and the emoji column widths line up with the letter columns.
*
* 🟩 correct · 🟨 partial · ⬜ wrong
*
* Each guess shows two lines: the colored markers and the upper-case letters
* underneath so the player can read the word at a glance.
* Output strings are intended to be sent with `parse_mode: "HTML"`. All
* game content is validated `[a-z]` so no HTML escaping is needed inside
* the grid; only callers embedding user-controlled text around the grid
* need to escape it.
*/
const MARKER = { correct: "🟩", partial: "🟨", wrong: "⬜" };
/**
* Render a single guess row (two lines: markers + letters).
* @param {ReturnType<import("./compare.js").compareWords>} results
*/
export function renderGuess(results) {
function rowPair(results) {
const markers = results.map((r) => MARKER[r.result] ?? "⬜").join("");
const letters = results.map((r) => ` ${r.letter.toUpperCase()} `).join("");
return `${markers}\n${letters}`;
}
/**
* Render all prior guesses stacked.
* Render a single guess row as an HTML <pre> block.
* @param {ReturnType<import("./compare.js").compareWords>} results
*/
export function renderGuess(results) {
return `<pre>${rowPair(results)}</pre>`;
}
/**
* Render the full board (all prior guesses, blank-line separated) as a
* single HTML <pre> block, so all rows share one monospace code box.
* @param {Array<{word:string, results: any[]}>} guesses
*/
export function renderBoard(guesses) {
if (guesses.length === 0) return "No guesses yet. Reply with `/wordle <word>`.";
return guesses.map((g) => renderGuess(g.results)).join("\n\n");
if (guesses.length === 0) return "No guesses yet. Reply with <code>/wordle &lt;word&gt;</code>.";
return `<pre>${guesses.map((g) => rowPair(g.results)).join("\n\n")}</pre>`;
}