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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/superpowers/plans/2026-08-30-fruit-merge.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions docs/superpowers/plans/2026-08-30-word-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Daily Word Guess — Implementation Plan

Spec: `docs/superpowers/specs/2026-08-30-word-guess-design.md`
Branch: `feat/word-guess` (off `origin/develop`)

## Task 1 — Word data `src/tools/games/wordguess.words.ts` + tests

- `EN_ANSWERS` (≥ 800 common 5-letter words), `EN_EXTRA` (valid guesses beyond answers), `ID_ANSWERS` (≥ 400), `ID_EXTRA`; each a space-separated string blob → exported arrays; export `wordSets(lang)` returning `{ answers, valid }` Sets.
- Tests (`wordguess.words.test.ts`): every list — all words match `/^[a-z]{5}$/`, no duplicates, min sizes; `valid` set ⊇ answers; no overlap between EN and ID answer lists (each word appears in one language's answers only — avoids cross-language confusion when guessing).

## Task 2 — Pure lib `src/tools/games/wordguess.lib.ts` + tests

- `evaluateGuess(guess, answer): LetterState[]` (`'correct' | 'present' | 'absent'`), two-pass duplicate handling.
- `dayIndex(date?: Date): number` — UTC days since epoch.
- `puzzleNumber(dayIndex): number` — days since 2026-01-01 epoch (+1, so Jan 1 2026 = #1).
- `dailyAnswer(dayIndex, answers): string` — deterministic via mulberry32(dayIndex) first output; EN/ID use the same function (lists differ, so answers differ).
- `updateStats(stats, won, tries): Stats` — played/wins/current+max streak/distribution[6]; losing the daily (or skipping a day) breaks the streak.
- `buildShareText(states: LetterState[][], won, tries, puzzleNumber): string` — emoji grid (🟩🟨⬛), `GoodWebTools Word Guess #N X/6`, no letters leaked.
- `keyboardStates(guesses: string[], answer: string): Record<string, LetterState>` — per-letter priority correct > present > absent.
- Tests: table-driven evaluate cases (duplicates: e.g. guess ROBOT vs answers with repeated letters), determinism of dailyAnswer, streak logic (win yesterday+today → 2; gap → reset), share text shape, keyboard priority.

## Task 3 — Island `src/islands/games/WordGuess.tsx`

- State: `mode: 'daily' | 'practice'`, `guesses`, `status`, loaded from localStorage per lang on mount (SSR-safe: empty initial state, hydrate in effect).
- Input: physical keyboard listener + on-screen QWERTY (3 rows + ENTER/⌫) with per-key state coloring; 5×6 tile grid with flip animation (`prefers-reduced-motion` respected, pattern from Game2048 KEYFRAMES).
- Toasts: not enough letters / not in word list / win / lose + answer reveal.
- End panel: stats summary, distribution bars, CopyButton share, countdown to next UTC midnight, Practice button (random word, in-memory, restartable).
- Persistence: state after each guess; stats on finish (daily only). Practice never writes storage.
- `TR` en/id strings; intro line above the board (self-explanatory before scrolling to how-to).

## Task 4 — Register + SEO

- `tools.ts`: `{ id: 'word-guess', name: 'Daily Word Guess', category: 'Games', route: '/tools/word-guess', keywords: [...], icon: WholeWord, summary, load: () => import('@/islands/games/WordGuess'), status: 'beta' }` next to the other games.
- `tool-seo.ts`: full EN + ID entries (title/description/intro/howTo/faqs) next to `'snake'` in both blocks. ID copy keeps "tool" untranslated, technical terms as-is.

## Task 5 — E2E `e2e/tools/word-guess.spec.ts`

- Fresh context → load `/tools/word-guess`; click on-screen keys to enter 6 valid fixed words (words from the EN lists, e.g. CRANE, STONE, …) with ENTER each; assert end panel + share button visible (win or lose, both reach the panel); assert a second ENTER after game over doesn't add rows.
- Also: invalid word path — type a non-word, ENTER, toast "not in word list", no row committed.

## Task 6 — Verify loop

- `npx vitest run` · `npm run test:e2e -- --grep word-guess` (plus full smoke) · `npm run lint` · `npm run build` (confirm `/tools/word-guess/index.html` + `/id/tools/word-guess/index.html`).
- Hand-review: hydration safety, localStorage try/catch on every path, no objectURL/leaks, reduced-motion, error/empty paths.
43 changes: 43 additions & 0 deletions docs/superpowers/specs/2026-08-30-fruit-merge-design.md
Original file line number Diff line number Diff line change
@@ -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).
42 changes: 42 additions & 0 deletions docs/superpowers/specs/2026-08-30-word-guess-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Daily Word Guess — Design

**Status:** approved
**Date:** 2026-08-30
**Goal:** A Wordle-style daily word game (EN + Bahasa Indonesia) that drives daily return visits; the first "daily puzzle" on GoodWebTools.

## Problem / opportunity

All 7 existing GWT games are arcade-style, one-shot sessions. The most addictive browser-game format of 2026 is the **daily puzzle** (Wordle/Connections/Nerdle): one shared puzzle per day, streak tracking, spoiler-free emoji sharing. It fits GWT perfectly — deterministic daily answer needs no server (client-side only, privacy promise holds), and the PWA makes the daily ritual work offline. No bilingual EN/ID wordle exists at scale; GWT's ID audience is a differentiator.

## Decisions (approved by user)

- **Word lists:** curated bundles, strict validation ("not in word list" rejection). EN + ID answer lists (~years of dailies each) + extra valid-guess lists, bundled as compact string blobs (a few KB gzipped).
- **Practice mode:** yes — unlimited random words, doesn't touch streak/stats.
- **Scope:** ship Word Guess first; Fruit Merge (Suika-style) as a separate follow-up tool.

## Game design

- 6 tries to guess a 5-letter word; green/yellow/gray clues with correct duplicate-letter handling (two-pass: greens first, then yellows against remaining letter counts).
- **Daily answer** deterministic from UTC day index (`floor(t / 86400000)`) hashed against the language's answer list — same word for everyone that day, no server, works offline.
- **Streak + stats** per language, persisted in localStorage (`gwt-wordguess-stats-<lang>-v1`): played, win %, current/max streak, guess distribution.
- **In-progress daily state persisted** (`gwt-wordguess-state-<lang>-v1`): refresh mid-game keeps guesses.
- **Share:** spoiler-free emoji grid + puzzle number, via clipboard. Puzzle number = days since a fixed epoch (2026-01-01), same for everyone.
- **Practice mode:** "Practice" button (any time) starts a random-word game; finished daily stays finished. Practice games are in-memory only.
- UI: on-screen QWERTY keyboard (EN layout covers ID — same 26 letters) + physical keyboard; tile flip animation (respects prefers-reduced-motion); toast messages; end panel with stats, distribution bars, share button, countdown to next puzzle; dark-mode aware via existing site palette; mobile-first.

## Architecture (GWT conventions)

- Pure lib `src/tools/games/wordguess.lib.ts` — `evaluateGuess`, `dayIndex`, `dailyAnswer`, `buildShareText`, `updateStats`, keyboard-state derivation. Vitest-unit-tested.
- Word data `src/tools/games/wordguess.words.ts` — space-separated string blobs → arrays; a test enforces every word is exactly 5 lowercase a–z letters, no duplicates, min count.
- Thin island `src/islands/games/WordGuess.tsx` — no game logic beyond wiring; `lang` prop from ToolHost with en/id `TR` strings.
- Registered in `src/registry/tools.ts` (`id: 'word-guess'`, Games, `WholeWord` icon, status `beta`) + full EN/ID SEO entries in `tool-seo.ts`.
- E2E `e2e/tools/word-guess.spec.ts` — play a full 6-guess daily game via the on-screen keyboard, assert end panel + share button appear; plus the automatic render smoke.

## Non-goals

- No multiplayer, no server, no accounts, no hints/solver (maybe later), no hard-mode toggle (maybe later), no custom word length.
- Not wired into Ask Agent (games aren't executors).

## Naming / trademark

"Daily Word Guess" — describes the mechanic; avoids the Wordle trademark. Summary/SEO may say "Wordle-style" (nominative use, same as clones).
66 changes: 66 additions & 0 deletions e2e/tools/fruit-merge.spec.ts
Original file line number Diff line number Diff line change
@@ -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');

Check failure on line 36 in e2e/tools/fruit-merge.spec.ts

View workflow job for this annotation

GitHub Actions / Playwright · Ask Agent

[chromium] › e2e/tools/fruit-merge.spec.ts:11:5 › dropping fruits merges pairs and scores points

1) [chromium] › e2e/tools/fruit-merge.spec.ts:11:5 › dropping fruits merges pairs and scores points Error: expect(received).not.toBe(expected) // Object.is equality Expected: not "0" Call Log: - Timeout 5000ms exceeded while waiting on the predicate 34 | } 35 | > 36 | await expect.poll(async () => await score.textContent(), { timeout: 5000 }).not.toBe('0'); | ^ 37 | }); 38 | 39 | test('restart resets the score after game over', async ({ page }) => { at /home/runner/work/goodwebtools/goodwebtools/e2e/tools/fruit-merge.spec.ts:36:83
});

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);
});
Loading
Loading