-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdata.js
More file actions
215 lines (182 loc) · 7.01 KB
/
Copy pathdata.js
File metadata and controls
215 lines (182 loc) · 7.01 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
/**
* Everything that reaches outside the page.
*
* Catch CP is derived from Pokémon GO base stats rather than stored, so it
* cannot drift out of date. Lookups resolve in three tiers, cheapest first:
* 1. cpOverrides.js — manual pins; normally empty
* 2. the vendored snapshot in data/ — same origin, refreshed weekly by CI
* 3. the live APIs — only when the snapshot misses
*
* Tier 3 exists so a Pokémon released after the last snapshot still resolves.
* Every call is best-effort: callers get null rather than an exception, and the
* status chip tells the user which tier is actually answering.
*/
import { calcCatchCP, normalizeName, lookupCPOverride, indexBaseStats, pokeApiSlug } from './parse.js';
const TIMEOUT_MS = 8000;
const SNAPSHOT = {
stats: './data/pokemon-stats.json',
shiny: './data/shiny-pokemon.json'
};
const LIVE = {
names: 'https://pokeapi.co/api/v2/pokemon?limit=2000',
stats: 'https://pogoapi.net/api/v1/pokemon_stats.json',
shiny: 'https://pogoapi.net/api/v1/shiny_pokemon.json',
pokemon: name => `https://pokeapi.co/api/v2/pokemon/${pokeApiSlug(name)}`,
artwork: id => `https://www.pokemon.com/static-assets/content-assets/cms2/img/pokedex/full/${id}.png`
};
// ---------------------------------------------------------------- status ----
const status = { source: 'loading', generated: null };
const listeners = new Set();
export function getStatus() {
return { ...status };
}
/** Subscribe to data-source changes; fires immediately with the current state. */
export function onStatusChange(fn) {
listeners.add(fn);
fn(getStatus());
return () => listeners.delete(fn);
}
function setStatus(patch) {
Object.assign(status, patch);
for (const fn of listeners) fn(getStatus());
}
// ----------------------------------------------------------------- fetch ----
async function getJSON(url) {
const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
if (!response.ok) throw new Error(`${response.status} for ${url}`);
return response.json();
}
/** Run a loader once and cache the promise, so concurrent callers share one request. */
function once(fn) {
let promise = null;
return () => (promise ??= fn().catch(error => {
promise = null; // let a later call retry
throw error;
}));
}
// ------------------------------------------------------------ base stats ----
/** Display names from whichever stats source loaded — the autocomplete fallback. */
let statsNames = [];
const loadSnapshotStats = once(async () => {
const snapshot = await getJSON(SNAPSHOT.stats);
setStatus({ source: 'snapshot', generated: snapshot.generated ?? null });
const pokemon = snapshot.pokemon ?? [];
statsNames = pokemon.map(p => p.name);
return indexBaseStats(pokemon);
});
const loadLiveStats = once(async () => {
const live = await getJSON(LIVE.stats);
const pokemon = live.map(p => ({
name: p.pokemon_name, atk: p.base_attack, def: p.base_defense, sta: p.base_stamina, form: p.form
}));
if (!statsNames.length) statsNames = [...new Set(pokemon.map(p => p.name))];
return indexBaseStats(pokemon);
});
const ensureStats = once(async () => {
try {
return await loadSnapshotStats();
} catch {
// No snapshot deployed (or unreadable) — fall back to the live API.
try {
const index = await loadLiveStats();
setStatus({ source: 'live', generated: null });
return index;
} catch {
setStatus({ source: 'offline', generated: null });
return null;
}
}
});
/**
* Start loading the data snapshot without needing a lookup to trigger it, so
* the status chip settles on page load rather than on first use.
*/
export function primeData() {
return ensureStats();
}
/**
* Catch CP for a Pokémon, or null if it cannot be resolved.
*
* Derived from current base stats, so it cannot go stale. A manual override
* wins if one exists, but that map is normally empty — see cpOverrides.js.
*/
export async function getCatchCP(name) {
if (!name) return null;
const pinned = lookupCPOverride(name);
if (pinned) return pinned;
const target = normalizeName(name);
const stats = await ensureStats();
const hit = stats?.get(target);
if (hit) return calcCatchCP(hit);
// Absent from the snapshot may just mean "released since it was taken".
if (status.source === 'snapshot') {
try {
const live = await loadLiveStats();
const liveHit = live.get(target);
if (liveHit) return calcCatchCP(liveHit);
} catch { /* live unavailable — nothing more to try */ }
}
return null;
}
// ------------------------------------------------------------ shiny list ----
const loadSnapshotShiny = once(async () => {
const snapshot = await getJSON(SNAPSHOT.shiny);
return new Set((snapshot.names ?? []).map(normalizeName));
});
const loadLiveShiny = once(async () => {
const live = await getJSON(LIVE.shiny);
return new Set(Object.values(live).map(entry => normalizeName(entry.name)));
});
const ensureShiny = once(async () => {
try {
return await loadSnapshotShiny();
} catch {
try {
return await loadLiveShiny();
} catch {
return null;
}
}
});
/** true / false if known, null if no shiny data could be loaded at all. */
export async function isShinyReleased(name) {
if (!name) return null;
const set = await ensureShiny();
if (!set) return null;
const target = normalizeName(name);
if (set.has(target)) return true;
// A miss against the snapshot might be a recent release; confirm against live.
if (status.source === 'snapshot') {
try {
const live = await loadLiveShiny();
return live.has(target);
} catch { /* fall through to the snapshot's answer */ }
}
return false;
}
// ------------------------------------------------------------ name list ----
/**
* Full Pokémon name list for autocomplete. Falls back to the names in the stats
* snapshot, which is same-origin and therefore available whenever the page is.
*/
export async function loadPokemonNames() {
try {
const data = await getJSON(LIVE.names);
return data.results.map(p => p.name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()));
} catch (error) {
console.error('Pokémon name list unavailable, falling back to the stats snapshot:', error);
await ensureStats();
return [...statsNames].sort();
}
}
// -------------------------------------------------------------- artwork ----
/** Official artwork URL for a Pokémon, or null if it can't be resolved. */
export async function fetchPokemonImage(name) {
try {
const data = await getJSON(LIVE.pokemon(name));
return LIVE.artwork(String(data.id).padStart(3, '0'));
} catch (error) {
console.error(`No artwork for ${name}:`, error);
return null;
}
}