From 55d26f50edf2ed3077ef292f84156d7acf4c4741 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 10 Sep 2026 13:19:03 -0400 Subject: [PATCH] =?UTF-8?q?Who's=20that=20Pok=C3=A9mon=3F:=20fuzzy=20guess?= =?UTF-8?q?ing=20+=20a=20generation=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fuzzy: src/lib/fuzzy.js — editDistance + closeEnough (length-scaled typo tolerance, 0 for names ≤4 chars). submitGuess() accepts a near-miss, so "charizrd" counts. Plus inline ghost-text completion: once the typed prefix matches exactly one Pokémon, the rest of the name shows greyed after the caret; → (at end), Tab or End accepts it. Generation filter: a "Pull from" select (All / Gen 1–9, built from the snapshot's species.generation). Choice persists in pdx.whosThat.gen; changing it reshuffles the pool (and the completion list) and resets the streak. 7 new unit tests for fuzzy.js (48 total). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- src/lib/fuzzy.js | 30 +++++++++++ src/store/whosThat.js | 2 +- src/styles/layout.css | 51 +++++++++++++++++-- src/views/WhosThatView.js | 104 +++++++++++++++++++++++++++++++++++--- test/fuzzy.test.js | 32 ++++++++++++ 5 files changed, 206 insertions(+), 13 deletions(-) create mode 100644 src/lib/fuzzy.js create mode 100644 test/fuzzy.test.js diff --git a/src/lib/fuzzy.js b/src/lib/fuzzy.js new file mode 100644 index 0000000..4690e5f --- /dev/null +++ b/src/lib/fuzzy.js @@ -0,0 +1,30 @@ +/** Levenshtein edit distance between two strings. */ +export function editDistance(a, b) { + a = String(a); + b = String(b); + if (a === b) return 0; + if (!a.length) return b.length; + if (!b.length) return a.length; + let prev = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + const cur = [i]; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost); + } + prev = cur; + } + return prev[b.length]; +} + +/** + * Is `guess` close enough to `target` to count as the same word? Exact + * match always; otherwise a length-scaled typo tolerance (0 for very short + * names so "mew" ≠ "meh", up to 2 for long ones). + */ +export function closeEnough(guess, target) { + if (!guess || !target) return guess === target; + if (guess === target) return true; + const tol = target.length <= 4 ? 0 : target.length <= 7 ? 1 : 2; + return editDistance(guess, target) <= tol; +} diff --git a/src/store/whosThat.js b/src/store/whosThat.js index e352613..036d611 100644 --- a/src/store/whosThat.js +++ b/src/store/whosThat.js @@ -1,7 +1,7 @@ import { createStore } from './createStore.js'; /** Score-keeping for the hidden "Who's that Pokémon?" game (#/whos-that). */ -export const whosThat = createStore('pdx.whosThat', { best: 0, plays: 0, wins: 0 }); +export const whosThat = createStore('pdx.whosThat', { best: 0, plays: 0, wins: 0, gen: 0 }); export function recordResult(win, streak) { whosThat.set((s) => ({ diff --git a/src/styles/layout.css b/src/styles/layout.css index 766c6e3..f3fabfe 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -3741,6 +3741,23 @@ max-width: 30rem; margin: 0 auto; } +.wtp__genrow { + display: flex; + align-items: center; + gap: 8px; + margin: 2px 0 12px; + font-size: 0.82rem; + color: var(--text-dim); +} +.wtp__gen { + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font: inherit; + font-size: 0.82rem; +} .wtp__score { display: flex; flex-wrap: wrap; @@ -3811,18 +3828,44 @@ gap: 8px; margin-top: 14px; } -.wtp__input { +.wtp__inputwrap { + position: relative; flex: 1 1 12rem; - padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); +} +.wtp__inputwrap:focus-within { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +.wtp__input { + width: 100%; + padding: 10px 14px; + border: none; + background: none; color: var(--text); font: inherit; } .wtp__input:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; + outline: none; +} +/* Ghost completion sits under the real text; the "typed" span is + transparent so the visible remainder lines up after the caret. */ +.wtp__ghost { + position: absolute; + inset: 0; + padding: 10px 14px; + font: inherit; + white-space: pre; + overflow: hidden; + pointer-events: none; +} +.wtp__ghost-typed { + color: transparent; +} +.wtp__ghost-rest { + color: var(--text-dim); } .wtp__feedback { min-height: 1.4em; diff --git a/src/views/WhosThatView.js b/src/views/WhosThatView.js index 302fcfd..6e8f8b2 100644 --- a/src/views/WhosThatView.js +++ b/src/views/WhosThatView.js @@ -3,31 +3,70 @@ import { loadSnapshot } from '../data/snapshot.js'; import { Sprite } from '../components/Sprite.js'; import { whosThat, recordResult } from '../store/whosThat.js'; import { prefersReducedMotion } from '../store/settings.js'; +import { closeEnough } from '../lib/fuzzy.js'; const loose = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); const pretty = (name) => name.replace(/-/g, ' '); /** - * Hidden mini-game — reachable by the Konami code (↑↑↓↓←→←→BA) or seven - * quick taps on the nav wordmark, and by the bare #/whos-that hash once - * you know it. Guess the Pokémon from its silhouette. + * Hidden mini-game — reachable by the Konami code (↑↑↓↓←→←→BA), seven quick + * taps on the dex feed's game title, or the bare #/whos-that hash. Guess + * the Pokémon from its silhouette; a light typo tolerance and a ghost-text + * completion (once your prefix is unambiguous) keep the typing painless. */ export async function WhosThatView() { const view = el('section', { class: 'view wtp' }); const snap = await loadSnapshot(); - const pool = snap.species; + const GENS = [...new Set(snap.species.map((s) => s.generation))] + .filter(Boolean) + .sort((a, b) => a - b); + + let genFilter = GENS.includes(whosThat.get().gen) ? whosThat.get().gen : 0; + let pool = []; + let NAMES = []; + function rebuildPool() { + pool = genFilter ? snap.species.filter((s) => s.generation === genFilter) : snap.species; + NAMES = pool.map((s) => ({ loose: loose(s.name), display: pretty(s.name) })); + } + rebuildPool(); let current = null; let revealed = false; let wrong = 0; let streak = 0; + const genSelect = el( + 'select', + { + class: 'wtp__gen', + 'aria-label': 'Which generations to pull from', + onchange: (e) => { + genFilter = Number(e.target.value) || 0; + whosThat.set((s) => ({ ...s, gen: genFilter })); + streak = 0; + rebuildPool(); + next(); + }, + }, + el('option', { value: '0', selected: genFilter === 0 }, 'All generations'), + ...GENS.map((g) => el('option', { value: String(g), selected: genFilter === g }, `Gen ${g}`)), + ); + const art = el('div', { class: 'wtp__art' }); const caption = el('div', { class: 'wtp__caption' }, 'Who’s that Pokémon?'); const hint = el('div', { class: 'wtp__hint' }); const feedback = el('div', { class: 'wtp__feedback' }); const scoreEl = el('div', { class: 'wtp__score' }); + const ghostTyped = el('span', { class: 'wtp__ghost-typed' }); + const ghostRest = el('span', { class: 'wtp__ghost-rest' }); + const ghost = el( + 'div', + { class: 'wtp__ghost', 'aria-hidden': 'true', hidden: true }, + ghostTyped, + ghostRest, + ); + const input = el('input', { class: 'wtp__input', type: 'text', @@ -36,10 +75,19 @@ export async function WhosThatView() { autocapitalize: 'off', autocorrect: 'off', spellcheck: 'false', + oninput: updateGhost, onkeydown: (e) => { - if (e.key === 'Enter') submitGuess(); + if (e.key === 'Enter') { + submitGuess(); + return; + } + const atEnd = + input.selectionStart === input.value.length && input.selectionStart === input.selectionEnd; + const wantsAccept = e.key === 'Tab' || e.key === 'End' || (e.key === 'ArrowRight' && atEnd); + if (wantsAccept && !ghost.hidden && acceptGhost()) e.preventDefault(); }, }); + const inputWrap = el('div', { class: 'wtp__inputwrap' }, ghost, input); const guessBtn = el( 'button', @@ -53,6 +101,42 @@ export async function WhosThatView() { ); const nextBtn = el('button', { class: 'button', type: 'button', onclick: next }, 'Next →'); + /** The one display name whose loose form starts with what's typed, else ''. */ + function currentCompletion() { + const raw = input.value; + if (!raw || revealed) return ''; + const key = loose(raw); + if (!key) return ''; + const hits = NAMES.filter((n) => n.loose.startsWith(key)); + if (hits.length !== 1) return ''; + const full = hits[0].display; + // Only when what's typed is a literal prefix of the pretty name — keeps + // us from mangling the space in "mr mime" etc. + if ( + full.length <= raw.length || + full.slice(0, raw.length).toLowerCase() !== raw.toLowerCase() + ) { + return ''; + } + return full; + } + + function updateGhost() { + const full = currentCompletion(); + ghost.hidden = !full; + if (!full) return; + ghostTyped.textContent = input.value; + ghostRest.textContent = full.slice(input.value.length); + } + + function acceptGhost() { + const full = currentCompletion(); + if (!full) return false; + input.value = full; + updateGhost(); + return true; + } + function renderScore() { const s = whosThat.get(); const pct = s.plays ? Math.round((s.wins / s.plays) * 100) : 0; @@ -64,7 +148,7 @@ export async function WhosThatView() { } function syncButtons() { - input.hidden = revealed; + inputWrap.hidden = revealed; guessBtn.hidden = revealed; revealBtn.hidden = revealed; nextBtn.hidden = !revealed; @@ -81,6 +165,7 @@ export async function WhosThatView() { feedback.textContent = ''; feedback.className = 'wtp__feedback'; input.value = ''; + updateGhost(); syncButtons(); input.focus(); } @@ -88,6 +173,7 @@ export async function WhosThatView() { function reveal(win) { revealed = true; art.classList.add('is-revealed'); + updateGhost(); caption.textContent = `It’s ${pretty(current.name)}! #${String(current.id).padStart(4, '0')}`; if (win) { streak += 1; @@ -112,12 +198,13 @@ export async function WhosThatView() { function submitGuess() { if (revealed || !input.value.trim()) return; - if (loose(input.value) === loose(current.name)) { + if (closeEnough(loose(input.value), loose(current.name))) { reveal(true); return; } wrong += 1; input.value = ''; + updateGhost(); feedback.className = 'wtp__feedback is-bad'; if (wrong === 1) { feedback.textContent = 'Nope — try again.'; @@ -140,11 +227,12 @@ export async function WhosThatView() { el('h1', {}, 'Who’s that Pokémon?'), el('p', {}, 'Name it from the silhouette. Three misses and it’s revealed.'), ), + el('div', { class: 'wtp__genrow' }, el('span', {}, 'Pull from'), genSelect), scoreEl, el('div', { class: 'wtp__stage' }, art), caption, hint, - el('div', { class: 'wtp__controls' }, input, guessBtn, revealBtn, nextBtn), + el('div', { class: 'wtp__controls' }, inputWrap, guessBtn, revealBtn, nextBtn), feedback, ); diff --git a/test/fuzzy.test.js b/test/fuzzy.test.js new file mode 100644 index 0000000..30fc9de --- /dev/null +++ b/test/fuzzy.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { editDistance, closeEnough } from '../src/lib/fuzzy.js'; + +describe('editDistance', () => { + it('counts insert / delete / substitute', () => { + expect(editDistance('', '')).toBe(0); + expect(editDistance('abc', 'abc')).toBe(0); + expect(editDistance('abc', 'ab')).toBe(1); + expect(editDistance('abc', 'abx')).toBe(1); + expect(editDistance('kitten', 'sitting')).toBe(3); + expect(editDistance('charizard', 'charizrd')).toBe(1); + }); +}); + +describe('closeEnough', () => { + it('accepts a small typo on a long name', () => { + expect(closeEnough('charizrd', 'charizard')).toBe(true); + expect(closeEnough('tyraniter', 'tyranitar')).toBe(true); + expect(closeEnough('gardevor', 'gardevoir')).toBe(true); + }); + + it('is strict on short names', () => { + expect(closeEnough('meh', 'mew')).toBe(false); + expect(closeEnough('mow', 'muk')).toBe(false); + expect(closeEnough('mew', 'mew')).toBe(true); + }); + + it("won't bridge two genuinely different names", () => { + expect(closeEnough('pikachu', 'raichu')).toBe(false); + expect(closeEnough('bulbasaur', 'ivysaur')).toBe(false); + }); +});