From c07292073dd808aa590a7c934cd01002e37e1b7b Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:50:59 +0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(games):=20Daily=20Word=20Guess=20?= =?UTF-8?q?=E2=80=94=20Wordle-style=20daily=20word=20puzzle=20(EN=20+=20ID?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pure lib: guess evaluation with duplicate-letter handling, deterministic daily answer from UTC day index, streak stats, spoiler-free emoji share, keyboard state derivation — all Vitest-covered - Curated strict word lists: 1827 EN answers + 219 extras, 620 ID answers + 163 extras (shape/dup/overlap enforced by tests) - Island: 6x5 tile grid with flip/shake/pop animations (reduced-motion safe), on-screen QWERTY + physical keyboard, toasts, end panel with stats, distribution, share button, next-puzzle countdown, unlimited practice mode - Daily state + stats persisted per language in localStorage - Registered in tools.ts + full EN/ID SEO entries with how-to and FAQs - E2E: full daily game, invalid-word rejection, practice mode --- .../plans/2026-08-30-word-guess.md | 44 + .../specs/2026-08-30-word-guess-design.md | 42 + e2e/tools/word-guess.spec.ts | 84 ++ src/islands/games/WordGuess.tsx | 376 +++++++ src/registry/tool-seo.ts | 38 + src/registry/tools.ts | 11 + src/tools/games/wordguess.lib.test.ts | 169 +++ src/tools/games/wordguess.lib.ts | 133 +++ src/tools/games/wordguess.words.test.ts | 47 + src/tools/games/wordguess.words.ts | 993 ++++++++++++++++++ 10 files changed, 1937 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-30-word-guess.md create mode 100644 docs/superpowers/specs/2026-08-30-word-guess-design.md create mode 100644 e2e/tools/word-guess.spec.ts create mode 100644 src/islands/games/WordGuess.tsx create mode 100644 src/tools/games/wordguess.lib.test.ts create mode 100644 src/tools/games/wordguess.lib.ts create mode 100644 src/tools/games/wordguess.words.test.ts create mode 100644 src/tools/games/wordguess.words.ts 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..957d5b9 --- /dev/null +++ b/e2e/tools/word-guess.spec.ts @@ -0,0 +1,84 @@ +import { test, expect } 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']; + +test('plays a full daily game and shows the end panel', async ({ page }) => { + await page.goto('/tools/word-guess'); + + 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'); + + // 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 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]) }; +} From 684c550e138962807d1d009a71a110f76e35a677 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:50:17 +0700 Subject: [PATCH 2/3] =?UTF-8?q?feat(games):=20Fruit=20Merge=20=E2=80=94=20?= =?UTF-8?q?Suika-style=20physics=20merge=20puzzle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hand-rolled circle physics in a pure lib: semi-implicit Euler, soft positional correction (slop + capped) with zero-restitution impulses, wall/floor constraints with rest cutoff, speed cap, and deterministic ordering — no physics dependency - Two solver bugs found and fixed via a 100-drop stress sim: restitution pumping launched fruits out of the box (fixed with e=0 impulses + soft correction), and speed-based calm detection never fired because jammed fruits hold phantom velocity while positionally frozen (fixed with displacement-based calm tracking) - 11-tier chain cherry→watermelon, triangular scoring, next-fruit preview, drop cooldown, deadline with dashed render - Island: canvas render (DPR-scaled), pointer aim + drop, fixed-step RAF loop with substeps, best score persisted in localStorage - Registered in tools.ts + full EN/ID SEO entries - E2E: merge scoring via real pointer drops, game-over + restart flow --- .../plans/2026-08-30-fruit-merge.md | 38 +++ .../specs/2026-08-30-fruit-merge-design.md | 43 +++ e2e/tools/fruit-merge.spec.ts | 66 +++++ src/islands/games/FruitMerge.tsx | 274 ++++++++++++++++++ src/registry/tool-seo.ts | 38 +++ src/registry/tools.ts | 13 +- src/tools/games/fruitmerge.lib.test.ts | 172 +++++++++++ src/tools/games/fruitmerge.lib.ts | 242 ++++++++++++++++ 8 files changed, 885 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-08-30-fruit-merge.md create mode 100644 docs/superpowers/specs/2026-08-30-fruit-merge-design.md create mode 100644 e2e/tools/fruit-merge.spec.ts create mode 100644 src/islands/games/FruitMerge.tsx create mode 100644 src/tools/games/fruitmerge.lib.test.ts create mode 100644 src/tools/games/fruitmerge.lib.ts diff --git a/docs/superpowers/plans/2026-08-30-fruit-merge.md b/docs/superpowers/plans/2026-08-30-fruit-merge.md new file mode 100644 index 0000000..b9aeb8c --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-fruit-merge.md @@ -0,0 +1,38 @@ +# Fruit Merge — Implementation Plan + +Spec: `docs/superpowers/specs/2026-08-30-fruit-merge-design.md` +Branch: `feat/fruit-merge` (off `origin/develop`) + +## Task 1 — Pure lib `src/tools/games/fruitmerge.lib.ts` + tests + +Types: `Fruit { id, x, y, vx, vy, tier }`; `World { fruits, nextId, score, over }`; constants `TIER_RADII[11]`, `MERGE_SCORES[11]`, `BOX { w: 360, h: 480, wall: 10 }`, `DROP_Y`, `DEADLINE_Y`, `MAX_DROP_TIER = 5`. + +Functions: +- `stepWorld(w, dt, opts?): World` — immutable update: gravity integrate → 8 iterations of (wall clamp + pair positional correction/impulse) → damping → merge pass (same-tier contact pairs, ascending id order, midpoint spawn, score += MERGE_SCORES[tier], cascade-safe: one pass per step) → game-over check (fruit center above deadline with speed < CALM_SPEED for CALM_FRAMES consecutive steps). +- `dropFruit(w, x, tier, rng?): World` — spawn at DROP_Y clamped to walls. +- `pickDropTier(rng): 0..4` — first 5 tiers, random. +- `isOverLine(f) / wouldRest(...) helpers` as needed. + +Tests (deterministic, no canvas): fruit falls under gravity; lands on floor and settles (speed → ~0); wall clamp keeps fruits inside; two same-tier fruits touching merge into tier+1 at midpoint with correct score; different tiers touching don't merge; merge chain across steps; game-over triggers when a fruit rests above the deadline; not triggered by a fast-moving fruit crossing the line; dropFruit clamps x. Table-driven where apt. + +## Task 2 — Island `src/islands/games/FruitMerge.tsx` + +- Refs: world in a `useRef`, RAF loop with fixed-step accumulator (dt = 1/60, 2 substeps), canvas 360×480 logical scaled by DPR. +- Held fruit + next preview state; drop cooldown 500 ms; pointer move (clamp x to walls minus radius), pointerup drops. +- Render: box walls, deadline dashed line, fruits (per-tier flat color + rim), guide line under held fruit, next preview chip, score/best header. +- Game over overlay: final score, best, restart button. Best persisted (`gwt-fruitmerge-best-v1`) with try/catch. +- TR en/id strings, intro paragraph, `useExpand` optional (skip — canvas is fixed logical size, responsive via max-width). + +## Task 3 — Register + SEO + +- `tools.ts`: `id: 'fruit-merge'`, Games, route `/tools/fruit-merge`, keywords (suika, watermelon game, merge, fruit drop, Gabungin buah…), icon `Apple` (verify exists in lucide), status beta. +- `tool-seo.ts` EN + ID entries next to the games (title/description/intro/howTo/faqs). ID keeps "tool" untranslated. + +## Task 4 — E2E `e2e/tools/fruit-merge.spec.ts` + +- Load page, assert canvas visible. Click/tap the canvas at a fixed x twice with the same next-tiers forced? — RNG not injectable in the page, so instead: drop ~10 fruits at varied positions; assert score number becomes > 0 eventually (same-tier drops are statistically certain with tier pool 5 across 10 drops) OR keep it deterministic by asserting non-loss invariants: score element exists, best exists, fruits render (canvas non-blank via pixel sample). +- Assert restart button appears after playing and resets the score. + +## Task 5 — Verify loop + +`npx vitest run` · `npm run test:e2e -- --grep fruit-merge` (+ full suite) · `npm run lint` · `npm run build` (both locales built). Hand-review: RAF cancellation on unmount, no SSR window access, listener cleanup, reduced-motion, error paths. diff --git a/docs/superpowers/specs/2026-08-30-fruit-merge-design.md b/docs/superpowers/specs/2026-08-30-fruit-merge-design.md new file mode 100644 index 0000000..41bd2d5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-fruit-merge-design.md @@ -0,0 +1,43 @@ +# Fruit Merge — Design + +**Status:** approved +**Date:** 2026-08-30 +**Goal:** A Suika/watermelon-game style physics merge puzzle — the second new "addictive game" (user-approved after Daily Word Guess). + +## Game design + +- Drop circular fruits from the top of a tall play box. When two fruits of the **same tier** touch, they merge into the next tier at their midpoint and award points. +- 11 tiers (cherry → watermelon), radii grow ~geometrically (16 → 64 px in a 360×480 logical box). +- Drop pool: only tiers 1–5 (like the original), preview of the next fruit. +- **Game over** when a settled fruit stays above the deadline (near the top) for a sustained period. The currently-held fruit doesn't count. +- Score: classic triangular scoring (1, 3, 6, 10, …, 66 for the watermelon merge). Best score persisted in localStorage. +- One-per-game "evolve" is out of scope; no power-ups (keep it pure like the original). + +## Physics (hand-rolled, no dependency) + +Circles + static walls only — a compact iterative impulse solver is enough and stays unit-testable: + +- Pure lib `src/tools/games/fruitmerge.lib.ts`: `stepWorld(state, dt)` — semi-implicit Euler integration, gravity, wall constraints, circle-circle impulses (low restitution ~0.15), positional correction (8 solver iterations), linear damping. Merges resolved after each step (one merge pass per step, highest-priority pairs first — merge is checked on *contact*, not velocity). +- **Deterministic order**: arrays processed in id order so tests are reproducible; the random next-fruit choice is injected (`rng` param), not called inside the lib. +- Game-over detection in the lib: track per-fruit "calm frames above line" — a fruit whose center is above the deadline and whose speed is under a threshold for N consecutive steps triggers loss. +- Island owns the RAF loop (fixed 60 Hz accumulator, substeps), canvas rendering, pointer aim + drop, and the UI chrome. + +## UI + +- Canvas render of the box + fruits (flat colors + darker rim per tier, subtle face-less minimal style matching the site's brutalist-adjacent look), drop guide line under the held fruit, next-fruit preview, score + best, game-over overlay with restart. +- Pointer/touch: move to aim (clamped so the fruit fits within walls), release/tap to drop. Small cooldown (~500 ms) before the next fruit is handed. +- TR en/id strings; `lang` prop from ToolHost; intro line above the canvas. +- prefers-reduced-motion: fruits still must fall (it's the game), but no decorative animations beyond physics. + +## Architecture (GWT conventions) + +- Pure lib + tests (`fruitmerge.lib.ts`), thin island (`src/islands/games/FruitMerge.tsx`), registered (`id: 'fruit-merge'`, Games, status `beta'`), full EN/ID SEO entries. +- E2E `e2e/tools/fruit-merge.spec.ts`: drop several fruits via synthetic pointer events, assert score increases after a same-tier merge; assert game-over path is reachable via scripted drops (or at minimum that the board state/score renders and restart works). + +## Non-goals + +- No sound (site has no audio infra convention yet — can add later), no leaderboard/multiplayer, no decorative particles. Not wired into Ask Agent. + +## Naming / trademark + +"Fruit Merge" — describes the mechanic; avoids the "Suika Game" trademark. Summary/SEO may say "Suika / watermelon-game style" (nominative use). diff --git a/e2e/tools/fruit-merge.spec.ts b/e2e/tools/fruit-merge.spec.ts new file mode 100644 index 0000000..7dc3aac --- /dev/null +++ b/e2e/tools/fruit-merge.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '@playwright/test'; + +/** + * Happy path for Fruit Merge: drop fruits through the real canvas (pointer + * events), expect same-tier merges to raise the score, and verify restart. + * + * The drop tier is random (pool of 5), so we drop a batch of fruits at varied + * positions: with 5 tiers across 14 drops, same-tier contact pairs are + * statistically certain (p ≈ 99.9%+), and each merge raises the score. + */ +test('dropping fruits merges pairs and scores points', async ({ page }) => { + await page.goto('/tools/fruit-merge'); + + const canvas = page.getByTestId('fm-canvas'); + await expect(canvas).toBeVisible(); + + const score = page.getByTestId('fm-score'); + await expect(score).toHaveText('0'); + + const box = await canvas.boundingBox(); + expect(box).not.toBeNull(); + + // Drop repeatedly onto the middle so every fruit lands on the pile and + // touches its neighbors; with a 5-tier pool a same-tier contact is near + // certain within this many drops (p(miss) < 0.1%). The 550ms cadence + // respects the in-game drop cooldown. + for (let i = 0; i < 40; i++) { + await page.mouse.move(box!.x + box!.width * 0.5, box!.y + 20); + await page.mouse.down(); + await page.mouse.up(); + await page.waitForTimeout(550); + const value = await score.textContent(); + if (value && value !== '0') break; + } + + await expect.poll(async () => await score.textContent(), { timeout: 5000 }).not.toBe('0'); +}); + +test('restart resets the score after game over', async ({ page }) => { + test.setTimeout(120_000); + await page.goto('/tools/fruit-merge'); + + const canvas = page.getByTestId('fm-canvas'); + await expect(canvas).toBeVisible(); + + const box = await canvas.boundingBox(); + expect(box).not.toBeNull(); + + // Vary the drop positions like a real player — a single perfect column is + // pathological. ~45+ effective drops overflow the box. + const xs = [0.5, 0.42, 0.58, 0.46, 0.54, 0.38, 0.62]; + for (let i = 0; i < 100; i++) { + await page.mouse.move(box!.x + box!.width * xs[i % xs.length]!, box!.y + 20); + await page.mouse.down(); + await page.mouse.up(); + await page.waitForTimeout(550); + if (await page.getByTestId('fm-over').count()) break; + } + + const overlay = page.getByTestId('fm-over'); + await expect(overlay).toBeVisible(); + + await overlay.getByRole('button', { name: /restart|mulai ulang/i }).click(); + await expect(page.getByTestId('fm-score')).toHaveText('0'); + await expect(page.getByTestId('fm-over')).toHaveCount(0); +}); diff --git a/src/islands/games/FruitMerge.tsx b/src/islands/games/FruitMerge.tsx new file mode 100644 index 0000000..5d28797 --- /dev/null +++ b/src/islands/games/FruitMerge.tsx @@ -0,0 +1,274 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { RotateCcw } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { + stepWorld, + dropFruit, + newWorld, + pickDropTier, + BOX, + DROP_Y, + DEADLINE_Y, + TIER_RADII, + type World, +} from '@/tools/games/fruitmerge.lib'; +import type { Lang } from '@/i18n/config'; + +const TR: Record> = { + en: { + intro: 'Drop fruits and merge matching pairs into bigger ones — cherry all the way to watermelon. Same fruits that touch fuse instantly; keep the box under control or the game ends. Runs entirely in your browser.', + score: 'Score', best: 'Best', next: 'Next', over: 'Game over', restart: 'Restart', + hint: 'Move to aim, tap or click to drop. Equal fruits merge on contact.', + finalScore: 'Final score', + }, + id: { + intro: 'Jatuhkan buah dan gabungkan pasangan yang sama menjadi buah lebih besar — dari ceri sampai semangka. Buah sama yang bersentuhan langsung menyatu; jaga kotak tetap lega atau permainan berakhir. Semuanya berjalan di browser Anda.', + score: 'Skor', best: 'Terbaik', next: 'Berikutnya', over: 'Permainan selesai', restart: 'Mulai ulang', + hint: 'Geser untuk membidik, ketuk atau klik untuk menjatuhkan. Buah yang sama menyatu saat bersentuhan.', + finalScore: 'Skor akhir', + }, +}; + +const BEST_KEY = 'gwt-fruitmerge-best-v1'; +const DROP_COOLDOWN_MS = 500; +const DT = 1 / 60; +const SUBSTEPS = 2; + +/** Flat fill + darker rim per tier — cherry red to watermelon green. */ +const TIER_COLORS: ReadonlyArray<[string, string]> = [ + ['#f87171', '#b91c1c'], // cherry + ['#fb923c', '#c2410c'], // strawberry-ish + ['#facc15', '#a16207'], // persimmon + ['#a3e635', '#4d7c0f'], // apple green + ['#34d399', '#047857'], // pear + ['#2dd4bf', '#0f766e'], // kiwi + ['#60a5fa', '#1d4ed8'], // blueberry + ['#c084fc', '#6d28d9'], // plum + ['#f472b6', '#be185d'], // peach-pink + ['#fdba74', '#c2410c'], // pineapple + ['#4ade80', '#15803d'], // watermelon +]; + +function clampX(x: number, tier: number): number { + const r = TIER_RADII[tier]; + return Math.min(Math.max(x, BOX.wall + r), BOX.w - BOX.wall - r); +} + +export default function FruitMerge({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const canvasRef = useRef(null); + const worldRef = useRef(newWorld()); + const heldRef = useRef<{ tier: number; x: number; nextTier: number; readyAt: number }>({ + tier: 0, x: BOX.w / 2, nextTier: 0, readyAt: 0, + }); + const [score, setScore] = useState(0); + const [over, setOver] = useState(false); + const [best, setBest] = useState(0); + const [nextTier, setNextTier] = useState(0); + + useEffect(() => { + try { setBest(Number(localStorage.getItem(BEST_KEY)) || 0); } catch { /* blocked */ } + }, []); + + const restart = useCallback(() => { + worldRef.current = newWorld(); + heldRef.current = { tier: pickDropTier(Math.random), x: BOX.w / 2, nextTier: pickDropTier(Math.random), readyAt: performance.now() + DROP_COOLDOWN_MS }; + setNextTier(heldRef.current.nextTier); + setScore(0); + setOver(false); + }, []); + + // Init held fruit on mount. + useEffect(() => { + restart(); + }, [restart]); + + // Game loop: fixed-step accumulator, canvas render each frame. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = BOX.w * dpr; + canvas.height = BOX.h * dpr; + + let raf = 0; + let last = performance.now(); + let acc = 0; + let running = true; + + const draw = () => { + const w = worldRef.current; + ctx.save(); + ctx.scale(dpr, dpr); + + // background + ctx.fillStyle = '#f5f5f4'; + ctx.fillRect(0, 0, BOX.w, BOX.h); + + // walls + ctx.fillStyle = '#d6d3d1'; + ctx.fillRect(0, 0, BOX.wall, BOX.h); + ctx.fillRect(BOX.w - BOX.wall, 0, BOX.wall, BOX.h); + ctx.fillRect(0, BOX.h - BOX.wall, BOX.w, BOX.wall); + + // deadline + ctx.strokeStyle = '#f87171'; + ctx.setLineDash([6, 6]); + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(BOX.wall, DEADLINE_Y); + ctx.lineTo(BOX.w - BOX.wall, DEADLINE_Y); + ctx.stroke(); + ctx.setLineDash([]); + + // fruits + for (const f of w.fruits) { + const [fill, rim] = TIER_COLORS[f.tier] ?? TIER_COLORS[0]!; + ctx.beginPath(); + ctx.arc(f.x, f.y, TIER_RADII[f.tier], 0, Math.PI * 2); + ctx.fillStyle = fill; + ctx.fill(); + ctx.lineWidth = 3; + ctx.strokeStyle = rim; + ctx.stroke(); + } + + // held fruit + guide + const held = heldRef.current; + if (!w.over && held.readyAt <= performance.now()) { + const r = TIER_RADII[held.tier]; + ctx.strokeStyle = 'rgba(0,0,0,0.15)'; + ctx.lineWidth = 2; + ctx.setLineDash([4, 8]); + ctx.beginPath(); + ctx.moveTo(held.x, DROP_Y + r); + ctx.lineTo(held.x, BOX.h - BOX.wall); + ctx.stroke(); + ctx.setLineDash([]); + const [fill, rim] = TIER_COLORS[held.tier] ?? TIER_COLORS[0]!; + ctx.beginPath(); + ctx.arc(held.x, DROP_Y, r, 0, Math.PI * 2); + ctx.fillStyle = fill; + ctx.fill(); + ctx.lineWidth = 3; + ctx.strokeStyle = rim; + ctx.stroke(); + } + + ctx.restore(); + }; + + const frame = (now: number) => { + if (!running) return; + acc += Math.min(now - last, 100) / 1000; + last = now; + while (acc >= DT) { + const prev = worldRef.current; + for (let i = 0; i < SUBSTEPS; i++) { + worldRef.current = stepWorld(worldRef.current, DT / SUBSTEPS); + } + const next = worldRef.current; + if (next.over && !prev.over) { + setOver(true); + setBest(b => { + const nb = Math.max(b, next.score); + try { localStorage.setItem(BEST_KEY, String(nb)); } catch { /* blocked */ } + return nb; + }); + } + if (next.score !== prev.score) setScore(next.score); + acc -= DT; + } + draw(); + raf = requestAnimationFrame(frame); + }; + + raf = requestAnimationFrame(frame); + return () => { + running = false; + cancelAnimationFrame(raf); + }; + }, []); + + const drop = useCallback(() => { + const w = worldRef.current; + if (w.over) return; + const held = heldRef.current; + if (held.readyAt > performance.now()) return; + worldRef.current = dropFruit(w, held.x, held.tier); + heldRef.current = { + tier: held.nextTier, + x: held.x, + nextTier: pickDropTier(Math.random), + readyAt: performance.now() + DROP_COOLDOWN_MS, + }; + setNextTier(heldRef.current.nextTier); + }, []); + + const aim = useCallback((clientX: number) => { + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const x = ((clientX - rect.left) / rect.width) * BOX.w; + heldRef.current.x = clampX(x, heldRef.current.tier); + }, []); + + const onPointerMove = (e: React.PointerEvent) => { + if (over) return; + aim(e.clientX); + }; + + const onPointerDown = (e: React.PointerEvent) => { + if (over) return; + aim(e.clientX); + drop(); + }; + + return ( +
+

