feat(wordle): port classic 5-letter guessing game

Replaces the wordle stub with a full implementation mirroring the loldle
module layout: compare/lookup/daily/render/state/handlers/index split,
per-subject KV state, standard 6 guesses, two-pass duplicate-letter
marking.

Commands: /wordle, /wordle_new, /wordle_giveup, /wordle_stats.

Word list (14,855 entries) sourced from dracos's gist
(https://gist.github.com/dracos/dd0668f281e685bad51479e5acaadb93) and
bundled via scripts/build-wordle-data.js. Credits in module README and
generated file headers.

Dispatcher test updated for the new command count (12 → 13).
This commit is contained in:
tiennm99 committed 2026-04-20 22:08:58 +07:00
1 parent b68b38feca
commit 574d855806
14 files changed
+15500 -41

No files matched your search

+2 -1
View File
@@ -9,8 +9,9 @@
},
"scripts": {
"dev": "wrangler dev",
"build": "npm run build:loldle-data",
"build": "npm run build:loldle-data && npm run build:wordle-data",
"build:loldle-data": "node scripts/build-loldle-data.js",
"build:wordle-data": "node scripts/build-wordle-data.js",
"deploy": "npm run build && wrangler deploy && npm run db:migrate && npm run register",
"db:migrate": "node scripts/migrate.js",
"register": "node --env-file-if-exists=.env.deploy scripts/register.js",
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* @file build-wordle-data — fetches the 5-letter wordle word list from the
* dracos gist and writes it as an ES module export to
* src/modules/wordle/words-data.js.
*
* Source: https://gist.github.com/dracos/dd0668f281e685bad51479e5acaadb93
* Credits: Anna Eilering (dracos) — combined Wordle dictionary of allowed guesses.
*
* Usage:
* node scripts/build-wordle-data.js
*/
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
const GIST_URL = "https://gist.githubusercontent.com/dracos/dd0668f281e685bad51479e5acaadb93/raw/";
const root = resolve(import.meta.dirname, "..");
const dst = resolve(root, "src/modules/wordle/words-data.js");
const res = await fetch(GIST_URL);
if (!res.ok) throw new Error(`fetch failed: ${res.status} ${res.statusText}`);
const text = await res.text();
const words = [
...new Set(
text
.split(/\r?\n/)
.map((w) => w.trim().toLowerCase())
.filter((w) => /^[a-z]{5}$/.test(w)),
),
].sort();
if (words.length === 0) throw new Error("no words parsed from gist");
const body = words.map((w) => ` "${w}",`).join("\n");
const out = [
"// Auto-generated from https://gist.github.com/dracos/dd0668f281e685bad51479e5acaadb93",
"// Credits: Anna Eilering (dracos) — combined Wordle dictionary of allowed 5-letter guesses.",
"// Regenerate with: node scripts/build-wordle-data.js",
"export default [",
body,
"];",
"",
].join("\n");
writeFileSync(dst, out);
console.log(`wrote ${dst} (${words.length} words)`);
+37 -14
View File
@@ -1,20 +1,34 @@
# Wordle Module
Word guessing game — currently a stub proving the plugin system.
Classic 5-letter word guessing game — player has 6 guesses; each letter is
marked 🟩 correct / 🟨 partial / ⬜ wrong with the standard duplicate-letter
rules.
## Commands
| Command | Visibility | Description |
|---------|-----------|-------------|
| `/wordle` | public | Start a wordle game (stub) |
| `/wstats` | protected | Show wordle stats |
| `/konami` | private | Easter egg — "secret wordle mode" |
| `/wordle` | public | Show current board / start game / submit a guess when an argument is given |
| `/wordle_new` | public | Start a new round (auto-gives-up any in-progress one) |
| `/wordle_giveup` | public | Reveal the current wordle answer |
| `/wordle_stats` | public | Show your wordle stats (wins, streak) |
Submit a guess with `/wordle <word>` — e.g. `/wordle crane`.
## Architecture
- All commands return stub responses. Real game logic is not yet implemented.
- `/wstats` reads a `stats` key from KV (returns 0 games played when empty).
- Module captures `db` in closure via `init()` for future game state persistence.
Mirrors the loldle module layout:
- `compare.js` — two-pass letter comparison with duplicate-letter handling
- `lookup.js` — normalize + validate guesses against the dictionary Set
- `daily.js` — `pickRandom` / `pickDaily` (djb2 hash of date seed)
- `render.js` — 🟩🟨⬜ markers + upper-case letters, stacked board view
- `state.js` — KV persistence, `MAX_GUESSES = 6`, stats with streak tracking
- `handlers.js` — wires subject resolution (user in DM, chat in groups) to the pure functions
- `words-data.js` — auto-generated word list (do not edit by hand)
Subject resolution matches loldle: private chats track per-user games, groups
track per-chat shared games.
## Database
@@ -22,14 +36,23 @@ KV namespace prefix: `wordle:`
| Key | Type | Description | Example |
|-----|------|-------------|---------|
| `stats` | JSON | Aggregate game statistics (planned) | `{ "gamesPlayed": 0 }` |
| `game:<subject>` | JSON | Active round: target word, guesses, solved/giveup flags | `{ "target": "crane", "guesses": [...], "solved": false }` |
| `stats:<subject>` | JSON | Aggregate stats per subject | `{ "played": 12, "wins": 8, "streak": 3, "bestStreak": 5 }` |
### Schema: `stats` (planned)
Active rounds expire after 7 days if untouched.
```json
{
"gamesPlayed": 0
}
## Word list
The 14,855-word dictionary is sourced from
[dracos/dd0668f281e685bad51479e5acaadb93](https://gist.github.com/dracos/dd0668f281e685bad51479e5acaadb93)
— Anna Eilering's combined Wordle dictionary of allowed 5-letter guesses.
All credit for compiling the list goes to the gist author.
Regenerate the bundled list with:
```bash
node scripts/build-wordle-data.js
```
No data is currently written — the module only reads this key defensively.
This writes `src/modules/wordle/words-data.js` as a sorted, deduplicated
ES-module export.
+48
View File
@@ -0,0 +1,48 @@
/**
* @file Wordle letter-by-letter comparison.
*
* Two-pass marking to handle duplicate letters correctly:
* pass 1 — mark exact positional matches as "correct" and consume those
* slots from the target's available pool.
* pass 2 — for remaining guess letters, mark "partial" if the letter still
* exists in the pool (and consume it), else "wrong".
*
* Example: target "abbey", guess "babes"
* → b@0 partial, a@1 partial, b@2 correct, e@3 correct, s@4 wrong.
*/
export const WORD_LENGTH = 5;
/**
* Compare a 5-letter guess to the 5-letter target.
* Both inputs are assumed lowercase a–z and of length WORD_LENGTH.
*
* @param {string} guess
* @param {string} target
* @returns {Array<{letter: string, result: "correct"|"partial"|"wrong"}>}
*/
export function compareWords(guess, target) {
const results = new Array(WORD_LENGTH);
const pool = [];
for (let i = 0; i < WORD_LENGTH; i++) {
if (guess[i] === target[i]) {
results[i] = { letter: guess[i], result: "correct" };
} else {
pool.push(target[i]);
}
}
for (let i = 0; i < WORD_LENGTH; i++) {
if (results[i]) continue;
const idx = pool.indexOf(guess[i]);
if (idx !== -1) {
pool.splice(idx, 1);
results[i] = { letter: guess[i], result: "partial" };
} else {
results[i] = { letter: guess[i], result: "wrong" };
}
}
return results;
}
+50
View File
@@ -0,0 +1,50 @@
/**
* @file Word pickers — deterministic daily seeding and fresh-random.
*
* Mirrors loldle/daily.js so both modules pick targets the same way.
*/
/** UTC date string YYYY-MM-DD. */
export function todayUtc(now = new Date()) {
return now.toISOString().slice(0, 10);
}
/** djb2 string hash. */
function hash(str) {
let h = 5381;
for (let i = 0; i < str.length; i++) {
h = (h * 33) ^ str.charCodeAt(i);
}
return h >>> 0;
}
/**
* Deterministic pick seeded by date (or any string).
* @template T
* @param {T[]} words
* @param {string} [seed]
* @returns {T}
*/
export function pickDaily(words, seed) {
assertNonEmpty(words);
const s = seed ?? todayUtc();
return words[hash(s) % words.length];
}
/**
* Uniformly random pick. `rng` defaults to Math.random — override for tests.
* @template T
* @param {T[]} words
* @param {() => number} [rng]
* @returns {T}
*/
export function pickRandom(words, rng = Math.random) {
assertNonEmpty(words);
return words[Math.floor(rng() * words.length)];
}
function assertNonEmpty(arr) {
if (!Array.isArray(arr) || arr.length === 0) {
throw new Error("picker: words array is empty");
}
}
+170
View File
@@ -0,0 +1,170 @@
/**
* @file Command handlers for wordle module.
*
* Subject resolution mirrors loldle:
* private chat → user id (per-user game)
* group/supergroup chat → chat id (shared game)
*
* Commands:
* /wordle → show board / start puzzle
* /wordle <word> → submit a guess
* /wordle_new → abandon current round (counts as giveup) + start fresh
* /wordle_giveup → reveal answer, end current round
* /wordle_stats → show stats
*/
import { WORD_LENGTH, compareWords } from "./compare.js";
import { pickRandom } from "./daily.js";
import { makeWordSet, validateGuess } from "./lookup.js";
import { renderBoard, renderGuess } from "./render.js";
import { MAX_GUESSES, loadGame, loadStats, recordResult, saveGame } from "./state.js";
import wordsData from "./words-data.js";
/** @type {string[]} */
const words = wordsData;
const wordSet = makeWordSet(words);
/**
* @param {import("grammy").Context} ctx
* @returns {number|null}
*/
function getSubject(ctx) {
const type = ctx.chat?.type;
if (type === "private") return ctx.from?.id ?? null;
if (type === "group" || type === "supergroup") return ctx.chat.id;
return ctx.from?.id ?? null;
}
function argAfterCommand(text) {
if (!text) return "";
const idx = text.indexOf(" ");
return idx === -1 ? "" : text.slice(idx + 1).trim();
}
function isFinished(game) {
return game.solved || game.giveup || game.guesses.length >= MAX_GUESSES;
}
async function getOrInitGame(db, subject) {
const existing = await loadGame(db, subject);
if (existing) return existing;
return startFreshGame(db, subject);
}
async function startFreshGame(db, subject) {
const target = pickRandom(words);
const fresh = { target, guesses: [], solved: false, startedAt: Date.now() };
await saveGame(db, subject, fresh);
return fresh;
}
function rejectReason(reason) {
if (reason === "empty") return `Please provide a ${WORD_LENGTH}-letter word.`;
if (reason === "length") return `Word must be exactly ${WORD_LENGTH} letters.`;
return "Not in the word list.";
}
/**
* @param {import("grammy").Context} ctx
* @param {import("../../db/kv-store-interface.js").KVStore} db
*/
export async function handleWordle(ctx, db) {
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
const arg = argAfterCommand(ctx.message?.text ?? "");
const game = await getOrInitGame(db, subject);
if (!arg) {
const header = game.solved
? `🎉 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)}`);
}
if (isFinished(game)) {
return ctx.reply(
`Current round is over. Use /wordle_new to start another. Answer was ${game.target.toUpperCase()}.`,
);
}
const validated = validateGuess(wordSet, arg);
if (!validated.ok) return ctx.reply(rejectReason(validated.reason));
const results = compareWords(validated.word, game.target);
game.guesses.push({ word: validated.word, results });
const won = validated.word === game.target;
if (won) game.solved = true;
await saveGame(db, subject, game);
const reply = renderGuess(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.`,
);
}
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.`,
);
}
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);
prelude = `🏳️ Previous round abandoned (auto-giveup). Answer was ${prior.target.toUpperCase()}.\n\n`;
}
await startFreshGame(db, subject);
return ctx.reply(`${prelude}🆕 New round started. Use \`/wordle <word>\` to guess.`);
}
/**
* @param {import("grammy").Context} ctx
* @param {import("../../db/kv-store-interface.js").KVStore} db
*/
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.toUpperCase()}.`);
if (game.giveup) return ctx.reply(`Already gave up — ${game.target.toUpperCase()}.`);
game.giveup = true;
await saveGame(db, subject, game);
await recordResult(db, subject, false);
return ctx.reply(`🏳️ Answer was ${game.target.toUpperCase()}. /wordle_new for another.`);
}
/**
* @param {import("grammy").Context} ctx
* @param {import("../../db/kv-store-interface.js").KVStore} db
*/
export async function handleStats(ctx, db) {
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
const s = await loadStats(db, subject);
const winRate = s.played ? Math.round((s.wins / s.played) * 100) : 0;
const scope = ctx.chat?.type === "private" ? "your" : "group";
return ctx.reply(
`📊 Wordle ${scope} stats\n` +
`Played: ${s.played}\n` +
`Wins: ${s.wins} (${winRate}%)\n` +
`Current streak: ${s.streak}\n` +
`Best streak: ${s.bestStreak}`,
);
}
+23 -22
View File
@@ -1,11 +1,14 @@
/**
* @file wordle module stub — proves the plugin system end-to-end.
* @file Wordle module — classic 5-letter word guessing game.
*
* One public, one protected, one private (hidden) slash command. Real game
* logic is out of scope for v1; this exercises the loader, visibility levels,
* registry, dispatcher, help renderer, and namespaced DB.
* Word list sourced from:
* https://gist.github.com/dracos/dd0668f281e685bad51479e5acaadb93
* (Anna Eilering (dracos) — combined Wordle dictionary of allowed guesses.)
* Synced via scripts/build-wordle-data.js into ./words-data.js.
*/
import { handleGiveup, handleNew, handleStats, handleWordle } from "./handlers.js";
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
let db = null;
@@ -19,28 +22,26 @@ const wordleModule = {
{
name: "wordle",
visibility: "public",
description: "Play wordle (stub)",
handler: async (ctx) => {
await ctx.reply("Wordle stub — real game TBD.");
},
description: "Classic wordle — guess the 5-letter word",
handler: (ctx) => handleWordle(ctx, db),
},
{
name: "wstats",
visibility: "protected",
description: "Wordle stats",
handler: async (ctx) => {
const stats = (await db?.getJSON("stats")) ?? null;
const played = stats?.gamesPlayed ?? 0;
await ctx.reply(`games played: ${played}`);
},
name: "wordle_new",
visibility: "public",
description: "Start a new round (auto-gives-up any in-progress one)",
handler: (ctx) => handleNew(ctx, db),
},
{
name: "konami",
visibility: "private",
description: "Easter egg — retro code",
handler: async (ctx) => {
await ctx.reply("⬆⬆⬇⬇⬅➡⬅➡BA — secret wordle mode unlocked (stub)");
},
name: "wordle_giveup",
visibility: "public",
description: "Reveal the current wordle answer",
handler: (ctx) => handleGiveup(ctx, db),
},
{
name: "wordle_stats",
visibility: "public",
description: "Show your wordle stats (wins, streak)",
handler: (ctx) => handleStats(ctx, db),
},
],
};
+44
View File
@@ -0,0 +1,44 @@
/**
* @file Wordle word validation — normalizes user input and checks membership.
*
* Normalization: lowercase, strip non-a-z. Valid words are exactly 5 letters
* (matching WORD_LENGTH) and present in the provided dictionary. The dictionary
* is checked via a Set built once per call-site (see makeWordSet below).
*/
import { WORD_LENGTH } from "./compare.js";
/**
* Lowercase + strip anything that isn't a–z.
* @param {string} input
*/
export function normalizeWord(input) {
return String(input || "")
.toLowerCase()
.replace(/[^a-z]/g, "");
}
/**
* Build a Set from an array of words for O(1) membership checks.
* @param {string[]} words
*/
export function makeWordSet(words) {
return new Set(words);
}
/**
* Validate a guess against the dictionary.
* null — input empty, wrong length, or not in dictionary (for "not a word")
* Returns a discriminated result so the caller can tell *why* validation failed.
*
* @param {Set<string>} wordSet
* @param {string} input
* @returns {{ok: true, word: string} | {ok: false, reason: "empty"|"length"|"unknown", word: string}}
*/
export function validateGuess(wordSet, input) {
const word = normalizeWord(input);
if (!word) return { ok: false, reason: "empty", word };
if (word.length !== WORD_LENGTH) return { ok: false, reason: "length", word };
if (!wordSet.has(word)) return { ok: false, reason: "unknown", word };
return { ok: true, word };
}
+28
View File
@@ -0,0 +1,28 @@
/**
* @file Render wordle comparison results as a Telegram-friendly grid.
* 🟩 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.
*/
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) {
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.
* @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");
}
+85
View File
@@ -0,0 +1,85 @@
/**
* @file Wordle game state in KV, keyed by "subject" (user in DM, chat in groups).
*
* One active round per subject. MAX_GUESSES = 6 (standard wordle).
* Mirrors loldle/state.js keying layout so both modules have parallel shape.
*
* Key layout (inside module-prefixed store):
* game:<subject> -> { target, guesses[], solved, giveup, startedAt }
* stats:<subject> -> { played, wins, streak, bestStreak, lastResultAt }
*/
const MAX_GUESSES = 6;
const GAME_TTL_SECONDS = 60 * 60 * 24 * 7;
/** @param {number|string} subject */
const gameKey = (subject) => `game:${subject}`;
/** @param {number|string} subject */
const statsKey = (subject) => `stats:${subject}`;
/**
* @typedef {object} WordleGameState
* @property {string} target — the target word (lowercase)
* @property {Array<{word:string, results:any[]}>} guesses
* @property {boolean} solved
* @property {boolean} [giveup]
* @property {number} [startedAt]
*/
/**
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number|string} subject
* @returns {Promise<WordleGameState|null>}
*/
export async function loadGame(db, subject) {
return db.getJSON(gameKey(subject));
}
/**
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number|string} subject
* @param {WordleGameState} state
*/
export async function saveGame(db, subject, state) {
await db.putJSON(gameKey(subject), state, { expirationTtl: GAME_TTL_SECONDS });
}
export { MAX_GUESSES };
/**
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number|string} subject
*/
export async function loadStats(db, subject) {
return (
(await db.getJSON(statsKey(subject))) ?? {
played: 0,
wins: 0,
streak: 0,
bestStreak: 0,
lastResultAt: null,
}
);
}
/**
* Record a finished round (win/loss/giveup). Streak resets on any non-win.
*
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number|string} subject
* @param {boolean} won
*/
export async function recordResult(db, subject, won) {
const s = await loadStats(db, subject);
s.played += 1;
if (won) {
s.wins += 1;
s.streak += 1;
if (s.streak > s.bestStreak) s.bestStreak = s.streak;
} else {
s.streak = 0;
}
s.lastResultAt = Date.now();
await db.putJSON(statsKey(subject), s);
return s;
}
File diff suppressed because it is too large. Load diff
+3 -4
View File
@@ -24,16 +24,15 @@ describe("installDispatcher", () => {
const env = { MODULES: "util,wordle,loldle,misc", KV: makeFakeKv() };
const reg = await installDispatcher(bot, env);
// Expect 12 total commands (8 public + 2 protected + 2 private).
expect(bot.commandCalls).toHaveLength(12);
expect(reg.allCommands.size).toBe(12);
// Expect 13 total commands (11 public + 1 protected + 1 private).
expect(bot.commandCalls).toHaveLength(13);
expect(reg.allCommands.size).toBe(13);
const registeredNames = bot.commandCalls.map((c) => c.name).sort();
const expected = [...reg.allCommands.keys()].sort();
expect(registeredNames).toEqual(expected);
// Assert private commands ARE registered (the whole point of unified routing).
expect(registeredNames).toContain("konami");
expect(registeredNames).toContain("fortytwo");
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { compareWords } from "../../../src/modules/wordle/compare.js";
function letters(results) {
return results.map((r) => r.result).join(",");
}
describe("compareWords", () => {
it("all correct when guess === target", () => {
const r = compareWords("crane", "crane");
expect(letters(r)).toBe("correct,correct,correct,correct,correct");
});
it("all wrong when no letters overlap", () => {
const r = compareWords("abcde", "fghij");
expect(letters(r)).toBe("wrong,wrong,wrong,wrong,wrong");
});
it("marks correct position over partial position", () => {
// guess "slate" vs target "shale" → s correct, l partial, a correct,
// t wrong, e correct
const r = compareWords("slate", "shale");
expect(letters(r)).toBe("correct,partial,correct,wrong,correct");
});
it("duplicate letters: excess guess letters mark wrong, not partial", () => {
// target "abbey", guess "babes"
// positions: b@0≠a (pool gets a,b,b,e,y minus correct slots)
// correct pass: b@2=b → consume b; e@3=e → consume e. pool = [a,b,y]
// partial pass:
// b@0 → in pool (b), consume → partial
// a@1 → in pool (a), consume → partial
// s@4 → not in pool → wrong
const r = compareWords("babes", "abbey");
expect(letters(r)).toBe("partial,partial,correct,correct,wrong");
});
it("duplicate letter in guess when target has only one: only first is partial", () => {
// target "abide", guess "aahed"
// correct: a@0=a (consume a), no other positional match
// pool = [b,i,d,e]
// partial pass: a@1 → not in pool → wrong; h@2 → wrong; e@3 → partial (consume e); d@4 → partial (consume d)
const r = compareWords("aahed", "abide");
expect(letters(r)).toBe("correct,wrong,wrong,partial,partial");
});
it("duplicate in guess, duplicate in target: both can mark", () => {
// target "lever", guess "ebbed"
// correct: e@2=v? no. Actually lever[2]='v'. ebbed = e,b,b,e,d
// e@0 vs l → no; b@1 vs e → no; b@2 vs v → no; e@3 vs e → correct (consume e);
// d@4 vs r → no. pool = [l,e,v,r]
// partial pass:
// e@0 → partial (consume remaining e); b@1 → wrong; b@2 → wrong; d@4 → wrong
const r = compareWords("ebbed", "lever");
expect(letters(r)).toBe("partial,wrong,wrong,correct,wrong");
});
it("returns letters alongside results", () => {
const r = compareWords("crane", "cloud");
expect(r.map((x) => x.letter)).toEqual(["c", "r", "a", "n", "e"]);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { makeWordSet, normalizeWord, validateGuess } from "../../../src/modules/wordle/lookup.js";
const dict = makeWordSet(["crane", "shale", "abbey", "lever"]);
describe("normalizeWord", () => {
it("lowercases and strips non-letters", () => {
expect(normalizeWord(" CRANE ")).toBe("crane");
expect(normalizeWord("cr-ane")).toBe("crane");
expect(normalizeWord("cr4ne")).toBe("crne");
});
it("handles null/undefined/empty", () => {
expect(normalizeWord("")).toBe("");
expect(normalizeWord(null)).toBe("");
expect(normalizeWord(undefined)).toBe("");
});
});
describe("validateGuess", () => {
it("accepts in-dictionary 5-letter words", () => {
expect(validateGuess(dict, "crane")).toEqual({ ok: true, word: "crane" });
expect(validateGuess(dict, "CRANE")).toEqual({ ok: true, word: "crane" });
});
it("rejects empty input as 'empty'", () => {
expect(validateGuess(dict, "")).toMatchObject({ ok: false, reason: "empty" });
expect(validateGuess(dict, "---")).toMatchObject({ ok: false, reason: "empty" });
});
it("rejects wrong length as 'length'", () => {
expect(validateGuess(dict, "cat")).toMatchObject({ ok: false, reason: "length" });
expect(validateGuess(dict, "catnaps")).toMatchObject({ ok: false, reason: "length" });
});
it("rejects unknown word as 'unknown'", () => {
expect(validateGuess(dict, "zzzzz")).toMatchObject({ ok: false, reason: "unknown" });
});
});