fix(wordle): drop column alignment — use NYT share format (word above colors)

Cross-client column alignment between the marker row and a letter row is
unreliable in Telegram:
- <pre> monospace doesn't enforce equal width for emoji
- fullwidth Latin (U+FF21..FF3A) falls back to base Latin on mobile fonts
- squared-letter emoji (U+1F130/1F170..) render at different intrinsic
  widths than color-square emoji (U+1F7E8/1F7E9/2B1C) on many clients

Instead, render each guess as the word on one line followed by the
colored marker row — the standard NYT Wordle share format:

  CRANE
  🟩🟨🟩🟩

The association between a letter and its color is visually unambiguous
without depending on character-column alignment. Also drops the HTML
parse_mode requirement — replies are plain text again.
This commit is contained in:
2026-04-20 22:52:40 +07:00
parent b228890ada
commit b180ea6660
2 changed files with 21 additions and 49 deletions
+5 -14
View File
@@ -24,10 +24,6 @@ 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}
@@ -90,8 +86,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 <code>/wordle &lt;word&gt;</code>.`;
return ctx.reply(`${header}\n\n${renderBoard(game.guesses)}`, HTML);
: `Guess ${game.guesses.length}/${MAX_GUESSES}. Use \`/wordle <word>\`.`;
return ctx.reply(`${header}\n\n${renderBoard(game.guesses)}`);
}
if (isFinished(game)) {
@@ -109,22 +105,20 @@ export async function handleWordle(ctx, db) {
if (won) game.solved = true;
await saveGame(db, subject, game);
const reply = renderGuess(results);
const reply = renderGuess(validated.word, results);
if (won) {
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}.`, HTML);
return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${MAX_GUESSES}.`);
}
/**
@@ -143,10 +137,7 @@ export async function handleNew(ctx, db) {
}
await startFreshGame(db, subject);
return ctx.reply(
`${prelude}🆕 New round started. Use <code>/wordle &lt;word&gt;</code> to guess.`,
HTML,
);
return ctx.reply(`${prelude}🆕 New round started. Use \`/wordle <word>\` to guess.`);
}
/**
+16 -35
View File
@@ -1,57 +1,38 @@
/**
* @file Render wordle comparison results as a Telegram monospace grid.
* @file Render wordle comparison results for Telegram.
*
* 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.
* Uses the NYT Wordle share format — guess word on one line, colored marker
* row below — so there's no cross-client column-alignment dependency:
*
* CRANE
* 🟩🟨⬜🟩🟩
*
* 🟩 correct · 🟨 partial · ⬜ wrong
*
* 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.
* Output is plain text; no HTML parse mode required.
*/
const MARKER = { correct: "🟩", partial: "🟨", wrong: "⬜" };
// Alignment under Telegram <pre>: the color markers are emoji; to guarantee
// the letter row is the same width, render letters as emoji too. Unicode
// block "Enclosed Alphanumeric Supplement" has emoji-class capitals at
// U+1F170..FF189 (🅰🅱🅲..🆉 — "Negative Squared Latin Capital Letter").
// Because both rows draw from the emoji font, column widths match 1-to-1
// on every client, regardless of the monospace font shipped with it.
const EMOJI_A = 0x1f170;
const ASCII_A = 0x41;
function toEmojiLetter(ch) {
const code = ch.toUpperCase().charCodeAt(0);
if (code >= ASCII_A && code <= 0x5a) {
return String.fromCodePoint(EMOJI_A + (code - ASCII_A));
}
return ch;
}
function rowPair(results) {
function rowPair({ word, results }) {
const markers = results.map((r) => MARKER[r.result] ?? "⬜").join("");
const letters = results.map((r) => toEmojiLetter(r.letter)).join("");
return `${markers}\n${letters}`;
return `${word.toUpperCase()}\n${markers}`;
}
/**
* Render a single guess row as an HTML <pre> block.
* Render a single guess (word over colors).
* @param {string} word — the submitted guess (lowercase a-z)
* @param {ReturnType<import("./compare.js").compareWords>} results
*/
export function renderGuess(results) {
return `<pre>${rowPair(results)}</pre>`;
export function renderGuess(word, results) {
return rowPair({ word, results });
}
/**
* Render the full board (all prior guesses, blank-line separated) as a
* single HTML <pre> block, so all rows share one monospace code box.
* Render the full board (all prior guesses, blank-line separated).
* @param {Array<{word:string, results: any[]}>} guesses
*/
export function renderBoard(guesses) {
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>`;
if (guesses.length === 0) return "No guesses yet. Reply with `/wordle <word>`.";
return guesses.map((g) => rowPair(g)).join("\n\n");
}