Compare commits

..

3 Commits

Author SHA1 Message Date
fb299d11f9 Hidden mini-game: "Who's that Pokémon?"
Guess the Pokémon from its silhouette (official artwork, filter:brightness(0)
until revealed). Streak + best + win% persisted in pdx.whosThat. Three
misses auto-reveal; hints (letter count, then first letter) after wrong
guesses; guess matching is punctuation/space/case-insensitive.

Not in the nav or any link. Reached by the Konami code (↑↑↓↓←→←→BA,
lib/konami.js wired in main.js), seven quick taps on the nav wordmark, or
the bare #/whos-that hash once you know it.

9 new unit tests for the konami matcher (sequence, case-insensitivity,
rewind, partial).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 12:34:45 -04:00
bbc4cb48ca Save import: read the Gen 2 Unown Report
Gen 2 (GSC) is the one mainline dex that records which Unown letters
you've caught — a 26-byte ordered list right after the Seen flag array
(seen + 0x20). Parse it, map letters to the app's Form Dex slugs
('unown' for A, 'unown-b'…'unown-z'), and tick them via a new
markFormsCaught() on the formTracking store. A garbage/out-of-range list
is ignored rather than guessed at.

The import dialog and result line now surface "· N Unown letters".

Verified: 6 new unit tests (synthetic Gen 2 SRAM → slugs, empty list,
garbage rejection; markFormsCaught behaviour) + a real end-to-end import
of a synthetic Crystal save writing {unown, unown-c, unown-o, unown-z}
to pdx.formTracking.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 12:21:41 -04:00
3f887b2f8a Add a National reset; replace native confirm()/alert() with the app dialog
- selection: clearAllGames() wipes seen/caught for every game but keeps
  the global favorites/notes record.
- Progress view: a "Reset caught / seen…" action (custom dialog) that
  calls clearAllGames — the soft, favorites-preserving reset. Settings →
  "Clear tracking data" stays as the full wipe (now spells out that it
  also drops favorites and notes, and points here for the softer one).
- Swept the remaining window.confirm / window.alert calls
  (Settings clear-tracking, clear-cache, backup import result;
  ShinyView delete-hunt) onto chooseDialog for a consistent look.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 11:58:36 -04:00
17 changed files with 637 additions and 35 deletions

View File

@ -126,7 +126,9 @@ Every game's seen/caught tally against its own dex, from its own per-game
bucket. Leads with the games you've played (selected at least once, or
imported a save for); the rest sit behind a toggle. The National Dex row is
the union across all games. **Edit** the "Your games" list to reset a
game's caught/seen or drop it from the list.
game's caught/seen or drop it from the list; **Reset caught / seen…** wipes
every game at once (favorites and notes are kept — only Settings →
_Clear tracking data_ removes those).
### Shiny hunt tracker (`#/shiny`)
@ -140,13 +142,13 @@ marks the Pokémon caught.
**Settings → Import from a game save.** Reads the game's own seen/owned
bitfields — not an approximation — from:
| Gen | Games | Format |
| --- | -------------------------------------------------- | -------------------------------------------------------- |
| 1 | Red / Blue / Yellow | 32 KB SRAM (`.sav` / `.srm`) |
| 2 | Gold / Silver / Crystal | 32 KB SRAM — checksum picks G/S vs Crystal |
| 3 | Ruby / Sapphire / Emerald, FireRed / LeafGreen | 128 KB — newer slot, rotated sections |
| 4 | Diamond / Pearl / Platinum, HeartGold / SoulSilver | 512 KB NDS — active slot, DeSmuME `.dsv` footer stripped |
| 5 | Black / White, Black 2 / White 2 | 512 KB NDS — per-form "seen" copies OR'd |
| Gen | Games | Format |
| --- | -------------------------------------------------- | ----------------------------------------------------------------------- |
| 1 | Red / Blue / Yellow | 32 KB SRAM (`.sav` / `.srm`) |
| 2 | Gold / Silver / Crystal | 32 KB SRAM — checksum picks G/S vs Crystal; also reads the Unown Report |
| 3 | Ruby / Sapphire / Emerald, FireRed / LeafGreen | 128 KB — newer slot, rotated sections |
| 4 | Diamond / Pearl / Platinum, HeartGold / SoulSilver | 512 KB NDS — active slot, DeSmuME `.dsv` footer stripped |
| 5 | Black / White, Black 2 / White 2 | 512 KB NDS — per-form "seen" copies OR'd |
Gen 13 are checksum-verified; Gen 45 offsets come from PKHeX and are
validated structurally (nothing set past the last species; every caught
@ -154,6 +156,11 @@ species is also seen). A dialog then offers **merge** or **replace** into
one game's bucket — the selected game if the save could be it, otherwise
the parser's best guess.
The dex bitfields are per-species, so form sets (Unown letters, Vivillon
patterns…) can't be recovered from them — **except** the Gen 2 Unown
Report, which records the letters you've caught; a Gen 2 import ticks
those in the per-letter Form Dex.
Full local state (settings, per-game tracking, team, form tracking, shiny
hunts, played games) also exports / imports as a single JSON file.