{t.intro}

+ +
+
{t.score} {score}
+
{t.best} {best}
+
+ {t.next} + +
+ {over && } +
+ +
+ + {over && ( +
+

{t.over}

+

{t.finalScore}: {score}

+ +
+ )} +
+ +

{t.hint}

+
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index 604d153..8c7c2c8 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.' }, ], }, + 'fruit-merge': { + title: 'Fruit Merge — Free Suika-Style Watermelon Game', + description: 'Drop and merge fruits in this Suika-style physics puzzle: equal fruits fuse on contact, from cherry to watermelon. Free, no ads, runs in your browser offline.', + intro: 'Fruit Merge is a Suika / watermelon-game style physics puzzle. Aim and drop circular fruits into the box — whenever two fruits of the same kind touch, they fuse into the next bigger fruit and score points. The catch: the box fills up fast, and when fruit piles stay above the line, the game is over. The physics runs entirely in your browser with a hand-built engine — no uploads, no ads, works offline.', + howTo: [ + 'Move your finger or mouse across the box to aim; the next fruit follows along the top.', + 'Tap or click to drop the fruit where the guide line points.', + 'Two fruits of the same size merge into the next bigger fruit the moment they touch.', + 'Watch the dashed line near the top — fruit resting above it ends the game.', + 'Keep merging toward the watermelon and beat your best score.', + ], + faqs: [ + { q: 'What is the goal?', a: 'Merge equal fruits to work your way up the 11-tier chain, from cherry to watermelon, and score as high as possible before the box overflows above the line.' }, + { q: 'How does scoring work?', a: 'Every merge awards points that grow with the tier — merging cherries gives 1 point, while creating a watermelon is worth 66. Bigger merges pay much more.' }, + { q: 'What ends the game?', a: 'When fruit settles and stays above the dashed deadline near the top of the box, the game is over. Fruits that are merely falling through the line are fine.' }, + { q: 'Can I choose which fruit to drop?', a: 'No — like the original, you get a random small fruit each time, shown in the Next preview, so placement strategy matters more than luck.' }, + { q: 'Does it work offline?', a: 'Yes. GoodWebTools is a PWA, so once loaded the game works with no internet connection.' }, + ], + }, '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.' }, ], }, + 'fruit-merge': { + title: 'Gabung Buah — Game Fisika Seperti Suika, Gratis', + description: 'Jatuhkan dan gabungkan buah dalam puzzle fisika bergaya Suika: buah yang sama menyatu saat bersentuhan, dari ceri sampai semangka. Gratis, tanpa iklan, offline.', + intro: 'Gabung Buah adalah puzzle fisika bergaya Suika / game semangka. Bidik dan jatuhkan buah bulat ke dalam kotak — setiap dua buah yang sama bersentuhan langsung menyatu menjadi buah yang lebih besar dan memberi poin. Jebakannya: kotak cepat penuh, dan ketika tumpukan buah bertahan di atas garis, permainan berakhir. Fisikanya berjalan sepenuhnya di browser Anda dengan engine buatan sendiri — tidak ada unggahan, tanpa iklan, dan bisa dimainkan offline.', + howTo: [ + 'Gerakkan jari atau mouse melintasi kotak untuk membidik; buah berikutnya mengikuti di bagian atas.', + 'Ketuk atau klik untuk menjatuhkan buah di tempat yang ditunjuk garis panduan.', + 'Dua buah dengan ukuran sama menyatu menjadi buah yang lebih besar begitu bersentuhan.', + 'Perhatikan garis putus-putus di dekat bagian atas — buah yang beristirahat di atasnya mengakhiri permainan.', + 'Terus gabungkan menuju semangka dan kalahkan skor terbaik Anda.', + ], + faqs: [ + { q: 'Apa tujuannya?', a: 'Gabungkan buah yang sama untuk naik rantai 11 tingkat, dari ceri sampai semangka, dan raih skor setinggi mungkin sebelum kotak meluap melewati garis.' }, + { q: 'Bagaimana perhitungan skornya?', a: 'Setiap penggabungan memberi poin yang makin besar mengikuti tingkatnya — menggabungkan ceri memberi 1 poin, sedangkan menciptakan semangka bernilai 66. Penggabungan besar jauh lebih menguntungkan.' }, + { q: 'Apa yang mengakhiri permainan?', a: 'Ketika buah berhenti dan bertahan di atas garis batas putus-putus dekat puncak kotak, permainan selesai. Buah yang sekadar jatuh melewati garis tidak masalah.' }, + { q: 'Bisakah saya memilih buah yang dijatuhkan?', a: 'Tidak — seperti game aslinya, Anda mendapat buah kecil acak setiap kali, ditampilkan di pratinjau Berikutnya, jadi strategi penempatan lebih menentukan daripada keberuntungan.' }, + { q: 'Apakah bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi setelah dimuat game bekerja tanpa koneksi internet.' }, + ], + }, '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..9ae671f 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -1,4 +1,4 @@ -import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock, ClipboardPaste, PlugZap, Regex, Contact, Wallet, Network, Subtitles, Presentation, SquareUser, WholeWord, Percent, Baseline, CaseSensitive, Brush, AppWindow, ListOrdered, FileSignature, Shrink, Cake, Ruler, Timer, Highlighter, Gauge, Speech, Accessibility, Tags, Link2Off, Home, HeartHandshake, Gift, Barcode, Disc3, Sticker, Glasses, HeartPulse, BookCopy, Users, Grip, MailOpen, Scan, Activity, Grid3x3, Bird, ServerCog, Pilcrow, MonitorSmartphone, Volume2, Monitor, MousePointerClick, ListChecks, Landmark, Hourglass, Globe, Smile, StickyNote, Waves, Music4, ScanBarcode, Brain, ToyBrick, Footprints, Rabbit, ListMusic, Link2, AlarmClock, Scale, Flame, GraduationCap } from 'lucide-react'; +import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock, ClipboardPaste, PlugZap, Regex, Contact, Wallet, Network, Subtitles, Presentation, SquareUser, WholeWord, Percent, Baseline, CaseSensitive, Brush, AppWindow, ListOrdered, FileSignature, Shrink, Cake, Ruler, Timer, Highlighter, Gauge, Speech, Accessibility, Tags, Link2Off, Home, HeartHandshake, Gift, Barcode, Disc3, Sticker, Glasses, HeartPulse, BookCopy, Users, Grip, MailOpen, Scan, Activity, Grid3x3, Apple, Bird, ServerCog, Pilcrow, MonitorSmartphone, Volume2, Monitor, MousePointerClick, ListChecks, Landmark, Hourglass, Globe, Smile, StickyNote, Waves, Music4, ScanBarcode, Brain, ToyBrick, Footprints, Rabbit, ListMusic, Link2, AlarmClock, Scale, Flame, GraduationCap } from 'lucide-react'; import type { ToolDef } from '@/types/tool'; export const tools: ToolDef[] = [ @@ -1004,6 +1004,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/games/DinoRun'), status: 'beta' }, + { + id: 'fruit-merge', + name: 'Fruit Merge', + category: 'Games', + route: '/tools/fruit-merge', + keywords: ['fruit merge', 'suika', 'suika game', 'watermelon game', 'merge game', 'drop fruit', 'physics puzzle', 'gabung buah', 'buah', 'game semangka'], + icon: Apple, + summary: 'Suika-style physics puzzle — drop & merge fruits into a watermelon', + load: () => import('@/islands/games/FruitMerge'), + status: 'beta' + }, { id: 'snake', name: 'Snake Game', diff --git a/src/tools/games/fruitmerge.lib.test.ts b/src/tools/games/fruitmerge.lib.test.ts new file mode 100644 index 0000000..5538ec9 --- /dev/null +++ b/src/tools/games/fruitmerge.lib.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect } from 'vitest'; +import { + stepWorld, + dropFruit, + newWorld, + TIER_RADII, + MERGE_SCORES, + BOX, + DROP_Y, + DEADLINE_Y, + pickDropTier, + type World, +} from './fruitmerge.lib'; + +const DT = 1 / 60; + +function run(w: World, steps: number): World { + let world = w; + for (let i = 0; i < steps; i++) world = stepWorld(world, DT); + return world; +} + +describe('constants', () => { + it('has 11 tiers with strictly growing radii and scores', () => { + expect(TIER_RADII).toHaveLength(11); + expect(MERGE_SCORES).toHaveLength(11); + for (let i = 1; i < 11; i++) { + expect(TIER_RADII[i]).toBeGreaterThan(TIER_RADII[i - 1]); + expect(MERGE_SCORES[i]).toBeGreaterThan(MERGE_SCORES[i - 1]); + } + }); +}); + +describe('stepWorld', () => { + it('applies gravity — a lone fruit falls', () => { + const w: World = { + ...newWorld(), + fruits: [{ id: 1, x: BOX.w / 2, y: 100, vx: 0, vy: 0, tier: 0 }], + }; + const next = stepWorld(w, DT); + expect(next.fruits[0]!.y).toBeGreaterThan(100); + expect(next.fruits[0]!.vy).toBeGreaterThan(0); + }); + + it('lands on the floor and settles', () => { + const w = run(settledWorldFromHeight(50), 400); + const f = w.fruits[0]!; + const r = TIER_RADII[0]; + expect(f.y).toBeGreaterThanOrEqual(BOX.h - r - 0.01); + expect(f.y).toBeLessThanOrEqual(BOX.h - r + 2); + expect(Math.abs(f.vy)).toBeLessThan(0.5); + expect(w.over).toBe(false); + }); + + it('clamps fruits inside the side walls', () => { + const w: World = { + ...newWorld(), + fruits: [{ id: 1, x: 2, y: 300, vx: -50, vy: 0, tier: 0 }], + }; + const out = run(w, 120); + const f = out.fruits[0]!; + expect(f.x - TIER_RADII[0]).toBeGreaterThanOrEqual(BOX.wall - 0.01); + }); + + it('merges two same-tier fruits in contact', () => { + const r = TIER_RADII[0]; + const w: World = { + ...newWorld(), + fruits: [ + { id: 1, x: BOX.w / 2 - r * 0.6, y: BOX.h - r, vx: 0, vy: 0, tier: 0 }, + { id: 2, x: BOX.w / 2 + r * 0.6, y: BOX.h - r, vx: 0, vy: 0, tier: 0 }, + ], + }; + const out = run(w, 1); + expect(out.fruits).toHaveLength(1); + expect(out.fruits[0]!.tier).toBe(1); + expect(out.score).toBe(MERGE_SCORES[0]); + // merged fruit sits near the pair midpoint + expect(out.fruits[0]!.x).toBeGreaterThan(BOX.w / 2 - 5); + expect(out.fruits[0]!.x).toBeLessThan(BOX.w / 2 + 5); + }); + + it('does not merge different tiers in contact', () => { + const r0 = TIER_RADII[0]; + const r1 = TIER_RADII[1]; + const w: World = { + ...newWorld(), + fruits: [ + { id: 1, x: BOX.w / 2 - 10, y: BOX.h - r0, vx: 0, vy: 0, tier: 0 }, + { id: 2, x: BOX.w / 2 + r0 + r1 - 12, y: BOX.h - r1, vx: 0, vy: 0, tier: 1 }, + ], + }; + const out = run(w, 5); + expect(out.fruits).toHaveLength(2); + expect(out.score).toBe(0); + }); + + it('merge chains settle over successive steps (three of a kind)', () => { + // Three tier-0 fruits stacked at the bottom: first two merge to tier 1, + // the new tier-1 fruit then rests — no tier-1 pair left, so no cascade. + const r = TIER_RADII[0]; + const w: World = { + ...newWorld(), + fruits: [ + { id: 1, x: BOX.w / 2 - r, y: BOX.h - r, vx: 0, vy: 0, tier: 0 }, + { id: 2, x: BOX.w / 2 + r, y: BOX.h - r, vx: 0, vy: 0, tier: 0 }, + { id: 3, x: BOX.w / 2, y: BOX.h - r * 2.2, vx: 0, vy: 0, tier: 0 }, + ], + }; + const out = run(w, 30); + expect(out.score).toBe(MERGE_SCORES[0]); + expect(out.fruits.map(f => f.tier).sort()).toEqual([0, 1]); + }); + + it('declares game over when a fruit rests above the deadline', () => { + // A stable tower: two watermelons stacked on the floor + a cherry resting + // on top — its center (y=24) sits above the deadline (y=90), calm. + const w: World = { + ...newWorld(), + fruits: [ + { id: 1, x: BOX.w / 2, y: BOX.h - TIER_RADII[10], vx: 0, vy: 0, tier: 10 }, + { id: 2, x: BOX.w / 2, y: 150, vx: 0, vy: 0, tier: 10 }, + { id: 3, x: BOX.w / 2, y: 24, vx: 0, vy: 0, tier: 0 }, + ], + }; + const out = run(w, 600); + expect(out.over).toBe(true); + expect(out.fruits.find(f => f.id === 3)!.y).toBeLessThan(DEADLINE_Y); + }); + + it('does not declare game over for a fruit merely crossing the line while moving', () => { + const w: World = { + ...newWorld(), + fruits: [{ id: 1, x: BOX.w / 2, y: 30, vx: 0, vy: 0, tier: 0 }], + }; + // fast fall through the deadline zone — needs many calm frames to lose + const out = run(w, 3); + expect(out.over).toBe(false); + }); +}); + +describe('dropFruit', () => { + it('spawns at the drop line clamped to the walls', () => { + const w = dropFruit(newWorld(), -100, 0); + expect(w.fruits[0]!.x).toBe(BOX.wall + TIER_RADII[0]); + const w2 = dropFruit(newWorld(), BOX.w + 100, 0); + expect(w2.fruits[0]!.x).toBe(BOX.w - BOX.wall - TIER_RADII[0]); + const w3 = dropFruit(newWorld(), BOX.w / 2, 2); + expect(w3.fruits[0]!.y).toBe(DROP_Y); + expect(w3.fruits[0]!.tier).toBe(2); + }); + + it('refuses tiers beyond the max drop tier', () => { + expect(() => dropFruit(newWorld(), BOX.w / 2, 5)).toThrow(); + }); +}); + +describe('pickDropTier', () => { + it('maps rng to tiers 0–4 deterministically', () => { + expect(pickDropTier(() => 0)).toBe(0); + expect(pickDropTier(() => 0.99)).toBe(4); + expect(pickDropTier(() => 0.5)).toBe(2); + }); +}); + +function settledWorldFromHeight(height: number): World { + const r = TIER_RADII[0]; + return { + ...newWorld(), + fruits: [{ id: 1, x: BOX.w / 2, y: BOX.h - r - height, vx: 0, vy: 0, tier: 0 }], + }; +} diff --git a/src/tools/games/fruitmerge.lib.ts b/src/tools/games/fruitmerge.lib.ts new file mode 100644 index 0000000..d0afe2d --- /dev/null +++ b/src/tools/games/fruitmerge.lib.ts @@ -0,0 +1,242 @@ +/** + * Pure helpers for Fruit Merge: a compact circle physics world (gravity, wall + * constraints, iterative pair correction with low restitution), same-tier + * merging, and game-over detection. Rendering, the RAF loop, and input live + * in the island. The world is treated immutably — every step returns a new + * World — and iteration order is deterministic (ascending id) so tests are + * reproducible. + */ + +export interface Fruit { + id: number; + x: number; + y: number; + vx: number; + vy: number; + tier: number; +} + +export interface World { + fruits: Fruit[]; + nextId: number; + score: number; + over: boolean; + /** Consecutive calm steps each fruit has spent above the deadline (internal). */ + calmMap: ReadonlyMap; +} + +/** Logical play box; the island scales the canvas to this. */ +export const BOX = { w: 360, h: 480, wall: 10 } as const; + +/** Radii per tier (px in logical units) — cherry → watermelon. */ +export const TIER_RADII = [16, 22, 29, 37, 46, 56, 66, 77, 88, 99, 110] as const; + +/** Points awarded for merging tier i + i → i+1 (triangular numbers). */ +export const MERGE_SCORES = [1, 3, 6, 10, 15, 21, 28, 36, 45, 55, 66] as const; + +/** Y position where dropped fruits spawn. */ +export const DROP_Y = 40; +/** The loss line — a settled fruit whose center is above this loses the game. */ +export const DEADLINE_Y = 90; +/** Only tiers 0..MAX_DROP_TIER are ever handed to the player. */ +export const MAX_DROP_TIER = 4; + +const GRAVITY = 900; +/** Near-inelastic contacts: Suika fruits barely bounce, and stacking stays calm. */ +const RESTITUTION = 0.02; +/** Bounces slower than this snap to rest instead of jittering forever. */ +const REST_CUTOFF = 20; +const DAMPING = 0.995; +const SOLVER_ITERATIONS = 8; +const MERGE_SLOP = 0.5; +/** Positional correction softness — hard correction launches fruits in jams. */ +const CORRECTION_FACTOR = 0.3; +const CORRECTION_SLOP = 0.5; +/** Max single correction per pair, per iteration (px). */ +const CORRECTION_MAX = 1.5; +/** Hard speed cap (px/s) — a safety net against solver energy spikes. */ +const MAX_SPEED = 1200; +/** Calm threshold — a fruit that moves slower than this (px/s, measured as + * per-step displacement) counts as settled for the game-over check. */ +const CALM_SPEED = 12; +/** Consecutive calm steps above the line before game over. */ +const CALM_FRAMES = 45; + +/** A fresh, empty world. */ +export function newWorld(): World { + return { fruits: [], nextId: 1, score: 0, over: false, calmMap: new Map() }; +} + +/** Map a [0,1) rng value to a droppable tier (0–4). */ +export function pickDropTier(rng: () => number): number { + const clamped = Math.min(Math.max(rng(), 0), 0.9999999); + return Math.floor(clamped * (MAX_DROP_TIER + 1)); +} + +/** Spawn a fruit of `tier` at (clamped) x on the drop line. */ +export function dropFruit(w: World, x: number, tier: number): World { + if (tier > MAX_DROP_TIER) throw new Error(`tier ${tier} exceeds max drop tier ${MAX_DROP_TIER}`); + const r = TIER_RADII[tier]; + const clampedX = Math.min(Math.max(x, BOX.wall + r), BOX.w - BOX.wall - r); + return { + ...w, + fruits: [...w.fruits, { id: w.nextId, x: clampedX, y: DROP_Y, vx: 0, vy: 0, tier }], + nextId: w.nextId + 1, + }; +} + +/** Advance the world by dt seconds (fixed step; use small dt like 1/60). */ +export function stepWorld(w: World, dt: number): World { + if (w.over) return w; + + // 1) Integrate (semi-implicit Euler + light damping). + const sim = w.fruits.map(f => ({ ...f })); + for (const f of sim) { + f.vy += GRAVITY * dt; + f.vx *= DAMPING; + f.vy *= DAMPING; + f.x += f.vx * dt; + f.y += f.vy * dt; + } + + // 2) Solve constraints iteratively: walls, then every pair. + for (let it = 0; it < SOLVER_ITERATIONS; it++) { + for (const f of sim) { + const r = TIER_RADII[f.tier]; + const left = BOX.wall + r; + const right = BOX.w - BOX.wall - r; + const floor = BOX.h - r; + const reflect = (v: number) => { + const bounced = -v * RESTITUTION; + return Math.abs(bounced) < REST_CUTOFF ? 0 : bounced; + }; + if (f.x < left) { f.x = left; if (f.vx < 0) f.vx = reflect(f.vx); } + if (f.x > right) { f.x = right; if (f.vx > 0) f.vx = reflect(f.vx); } + if (f.y > floor) { f.y = floor; if (f.vy > 0) f.vy = reflect(f.vy); } + // no ceiling — fruits may bounce above the box and fall back + } + for (let i = 0; i < sim.length; i++) { + for (let j = i + 1; j < sim.length; j++) { + const a = sim[i]!; + const b = sim[j]!; + const ra = TIER_RADII[a.tier]; + const rb = TIER_RADII[b.tier]; + const dx = b.x - a.x; + const dy = b.y - a.y; + const distSq = dx * dx + dy * dy; + const minDist = ra + rb; + if (distSq >= minDist * minDist) continue; + let dist = Math.sqrt(distSq); + let nx: number; + let ny: number; + if (dist === 0) { + // Coincident centers (a fruit dropped dead-center on another): + // separate along a deterministic axis so the pair can't fuse into + // an unresolvable blob that later fruits launch upward. + nx = 0; + ny = a.id < b.id ? -1 : 1; + dist = 0; + } else { + nx = dx / dist; + ny = dy / dist; + } + const overlap = minDist - dist; // Soft positional correction (slop + capped), split by inverse radius + // (bigger = heavier). Soft is essential: hard correction in a jammed + // pile launches fruits out of the box. + const correction = Math.min( + Math.max(overlap - CORRECTION_SLOP, 0) * CORRECTION_FACTOR, + CORRECTION_MAX, + ); + const wa = rb / (ra + rb); + const wb = ra / (ra + rb); + a.x -= nx * correction * wa; + a.y -= ny * correction * wa; + b.x += nx * correction * wb; + b.y += ny * correction * wb; + // Zero-restitution impulse: stops approach without adding bounce. + // (With e > 0 the correction/impulse loop pumps energy in jams and + // launches fruits out of the box; with e = 0 momentum is conserved + // and stacks stay put.) + const rvx = b.vx - a.vx; + const rvy = b.vy - a.vy; + const vn = rvx * nx + rvy * ny; + if (vn < 0) { + const ma = ra * ra; + const mb = rb * rb; + const jimp = -vn / (1 / ma + 1 / mb); + a.vx -= (jimp / ma) * nx; + a.vy -= (jimp / ma) * ny; + b.vx += (jimp / mb) * nx; + b.vy += (jimp / mb) * ny; + } + } + } + } + + // 3) Speed cap — safety net against solver energy spikes in dense piles. + for (const f of sim) { + const speed = Math.hypot(f.vx, f.vy); + if (speed > MAX_SPEED) { + const scale = MAX_SPEED / speed; + f.vx *= scale; + f.vy *= scale; + } + } + + // 4) Merge pass: same-tier pairs in contact (ascending id, one pass/step). + const ordered = [...sim].sort((a, b) => a.id - b.id); + const merged = new Set(); + const pairs: [typeof ordered[0], typeof ordered[0]][] = []; + for (let i = 0; i < ordered.length; i++) { + for (let j = i + 1; j < ordered.length; j++) { + const a = ordered[i]!; + const b = ordered[j]!; + if (a.tier !== b.tier) continue; + if (a.tier >= TIER_RADII.length - 1) continue; // watermelons don't merge + if (merged.has(a.id) || merged.has(b.id)) continue; + const minDist = TIER_RADII[a.tier] + TIER_RADII[b.tier] + MERGE_SLOP; + const dx = b.x - a.x; + const dy = b.y - a.y; + if (dx * dx + dy * dy <= minDist * minDist) { + pairs.push([a, b]); + merged.add(a.id); + merged.add(b.id); + } + } + } + const survivors: Fruit[] = ordered + .filter(f => !merged.has(f.id)) + .map(f => ({ id: f.id, x: f.x, y: f.y, vx: f.vx, vy: f.vy, tier: f.tier })); + let score = w.score; + let nextId = w.nextId; + for (const [a, b] of pairs) { + const newTier = a.tier + 1; + const nr = TIER_RADII[newTier]!; + const x = (a.x + b.x) / 2; + const y = (a.y + b.y) / 2; + // Keep the merged fruit inside the walls and above the floor. + const cx = Math.min(Math.max(x, BOX.wall + nr), BOX.w - BOX.wall - nr); + const cy = Math.min(y, BOX.h - nr); + survivors.push({ id: nextId++, x: cx, y: cy, vx: (a.vx + b.vx) / 2, vy: (a.vy + b.vy) / 2, tier: newTier }); + score += MERGE_SCORES[a.tier]!; + } + + // 5) Game over: a fruit above the deadline that has not *moved* for + // sustained frames. Displacement (not velocity) is the test — in a jammed + // pile gravity and contact impulses cancel, leaving fruits with a phantom + // velocity while sitting perfectly still. + const prevById = new Map(w.fruits.map(f => [f.id, f])); + const calmMap = new Map(); + let over = false; + for (const f of survivors) { + if (f.y >= DEADLINE_Y) continue; + const prev = prevById.get(f.id); + const moved = prev ? Math.hypot(f.x - prev.x, f.y - prev.y) : Infinity; + if (moved >= dt * CALM_SPEED) continue; + const nextCalm = (w.calmMap.get(f.id) ?? 0) + 1; + calmMap.set(f.id, nextCalm); + if (nextCalm >= CALM_FRAMES) over = true; + } + + return { fruits: survivors, nextId, score, over, calmMap }; +} From 4240fa98b060eaaeca915311d6ee9eaab68741fd Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:20:33 +0700 Subject: [PATCH 3/3] test(e2e): wait for island hydration before clicking word-guess keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a cold CI runner the first on-screen-key click landed before React attached props to the island — the click was silently dropped, every subsequent Enter hit a mangled draft, and the game never finished. Wait for the __react props marker on a keyboard key before interacting. --- e2e/tools/word-guess.spec.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/e2e/tools/word-guess.spec.ts b/e2e/tools/word-guess.spec.ts index 957d5b9..800f677 100644 --- a/e2e/tools/word-guess.spec.ts +++ b/e2e/tools/word-guess.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; import { EN_ANSWERS, EN_EXTRA } from '../../src/tools/games/wordguess.words'; /** @@ -11,8 +11,22 @@ import { EN_ANSWERS, EN_EXTRA } from '../../src/tools/games/wordguess.words'; */ 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(); @@ -40,6 +54,7 @@ test('plays a full daily game and shows the end panel', async ({ page }) => { 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'; @@ -69,6 +84,7 @@ test('rejects a word that is not in the list', async ({ page }) => { 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.