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';
|
||||
|
||||
/** 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) => ({
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
|
||||
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