View File

@ -25,6 +25,20 @@ export function Nav() {
const nav = el('nav', { class: 'nav', 'aria-label': 'Primary' });
let links = [];
// Easter egg: seven quick taps on the wordmark opens the hidden game
// (the Konami code does the same on a keyboard).
let taps = 0;
let tapTimer = null;
function bumpBrand() {
taps += 1;
clearTimeout(tapTimer);
tapTimer = setTimeout(() => (taps = 0), 1500);
if (taps >= 7) {
taps = 0;
location.hash = '#/whos-that';
}
}
function build() {
const st = settings.get();
const items = ITEMS.filter((it) => !it.optional || st[it.optional]);
@ -41,7 +55,10 @@ export function Nav() {
)
: el('a', { class: 'nav__link', href: item.href }, ...inner);
});
nav.replaceChildren(el('span', { class: 'nav__brand' }, 'Pocketdex'), ...links);
nav.replaceChildren(
el('span', { class: 'nav__brand', onclick: bumpBrand }, 'Pocketdex'),
...links,
);
nav._items = items;
sync();
}

42
src/lib/konami.js Normal file
View File

@ -0,0 +1,42 @@
/** ↑ ↑ ↓ ↓ ← → ← → B A */
export const KONAMI_SEQUENCE = [
'arrowup',
'arrowup',
'arrowdown',
'arrowdown',
'arrowleft',
'arrowright',
'arrowleft',
'arrowright',
'b',
'a',
];
/**
* Returns a `(key) => void` that calls `onUnlock` once the full sequence
* has been entered in order. A wrong key rewinds (but a stray "↑" still
* counts as the start of a fresh attempt). Pure no DOM.
*/
export function konamiHandler(onUnlock) {
let i = 0;
return (key) => {
const k = String(key).toLowerCase();
if (k === KONAMI_SEQUENCE[i]) {
i += 1;
if (i === KONAMI_SEQUENCE.length) {
i = 0;
onUnlock();
}
} else {
i = k === KONAMI_SEQUENCE[0] ? 1 : 0;
}
};
}
/** Wire {@link konamiHandler} to window keydowns. Returns an unsubscribe. */
export function onKonami(onUnlock) {
const step = konamiHandler(onUnlock);
const listener = (e) => step(e.key);
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
}

View File

