diff --git a/docs/superpowers/plans/2026-08-30-word-guess.md b/docs/superpowers/plans/2026-08-30-word-guess.md new file mode 100644 index 0000000..50bf53c --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-word-guess.md @@ -0,0 +1,44 @@ +# Daily Word Guess — Implementation Plan + +Spec: `docs/superpowers/specs/2026-08-30-word-guess-design.md` +Branch: `feat/word-guess` (off `origin/develop`) + +## Task 1 — Word data `src/tools/games/wordguess.words.ts` + tests + +- `EN_ANSWERS` (≥ 800 common 5-letter words), `EN_EXTRA` (valid guesses beyond answers), `ID_ANSWERS` (≥ 400), `ID_EXTRA`; each a space-separated string blob → exported arrays; export `wordSets(lang)` returning `{ answers, valid }` Sets. +- Tests (`wordguess.words.test.ts`): every list — all words match `/^[a-z]{5}$/`, no duplicates, min sizes; `valid` set ⊇ answers; no overlap between EN and ID answer lists (each word appears in one language's answers only — avoids cross-language confusion when guessing). + +## Task 2 — Pure lib `src/tools/games/wordguess.lib.ts` + tests + +- `evaluateGuess(guess, answer): LetterState[]` (`'correct' | 'present' | 'absent'`), two-pass duplicate handling. +- `dayIndex(date?: Date): number` — UTC days since epoch. +- `puzzleNumber(dayIndex): number` — days since 2026-01-01 epoch (+1, so Jan 1 2026 = #1). +- `dailyAnswer(dayIndex, answers): string` — deterministic via mulberry32(dayIndex) first output; EN/ID use the same function (lists differ, so answers differ). +- `updateStats(stats, won, tries): Stats` — played/wins/current+max streak/distribution[6]; losing the daily (or skipping a day) breaks the streak. +- `buildShareText(states: LetterState[][], won, tries, puzzleNumber): string` — emoji grid (🟩🟨⬛), `GoodWebTools Word Guess #N X/6`, no letters leaked. +- `keyboardStates(guesses: string[], answer: string): Record` — per-letter priority correct > present > absent. +- Tests: table-driven evaluate cases (duplicates: e.g. guess ROBOT vs answers with repeated letters), determinism of dailyAnswer, streak logic (win yesterday+today → 2; gap → reset), share text shape, keyboard priority. + +## Task 3 — Island `src/islands/games/WordGuess.tsx` + +- State: `mode: 'daily' | 'practice'`, `guesses`, `status`, loaded from localStorage per lang on mount (SSR-safe: empty initial state, hydrate in effect). +- Input: physical keyboard listener + on-screen QWERTY (3 rows + ENTER/⌫) with per-key state coloring; 5×6 tile grid with flip animation (`prefers-reduced-motion` respected, pattern from Game2048 KEYFRAMES). +- Toasts: not enough letters / not in word list / win / lose + answer reveal. +- End panel: stats summary, distribution bars, CopyButton share, countdown to next UTC midnight, Practice button (random word, in-memory, restartable). +- Persistence: state after each guess; stats on finish (daily only). Practice never writes storage. +- `TR` en/id strings; intro line above the board (self-explanatory before scrolling to how-to). + +## Task 4 — Register + SEO + +- `tools.ts`: `{ id: 'word-guess', name: 'Daily Word Guess', category: 'Games', route: '/tools/word-guess', keywords: [...], icon: WholeWord, summary, load: () => import('@/islands/games/WordGuess'), status: 'beta' }` next to the other games. +- `tool-seo.ts`: full EN + ID entries (title/description/intro/howTo/faqs) next to `'snake'` in both blocks. ID copy keeps "tool" untranslated, technical terms as-is. + +## Task 5 — E2E `e2e/tools/word-guess.spec.ts` + +- Fresh context → load `/tools/word-guess`; click on-screen keys to enter 6 valid fixed words (words from the EN lists, e.g. CRANE, STONE, …) with ENTER each; assert end panel + share button visible (win or lose, both reach the panel); assert a second ENTER after game over doesn't add rows. +- Also: invalid word path — type a non-word, ENTER, toast "not in word list", no row committed. + +## Task 6 — Verify loop + +- `npx vitest run` · `npm run test:e2e -- --grep word-guess` (plus full smoke) · `npm run lint` · `npm run build` (confirm `/tools/word-guess/index.html` + `/id/tools/word-guess/index.html`). +- Hand-review: hydration safety, localStorage try/catch on every path, no objectURL/leaks, reduced-motion, error/empty paths. diff --git a/docs/superpowers/specs/2026-08-30-word-guess-design.md b/docs/superpowers/specs/2026-08-30-word-guess-design.md new file mode 100644 index 0000000..b93bef0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-word-guess-design.md @@ -0,0 +1,42 @@ +# Daily Word Guess — Design + +**Status:** approved +**Date:** 2026-08-30 +**Goal:** A Wordle-style daily word game (EN + Bahasa Indonesia) that drives daily return visits; the first "daily puzzle" on GoodWebTools. + +## Problem / opportunity + +All 7 existing GWT games are arcade-style, one-shot sessions. The most addictive browser-game format of 2026 is the **daily puzzle** (Wordle/Connections/Nerdle): one shared puzzle per day, streak tracking, spoiler-free emoji sharing. It fits GWT perfectly — deterministic daily answer needs no server (client-side only, privacy promise holds), and the PWA makes the daily ritual work offline. No bilingual EN/ID wordle exists at scale; GWT's ID audience is a differentiator. + +## Decisions (approved by user) + +- **Word lists:** curated bundles, strict validation ("not in word list" rejection). EN + ID answer lists (~years of dailies each) + extra valid-guess lists, bundled as compact string blobs (a few KB gzipped). +- **Practice mode:** yes — unlimited random words, doesn't touch streak/stats. +- **Scope:** ship Word Guess first; Fruit Merge (Suika-style) as a separate follow-up tool. + +## Game design + +- 6 tries to guess a 5-letter word; green/yellow/gray clues with correct duplicate-letter handling (two-pass: greens first, then yellows against remaining letter counts). +- **Daily answer** deterministic from UTC day index (`floor(t / 86400000)`) hashed against the language's answer list — same word for everyone that day, no server, works offline. +- **Streak + stats** per language, persisted in localStorage (`gwt-wordguess-stats--v1`): played, win %, current/max streak, guess distribution. +- **In-progress daily state persisted** (`gwt-wordguess-state--v1`): refresh mid-game keeps guesses. +- **Share:** spoiler-free emoji grid + puzzle number, via clipboard. Puzzle number = days since a fixed epoch (2026-01-01), same for everyone. +- **Practice mode:** "Practice" button (any time) starts a random-word game; finished daily stays finished. Practice games are in-memory only. +- UI: on-screen QWERTY keyboard (EN layout covers ID — same 26 letters) + physical keyboard; tile flip animation (respects prefers-reduced-motion); toast messages; end panel with stats, distribution bars, share button, countdown to next puzzle; dark-mode aware via existing site palette; mobile-first. + +## Architecture (GWT conventions) + +- Pure lib `src/tools/games/wordguess.lib.ts` — `evaluateGuess`, `dayIndex`, `dailyAnswer`, `buildShareText`, `updateStats`, keyboard-state derivation. Vitest-unit-tested. +- Word data `src/tools/games/wordguess.words.ts` — space-separated string blobs → arrays; a test enforces every word is exactly 5 lowercase a–z letters, no duplicates, min count. +- Thin island `src/islands/games/WordGuess.tsx` — no game logic beyond wiring; `lang` prop from ToolHost with en/id `TR` strings. +- Registered in `src/registry/tools.ts` (`id: 'word-guess'`, Games, `WholeWord` icon, status `beta`) + full EN/ID SEO entries in `tool-seo.ts`. +- E2E `e2e/tools/word-guess.spec.ts` — play a full 6-guess daily game via the on-screen keyboard, assert end panel + share button appear; plus the automatic render smoke. + +## Non-goals + +- No multiplayer, no server, no accounts, no hints/solver (maybe later), no hard-mode toggle (maybe later), no custom word length. +- Not wired into Ask Agent (games aren't executors). + +## Naming / trademark + +"Daily Word Guess" — describes the mechanic; avoids the Wordle trademark. Summary/SEO may say "Wordle-style" (nominative use, same as clones). diff --git a/e2e/tools/word-guess.spec.ts b/e2e/tools/word-guess.spec.ts new file mode 100644 index 0000000..800f677 --- /dev/null +++ b/e2e/tools/word-guess.spec.ts @@ -0,0 +1,100 @@ +import { test, expect, type Page } from '@playwright/test'; +import { EN_ANSWERS, EN_EXTRA } from '../../src/tools/games/wordguess.words'; + +/** + * Happy path for Daily Word Guess: play a full daily game through the real + * on-screen keyboard and reach the end panel (win or lose both end the game), + * plus the invalid-word path. + * + * Guess words are fixed valid 5-letter words; the daily answer is + * deterministic by date, so the game always ends within these six guesses. + */ +const GUESSES = ['crane', 'solar', 'piano', 'stone', 'valid', 'zebra']; + +/** + * Wait until the island is interactive. The grid and keyboard are in the + * server HTML, so visibility proves nothing — clicks before React attaches + * its props are silently dropped (a cold CI runner lost exactly that way). + */ +async function waitForHydration(page: Page) { + await page.locator('button[aria-label="letter q"]').waitFor(); + await page.waitForFunction(() => { + const el = document.querySelector('button[aria-label="letter q"]'); + return !!el && Object.keys(el).some(k => k.startsWith('__react')); + }); +} + +test('plays a full daily game and shows the end panel', async ({ page }) => { + await page.goto('/tools/word-guess'); + await waitForHydration(page); + + const grid = page.locator('[aria-label="word grid"]'); + await expect(grid).toBeVisible(); + + for (const word of GUESSES) { + for (const ch of word) { + await page.getByRole('button', { name: `letter ${ch}` }).click(); + } + await page.getByRole('button', { name: 'Enter' }).click(); + } + + // Six guesses always finish the daily (win or lose) → end panel appears. + const panel = page.getByTestId('wg-end-panel'); + await expect(panel).toBeVisible(); + await expect(panel.getByRole('button', { name: /share|bagikan/i })).toBeVisible(); + + // The end panel must show stats and the practice button. + await expect(panel.getByText(/played|dimainkan/i)).toBeVisible(); + + // Typing after the game is over must not add new rows. + await page.keyboard.type('crane'); + const rows = grid.locator('div.grid'); + await expect(rows).toHaveCount(6); +}); + +test('rejects a word that is not in the list', async ({ page }) => { + await page.goto('/tools/word-guess'); + await waitForHydration(page); + + // zzzyx is shape-valid but not a word in either list. + const junk = 'zzzyx'; + expect(EN_ANSWERS.includes(junk)).toBe(false); + expect(EN_EXTRA.includes(junk)).toBe(false); + + for (const ch of junk) { + await page.getByRole('button', { name: `letter ${ch}` }).click(); + } + await page.getByRole('button', { name: 'Enter' }).click(); + + // Toast appears and the junk word stays in the (uncommitted) draft row. + await expect(page.getByText(/not in word list|tidak ada dalam daftar kata/i)).toBeVisible(); + const firstRow = page.locator('[aria-label="word grid"] > div').first(); + await expect(firstRow).toContainText('zzzyx'); + + // A valid word after it commits to row 1 instead — zzzyx never took a row. + const del = page.getByRole('button', { name: 'Backspace' }); + for (let i = 0; i < 5; i++) await del.click(); + for (const ch of 'crane') { + await page.getByRole('button', { name: `letter ${ch}` }).click(); + } + await page.getByRole('button', { name: 'Enter' }).click(); + await expect(firstRow).toContainText('crane'); + await expect(firstRow).not.toContainText('zzzyx'); +}); + +test('practice mode serves random games that never persist stats', async ({ page }) => { + await page.goto('/tools/word-guess'); + await waitForHydration(page); + await page.getByRole('button', { name: /practice|latihan/i }).first().click(); + + // Practice label appears and the grid is fresh. + await expect(page.getByText(/practice — random word|latihan — kata acak/i)).toBeVisible(); + + // Play one guess; a row must commit. + for (const ch of 'crane') { + await page.getByRole('button', { name: `letter ${ch}` }).click(); + } + await page.getByRole('button', { name: 'Enter' }).click(); + const firstRow = page.locator('[aria-label="word grid"] > div').first(); + await expect(firstRow).toContainText('c'); +}); diff --git a/src/islands/games/WordGuess.tsx b/src/islands/games/WordGuess.tsx new file mode 100644 index 0000000..2445157 --- /dev/null +++ b/src/islands/games/WordGuess.tsx @@ -0,0 +1,376 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { CopyButton } from '@/components/ui/CopyButton'; +import { Button } from '@/components/ui/Button'; +import { + evaluateGuess, + dayIndex, + puzzleNumber, + dailyAnswer, + practiceAnswer, + updateStats, + buildShareText, + keyboardStates, + type Stats, + type LetterState, +} from '@/tools/games/wordguess.lib'; +import { wordSets } from '@/tools/games/wordguess.words'; +import type { Lang } from '@/i18n/config'; + +const TR: Record> = { + en: { + intro: 'A new 5-letter word puzzle every day — right in your browser, in English or Bahasa. Six tries, color clues, and a streak to keep alive. Nothing is uploaded and it works offline.', + daily: 'Daily', practice: 'Practice', practiceTitle: 'Practice — random word', streak: 'Streak', + notEnough: 'Not enough letters', notInList: 'Not in word list', win: 'Splendid!', lose: 'The word was', + guessed: 'You already finished today — come back tomorrow for a new word.', nextIn: 'Next puzzle in', + stats: 'Statistics', played: 'Played', winPct: 'Win %', maxStreak: 'Max streak', distribution: 'Guess distribution', + share: 'Share result', playAgain: 'Play another (practice)', + howToHint: 'Guess the word in 6 tries. Green = right spot, yellow = wrong spot, gray = not in the word.', + }, + id: { + intro: 'Teka-teki kata 5 huruf baru setiap hari — langsung di browser Anda, dalam bahasa Inggris atau Indonesia. Enam kesempatan, petunjuk warna, dan streak yang harus dijaga. Tidak ada yang diunggah dan bisa dipakai offline.', + daily: 'Harian', practice: 'Latihan', practiceTitle: 'Latihan — kata acak', streak: 'Streak', + notEnough: 'Hurufnya belum cukup', notInList: 'Tidak ada dalam daftar kata', win: 'Luar biasa!', lose: 'Katanya adalah', + guessed: 'Anda sudah menyelesaikan teka-teki hari ini — kembali besok untuk kata baru.', nextIn: 'Teka-teki berikutnya dalam', + stats: 'Statistik', played: 'Dimainkan', winPct: '% menang', maxStreak: 'Streak terpanjang', distribution: 'Distribusi tebakan', + share: 'Bagikan hasil', playAgain: 'Main lagi (latihan)', + howToHint: 'Tebak kata dalam 6 kesempatan. Hijau = posisi benar, kuning = posisi salah, abu-abu = tidak ada dalam kata.', + }, +}; + +const ROWS = 6; + +const KEY_ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm']; + +const TILE_STYLE: Record = { + empty: 'border-2 border-border/40 bg-muted text-foreground', + pending: 'border-2 border-border bg-muted text-foreground', + correct: 'border-2 border-border bg-emerald-500 text-white', + present: 'border-2 border-border bg-yellow-400 text-black', + absent: 'border-2 border-border bg-stone-500 text-white', +}; + +const KEY_STYLE: Record = { + correct: 'bg-emerald-500 text-white', + present: 'bg-yellow-400 text-black', + absent: 'bg-stone-500 text-white', +}; + +const KEYFRAMES = ` +@keyframes gwtwg-shake { 0%,100% { translate: 0; } 20% { translate: -4px 0; } 40% { translate: 4px 0; } 60% { translate: -3px 0; } 80% { translate: 3px 0; } } +@keyframes gwtwg-pop { 0% { scale: 1; } 60% { scale: 1.12; } 100% { scale: 1; } } +@keyframes gwtwg-flip { 0% { transform: rotateX(0); } 49% { transform: rotateX(90deg); } 50% { transform: rotateX(90deg); } 100% { transform: rotateX(0); } } +.gwtwg-shake { animation: gwtwg-shake 300ms ease-in-out; } +.gwtwg-pop { animation: gwtwg-pop 140ms ease-out; } +.gwtwg-flip { animation: gwtwg-flip 500ms ease; } +@media (prefers-reduced-motion: reduce) { + .gwtwg-shake, .gwtwg-pop, .gwtwg-flip { animation: none; } +} +`; + +interface DailyState { + day: number; + guesses: string[]; + status: 'playing' | 'won' | 'lost'; +} + +interface PracticeState { + answer: string; + guesses: string[]; + status: 'playing' | 'won' | 'lost'; +} + +const statsKey = (lang: Lang) => `gwt-wordguess-stats-${lang}-v1`; +const stateKey = (lang: Lang) => `gwt-wordguess-state-${lang}-v1`; + +function fmtCountdown(ms: number): string { + if (ms < 0) ms = 0; + const h = Math.floor(ms / 3_600_000); + const m = Math.floor((ms % 3_600_000) / 60_000); + const s = Math.floor((ms % 60_000) / 1000); + const p = (n: number) => String(n).padStart(2, '0'); + return `${p(h)}:${p(m)}:${p(s)}`; +} + +export default function WordGuess({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const { answers, valid } = useMemo(() => wordSets(lang), [lang]); + const today = useMemo(() => dayIndex(), []); + const puzzle = puzzleNumber(today); + + // Practice and daily share the answer logic; only the daily persists. + const [mode, setMode] = useState<'daily' | 'practice'>('daily'); + const [daily, setDaily] = useState({ day: today, guesses: [], status: 'playing' }); + const [practice, setPractice] = useState(null); + const [stats, setStats] = useState(null); + const [draft, setDraft] = useState(''); + const [toast, setToast] = useState(''); + const [shake, setShake] = useState(false); + const [revealRow, setRevealRow] = useState(-1); + const [countdown, setCountdown] = useState(''); + const toastTimer = useRef(undefined); + + const answer = mode === 'daily' ? dailyAnswer(today, answers) : (practice?.answer ?? ''); + const active = mode === 'daily' ? daily : practice ?? { guesses: [], status: 'playing' as const }; + const guesses = active.guesses; + const status = active.status; + const finished = mode === 'daily' && daily.status !== 'playing'; + + // Hydrate persisted daily state + stats on mount (never during SSR). + useEffect(() => { + try { + const rawState = localStorage.getItem(stateKey(lang)); + if (rawState) { + const parsed = JSON.parse(rawState) as DailyState; + if (parsed.day === today) setDaily(parsed); + } + const rawStats = localStorage.getItem(statsKey(lang)); + if (rawStats) setStats(JSON.parse(rawStats) as Stats); + } catch { /* blocked or corrupt */ } + }, [lang, today]); + + const persistDaily = useCallback((next: DailyState) => { + setDaily(next); + try { localStorage.setItem(stateKey(lang), JSON.stringify(next)); } catch { /* blocked */ } + }, [lang]); + + const showToast = useCallback((msg: string) => { + setToast(msg); + window.clearTimeout(toastTimer.current); + toastTimer.current = window.setTimeout(() => setToast(''), 1600); + }, []); + + useEffect(() => () => window.clearTimeout(toastTimer.current), []); + + // Countdown to the next UTC midnight while the daily is finished. + useEffect(() => { + if (!finished) return; + const tick = () => { + const next = (today + 1) * 86_400_000; + setCountdown(fmtCountdown(next - Date.now())); + }; + tick(); + const id = window.setInterval(tick, 1000); + return () => window.clearInterval(id); + }, [finished, today]); + + const submit = useCallback((word: string) => { + if (status !== 'playing') return; + if (word.length < 5) { + setShake(true); + window.setTimeout(() => setShake(false), 320); + showToast(t.notEnough); + return; + } + if (!valid.has(word)) { + setShake(true); + window.setTimeout(() => setShake(false), 320); + showToast(t.notInList); + return; + } + + const nextGuesses = [...guesses, word]; + setDraft(''); + setRevealRow(guesses.length); + const won = word === answer; + const lost = !won && nextGuesses.length >= ROWS; + + if (mode === 'daily') { + const next: DailyState = { day: today, guesses: nextGuesses, status: won ? 'won' : lost ? 'lost' : 'playing' }; + persistDaily(next); + if (won || lost) { + const base: Stats = stats ?? { played: 0, wins: 0, streak: 0, maxStreak: 0, distribution: [0, 0, 0, 0, 0, 0] }; + const nextStats = updateStats(base, won, nextGuesses.length); + setStats(nextStats); + try { localStorage.setItem(statsKey(lang), JSON.stringify(nextStats)); } catch { /* blocked */ } + } + } else if (practice) { + setPractice({ ...practice, guesses: nextGuesses, status: won ? 'won' : lost ? 'lost' : 'playing' }); + } + + window.setTimeout(() => showToast(won ? t.win : lost ? `${t.lose} ${answer.toUpperCase()}` : ''), 550); + }, [status, valid, guesses, answer, mode, practice, persistDaily, stats, today, lang, showToast, t]); + + // Physical keyboard input. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (e.key === 'Enter') { submit(draft); return; } + if (e.key === 'Backspace') { setDraft(d => d.slice(0, -1)); return; } + if (/^[a-zA-Z]$/.test(e.key) && status === 'playing') setDraft(d => (d.length < 5 ? d + e.key.toLowerCase() : d)); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [draft, status, submit]); + + const onScreenKey = (key: 'ENTER' | 'DEL' | string) => { + if (key === 'ENTER') { submit(draft); return; } + if (key === 'DEL') { setDraft(d => d.slice(0, -1)); return; } + if (status === 'playing') setDraft(d => (d.length < 5 ? d + key : d)); + }; + + const keyStates = useMemo(() => keyboardStates(guesses, answer), [guesses, answer]); + + const statesGrid: (LetterState | null)[][] = useMemo( + () => guesses.map(g => evaluateGuess(g, answer)), + [guesses, answer], + ); + + const startPractice = () => { + setPractice({ answer: practiceAnswer(answers), guesses: [], status: 'playing' }); + setMode('practice'); + setDraft(''); + setToast(''); + }; + + const backToDaily = () => { + setMode('daily'); + setDraft(''); + setToast(''); + }; + + const shareText = status !== 'playing' + ? buildShareText(statesGrid as LetterState[][], status === 'won', guesses.length, mode === 'daily' ? puzzle : 0) + : ''; + + const distMax = stats ? Math.max(1, ...stats.distribution) : 1; + + return ( +
+ +

