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:
parent
6c6158ad5e
commit
55d26f50ed
30
src/lib/fuzzy.js
Normal file
30
src/lib/fuzzy.js
Normal 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;
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { createStore } from './createStore.js';
|
import { createStore } from './createStore.js';
|
||||||
|
|
||||||
/** Score-keeping for the hidden "Who's that Pokémon?" game (#/whos-that). */
|
/** 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) {
|
export function recordResult(win, streak) {
|
||||||
whosThat.set((s) => ({
|
whosThat.set((s) => ({
|
||||||
|
|||||||
@ -3741,6 +3741,23 @@
|
|||||||
max-width: 30rem;
|
max-width: 30rem;
|
||||||
margin: 0 auto;
|
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 {
|
.wtp__score {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@ -3811,18 +3828,44 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
.wtp__input {
|
.wtp__inputwrap {
|
||||||
|
position: relative;
|
||||||
flex: 1 1 12rem;
|
flex: 1 1 12rem;
|
||||||
padding: 10px 14px;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--surface);
|
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);
|
color: var(--text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
.wtp__input:focus-visible {
|
.wtp__input:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: none;
|
||||||
outline-offset: 1px;
|
}
|
||||||
|
/* 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 {
|
.wtp__feedback {
|
||||||
min-height: 1.4em;
|
min-height: 1.4em;
|
||||||
|
|||||||
@ -3,31 +3,70 @@ import { loadSnapshot } from '../data/snapshot.js';
|
|||||||
import { Sprite } from '../components/Sprite.js';
|
import { Sprite } from '../components/Sprite.js';
|
||||||
import { whosThat, recordResult } from '../store/whosThat.js';
|
import { whosThat, recordResult } from '../store/whosThat.js';
|
||||||
import { prefersReducedMotion } from '../store/settings.js';
|
import { prefersReducedMotion } from '../store/settings.js';
|
||||||
|
import { closeEnough } from '../lib/fuzzy.js';
|
||||||
|
|
||||||
const loose = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
const loose = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
const pretty = (name) => name.replace(/-/g, ' ');
|
const pretty = (name) => name.replace(/-/g, ' ');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hidden mini-game — reachable by the Konami code (↑↑↓↓←→←→BA) or seven
|
* Hidden mini-game — reachable by the Konami code (↑↑↓↓←→←→BA), seven quick
|
||||||
* quick taps on the nav wordmark, and by the bare #/whos-that hash once
|
* taps on the dex feed's game title, or the bare #/whos-that hash. Guess
|
||||||
* you know it. Guess the Pokémon from its silhouette.
|
* 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() {
|
export async function WhosThatView() {
|
||||||
const view = el('section', { class: 'view wtp' });
|
const view = el('section', { class: 'view wtp' });
|
||||||
const snap = await loadSnapshot();
|
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 current = null;
|
||||||
let revealed = false;
|
let revealed = false;
|
||||||
let wrong = 0;
|
let wrong = 0;
|
||||||
let streak = 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 art = el('div', { class: 'wtp__art' });
|
||||||
const caption = el('div', { class: 'wtp__caption' }, 'Who’s that Pokémon?');
|
const caption = el('div', { class: 'wtp__caption' }, 'Who’s that Pokémon?');
|
||||||
const hint = el('div', { class: 'wtp__hint' });
|
const hint = el('div', { class: 'wtp__hint' });
|
||||||
const feedback = el('div', { class: 'wtp__feedback' });
|
const feedback = el('div', { class: 'wtp__feedback' });
|
||||||
const scoreEl = el('div', { class: 'wtp__score' });
|
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', {
|
const input = el('input', {
|
||||||
class: 'wtp__input',
|
class: 'wtp__input',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@ -36,10 +75,19 @@ export async function WhosThatView() {
|
|||||||
autocapitalize: 'off',
|
autocapitalize: 'off',
|
||||||
autocorrect: 'off',
|
autocorrect: 'off',
|
||||||
spellcheck: 'false',
|
spellcheck: 'false',
|
||||||
|
oninput: updateGhost,
|
||||||
onkeydown: (e) => {
|
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(
|
const guessBtn = el(
|
||||||
'button',
|
'button',
|
||||||
@ -53,6 +101,42 @@ export async function WhosThatView() {
|
|||||||
);
|
);
|
||||||
const nextBtn = el('button', { class: 'button', type: 'button', onclick: next }, 'Next →');
|
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() {
|
function renderScore() {
|
||||||
const s = whosThat.get();
|
const s = whosThat.get();
|
||||||
const pct = s.plays ? Math.round((s.wins / s.plays) * 100) : 0;
|
const pct = s.plays ? Math.round((s.wins / s.plays) * 100) : 0;
|
||||||
@ -64,7 +148,7 @@ export async function WhosThatView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncButtons() {
|
function syncButtons() {
|
||||||
input.hidden = revealed;
|
inputWrap.hidden = revealed;
|
||||||
guessBtn.hidden = revealed;
|
guessBtn.hidden = revealed;
|
||||||
revealBtn.hidden = revealed;
|
revealBtn.hidden = revealed;
|
||||||
nextBtn.hidden = !revealed;
|
nextBtn.hidden = !revealed;
|
||||||
@ -81,6 +165,7 @@ export async function WhosThatView() {
|
|||||||
feedback.textContent = '';
|
feedback.textContent = '';
|
||||||
feedback.className = 'wtp__feedback';
|
feedback.className = 'wtp__feedback';
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
updateGhost();
|
||||||
syncButtons();
|
syncButtons();
|
||||||
input.focus();
|
input.focus();
|
||||||
}
|
}
|
||||||
@ -88,6 +173,7 @@ export async function WhosThatView() {
|
|||||||
function reveal(win) {
|
function reveal(win) {
|
||||||
revealed = true;
|
revealed = true;
|
||||||
art.classList.add('is-revealed');
|
art.classList.add('is-revealed');
|
||||||
|
updateGhost();
|
||||||
caption.textContent = `It’s ${pretty(current.name)}! #${String(current.id).padStart(4, '0')}`;
|
caption.textContent = `It’s ${pretty(current.name)}! #${String(current.id).padStart(4, '0')}`;
|
||||||
if (win) {
|
if (win) {
|
||||||
streak += 1;
|
streak += 1;
|
||||||
@ -112,12 +198,13 @@ export async function WhosThatView() {
|
|||||||
|
|
||||||
function submitGuess() {
|
function submitGuess() {
|
||||||
if (revealed || !input.value.trim()) return;
|
if (revealed || !input.value.trim()) return;
|
||||||
if (loose(input.value) === loose(current.name)) {
|
if (closeEnough(loose(input.value), loose(current.name))) {
|
||||||
reveal(true);
|
reveal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
wrong += 1;
|
wrong += 1;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
updateGhost();
|
||||||
feedback.className = 'wtp__feedback is-bad';
|
feedback.className = 'wtp__feedback is-bad';
|
||||||
if (wrong === 1) {
|
if (wrong === 1) {
|
||||||
feedback.textContent = 'Nope — try again.';
|
feedback.textContent = 'Nope — try again.';
|
||||||
@ -140,11 +227,12 @@ export async function WhosThatView() {
|
|||||||
el('h1', {}, 'Who’s that Pokémon?'),
|
el('h1', {}, 'Who’s that Pokémon?'),
|
||||||
el('p', {}, 'Name it from the silhouette. Three misses and it’s revealed.'),
|
el('p', {}, 'Name it from the silhouette. Three misses and it’s revealed.'),
|
||||||
),
|
),
|
||||||
|
el('div', { class: 'wtp__genrow' }, el('span', {}, 'Pull from'), genSelect),
|
||||||
scoreEl,
|
scoreEl,
|
||||||
el('div', { class: 'wtp__stage' }, art),
|
el('div', { class: 'wtp__stage' }, art),
|
||||||
caption,
|
caption,
|
||||||
hint,
|
hint,
|
||||||
el('div', { class: 'wtp__controls' }, input, guessBtn, revealBtn, nextBtn),
|
el('div', { class: 'wtp__controls' }, inputWrap, guessBtn, revealBtn, nextBtn),
|
||||||
feedback,
|
feedback,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
32
test/fuzzy.test.js
Normal file
32
test/fuzzy.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user