@ -122,10 +122,14 @@ function tryGen1(bytes) {
* ------------------------------------------------------------------ */
const G2 = {
// `unown` is the Unown Report list — the letter forms caught, in
// first-caught order, one byte each (1 = A … 26 = Z), zero-terminated. It
// sits right after the 32-byte Seen flag array (`unown === seen + 0x20`).
gs: {
name: 0x200b,
owned: 0x2a4c,
seen: 0x2a6c,
unown: 0x2a8c,
ck: 0x2d69,
ckFrom: 0x2009,
ckTo: 0x2d68,
@ -136,6 +140,7 @@ const G2 = {
name: 0x2009,
owned: 0x2a27,
seen: 0x2a47,
unown: 0x2a67,
ck: 0x2d0d,
ckFrom: 0x2009,
ckTo: 0x2b82,
@ -145,6 +150,32 @@ const G2 = {
};
const G2_SPECIES = 251;
// Letter index (1 = A) → the app's cosmetic-form slug. The base "A" form is
// tracked under the bare species slug; B…Z are `unown-b` … `unown-z`.
const unownSlug = (n) => (n === 1 ? 'unown' : `unown-${String.fromCharCode(96 + n)}`);
/**
* Read the Gen 2 Unown Report. Returns the caught letters as form slugs, or
* `null` if the bytes don't look like a valid ordered list (wrong offset,
* corruption) a species-level import shouldn't tick form boxes on a guess.
*/
function readUnownDex(bytes, offset) {
const slugs = [];
const seen = new Set();
let ended = false;
for (let i = 0; i < 26; i++) {
const b = bytes[offset + i];
if (b === 0) {
ended = true;
continue;
}
if (ended || b > 26 || seen.has(b)) return null;
seen.add(b);
slugs.push(unownSlug(b));
}
return slugs;
}
function tryGen2Variant(bytes, v) {
const caught = bitsToList(bytes, v.owned, G2_SPECIES);
const seen = bitsToList(bytes, v.seen, G2_SPECIES);
@ -162,6 +193,9 @@ function tryGen2Variant(bytes, v) {
trainer: decodeName(bytes.subarray(v.name, v.name + 11), GB_CHARS, 0x50),
seen,
caught,
// Gen 2 is the only mainline dex that records which Unown letters you
// have — tick them in the app's per-letter Form Dex.
formCaught: readUnownDex(bytes, v.unown) || [],
nationalUnlocked: true,
checksumOk,
};
@ -387,7 +421,7 @@ function tryGen5(raw) {
/**
* @param {ArrayBuffer} buffer
* @returns {{ gen:number, game:string, versionGroups:string[], trainer:string, seen:number[], caught:number[], nationalUnlocked:boolean }}
* @returns {{ gen:number, game:string, versionGroups:string[], trainer:string, seen:number[], caught:number[], formCaught?:string[], nationalUnlocked:boolean }}
* @throws {Error} with a user-facing message if it can't be read.
*/
export function parseSaveDex(buffer) {

View File

@ -9,6 +9,7 @@ import { settings, applyTheme } from './store/settings.js';
import { ui } from './store/ui.js';
import { markPlayed } from './store/playedGames.js';
import { onInstallChange, promptInstall } from './lib/install.js';
import { onKonami } from './lib/konami.js';
applyTheme();
@ -51,6 +52,11 @@ app.append(Nav(), viewHost);
initRouter(viewHost);
// --- Easter egg: Konami code jumps to the hidden mini-game --------------
onKonami(() => {
if ((location.hash || '') !== '#/whos-that') location.hash = '#/whos-that';
});
// --- Service worker: prompt to refresh rather than silently swapping ------
const updateSW = registerSW({
onNeedRefresh() {

View File

@ -13,6 +13,7 @@ import { CompareView } from './views/CompareView.js';
import { ProgressView } from './views/ProgressView.js';
import { BreedingView } from './views/BreedingView.js';
import { ShinyView } from './views/ShinyView.js';
import { WhosThatView } from './views/WhosThatView.js';
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
import { prefersReducedMotion } from './store/settings.js';
@ -39,6 +40,7 @@ const routes = [
{ pattern: /^#\/shiny$/, view: () => ShinyView() },
{ pattern: /^#\/search$/, view: () => SearchView() },
{ pattern: /^#\/settings$/, view: () => SettingsView() },
{ pattern: /^#\/whos-that$/, view: () => WhosThatView() }, // hidden — Konami / logo taps
];
function resolve(hash) {

View File

@ -18,3 +18,13 @@ export function toggleFormCaught(slug) {
caught: { ...s.caught, [slug]: !s.caught[slug] },
}));
}
/** Mark a batch of form slugs caught (e.g. Unown letters from a save import). Never un-marks. */
export function markFormsCaught(slugs) {
if (!slugs || !slugs.length) return;
formTracking.set((s) => {
const caught = { ...s.caught };
for (const slug of slugs) caught[slug] = true;
return { ...s, caught };
});
}

View File

@ -149,6 +149,15 @@ export function clearGame(vgKey) {
});
}
/**
* Wipe seen/caught for every game (a "National Dex reset"). Favorites and
* notes the global `pokemon` record are kept. Use `selection.replace`
* for the full wipe that also drops those.
*/
export function clearAllGames() {
selection.set((s) => ({ ...s, games: {} }));
}
/** Aggregate seen/caught over a species list for one game (union when 'all'). */
export function stats(speciesIds, vgKey = curGame()) {
const get = vgKey === 'all' ? unionEntry : (id) => selection.get().games[vgKey]?.[id] || GBLANK;

13
src/store/whosThat.js Normal file
View File

@ -0,0 +1,13 @@
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 function recordResult(win, streak) {
whosThat.set((s) => ({
...s,
plays: s.plays + 1,
wins: s.wins + (win ? 1 : 0),
best: Math.max(s.best, streak),
}));
}

View File

@ -3382,6 +3382,13 @@
color: var(--text-dim);
text-align: right;
}
.prog__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.prog__legend {
display: flex;
align-items: center;
@ -3389,6 +3396,19 @@
font-size: 0.75rem;
color: var(--text-dim);
}
.prog__resetall {
border: none;
background: none;
font: inherit;
font-size: 0.78rem;
font-weight: 600;
color: var(--danger);
cursor: pointer;
padding: 4px 0;
}
.prog__resetall:hover {
text-decoration: underline;
}
.prog__key {
display: inline-block;
width: 1.1rem;
@ -3715,3 +3735,103 @@
width: 100%;
justify-content: center;
}
/* ---- Hidden mini-game: Who's that Pokémon? (#/whos-that) ---------- */
.wtp {
max-width: 30rem;
margin: 0 auto;
}
.wtp__score {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 4px 0 14px;
}
.wtp__pill {
padding: 4px 10px;
border-radius: 999px;
background: var(--surface-2);
font-size: 0.78rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.wtp__pill--dim {
background: none;
color: var(--text-dim);
font-weight: 600;
}
.wtp__stage {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 22px 16px;
border-radius: var(--radius-lg);
background:
radial-gradient(
60% 55% at 50% 42%,
color-mix(in srgb, var(--accent) 22%, transparent),
transparent 70%
),
var(--surface);
border: 1px solid var(--border);
}
.wtp__art {
width: 260px;
max-width: 78vw;
aspect-ratio: 1;
display: grid;
place-items: center;
}
.wtp__art img {
width: 100%;
height: 100%;
object-fit: contain;
filter: brightness(0);
transition: filter 0.5s ease;
}
.wtp__art.is-revealed img {
filter: none;
}
.wtp__caption {
font-weight: 800;
font-size: 1.05rem;
text-align: center;
letter-spacing: 0.01em;
}
.wtp__hint {
min-height: 1.1em;
font-size: 0.82rem;
color: var(--text-dim);
}
.wtp__controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.wtp__input {
flex: 1 1 12rem;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
font: inherit;
}
.wtp__input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.wtp__feedback {
min-height: 1.4em;
margin-top: 10px;
font-weight: 700;
text-align: center;
}
.wtp__feedback.is-good {
color: var(--good);
}
.wtp__feedback.is-bad {
color: var(--danger);
}

View File

@ -1,7 +1,7 @@
import { el, clear, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { selection, stats, statsNational, clearGame } from '../store/selection.js';
import { selection, stats, statsNational, clearGame, clearAllGames } from '../store/selection.js';
import { ui } from '../store/ui.js';
import { playedGames, isPlayed, unmarkPlayed } from '../store/playedGames.js';
import { dexesForVersionGroup, versionGroupsByGeneration } from '../data/pokedex-resolver.js';
@ -172,6 +172,23 @@ export async function ProgressView() {
}
render();
async function resetEverything() {
const nat = statsNational(snap.species.map((s) => s.id));
if (!nat.seen && !nat.caught) return;
const pick = await chooseDialog({
title: 'Reset caught / seen',
body: `Clear all ${nat.caught} caught and ${nat.seen} seen, across every game? Favorites and notes are kept.`,
choices: [
{ key: 'reset', label: 'Reset everything', class: 'button--danger' },
{ key: null, label: 'Cancel', class: 'button--ghost' },
],
});
if (pick === 'reset') {
clearAllGames();
render();
}
}
clear(view).append(
el(
'nav',
@ -185,12 +202,21 @@ export async function ProgressView() {
el('p', {}, 'What youve caught in each game. Tap one to switch to it.'),
),
el(
'p',
{ class: 'prog__legend' },
el('span', { class: 'prog__key prog__key--caught' }),
' caught ',
el('span', { class: 'prog__key prog__key--seen' }),
' seen',
'div',
{ class: 'prog__bar' },
el(
'p',
{ class: 'prog__legend' },
el('span', { class: 'prog__key prog__key--caught' }),
' caught ',
el('span', { class: 'prog__key prog__key--seen' }),
' seen',
),
el(
'button',
{ type: 'button', class: 'prog__resetall', onclick: resetEverything },
'Reset caught / seen…',
),
),
list,
);

View File

@ -4,7 +4,7 @@ import { selection, importFlags, replaceFlags, normalizeSelection } from '../sto
import { prettify } from '../data/pokedex-resolver.js';
import { chooseDialog } from '../lib/dialog.js';
import { team } from '../store/team.js';
import { formTracking } from '../store/formTracking.js';
import { formTracking, markFormsCaught } from '../store/formTracking.js';
import { shinyHunts } from '../store/shinyHunts.js';
import { playedGames, markPlayed } from '../store/playedGames.js';
import { parseSaveDex } from '../lib/savedex.js';
@ -180,9 +180,11 @@ export async function SettingsView() {
const target = cands.includes(settings.get().versionGroup)
? settings.get().versionGroup
: cands[0];
const unown = dex.formCaught?.length || 0;
const unownLine = unown ? ` · ${unown} Unown letter${unown === 1 ? '' : 's'}` : '';
const choice = await chooseDialog({
title: `${dex.game}${dex.trainer ? ` · ${dex.trainer}` : ''}`,
body: `${dex.seen.length} seen · ${dex.caught.length} caught in this save.`,
body: `${dex.seen.length} seen · ${dex.caught.length} caught${unownLine} in this save.`,
choices: [
{ key: 'merge', label: `Add to ${prettify(target)}` },
{
@ -195,12 +197,14 @@ export async function SettingsView() {
});
if (choice === 'merge') {
importFlags({ seen: dex.seen, caught: dex.caught, game: target });
markFormsCaught(dex.formCaught);
markPlayed(...cands);
saveNote.textContent = `Added ${dex.caught.length} caught / ${dex.seen.length} seen to ${prettify(target)}.`;
saveNote.textContent = `Added ${dex.caught.length} caught / ${dex.seen.length} seen${unownLine} to ${prettify(target)}.`;
} else if (choice === 'replace') {
replaceFlags({ seen: dex.seen, caught: dex.caught, game: target });
markFormsCaught(dex.formCaught);
markPlayed(...cands);
saveNote.textContent = `Set ${prettify(target)}'s Pokédex from ${who}.`;
saveNote.textContent = `Set ${prettify(target)}'s Pokédex from ${who}${unownLine ? ` (${unown} Unown letters)` : ''}.`;
} else {
saveNote.textContent = '';
}
@ -223,9 +227,17 @@ export async function SettingsView() {
if (data.formTracking) formTracking.replace(data.formTracking);
if (data.shinyHunts) shinyHunts.replace(data.shinyHunts);
if (data.playedGames) playedGames.replace(data.playedGames);
alert('Import complete.');
await chooseDialog({
title: 'Import complete',
body: 'Your backup was restored.',
choices: [{ key: null, label: 'OK' }],
});
} catch {
alert('That file could not be read as a Pokédex backup.');
await chooseDialog({
title: 'Couldnt read that file',
body: 'It doesnt look like a Pocketdex backup.',
choices: [{ key: null, label: 'OK' }],
});
}
},
});
@ -322,10 +334,16 @@ export async function SettingsView() {
{
class: 'button button--danger',
type: 'button',
onclick: () => {
if (confirm('Clear all seen/caught/favorite tracking? This cannot be undone.')) {
selection.replace({ version: 3, pokemon: {}, games: {} });
}
onclick: async () => {
const pick = await chooseDialog({
title: 'Clear all tracking data',
body: 'Removes seen/caught for every game plus all favorites and notes, and cant be undone. To keep favorites and notes, use “Reset caught / seen” on the Progress screen instead.',
choices: [
{ key: 'clear', label: 'Clear everything', class: 'button--danger' },
{ key: null, label: 'Cancel', class: 'button--ghost' },
],
});
if (pick === 'clear') selection.replace({ version: 3, pokemon: {}, games: {} });
},
},
'Clear tracking data',
@ -337,12 +355,21 @@ export async function SettingsView() {
type: 'button',
onclick: async () => {
if (!('caches' in window)) return;
if (
confirm('Clear cached PokéAPI data and sprites? They will re-download when online.')
) {
for (const key of await caches.keys()) await caches.delete(key);
alert('Caches cleared.');
}
const pick = await chooseDialog({
title: 'Clear cached data',
body: 'Drops cached PokéAPI responses and sprites. They re-download next time youre online.',
choices: [
{ key: 'clear', label: 'Clear cache', class: 'button--danger' },
{ key: null, label: 'Cancel', class: 'button--ghost' },
],
});
if (pick !== 'clear') return;
for (const key of await caches.keys()) await caches.delete(key);
await chooseDialog({
title: 'Cache cleared',
body: '',
choices: [{ key: null, label: 'OK' }],
});
},
},
'Clear cached API data',

View File

@ -6,6 +6,7 @@ import { toggle, entry } from '../store/selection.js';
import { buzz } from '../lib/haptics.js';
import { Sprite } from '../components/Sprite.js';
import { openPokemonPicker } from '../components/PokemonPicker.js';
import { chooseDialog } from '../lib/dialog.js';
// Representative full-game odds (as 1/N), with a shiny-charm variant.
export const METHODS = [
@ -131,8 +132,16 @@ export async function ShinyView() {
{
type: 'button',
class: 'button button--danger',
onclick: () => {
if (confirm('Delete this hunt?')) {
onclick: async () => {
const pick = await chooseDialog({
title: 'Delete this hunt?',
body: `${(sp?.name || 'This hunt').replace(/-/g, ' ')}${h.count} checks logged.`,
choices: [
{ key: 'del', label: 'Delete', class: 'button--danger' },
{ key: null, label: 'Cancel', class: 'button--ghost' },
],
});
if (pick === 'del') {
removeHunt(h.id);
render();
}

157
src/views/WhosThatView.js Normal file
View File

@ -0,0 +1,157 @@
import { el, clear, onTeardown } from '../lib/dom.js';
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';
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.
*/
export async function WhosThatView() {
const view = el('section', { class: 'view wtp' });
const snap = await loadSnapshot();
const pool = snap.species;
let current = null;
let revealed = false;
let wrong = 0;
let streak = 0;
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 input = el('input', {
class: 'wtp__input',
type: 'text',
placeholder: 'Your guess…',
autocomplete: 'off',
autocapitalize: 'off',
autocorrect: 'off',
spellcheck: 'false',
onkeydown: (e) => {
if (e.key === 'Enter') submitGuess();
},
});
const guessBtn = el(
'button',
{ class: 'button wtp__go', type: 'button', onclick: submitGuess },
'Guess',
);
const revealBtn = el(
'button',
{ class: 'button button--ghost', type: 'button', onclick: () => reveal(false) },
'Reveal',
);
const nextBtn = el('button', { class: 'button', type: 'button', onclick: next }, 'Next →');
function renderScore() {
const s = whosThat.get();
const pct = s.plays ? Math.round((s.wins / s.plays) * 100) : 0;
clear(scoreEl).append(
el('span', { class: 'wtp__pill' }, `Streak ${streak}`),
el('span', { class: 'wtp__pill' }, `Best ${s.best}`),
el('span', { class: 'wtp__pill wtp__pill--dim' }, `${s.wins}/${s.plays} · ${pct}%`),
);
}
function syncButtons() {
input.hidden = revealed;
guessBtn.hidden = revealed;
revealBtn.hidden = revealed;
nextBtn.hidden = !revealed;
}
function next() {
current = pool[Math.floor(Math.random() * pool.length)];
revealed = false;
wrong = 0;
art.classList.remove('is-revealed');
clear(art).append(Sprite(current.id, { style: 'official', size: 260, alt: 'Mystery Pokémon' }));
caption.textContent = 'Whos that Pokémon?';
hint.textContent = '';
feedback.textContent = '';
feedback.className = 'wtp__feedback';
input.value = '';
syncButtons();
input.focus();
}
function reveal(win) {
revealed = true;
art.classList.add('is-revealed');
caption.textContent = `Its ${pretty(current.name)}! #${String(current.id).padStart(4, '0')}`;
if (win) {
streak += 1;
feedback.textContent = wrong === 0 ? 'Nailed it!' : 'Got there!';
feedback.className = 'wtp__feedback is-good';
} else {
streak = 0;
feedback.textContent = 'That one got away.';
feedback.className = 'wtp__feedback is-bad';
}
recordResult(win, streak);
renderScore();
syncButtons();
if (win && !prefersReducedMotion()) {
art.animate(
[{ transform: 'scale(0.9)' }, { transform: 'scale(1.05)' }, { transform: 'scale(1)' }],
{ duration: 380, easing: 'cubic-bezier(.34,1.4,.64,1)' },
);
}
nextBtn.focus();
}
function submitGuess() {
if (revealed || !input.value.trim()) return;
if (loose(input.value) === loose(current.name)) {
reveal(true);
return;
}
wrong += 1;
input.value = '';
feedback.className = 'wtp__feedback is-bad';
if (wrong === 1) {
feedback.textContent = 'Nope — try again.';
hint.textContent = `${loose(current.name).length} letters`;
} else if (wrong === 2) {
feedback.textContent = 'Last clue…';
hint.textContent = `Starts with “${current.name[0].toUpperCase()}”, ${loose(current.name).length} letters`;
} else {
reveal(false); // three strikes
return;
}
input.focus();
}
view.append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/' }, ' Dex')),
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Whos that Pokémon?'),
el('p', {}, 'Name it from the silhouette. Three misses and its revealed.'),
),
scoreEl,
el('div', { class: 'wtp__stage' }, art, caption, hint),
el('div', { class: 'wtp__controls' }, input, guessBtn, revealBtn, nextBtn),
feedback,
);
renderScore();
next();
const off = whosThat.subscribe(() => {
if (revealed) renderScore();
});
onTeardown(view, off);
return view;
}

37
test/formTracking.test.js Normal file
View File

@ -0,0 +1,37 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
formTracking,
isFormCaught,
toggleFormCaught,
markFormsCaught,
} from '../src/store/formTracking.js';
beforeEach(() => {
localStorage.clear();
formTracking.replace({ caught: {} });
});
describe('formTracking', () => {
it('toggle flips a slug', () => {
expect(isFormCaught('unown-c')).toBe(false);
toggleFormCaught('unown-c');
expect(isFormCaught('unown-c')).toBe(true);
toggleFormCaught('unown-c');
expect(isFormCaught('unown-c')).toBe(false);
});
it('markFormsCaught sets a batch and never un-marks', () => {
toggleFormCaught('unown-z'); // already caught
markFormsCaught(['unown', 'unown-c', 'unown-z']);
expect(isFormCaught('unown')).toBe(true);
expect(isFormCaught('unown-c')).toBe(true);
expect(isFormCaught('unown-z')).toBe(true); // stays caught
expect(isFormCaught('unown-q')).toBe(false); // untouched
});
it('markFormsCaught tolerates empty / missing input', () => {
markFormsCaught([]);
markFormsCaught(undefined);
expect(formTracking.get().caught).toEqual({});
});
});

48
test/konami.test.js Normal file
View File

@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { konamiHandler, KONAMI_SEQUENCE } from '../src/lib/konami.js';
describe('konamiHandler', () => {
it('fires once the full sequence is entered', () => {
let hits = 0;
const step = konamiHandler(() => hits++);
KONAMI_SEQUENCE.forEach((k) => step(k));
expect(hits).toBe(1);
});
it('is case-insensitive and fires again on a repeat', () => {
let hits = 0;
const step = konamiHandler(() => hits++);
const upper = [
'ArrowUp',
'ArrowUp',
'ArrowDown',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowLeft',
'ArrowRight',
'B',
'A',
];
upper.forEach((k) => step(k));
upper.forEach((k) => step(k));
expect(hits).toBe(2);
});
it('a wrong key rewinds, but a stray ↑ starts a fresh attempt', () => {
let hits = 0;
const step = konamiHandler(() => hits++);
step('arrowup');
step('arrowdown'); // wrong (expected another arrowup) -> reset
step('x'); // noise
KONAMI_SEQUENCE.forEach((k) => step(k)); // clean run
expect(hits).toBe(1);
});
it('does not fire on a partial sequence', () => {
let hits = 0;
const step = konamiHandler(() => hits++);
KONAMI_SEQUENCE.slice(0, -1).forEach((k) => step(k));
expect(hits).toBe(0);
});
});

View File

@ -59,3 +59,41 @@ describe('parseSaveDex — Gen 1', () => {
expect(() => parseSaveDex(new Uint8Array(0x20000).buffer)).toThrow();
});
});
// --- Synthetic Gen 2 (Crystal) SRAM ---------------------------------
// Offsets mirror src/lib/savedex.js `G2.cr`.
const G2 = { owned: 0x2a27, seen: 0x2a47, unown: 0x2a67, ck: 0x2d0d, ckFrom: 0x2009, ckTo: 0x2b82 };
function makeGen2Save({ caught, seen, unown = [] }) {
const bytes = new Uint8Array(0x8000);
setBits(bytes, G2.owned, caught);
setBits(bytes, G2.seen, seen);
unown.forEach((letter, i) => (bytes[G2.unown + i] = letter)); // 1 = A … 26 = Z
let sum = 0;
for (let i = G2.ckFrom; i <= G2.ckTo; i++) sum = (sum + bytes[i]) & 0xffffffff;
bytes[G2.ck] = sum & 0xff;
bytes[G2.ck + 1] = (sum >> 8) & 0xff;
return bytes.buffer;
}
describe('parseSaveDex — Gen 2 Unown Report', () => {
it('turns the caught-letter list into Form Dex slugs', () => {
const dex = parseSaveDex(
makeGen2Save({ caught: [1, 25], seen: [1, 25, 201], unown: [3, 1, 20] }), // C, A, T
);
expect(dex.gen).toBe(2);
expect(dex.game).toBe('Crystal');
expect(dex.checksumOk).toBe(true);
expect([...dex.formCaught].sort()).toEqual(['unown', 'unown-c', 'unown-t']);
});
it('no Unown caught → empty list', () => {
const dex = parseSaveDex(makeGen2Save({ caught: [1], seen: [1], unown: [] }));
expect(dex.formCaught).toEqual([]);
});
it('ignores a garbage list rather than ticking wrong boxes', () => {
const dex = parseSaveDex(makeGen2Save({ caught: [1], seen: [1], unown: [3, 99, 5] }));
expect(dex.formCaught).toEqual([]);
});
});