import { createStore } from './createStore.js'; import { settings } from './settings.js'; /** * Per-game Pokémon tracking. * * Seen / caught are stored *per game* (PokéAPI version-group key) — catching * Pikachu in Blue says nothing about your Gold save. `favorite` and `note` * are the exception: those describe the Pokémon itself, so they're global, * one record keyed by National dex id. * * pokemon: { [id]: { favorite, note, updatedAt } } // global * games: { [vgKey]: { [id]: { seen, caught, updatedAt } } } // per game * * The 'all' key ("All games" / National mode) is special: reading it returns * the union of every game — "have I caught this anywhere". It's also a real * bucket you write to when you mark something while no specific game is * selected (and where a pre-per-game save's data was migrated to). */ export const selection = createStore('pdx.selection', { version: 3, pokemon: {}, games: {}, }); const GBLANK = { seen: false, caught: false }; const PBLANK = { favorite: false, note: '' }; /** Convert any older (flat, one-record) shape to the current per-game one. */ export function normalizeSelection(raw) { if (!raw || typeof raw !== 'object') return { version: 3, pokemon: {}, games: {} }; if (raw.version >= 3 && raw.games) { return { version: 3, pokemon: raw.pokemon || {}, games: raw.games || {} }; } const pokemon = {}; const all = {}; for (const [id, e] of Object.entries(raw.pokemon || {})) { if (e.favorite || (e.note && String(e.note).trim())) { pokemon[id] = { favorite: !!e.favorite, note: e.note || '', updatedAt: e.updatedAt }; } if (e.seen || e.caught) { all[id] = { seen: !!e.seen, caught: !!e.caught, updatedAt: e.updatedAt }; } } return { version: 3, pokemon, games: Object.keys(all).length ? { all } : {} }; } // One-time migration of an existing v2 store on this device. { const cur = selection.get(); if (!cur || cur.version < 3 || !cur.games) selection.replace(normalizeSelection(cur)); } function curGame() { return settings.get().versionGroup || 'all'; } function unionEntry(id) { const games = selection.get().games; let seen = false; let caught = false; let updatedAt = ''; for (const k in games) { const e = games[k][id]; if (!e) continue; if (e.seen || e.caught) seen = true; if (e.caught) caught = true; if (e.updatedAt && e.updatedAt > updatedAt) updatedAt = e.updatedAt; } return { seen, caught, updatedAt }; } /** Raw seen/caught for one game (union when vgKey === 'all'). */ export function gameEntry(id, vgKey = curGame()) { if (vgKey === 'all') return unionEntry(id); return selection.get().games[vgKey]?.[id] || GBLANK; } /** Merged view for a game: its seen/caught + the global favorite/note. */ export function entry(id, vgKey = curGame()) { const g = gameEntry(id, vgKey); const p = selection.get().pokemon[id] || PBLANK; return { seen: !!g.seen, caught: !!g.caught, favorite: !!p.favorite, note: p.note || '' }; } export function toggle(id, field, vgKey = curGame()) { selection.set((s) => { const stamp = new Date().toISOString(); if (field === 'favorite') { const cur = s.pokemon[id] || PBLANK; return { ...s, pokemon: { ...s.pokemon, [id]: { ...cur, favorite: !cur.favorite, updatedAt: stamp } }, }; } const bucket = s.games[vgKey] || {}; const cur = bucket[id] || GBLANK; const next = { ...cur, [field]: !cur[field], updatedAt: stamp }; if (field === 'caught' && next.caught) next.seen = true; return { ...s, games: { ...s.games, [vgKey]: { ...bucket, [id]: next } } }; }); } export function setNote(id, note) { selection.set((s) => { const cur = s.pokemon[id] || PBLANK; return { ...s, pokemon: { ...s.pokemon, [id]: { ...cur, note, updatedAt: new Date().toISOString() } }, }; }); } /** * Merge seen/caught lists into one game's bucket (a save-file import). * Never clears. `game` defaults to the selected one. */ export function importFlags({ seen = [], caught = [], game } = {}) { const vgKey = game || curGame(); selection.set((s) => { const stamp = new Date().toISOString(); const bucket = { ...(s.games[vgKey] || {}) }; for (const id of seen) bucket[id] = { ...(bucket[id] || GBLANK), seen: true, updatedAt: stamp }; for (const id of caught) bucket[id] = { ...(bucket[id] || GBLANK), seen: true, caught: true, updatedAt: stamp }; return { ...s, games: { ...s.games, [vgKey]: bucket } }; }); } /** Set one game's bucket to exactly these lists (a "replace with this save" import). */ export function replaceFlags({ seen = [], caught = [], game } = {}) { const vgKey = game || curGame(); selection.set((s) => { const stamp = new Date().toISOString(); const bucket = {}; for (const id of seen) bucket[id] = { seen: true, caught: false, updatedAt: stamp }; for (const id of caught) bucket[id] = { seen: true, caught: true, updatedAt: stamp }; return { ...s, games: { ...s.games, [vgKey]: bucket } }; }); } /** Drop one game's seen/caught bucket entirely (its own progress reset). */ export function clearGame(vgKey) { selection.set((s) => { if (!s.games[vgKey]) return s; const games = { ...s.games }; delete games[vgKey]; return { ...s, games }; }); } /** * 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; let seen = 0; let caught = 0; for (const id of speciesIds) { const e = get(id); if (e.seen || e.caught) seen++; if (e.caught) caught++; } return { seen, caught, total: speciesIds.length }; } /** Caught/seen across every game — the true National Dex tally. */ export function statsNational(speciesIds) { return stats(speciesIds, 'all'); }