Who's that Pokémon?: fuzzy guessing + a generation filter

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-10 13:19:03 -04:00
parent 6c6158ad5e
commit 55d26f50ed
5 changed files with 206 additions and 13 deletions

30
src/lib/fuzzy.js Normal file
View File

@ -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;
}

View File

@ -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) => ({

View File

@ -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;

View File

@ -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' }, 'Whos 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 = `Its ${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', {}, 'Whos that Pokémon?'),
el('p', {}, 'Name it from the silhouette. Three misses and its 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,
);

32
test/fuzzy.test.js Normal file
View File

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