{t.intro}

+ +
+ + {mode === 'daily' ? `${t.daily} · #${puzzle}` : t.practiceTitle} + + {mode === 'practice' + ? + : } +
+ +
+
+ {Array.from({ length: ROWS }, (_, r) => { + const word = guesses[r] ?? (r === guesses.length ? draft.padEnd(5) : ' '); + const revealed = r < guesses.length; + const flipClass = revealed && r === revealRow ? 'gwtwg-flip' : ''; + return ( +
+ {Array.from({ length: 5 }, (_, c) => { + const ch = word[c]; + const st: LetterState | 'empty' | 'pending' = revealed + ? statesGrid[r]![c]! + : ch === ' ' ? 'empty' : 'pending'; + return ( +
+ {ch === ' ' ? '' : ch} +
+ ); + })} +
+ ); + })} +
+ + {toast && ( +
+
+ {toast} +
+
+ )} +
+ +
+ {KEY_ROWS.map((row, i) => ( +
+ {i === 2 && ( + + )} + {row.split('').map(k => ( + + ))} + {i === 2 && ( + + )} +
+ ))} +
+ + {finished && mode === 'daily' && ( +
+

+ {daily.status === 'won' ? t.win : `${t.lose} ${answer.toUpperCase()}.`} +

+

{t.guessed} {t.nextIn} {countdown}

+ + {stats && ( +
+

{t.stats}

+
+
{stats.played}
{t.played}
+
{stats.played ? Math.round(100 * stats.wins / stats.played) : 0}
{t.winPct}
+
{stats.streak}
{t.streak}
+
{stats.maxStreak}
{t.maxStreak}
+
+
+

{t.distribution}

+ {stats.distribution.map((n, i) => ( +
+ {i + 1} +
+ {n} +
+
+ ))} +
+
+ )} + +
+ + +
+
+ )} + + {mode === 'practice' && practice && practice.status !== 'playing' && ( +
+

+ {practice.status === 'won' ? t.win : `${t.lose} ${practice.answer.toUpperCase()}.`} +

+
+ + +
+
+ )} +
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index 604d153..2d8cf90 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -1105,6 +1105,25 @@ const en: Record = { { q: 'Does it work offline?', a: 'Yes. GoodWebTools is a PWA, so once loaded the game works with no internet connection.' }, ], }, + 'word-guess': { + title: 'Daily Word Guess — Free Wordle-Style Word Game', + description: 'Guess the 5-letter word in 6 tries with color clues. A new puzzle every day in English or Bahasa Indonesia, with streaks and shareable results. Free, works offline.', + intro: 'Daily Word Guess is a Wordle-style puzzle: guess the hidden 5-letter word in six tries. After each guess, tiles light up green (right letter, right spot), yellow (right letter, wrong spot), or gray (not in the word). Everyone gets the same word each day, your streak is saved on your device, and you can share your result as a spoiler-free emoji grid. Play in English or Bahasa Indonesia — everything runs in your browser, nothing is uploaded.', + howTo: [ + 'Type a 5-letter word (or tap the on-screen keys) and press Enter.', + 'Green means the letter is in the right spot; yellow means it is in the word but elsewhere; gray means it is not in the word.', + 'Use the color clues to narrow it down — you have six tries.', + 'Win to extend your streak, then share your emoji-grid result without spoiling the word.', + 'Finished early? Switch to Practice mode for unlimited random words.', + ], + faqs: [ + { q: 'Is it the same word for everyone?', a: 'Yes. The daily word is picked deterministically from the date, so everyone playing that day — in the same language — gets the same word, with no server involved.' }, + { q: 'Can I play in Bahasa Indonesia?', a: 'Yes. The game follows the site language: English uses an English word list, and Bahasa Indonesia uses a curated Indonesian (kata) list. The daily word differs between the two languages.' }, + { q: 'What happens if I refresh mid-game?', a: 'Your guesses are saved on your device, so reloading brings you back exactly where you left off.' }, + { q: 'What is the difference between Daily and Practice?', a: 'Daily gives one shared puzzle per day and feeds your streak and statistics. Practice serves unlimited random words and never affects your stats.' }, + { q: 'Does it work offline?', a: 'Yes. GoodWebTools is a PWA, so once loaded the game works with no internet connection — and tomorrow’s word is already decided by the date.' }, + ], + }, 'wheel-spinner': { title: 'Wheel Spinner — Random Name Picker & Decision Wheel', description: 'Spin a wheel of names to pick a winner at random — for giveaways, classrooms, or deciding who goes first. Add your entries and spin. Free, in your browser.', @@ -4442,6 +4461,25 @@ const id: Record = { { q: 'Apakah bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi setelah dimuat game bekerja tanpa koneksi internet.' }, ], }, + 'word-guess': { + title: 'Tebak Kata Harian — Game Kata Seperti Wordle, Gratis', + description: 'Tebak kata 5 huruf dalam 6 kesempatan dengan petunjuk warna. Teka-teki baru setiap hari dalam bahasa Indonesia atau Inggris, dengan streak dan hasil yang bisa dibagikan. Gratis, offline.', + intro: 'Tebak Kata Harian adalah teka-teki bergaya Wordle: tebak kata tersembunyi 5 huruf dalam enam kesempatan. Setelah tiap tebakan, ubin menyala hijau (huruf benar di posisi tepat), kuning (huruf ada di kata tapi posisinya salah), atau abu-abu (tidak ada di kata). Semua orang mendapat kata yang sama setiap hari, streak tersimpan di perangkat Anda, dan hasilnya bisa dibagikan sebagai emoji grid tanpa spoiler. Main dalam bahasa Indonesia atau Inggris — semuanya berjalan di browser Anda, tidak ada yang diunggah.', + howTo: [ + 'Ketik kata 5 huruf (atau ketuk papan tombol di layar) lalu tekan Enter.', + 'Hijau berarti huruf berada di posisi yang tepat; kuning berarti huruf ada di kata tetapi di posisi lain; abu-abu berarti tidak ada dalam kata.', + 'Gunakan petunjuk warna untuk mempersempit — Anda punya enam kesempatan.', + 'Menang untuk memperpanjang streak, lalu bagikan hasil emoji grid Anda tanpa membocorkan katanya.', + 'Sudah selesai lebih awal? Pindah ke mode Latihan untuk kata acak tanpa batas.', + ], + faqs: [ + { q: 'Apakah katanya sama untuk semua orang?', a: 'Ya. Kata harian dipilih secara deterministik dari tanggalnya, jadi semua yang main hari itu — dalam bahasa yang sama — mendapat kata yang sama, tanpa server.' }, + { q: 'Bisakah main dalam bahasa Inggris?', a: 'Ya. Game mengikuti bahasa situs: Inggris memakai daftar kata Inggris, Indonesia memakai daftar kata Indonesia yang dikurasi. Kata harian berbeda antara kedua bahasa.' }, + { q: 'Bagaimana jika saya menyegarkan halaman di tengah permainan?', a: 'Tebakan Anda tersimpan di perangkat, jadi memuat ulang mengembalikan Anda tepat di posisi terakhir.' }, + { q: 'Apa bedanya Harian dan Latihan?', a: 'Harian memberi satu teka-teki bersama per hari dan memengaruhi streak serta statistik Anda. Latihan menyajikan kata acak tanpa batas dan tidak pernah memengaruhi statistik.' }, + { q: 'Apakah bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi setelah dimuat game bekerja tanpa koneksi internet — dan kata besok sudah ditentukan oleh tanggalnya.' }, + ], + }, 'wheel-spinner': { title: 'Roda Putar — Pemilih Nama Acak & Roda Keputusan', description: 'Putar roda berisi nama untuk memilih pemenang secara acak — untuk giveaway, kelas, atau menentukan giliran. Tambahkan entri lalu putar. Gratis, di browser Anda.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 5460036..f3fce81 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -971,6 +971,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/games/Game2048'), status: 'beta' }, + { + id: 'word-guess', + name: 'Daily Word Guess', + category: 'Games', + route: '/tools/word-guess', + keywords: ['word guess', 'wordle', 'word game', 'daily word', 'guess the word', 'word puzzle', 'kata', 'tebak kata', 'teka-teki kata'], + icon: WholeWord, + summary: 'A Wordle-style daily word puzzle in English & Bahasa', + load: () => import('@/islands/games/WordGuess'), + status: 'beta' + }, { id: 'flappy-bird', name: 'Flying Bird Game', diff --git a/src/tools/games/wordguess.lib.test.ts b/src/tools/games/wordguess.lib.test.ts new file mode 100644 index 0000000..42c3f35 --- /dev/null +++ b/src/tools/games/wordguess.lib.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect } from 'vitest'; +import { + evaluateGuess, + dayIndex, + puzzleNumber, + dailyAnswer, + updateStats, + buildShareText, + keyboardStates, + type Stats, + type LetterState, +} from './wordguess.lib'; +import { EN_ANSWERS, ID_ANSWERS } from './wordguess.words'; + +describe('evaluateGuess', () => { + it('marks all correct', () => { + expect(evaluateGuess('crane', 'crane')).toEqual([ + 'correct', 'correct', 'correct', 'correct', 'correct', + ]); + }); + + it('marks present and absent', () => { + // answer ADIEU, guess ADOBE: A ok, D ok, O absent, B absent, E present + expect(evaluateGuess('adobe', 'adieu')).toEqual([ + 'correct', 'correct', 'absent', 'absent', 'present', + ]); + }); + + it('handles duplicate guess letters with fewer in answer', () => { + // guess ROBOT (two O), answer ABOUT (one O): first O present, second absent; B present + expect(evaluateGuess('robot', 'about')).toEqual([ + 'absent', 'present', 'present', 'absent', 'correct', + ]); + }); + + it('handles duplicate answer letters', () => { + // answer SASSY, guess STARS: S green, A present, last S present + expect(evaluateGuess('stars', 'sassy')).toEqual([ + 'correct', 'absent', 'present', 'absent', 'present', + ]); + }); + + it('green takes priority when counts compete', () => { + // answer BALMY, guess MAMBO: M present, A correct, M absent (only one M left), B present, O absent + expect(evaluateGuess('mambo', 'balmy')).toEqual([ + 'present', 'correct', 'absent', 'present', 'absent', + ]); + }); +}); + +describe('dayIndex / puzzleNumber', () => { + it('dayIndex is stable across the UTC day', () => { + const a = dayIndex(new Date(Date.UTC(2026, 7, 30, 0, 0, 0))); + const b = dayIndex(new Date(Date.UTC(2026, 7, 30, 23, 59, 59))); + expect(a).toBe(b); + }); + + it('dayIndex rolls over at UTC midnight', () => { + const a = dayIndex(new Date(Date.UTC(2026, 7, 30, 23, 59, 59))); + const b = dayIndex(new Date(Date.UTC(2026, 7, 31, 0, 0, 0))); + expect(b).toBe(a + 1); + }); + + it('puzzleNumber counts from the 2026-01-01 epoch (#1)', () => { + const epoch = dayIndex(new Date(Date.UTC(2026, 0, 1))); + expect(puzzleNumber(epoch)).toBe(1); + expect(puzzleNumber(epoch + 1)).toBe(2); + const before = dayIndex(new Date(Date.UTC(2025, 11, 31))); + expect(puzzleNumber(before)).toBe(0); + }); +}); + +describe('dailyAnswer', () => { + it('is deterministic for a given day and list', () => { + const a = dailyAnswer(19000, EN_ANSWERS); + const b = dailyAnswer(19000, EN_ANSWERS); + expect(a).toBe(b); + expect(EN_ANSWERS).toContain(a); + }); + + it('changes across days (at least once in a week)', () => { + const words = new Set([0, 1, 2, 3, 4, 5, 6].map(d => dailyAnswer(20000 + d, EN_ANSWERS))); + expect(words.size).toBeGreaterThan(1); + }); + + it('uses the list it is given (ID answers differ from EN)', () => { + const en = dailyAnswer(19000, EN_ANSWERS); + const id = dailyAnswer(19000, ID_ANSWERS); + expect(ID_ANSWERS).toContain(id); + expect(id === en).toBe(false); + }); +}); + +describe('updateStats', () => { + const base: Stats = { played: 0, wins: 0, streak: 0, maxStreak: 0, distribution: [0, 0, 0, 0, 0, 0] }; + + it('records a win and builds the streak', () => { + const s1 = updateStats(base, true, 3); + expect(s1).toEqual({ played: 1, wins: 1, streak: 1, maxStreak: 1, distribution: [0, 0, 1, 0, 0, 0] }); + const s2 = updateStats(s1, true, 4); + expect(s2.streak).toBe(2); + expect(s2.maxStreak).toBe(2); + expect(s2.distribution[3]).toBe(1); + }); + + it('a loss zeroes the streak and does not touch distribution', () => { + const s1 = updateStats(base, true, 2); + const s2 = updateStats(s1, false, 6); + expect(s2).toEqual({ played: 2, wins: 1, streak: 0, maxStreak: 1, distribution: [0, 1, 0, 0, 0, 0] }); + }); + + it('maxStreak survives a streak reset', () => { + let s = base; + for (let i = 0; i < 3; i++) s = updateStats(s, true, 1); + s = updateStats(s, false, 6); + s = updateStats(s, true, 1); + expect(s.streak).toBe(1); + expect(s.maxStreak).toBe(3); + }); + + it('rejects out-of-range tries', () => { + expect(() => updateStats(base, true, 0)).toThrow(); + expect(() => updateStats(base, true, 7)).toThrow(); + }); +}); + +describe('buildShareText', () => { + it('renders the emoji grid without leaking letters', () => { + const rows: LetterState[][] = [ + ['absent', 'present', 'absent', 'absent', 'absent'], + ['correct', 'correct', 'correct', 'correct', 'correct'], + ]; + const text = buildShareText(rows, true, 2, 42); + const lines = text.trim().split('\n'); + expect(lines[0]).toContain('#42'); + expect(lines[0]).toContain('2/6'); + expect(lines[1]).toBe('⬛🟨⬛⬛⬛'); + expect(lines[2]).toBe('🟩🟩🟩🟩🟩'); + // only the header may contain letters; the grid rows are pure emoji + expect(lines.slice(1).join('\n')).not.toMatch(/[a-z]/i); + }); + + it('renders a loss as X/6', () => { + const rows: LetterState[][] = Array.from({ length: 6 }, () => + Array.from({ length: 5 }, () => 'absent' as LetterState, + )); + const text = buildShareText(rows, false, 6, 7); + expect(text).toContain('X/6'); + }); +}); + +describe('keyboardStates', () => { + it('prioritizes correct > present > absent', () => { + // guess STEAK, answer STAKE: S,T,A green; E,K present + const ks = keyboardStates(['steak'], 'stake'); + expect(ks.s).toBe('correct'); + expect(ks.e).toBe('present'); + expect(ks.k).toBe('present'); + expect(ks.z).toBeUndefined(); + }); + + it('a later correct upgrades an earlier present', () => { + // E is present in ADOBE, then correct in ADIEU — must end correct, not downgraded + const ks = keyboardStates(['adobe', 'adieu'], 'adieu'); + expect(ks.a).toBe('correct'); + expect(ks.e).toBe('correct'); + expect(ks.b).toBe('absent'); + }); +}); diff --git a/src/tools/games/wordguess.lib.ts b/src/tools/games/wordguess.lib.ts new file mode 100644 index 0000000..8647d19 --- /dev/null +++ b/src/tools/games/wordguess.lib.ts @@ -0,0 +1,133 @@ +/** + * Pure helpers for Daily Word Guess: guess evaluation with correct + * duplicate-letter handling, the deterministic daily answer, stats, the + * spoiler-free share text, and keyboard state derivation. All UI (tiles, + * keyboard, animations) lives in the island. + */ + +export type LetterState = 'correct' | 'present' | 'absent'; + +export interface Stats { + played: number; + wins: number; + streak: number; + maxStreak: number; + distribution: number[]; // length 6, tries 1–6 +} + +/** Milliseconds in a UTC day. */ +const DAY_MS = 86_400_000; +/** Fixed epoch for puzzle numbering: 2026-01-01T00:00:00Z → puzzle #1. */ +const PUZZLE_EPOCH_DAYS = Math.floor(Date.UTC(2026, 0, 1) / DAY_MS); + +/** + * Evaluate a 5-letter guess against the answer. Greens are marked first; then + * yellows consume the answer's remaining letter counts, so duplicates can + * never over-report. Caller guarantees both are 5 lowercase letters. + */ +export function evaluateGuess(guess: string, answer: string): LetterState[] { + const states: LetterState[] = ['absent', 'absent', 'absent', 'absent', 'absent']; + const remaining = new Map(); + for (let i = 0; i < 5; i++) { + if (guess[i] === answer[i]) states[i] = 'correct'; + else remaining.set(answer[i], (remaining.get(answer[i]) ?? 0) + 1); + } + for (let i = 0; i < 5; i++) { + if (states[i] !== 'absent') continue; + const left = remaining.get(guess[i]) ?? 0; + if (left > 0) { + states[i] = 'present'; + remaining.set(guess[i], left - 1); + } + } + return states; +} + +/** Whole UTC days since the Unix epoch for the given instant. */ +export function dayIndex(date: Date = new Date()): number { + return Math.floor(date.getTime() / DAY_MS); +} + +/** Human-facing puzzle number: 1 on 2026-01-01, counting up daily. */ +export function puzzleNumber(day: number): number { + return day - PUZZLE_EPOCH_DAYS + 1; +} + +/** Small fast seeded PRNG (mulberry32) — enough entropy for word selection. */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** The deterministic daily answer for a day index, within the given list. */ +export function dailyAnswer(day: number, answers: readonly string[]): string { + const rand = mulberry32(day * 2654435761); + return answers[Math.floor(rand() * answers.length)]; +} + +/** A random practice answer (never the daily one, when avoid is given). */ +export function practiceAnswer(answers: readonly string[], avoid?: string): string { + let word = answers[Math.floor(Math.random() * answers.length)]; + if (answers.length > 1) { + let guard = 0; + while (word === avoid && guard++ < 10) { + word = answers[Math.floor(Math.random() * answers.length)]; + } + } + return word; +} + +/** Fold a finished daily into the running stats. `tries` is 1–6. */ +export function updateStats(stats: Stats, won: boolean, tries: number): Stats { + if (tries < 1 || tries > 6) throw new Error(`tries out of range: ${tries}`); + const distribution = [...stats.distribution]; + if (won) distribution[tries - 1] += 1; + const streak = won ? stats.streak + 1 : 0; + return { + played: stats.played + 1, + wins: stats.wins + (won ? 1 : 0), + streak, + maxStreak: Math.max(stats.maxStreak, streak), + distribution, + }; +} + +const SHARE_EMOJI: Record = { + correct: '🟩', + present: '🟨', + absent: '⬛', +}; + +/** Spoiler-free share text: header line + one emoji row per guess. */ +export function buildShareText( + rows: LetterState[][], + won: boolean, + tries: number, + puzzle: number, +): string { + const header = `GoodWebTools Word Guess #${puzzle} ${won ? `${tries}/6` : 'X/6'}`; + const grid = rows.map(row => row.map(s => SHARE_EMOJI[s]).join('')).join('\n'); + return `${header}\n${grid}`; +} + +const PRIORITY: Record = { absent: 0, present: 1, correct: 2 }; + +/** Best-known state per letter across all guesses (for keyboard coloring). */ +export function keyboardStates(guesses: string[], answer: string): Record { + const best: Record = {}; + for (const guess of guesses) { + const states = evaluateGuess(guess, answer); + for (let i = 0; i < guess.length; i++) { + const letter = guess[i]; + const prev = best[letter]; + if (!prev || PRIORITY[states[i]] > PRIORITY[prev]) best[letter] = states[i]; + } + } + return best; +} diff --git a/src/tools/games/wordguess.words.test.ts b/src/tools/games/wordguess.words.test.ts new file mode 100644 index 0000000..2d280b6 --- /dev/null +++ b/src/tools/games/wordguess.words.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { EN_ANSWERS, EN_EXTRA, ID_ANSWERS, ID_EXTRA, wordSets } from './wordguess.words'; + +const FIVE = /^[a-z]{5}$/; + +describe('word list shape', () => { + it.each([ + ['EN_ANSWERS', EN_ANSWERS, 800], + ['EN_EXTRA', EN_EXTRA, 100], + ['ID_ANSWERS', ID_ANSWERS, 400], + ['ID_EXTRA', ID_EXTRA, 100], + ])('%s: all words are 5 lowercase a–z letters and above the minimum size', (_name, list, min) => { + expect(list.length).toBeGreaterThanOrEqual(min); + for (const w of list) expect(w).toMatch(FIVE); + }); + + it.each([ + ['EN_ANSWERS', EN_ANSWERS], + ['EN_EXTRA', EN_EXTRA], + ['ID_ANSWERS', ID_ANSWERS], + ['ID_EXTRA', ID_EXTRA], + ])('%s: no duplicates', (_name, list) => { + expect(new Set(list).size).toBe(list.length); + }); + + it('no word is both an EN answer and an ID answer', () => { + const en = new Set(EN_ANSWERS); + const overlap = ID_ANSWERS.filter(w => en.has(w)); + expect(overlap).toEqual([]); + }); +}); + +describe('wordSets', () => { + it('valid ⊇ answers for both languages', () => { + for (const lang of ['en', 'id'] as const) { + const { answers, valid } = wordSets(lang); + for (const w of answers) expect(valid.has(w)).toBe(true); + } + }); + + it('returns distinct lists per language', () => { + const en = wordSets('en'); + const id = wordSets('id'); + expect(en.answers.includes('crane')).toBe(true); + expect(id.answers.includes('crane')).toBe(false); + }); +}); diff --git a/src/tools/games/wordguess.words.ts b/src/tools/games/wordguess.words.ts new file mode 100644 index 0000000..5daae0b --- /dev/null +++ b/src/tools/games/wordguess.words.ts @@ -0,0 +1,993 @@ +/** + * Word lists for Daily Word Guess — curated, bundled, strict. + * + * Each list is a space-separated blob (compact, gzip-friendly) split at module + * load. ANSWERS are the possible daily/random answers; EXTRA words are valid + * guesses that will never be the answer. A unit test enforces shape (5 + * lowercase a–z letters), uniqueness, and minimum sizes. + */ + +const EN_ANSWERS_RAW = ` +abide about above admit adobe adopt adult again agent agree ahead aisle +alarm album alert alike alive allow alloy alone along altar amber amend +among ample angel anger angle angry ankle apart apple apply apron arbor +ardor arena argue arise armor aroma array arrow aside asset audio audit +avoid awake award aware awful axiom bacon badge baker balmy banjo barge +basic basin batch baton beach beady beard beast began begin begun being +below bench berry bigot bilge birch birth bison black blade blame bland +blank blast blaze bleak bleat bleed blend bless blimp blind blink bliss +blitz block bloke blond blood bloom blown blues blunt blurb blurt blush +board boast bogus boost booth boots bosom bossy botch bough bound bowed +bowel boxer boxes brace braid brain brake brand brass brave bread break +breed brick bride brief brine bring brink brisk broad broil broke brook +broom broth brown brush brute buggy build built bulbs bulky bully bunch +bunny burly burnt burst buses bushy butch buyer cabin cable cameo candy +canoe canon cargo carol carry carve catch cause cease cedar chain chair +chalk charm chart chase cheap check cheek cheer chess chest chick chief +child chill chime choir choke chord chore chose chunk churn cider cigar +cinch circa cited civil claim clamp clang clash clasp class clean clear +cleat cleft clerk click cliff climb cling clink cloak clock close cloth +cloud clout clown cluck clump clung coach coast cobra cocoa colon color +comet comic comma conch condo coral could count court cover crack craft +cramp crane crank crash crate craze crazy cream credo creed creek creep +crept crest crime crisp croak crock crone crony crook cross crowd crown +crude cruel crumb crush crust crypt cubic cumin curly curse curve cycle +cynic daddy daily dairy daisy dance dandy datum daunt dealt debit debut +decay decor decoy defer deity delay delta delve demon denim dense depth +derby devil diary digit diner dingo dingy diode dirge dirty disco ditch +ditto ditty diver dizzy dodge doing dolly donor donut dough dozen draft +drain drake drama drank drape drawl drawn dread dream dress dried drier +drift drill drink drive droll droop drops drove drown drunk dryer dusky +dusty dutch dwarf dwell dwelt dying eager eagle early earth easel eaten +eater ebony edict edify eerie egret eight eject elbow elder elect elite +elope elude email embed ember emote empty enact ended enemy enjoy ennui +ensue enter entry envoy epoch equal equip erase erect erode error essay +ether ethic evade event every evict evoke exact exalt excel exert exile +exist expel extol extra exult fable facet faint fairy faith false fancy +farce fatal fault fauna favor feast feign felon femur fence feral ferry +fetal fetch fever fewer fiber field fiend fiery fifth fifty fight filch +filed files filly films final finch first fishy fixed fjord flack flail +flair flake flaky flame flank flare flash flask fleck fleet flesh flick +flier fling flint flirt float flock flood floor flora floss flour flout +flown fluff fluid fluke flung flunk flush flute foamy focal focus foggy +folio folly foray force forge forgo forte forth forty forum found foyer +frail frame frank fraud freak freed fresh fried frill frisk frock frond +front frost froth frown froze fruit fudge fully fumes fungi funky funny +furor furry fussy fuzzy gaffe gamer gauge gaunt gauze gavel gawky gecko +geeky geese genie genre ghost ghoul giant giddy girth given giver gizmo +glade gland glare glass glaze gleam glean glide glint gloat globe gloom +glory gloss glove gnash gnome godly going goner goody gooey goofy goose +gorge gouge gourd grace grade graft grain grand grant grape graph grasp +grass grate grave gravy graze great greed green greet grief grill grime +grimy grind gripe groan groin groom grope gross group grout grove growl +grown gruel gruff grunt guard guava guess guest guide guild guilt guise +gulch gully gumbo guppy gusto gusty gypsy habit hairy halve handy happy +hardy harem haste hasty hatch hater haunt haven havoc hazel heady heard +heart heath heave heavy hedge hefty heist helix hello hence heron hilly +hinge hippo hitch hoard hobby hoist holly homer honey honor horde horse +hotel hound house hovel hover howdy human humid humor hunch hurry husky +hutch hydro hyena icing ideal idiom idiot idols image imply inbox incur +index inept inert infer ingot inlay inlet inner input irony issue itchy +ivory jaunt jazzy jerky jetty jewel jiffy joint joist joker jolly joust +judge juice juicy jumbo junky karma kayak kebab khaki kinky kiosk kitty +knack knead kneel knelt knife knock knoll known koala kudos label labor +laced lacks lanky lapel larch large larva lasso latch later latte laugh +layer leach leafy leaky leant leapt learn lease leash least leave ledge +leech lefty legal lemon lemur level lever light liken lilac limbo limit +linen lingo lipid liter lithe liver livid llama loamy loath lobby local +locus lodge lofty logic login loopy loose lorry loser lotus louse lousy +loyal lucid lucky lumen lumpy lunar lunch lunge lurch lurid lusty lying +lyric macaw macho macro madam magic magma maize major maker mambo mango +mania manic manor maple march marry marsh mason match maxim maybe mayor +mealy meant meaty medal media medic melee melon merry messy metal meter +metro micro midge midst might milky mimic mince miner minor minty minus +mirth miser missy modal model modem moist molar moldy money month moody +moose moral morph mossy motel motif motor motto mound mount mourn mouse +mouth mover movie mower mucky muddy mulch mummy munch mural murky mushy +music musky musty muted nacho naive nanny nasal natty naval navel needy +neigh nerve never newer newly niche nifty night ninja ninny ninth noble +noise noisy nomad noose north notch novel nudge nurse nutty nylon oaken +oasis occur octal octet odder oddly offal offer often olive omega onion +onset opera opine optic orbit order organ other otter ought ounce outdo +outer ovary owing owned oxide ozone paddy pagan paint paler palsy panel +panic pansy pants papal paper parch parka party pasta paste pasty patch +patio patty pause paved paver pawed peace peach pearl pecan pedal penal +pence penny perch peril perky pesky pesto petal petty phase phone phony +photo piano picky piece piety piggy pilot pinch pined pinky pinto piper +pique pitch pithy pivot pixel pixie pizza place plaid plain plank plead +pleat plied plier pluck plumb plume plump plush point poise poker polar +polka pooch poppy porch poser posit posse pouch pound power prank prawn +preen press price prick pride pried prime primo print prior prism privy +prize probe prone prong proof props prose proud prove prowl proxy prune +psalm pudgy puffy pulpy pulse punch pupil puppy puree purge pushy putty +quack quark quart quash queen queer quell query quest queue quick quiet +quill quilt quirk quite quota quote rabbi rabid raced racer radar radio +rainy raise rally ranch range rapid ratio rayon razor react ready realm +rebel recap recur reeds reedy refer regal reign relax relay relic remit +renal renew repay repel reply rerun reset resin retro retry reuse revel +rhino rhyme rider ridge rifle right rigid rigor rinse ripen risen riser +risky rival river rivet roast robin robot rocky rodeo rogue roomy roost +rotor rouge rough round rouse route rover rowdy royal ruddy ruler rumba +rumor rupee rural rusty sable sadly safer saint salad sally salon salsa +salty sandy saner sappy sassy satin sauce sauna savor savvy scald scale +scalp scaly scamp scant scarf scary scene scent scoff scold scone scoop +scoot scope score scorn scour scout scowl scram scrap screw scrub scuba +scuff sedan seedy sense sepia serif serum serve setup seven sever sewer +shack shade shady shaft shake shaky shale shall shame shape shard share +shark sharp shave shawl sheaf shear sheen sheep sheer sheet shelf shell +shift shine shiny shire shirk shirt shoal shock shone shook shoot shore +short shout shove shown showy shred shrew shrub shrug shunt shush shyly +sight sigma silky silly since sinew singe siren sixth sixty skate skier +skiff skill skimp skirt skull skunk slack slain slang slant slash slate +slave sleek sleep sleet slept slice slick slide slime slimy sling slink +slope slosh sloth slump slung slurp slush slyly smack small smart smash +smear smell smile smirk smite smith smock smoke smoky snack snail snake +snaky snare snarl sneak sneer snide sniff snipe snoop snore snort snout +snowy snuck snuff soapy sober soggy solar solid solve sonar sonic sooty +sorry sound south space spade spare spark spasm spawn speak spear speck +speed spell spend spent spice spicy spied spiel spike spill spine spiny +spire spite splat split spoil spoke spoof spook spool spoon spore sport +spout spray spree sprig spurn spurt squad squat squid stack staff stage +staid stain stair stake stale stalk stall stamp stand stank staph stare +stark start stash state stave stead steak steal steam steed steel steep +steer stein stern stick stiff still stilt sting stink stint stock stoic +stoke stole stomp stone stony stood stool stoop store stork storm story +stout stove strap straw stray strip strut stuck study stuff stump stung +stunt style suave sugar suite sulky sully sumac sunny super surge sushi +swami swamp swarm swath swear sweat sweep sweet swell swept swift swill +swine swing swipe swirl swish swoon swoop sword swore sworn swung synod +syrup tabby table taboo tacit tacky taffy taint taken taker tally talon +tamer tango tangy taper tapir tardy tarot taste tasty tatty taunt tawny +teach teary tease teddy teeth tempo tenet tenor tense tenth tepee tepid +terse thank theft their theme there these thick thief thigh thing think +third thong thorn those three threw throb throw thrum thumb thump thyme +tiara tibia tidal tiger tight tilde timer timid tipsy titan title toast +today token tonal tonic tooth topaz topic torch torso total totem touch +tough towel tower toxic toxin trace track tract trade trail train trait +tramp trash treat trend triad trial tribe trice trick tried tries tripe +trite troll troop trope trout trove truce truck truly trump trunk truss +trust truth tryst tulip tulle tumor tunic turbo tutor twang tweak tweed +tweet twice twine twirl twist tying udder ulcer ultra umber uncle uncut +under undue unfed unfit unify union unite unity unlit untie until unzip +upper upset urban urine usage usher usual utter vague valet valid valor +value valve vapid vapor vault vegan venom venue verge verse verso verve +vicar video vigil vigor villa vinyl viola viper viral virus visit visor +vista vital vivid vixen vocal vodka vogue voice vowel vying wacky wafer +wager wagon waist waltz warty waste watch water waver waxen weary weave +wedge weedy weigh weird welsh wench whack whale wharf wheat wheel whelp +where which whiff while whine whiny whirl whisk white whole whoop whose +widen wider width wield wince winch windy wiser wispy witch witty woken +woman women woody wooly woozy wordy world worry worse worst worth would +wound woven wrack wrath wreak wreck wrest wring wrist write wrong wrote +wrung wryly yacht yearn yeast yield yodel yokel young yours youth yummy +zebra zesty zonal +`; + +const EN_EXTRA_RAW = ` +abbey abhor abode abort acorn adage adept adieu adore aegis aerie affix +afire afoot agape agate agave agile aging aglow agony aired alamo alder +algae alias alibi align allay alley allot aloft amaze amble amiss amity +amply amuse anise annul anode antic anvil aorta apace aphid aping apish +apter areal argon argot arose ashen askew aspic assay atoll atoms atone +attic aught aural avail avert avian awash azure bagel banns baron baste +bayou befit beige beret berth beset betel bevel bezel bicep bidet bight +blare bolas bonny boric bosky boule bourn bovid briar brunt buxom cabal +cacao cache cadet cadre carat catty caulk chafe chaff champ chant chaos +chard chary chasm chert chide chive chock chomp cilia clank colza conic +copse creel cress crick crimp croup deign depot deter dogma dowdy drone +drool erupt ethyl evert flume fount friar gaudy geode gnarl gorse grail +grebe hoary honed humus igloo irate junta juror krill laden ladle lager +lance lapis lathe levee liege lifer litre loner manna mercy merit mocha +nadir newel nosed nymph odium oriel parry parse phial phlox pious plait +poach podgy prate pseud pubes quaff quail qualm quirt raspy retch ritzy +rosin roust saber salvo sedge segue siege sieve skulk soupy spume stoup +strum surly swank sward sylph thine trawl tread tress twain usurp veldt +waive whelm whorl +`; + +const ID_ANSWERS_RAW = ` +antar +arang +arsip +aspal +atlas +badai +badan +bagus +bahas +bakar +bakau +bakso +balik +bantu +barat +basah +batal +bebas +belas +belia +benar +benda +beres +biasa +bibir +bijak +bikin +bilik +bisik +bodoh +bosan +buana +buang +buaya +bukan +bukit +bulan +bulat +bunga +bunuh +bunyi +bursa +buruh +buruk +busuk +butuh +cabai +cadas +cagar +cakap +calon +campu +candu +capai +carik +carut +catat +cemas +cepat +cerah +cerai +cerna +ceruk +cewek +cicak +cicip +cinta +cleng +cocok +codet +comel +curah +dalam +damai +danau +dasar +datar +dayak +dekat +delik +derap +deras +derma +desak +desir +detak +detil +dikit +disko +doang +dodol +dolar +dosis +duduk +dunia +duren +empat +etnik +fabel +fajar +falak +fikir +filem +fizik +fokus +frase +gagak +gagap +galak +galer +ganas +ganda +ganja +gapai +garis +gelar +gelas +gemar +gemuk +genap +gener +genit +genta +gerak +gerbu +getah +gilir +gincu +graha +gubuk +gugup +gulai +gulma +gumam +gusur +hakim +halus +harta +hasil +helai +hemat +hewan +hidro +hidup +hijau +hilir +himne +hisap +hitam +hokum +hutan +idola +ilham +imbas +impas +incar +indek +indra +induk +infaq +ingat +injak +insan +intan +intro +irama +ironi +jabat +jahit +jalur +jaman +jambu +jarak +jatuh +jebol +jelas +jelma +jemur +jenuh +jepit +jeruk +jodoh +joged +jorah +joran +jujur +jumpa +kabar +kabau +kabel +kabul +kacau +kader +kadet +kakus +kalam +kalbu +kalem +kalor +kapal +kapok +kapur +karib +kasih +kasus +katak +katun +kawal +kawin +kedai +kedip +kejar +kelir +kenal +keran +keras +kerat +kerja +kesan +kesat +ketam +kilau +kipas +kirim +kisah +kista +kitab +klaim +klien +kodok +kolam +kolom +koper +koran +kotor +kuasa +kubah +kubur +kukuh +kulit +kumal +kumat +kunci +kursi +kutip +lacak +lahar +lamar +lampu +landa +laris +lasak +lawan +lebah +lebar +lebat +leher +lekat +lelap +lemah +lemas +lembu +lepas +lepet +lerai +lesuh +lezat +lidah +lihai +lihat +lokal +loket +lomba +luber +ludes +lukis +lumba +lurah +luruh +lurus +lutut +mahal +mahar +mahir +makam +makan +makna +malam +malas +mandi +manis +marah +marga +masak +masam +massa +masuk +matur +medis +megah +merah +merak +merdu +mesin +migas +mimpi +minum +mirah +misal +mitra +mobil +mohon +monas +mufak +mujur +mulai +mulut +murah +musik +musim +mutan +nafas +nagih +najis +nanti +napas +nasib +nenas +nikah +nilai +nisan +nobel +nomor +nyala +nyata +nyeri +ombak +opini +oprak +opsir +optim +orang +pacar +padat +paham +pahat +pahit +pakai +paket +paksa +pamit +panas +panci +panen +panik +papan +parad +parah +paras +pasar +pasir +pasti +pasuk +patin +patuh +pekal +pekat +pelan +penuh +penyu +perak +peras +pergi +perih +perlu +pesan +petak +petir +piara +pijak +piket +pikir +pilih +pinda +pipih +pipit +pohon +polah +polos +posel +posko +praja +prima +prosa +pukat +pukul +pulau +pulsa +puluh +punya +purba +pusar +pusat +puspa +quran +rafia +ragam +rahim +rajut +ramai +ramal +ranah +randu +rangs +rasio +ratus +rawat +rayap +razia +rebut +redam +redup +remah +remed +renda +resep +reses +resmi +restu +retak +reviu +ribut +ricuh +rigen +rikuh +rimba +riset +riyal +robek +rokok +rompi +rotan +ruang +rucah +ruder +rujak +rukun +rusak +rusuh +sabar +sabot +sabuk +sabun +sadar +sadis +safir +saham +sahih +sajak +salam +salap +salju +salur +samud +sangk +santi +sapat +sapon +sasak +sasar +satir +sawan +sawer +sebab +sedap +sedih +seduh +sehat +sekit +selok +selur +semai +semak +sendi +senja +serat +serba +serig +sesak +sesud +setia +siaga +sibuk +sigap +sikap +silat +silau +sinis +sipil +sipir +siram +sirih +sirup +siswa +sitar +situs +siung +skala +sobek +sodor +sohor +sonik +stupa +subuh +sulit +sumbu +sunah +sunat +sunyi +supel +surat +surau +surga +surut +susah +susul +sutra +tabib +tabik +tabir +tabor +tagih +tahun +takut +talen +taman +tanda +tante +tapal +tarif +tarik +taruh +tatar +tawar +tawon +tegas +tegur +tekor +telan +telur +teman +tenda +tenis +tenun +tepat +terap +teras +teror +tibur +tidur +tikar +timah +timba +timur +tinja +tinju +tipis +titip +tokoh +tolok +tomat +topik +totok +tuang +tugas +tukar +tunda +tupai +turis +turun +tutup +tuyul +udang +ujung +ulang +undur +upaya +usaha +usang +ustad +usung +utama +utang +utara +versi +visum +vokal +wabah +wahid +wajah +wajar +wajib +wakaf +wakil +walau +wangi +waras +warna +wasir +wasta +wedus +welas +wiras +wisma +wudhu +wujud +yakin +yudis +zaman +zenit +`; + +const ID_EXTRA_RAW = ` +abadi +absen +aktif +alang +andai +aneka +anjur +antre +apung +artis +asing +bakmi +balok +balon +bambu +bapak +bedil +bekal +belok +betul +bibit +bidak +bijih +biksu +bivak +botak +bukti +bulak +buron +cacah +dapur +debar +dekil +dewan +dusta +eksis +emisi +etnis +fiksi +firma +fisik +fosil +gagal +galon +garpu +gerus +gesit +getir +gibah +gubal +gudeg +gugur +gusar +hadap +hafal +hajar +hajat +halal +hantu +haram +heboh +heksa +hilal +iblis +ihram +iklan +iklim +ilahi +imbau +imbuh +indah +infak +infra +inter +islah +islam +jadul +jamin +jarum +jatah +jawab +judes +kabin +kagum +kanal +kapas +karam +kayuh +kekar +keong +kerah +keset +ketik +ketua +kilat +kimia +korup +kuota +kurva +kusut +lahan +lahir +lajur +laksa +lapuk +latah +lebur +leceh +lelah +letih +letup +libur +licik +limau +lirih +mabuk +mamut +mayat +mercu +mesiu +mesra +mewah +micin +mikro +milik +minat +mukim +mulia +muram +murka +nanas +ngilu +obrol +obyek +paraf +pasca +payah +pekan +pesat +pijat +pisau +pleno +poros +premi +pucat +pucuk +pudar +pupuk +putra +putri +putus +qunut +rabun +racun +rakit +raung +rawan +rehat +rindu +ritme +rubel +sakit +tolak +`; + +export const EN_ANSWERS = EN_ANSWERS_RAW.trim().split(/\s+/); +export const EN_EXTRA = EN_EXTRA_RAW.trim().split(/\s+/); +export const ID_ANSWERS = ID_ANSWERS_RAW.trim().split(/\s+/); +export const ID_EXTRA = ID_EXTRA_RAW.trim().split(/\s+/); + +export interface WordSets { + /** Ordered list — indexed for the deterministic daily pick. */ + answers: string[]; + /** Membership set for guess validation (answers + extra guesses). */ + valid: Set; +} + +export function wordSets(lang: 'en' | 'id'): WordSets { + const answers = lang === 'id' ? ID_ANSWERS : EN_ANSWERS; + const extra = lang === 'id' ? ID_EXTRA : EN_EXTRA; + return { answers, valid: new Set([...answers, ...extra]) }; +}