Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/superpowers/plans/2026-08-30-word-guess.md
Original file line number Diff line number Diff line change
@@ -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<string, LetterState>` — 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.
42 changes: 42 additions & 0 deletions docs/superpowers/specs/2026-08-30-word-guess-design.md
Original file line number Diff line number Diff line change
@@ -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-<lang>-v1`): played, win %, current/max streak, guess distribution.
- **In-progress daily state persisted** (`gwt-wordguess-state-<lang>-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).
100 changes: 100 additions & 0 deletions e2e/tools/word-guess.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
Loading
Loading