mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-05-01 04:21:19 +00:00
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:
@@ -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 <word></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 <word></code> to guess.`,
|
||||
HTML,
|
||||
);
|
||||
return ctx.reply(`${prelude}🆕 New round started. Use \`/wordle <word>\` to guess.`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 <word></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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user