mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 20:17:48 +00:00
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:
1 parent
b68b38feca
commit
574d855806
14 files changed
+15500
-41
No files matched your search
+2
-1
@@ -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",
|
||||
|
||||
@@ -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)`);
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user