Version-exclusive badges on dex cards

build-snapshot: a new pass fetches /pokemon/:id/encounters for every
species and records, per game, the versions it has wild encounters in —
when that's a non-empty strict subset of the game's versions,
species.exclusiveIn[vg] holds them. Wild-only, so gift/trade/evolution
exclusives are missed.

Card: with a specific game selected, shows a "<Version> only" pill
(e.g. Seedot -> "Ruby only", Lotad -> "Sapphire only"). 220 species
flagged across all games; nothing shown in National mode.

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-09 19:26:56 -04:00
parent 7f12f08718
commit 2e4d9ab329
4 changed files with 54 additions and 3 deletions

View File

@ -75,7 +75,10 @@ Working app with a type-themed UI:
filter drawer: type, generation, ability, egg group, minimum BST, and filter drawer: type, generation, ability, egg group, minimum BST, and
"fully evolved only" (derived client-side from each species' evolves-from "fully evolved only" (derived client-side from each species' evolves-from
link — no extra fetches). A "Notes" chip filters to Pokémon with a note; link — no extra fetches). A "Notes" chip filters to Pokémon with a note;
cards with one get a small 📝 mark. cards with one get a small 📝 mark. When a specific game is selected,
cards show a version-exclusive badge ("Ruby only") — derived from the
snapshot's baked wild-encounter data, so gift/trade-only exclusives are
missed.
- **Notes** — a free-text note per Pokémon on its detail page (trade plans, - **Notes** — a free-text note per Pokémon on its detail page (trade plans,
where you caught it, anything), autosaving as you type and flushing where you caught it, anything), autosaving as you type and flushing
immediately on blur or navigation so a quick tab-away never drops a immediately on blur or navigation so a quick tab-away never drops a
@ -135,8 +138,8 @@ Working app with a type-themed UI:
offer (toast + Settings button), manifest shortcuts to Team / Search / offer (toast + Settings button), manifest shortcuts to Team / Search /
Type chart. Type chart.
Not yet done: version-exclusive badges, an interactive region map (PokéAPI Not yet done: an interactive region map (PokéAPI has no map imagery or
has no map imagery or coordinates), and a formal Lighthouse pass. coordinates), and a formal Lighthouse pass.
## Notes ## Notes

View File

@ -320,6 +320,35 @@ async function main() {
}; };
}); });
// ---- Version exclusivity (from wild encounters) ---------------
// For each game, the versions a species has wild encounters in. If that's
// a non-empty strict subset of the game's versions, it's version
// exclusive there. Wild-only — gift/trade/evolution exclusives are missed.
const versionToVg = {};
for (const vg of versionGroups) for (const v of vg.versions) versionToVg[v] = vg.key;
const vgVersionCount = Object.fromEntries(versionGroups.map((vg) => [vg.key, vg.versions.length]));
console.log(`Fetching wild encounters for ${species.length} species …`);
await mapLimit(species, CONCURRENCY, async (sp) => {
let enc;
try {
enc = await api(`pokemon/${sp.id}/encounters`);
} catch {
return;
}
const byVg = {};
for (const area of enc) {
for (const vd of area.version_details || []) {
const vg = versionToVg[vd.version.name];
if (vg) (byVg[vg] ||= new Set()).add(vd.version.name);
}
}
const excl = {};
for (const [vg, set] of Object.entries(byVg)) {
if (set.size > 0 && set.size < (vgVersionCount[vg] || 1)) excl[vg] = [...set];
}
if (Object.keys(excl).length) sp.exclusiveIn = excl;
});
// ---- Moves index ------------------------------------------- // ---- Moves index -------------------------------------------
const moveIndex = await api('move?limit=100000'); const moveIndex = await api('move?limit=100000');
console.log(`Fetching ${moveIndex.results.length} moves …`); console.log(`Fetching ${moveIndex.results.length} moves …`);

View File

@ -6,6 +6,7 @@ import { typeHex } from '../lib/type-color.js';
import { typesForGen } from '../lib/type-resolve.js'; import { typesForGen } from '../lib/type-resolve.js';
import { prefersReducedMotion } from '../store/settings.js'; import { prefersReducedMotion } from '../store/settings.js';
import { buzz } from '../lib/haptics.js'; import { buzz } from '../lib/haptics.js';
import { prettify } from '../data/pokedex-resolver.js';
/** /**
* One Pokémon in the dex feed. Tinted by its primary type: a soft top-down * One Pokémon in the dex feed. Tinted by its primary type: a soft top-down
@ -73,6 +74,12 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
const n = (species.forms?.length || 0) + (species.cosmeticForms?.length || 0); const n = (species.forms?.length || 0) + (species.cosmeticForms?.length || 0);
return n ? el('span', { class: 'card__forms' }, `+${n} form${n > 1 ? 's' : ''}`) : null; return n ? el('span', { class: 'card__forms' }, `+${n} form${n > 1 ? 's' : ''}`) : null;
})(), })(),
(() => {
const ex = versionGroup && versionGroup !== 'all' && species.exclusiveIn?.[versionGroup];
return ex
? el('span', { class: 'card__excl', title: 'Version exclusive (wild)' }, `${ex.map(prettify).join(' & ')} only`)
: null;
})(),
metric ? el('span', { class: 'card__metric' }, metric) : null, metric ? el('span', { class: 'card__metric' }, metric) : null,
), ),
); );

View File

@ -2912,6 +2912,18 @@
font-weight: 700; font-weight: 700;
color: color-mix(in srgb, var(--type-main) 55%, var(--text-dim)); color: color-mix(in srgb, var(--type-main) 55%, var(--text-dim));
} }
.card__excl {
align-self: flex-start;
margin-top: 4px;
font-size: 0.6rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 2px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--type-main) 22%, var(--surface-2));
color: color-mix(in srgb, var(--type-main) 70%, var(--ink));
}
/* ---- Type chart page ---------------------------------------- */ /* ---- Type chart page ---------------------------------------- */
.tchart__scroll { .tchart__scroll {