@omnigateway/pokemon 1.1.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/index.js CHANGED
@@ -86,909 +86,1094 @@ function hasAnimatedSprite(speciesId) {
86
86
  return speciesId >= 1 && speciesId <= ANIMATED_SPECIES_MAX;
87
87
  }
88
88
 
89
- // node_modules/@omnigateway/plugin-api/src/events.ts
90
- var WINDOW_MS = {
91
- "1m": 60000,
92
- "5h": 5 * 60 * 60 * 1000,
93
- "1w": 7 * 24 * 60 * 60 * 1000
94
- };
95
-
96
- // src/grants.ts
97
- function windowKey(event) {
98
- return `${event.dimension}:${event.window}`;
99
- }
100
- function grantSize(window) {
101
- if (window === "1w")
102
- return 5;
103
- if (window === "5h")
104
- return 1;
105
- return 0;
89
+ // src/advance.ts
90
+ var MAX_TRANSITIONS_PER_ADVANCE = 64;
91
+ function advance(state, tokensTotal, now) {
92
+ const gained = Math.max(0, Math.trunc(tokensTotal) - state.consumedTotal);
93
+ const events = [];
94
+ let next = { ...state, consumedTotal: Math.trunc(tokensTotal) };
95
+ if (gained > 0) {
96
+ const active = next.active;
97
+ if (active === null) {
98
+ next = { ...next, eggUsage: next.eggUsage + gained };
99
+ } else if (!active.soothe) {
100
+ next = { ...next, active: { ...active, usedAtStage: active.usedAtStage + gained } };
101
+ } else {
102
+ const raw = active.soothedRaw + gained;
103
+ const owed = Math.floor(raw * SOOTHE_BONUS) - Math.floor(active.soothedRaw * SOOTHE_BONUS);
104
+ next = {
105
+ ...next,
106
+ active: { ...active, soothedRaw: raw, usedAtStage: active.usedAtStage + gained + owed }
107
+ };
108
+ }
109
+ }
110
+ for (let step = 0;step < MAX_TRANSITIONS_PER_ADVANCE; step++) {
111
+ if (next.active === null) {
112
+ if (next.eggUsage < EGG_HATCH_THRESHOLD)
113
+ break;
114
+ if (next.pendingHatch === null)
115
+ break;
116
+ const hatch = next.pendingHatch;
117
+ const active = {
118
+ baseId: hatch.path[0] ?? hatch.speciesId,
119
+ plannedPath: hatch.path,
120
+ stageIndex: 0,
121
+ stageTimes: [now],
122
+ usedAtStage: next.eggUsage - EGG_HATCH_THRESHOLD,
123
+ rarity: hatch.rarity,
124
+ isShiny: hatch.isShiny,
125
+ nature: hatch.nature,
126
+ dittoDisguise: hatch.ditto ? hatch.speciesId : null,
127
+ dittoRevealed: false,
128
+ everstone: false,
129
+ soothe: false,
130
+ soothedRaw: 0
131
+ };
132
+ events.push({
133
+ kind: "hatched",
134
+ speciesId: hatch.speciesId,
135
+ isShiny: active.isShiny,
136
+ ditto: active.dittoDisguise !== null
137
+ });
138
+ next = { ...next, active, eggUsage: 0, eggTier: null, pendingHatch: null };
139
+ continue;
140
+ }
141
+ const mon = next.active;
142
+ if (mon.everstone)
143
+ break;
144
+ const needed = phaseThreshold(mon.rarity, mon.plannedPath.length, mon.stageIndex);
145
+ if (mon.usedAtStage < needed)
146
+ break;
147
+ const excess = mon.usedAtStage - needed;
148
+ if (mon.dittoDisguise !== null && !mon.dittoRevealed) {
149
+ if (next.pendingReveal === null)
150
+ break;
151
+ const reveal = next.pendingReveal;
152
+ events.push({
153
+ kind: "revealed",
154
+ disguisedAs: mon.plannedPath[mon.stageIndex] ?? mon.baseId,
155
+ speciesId: reveal.path[0]
156
+ });
157
+ next = {
158
+ ...next,
159
+ active: {
160
+ ...mon,
161
+ baseId: reveal.path[0],
162
+ plannedPath: reveal.path,
163
+ stageIndex: 0,
164
+ stageTimes: [now],
165
+ usedAtStage: excess,
166
+ rarity: reveal.rarity,
167
+ dittoRevealed: true
168
+ },
169
+ pendingReveal: null
170
+ };
171
+ continue;
172
+ }
173
+ if (mon.stageIndex < mon.plannedPath.length - 1) {
174
+ events.push({
175
+ kind: "evolved",
176
+ from: mon.plannedPath[mon.stageIndex],
177
+ to: mon.plannedPath[mon.stageIndex + 1]
178
+ });
179
+ next = {
180
+ ...next,
181
+ active: {
182
+ ...mon,
183
+ stageIndex: mon.stageIndex + 1,
184
+ usedAtStage: excess,
185
+ stageTimes: stampedAt(mon.stageTimes, mon.stageIndex + 1, now)
186
+ }
187
+ };
188
+ continue;
189
+ }
190
+ events.push({
191
+ kind: "graduated",
192
+ baseId: mon.baseId,
193
+ finalId: mon.plannedPath[mon.plannedPath.length - 1],
194
+ chainOrder: mon.plannedPath,
195
+ stageTimes: stampedAt(mon.stageTimes, mon.plannedPath.length - 1, now),
196
+ rarity: mon.rarity,
197
+ isShiny: mon.isShiny,
198
+ nature: mon.nature
199
+ });
200
+ next = { ...next, active: null, eggUsage: excess, eggTier: null, pendingHatch: null };
201
+ }
202
+ return { state: next, events };
106
203
  }
107
- function decideGrant(input) {
108
- if (grantSize(input.window) === 0)
109
- return { grant: false };
110
- if (input.lastGrantedAt === null)
111
- return { grant: false, seedAt: input.now };
112
- if (input.now - input.lastGrantedAt < WINDOW_MS[input.window])
113
- return { grant: false };
114
- return { grant: true, count: grantSize(input.window), at: input.now };
204
+ function stampedAt(stageTimes, index, now) {
205
+ const placed = [...stageTimes];
206
+ while (placed.length <= index)
207
+ placed.push(null);
208
+ placed[index] ??= now;
209
+ return placed;
115
210
  }
116
211
 
117
- // src/pokeapi.ts
118
- var POKEAPI_ORIGIN = "https://pokeapi.co";
119
- var SPRITE_ORIGIN = "https://raw.githubusercontent.com";
120
- var SPRITE_DIR = "/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated";
121
- var ITEM_SPRITE_DIR = "/PokeAPI/sprites/master/sprites/items";
122
- var MAX_EVOLUTION_CHAIN_ID = 2000;
123
- var INDEX_FETCH_CONCURRENCY = 8;
124
- var INDEX_PATH = "species/index.json";
125
- var speciesPath = (id) => `species/${id}.json`;
126
- var chainPath = (chainId) => `species/chain-${chainId}.json`;
127
- var spritePath = (id, shiny) => shiny ? `sprites/shiny/${id}.gif` : `sprites/${id}.gif`;
128
- var itemSpritePath = (name) => `sprites/items/${name}.png`;
129
- function isFetchableSpeciesId(id) {
130
- return Number.isInteger(id) && hasAnimatedSprite(id);
212
+ // src/roll.ts
213
+ var NATURES = [
214
+ "hardy",
215
+ "lonely",
216
+ "brave",
217
+ "adamant",
218
+ "naughty",
219
+ "bold",
220
+ "docile",
221
+ "relaxed",
222
+ "impish",
223
+ "lax",
224
+ "timid",
225
+ "hasty",
226
+ "serious",
227
+ "jolly",
228
+ "naive",
229
+ "modest",
230
+ "mild",
231
+ "quiet",
232
+ "bashful",
233
+ "rash",
234
+ "calm",
235
+ "gentle",
236
+ "sassy",
237
+ "careful",
238
+ "quirky"
239
+ ];
240
+ function mulberry32(seed) {
241
+ let state = seed >>> 0;
242
+ return () => {
243
+ state = state + 1831565813 >>> 0;
244
+ let t = state;
245
+ t = Math.imul(t ^ t >>> 15, t | 1);
246
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
247
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
248
+ };
131
249
  }
132
- function asRecord(value) {
133
- if (typeof value !== "object" || value === null || Array.isArray(value))
250
+ var COLLECTED_WEIGHT = 0.25;
251
+ var FORM_WEIGHT = 0.6;
252
+ function roll(input) {
253
+ const random = mulberry32(input.seed);
254
+ const eligibleWith = (withLure) => input.candidates.filter((candidate) => {
255
+ if (!hasAnimatedSprite(candidate.id))
256
+ return false;
257
+ if (candidate.id === DITTO_SPECIES_ID)
258
+ return false;
259
+ if (input.excludeFinal != null && candidate.finalId === input.excludeFinal)
260
+ return false;
261
+ if (withLure && input.collectedFinals.has(candidate.finalId))
262
+ return false;
263
+ if (input.guarantee === null)
264
+ return true;
265
+ const rarity2 = rarityFromCaptureRate(candidate.captureRate, false, false);
266
+ return sortRank(rarity2) >= sortRank(input.guarantee);
267
+ });
268
+ const wanted = input.onlyUncollected === true;
269
+ const lured = wanted ? eligibleWith(true) : [];
270
+ const usedLure = wanted && lured.length > 0;
271
+ const eligible = usedLure ? lured : eligibleWith(false);
272
+ if (eligible.length === 0)
134
273
  return null;
135
- return value;
136
- }
137
- function asArray(value) {
138
- return Array.isArray(value) ? value : null;
274
+ const weights = eligible.map((candidate) => {
275
+ const base = Math.max(1, candidate.captureRate);
276
+ const collected = input.collectedFinals.has(candidate.finalId) ? base * COLLECTED_WEIGHT : base;
277
+ return input.preferLongLines === true ? collected * (1 + FORM_WEIGHT * (Math.max(1, candidate.forms) - 1)) : collected;
278
+ });
279
+ const total = weights.reduce((a, b) => a + b, 0);
280
+ let target = random() * total;
281
+ let index = 0;
282
+ for (let i = 0;i < weights.length; i++) {
283
+ target -= weights[i];
284
+ if (target <= 0) {
285
+ index = i;
286
+ break;
287
+ }
288
+ }
289
+ const chosen = eligible[index];
290
+ const shinyDenominator = input.hasShinyCharm ? ODDS.shinyWithCharm : ODDS.shiny;
291
+ const isShiny = random() < 1 / shinyDenominator;
292
+ const nature = NATURES[Math.floor(random() * NATURES.length)];
293
+ const rarity = rarityFromCaptureRate(chosen.captureRate, false, false);
294
+ const dittoEligible = rarity === "common" && chosen.forms >= 2;
295
+ const ditto = dittoEligible && random() < 1 / ODDS.dittoDisguise;
296
+ return { speciesId: chosen.id, isShiny, nature, ditto, usedLure };
139
297
  }
140
- function asFiniteNumber(value) {
141
- return typeof value === "number" && Number.isFinite(value) ? value : null;
298
+
299
+ // src/state.ts
300
+ function emptyInventory() {
301
+ const inventory = {};
302
+ for (const kind of ITEM_KINDS)
303
+ inventory[kind] = 0;
304
+ return inventory;
142
305
  }
143
- var encoder = new TextEncoder;
144
- var decoder = new TextDecoder;
145
- async function readJson(deps, path) {
146
- try {
147
- const bytes = await deps.files.read(path);
148
- if (bytes === null)
149
- return null;
150
- return JSON.parse(decoder.decode(bytes));
151
- } catch {
152
- return null;
153
- }
306
+ function freshState() {
307
+ return {
308
+ consumedTotal: 0,
309
+ active: null,
310
+ eggUsage: 0,
311
+ eggTier: null,
312
+ pendingHatch: null,
313
+ pendingReveal: null,
314
+ lure: false,
315
+ incense: false,
316
+ repel: null,
317
+ inventory: emptyInventory()
318
+ };
154
319
  }
155
- async function writeCache(deps, path, bytes) {
156
- try {
157
- await deps.files.write(path, bytes);
158
- } catch {}
320
+ function isRecord(value) {
321
+ return typeof value === "object" && value !== null && !Array.isArray(value);
159
322
  }
160
- async function writeJson(deps, path, value) {
161
- await writeCache(deps, path, encoder.encode(JSON.stringify(value)));
323
+ function asInt(value, fallback) {
324
+ return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : fallback;
162
325
  }
163
- async function fetchJson(deps, url) {
164
- try {
165
- const response = await deps.net(url);
166
- if (!response.ok)
167
- return null;
168
- return JSON.parse(await response.text());
169
- } catch {
170
- return null;
171
- }
326
+ function asRarity(value) {
327
+ return RARITIES.includes(value) ? value : null;
172
328
  }
173
- async function fetchBytes(deps, url) {
329
+ function parseState(raw) {
330
+ let parsed;
174
331
  try {
175
- const response = await deps.net(url);
176
- if (!response.ok)
177
- return null;
178
- const bytes = new Uint8Array(await response.arrayBuffer());
179
- return bytes.length === 0 ? null : bytes;
332
+ parsed = JSON.parse(raw);
180
333
  } catch {
181
334
  return null;
182
335
  }
183
- }
184
- var CHAIN_URL_ID = /\/evolution-chain\/(\d+)\/?$/;
185
- function chainIdFromUrl(url) {
186
- if (typeof url !== "string")
187
- return null;
188
- const match = CHAIN_URL_ID.exec(url);
189
- if (match === null)
190
- return null;
191
- const id = Number(match[1]);
192
- if (!Number.isInteger(id) || id < 1 || id > MAX_EVOLUTION_CHAIN_ID)
193
- return null;
194
- return id;
195
- }
196
- var SPECIES_URL_ID = /\/pokemon-species\/(\d+)\/?$/;
197
- function speciesIdFromUrl(url) {
198
- if (typeof url !== "string")
199
- return null;
200
- const match = SPECIES_URL_ID.exec(url);
201
- if (match === null)
202
- return null;
203
- const id = Number(match[1]);
204
- return isFetchableSpeciesId(id) ? id : null;
205
- }
206
- function parseChainNode(raw) {
207
- const node = asRecord(raw);
208
- if (node === null)
209
- return null;
210
- const species = asRecord(node.species);
211
- const id = speciesIdFromUrl(species?.url);
212
- if (id === null)
336
+ if (!isRecord(parsed))
213
337
  return null;
214
- const children = asArray(node.evolves_to) ?? [];
215
- const evolvesTo = [];
216
- for (const child of children) {
217
- const parsed = parseChainNode(child);
218
- if (parsed !== null)
219
- evolvesTo.push(parsed);
338
+ const inventory = emptyInventory();
339
+ const storedInventory = parsed.inventory;
340
+ if (isRecord(storedInventory)) {
341
+ for (const kind of ITEM_KINDS) {
342
+ inventory[kind] = Math.max(0, asInt(storedInventory[kind], 0));
343
+ }
220
344
  }
221
- return { id, evolvesTo };
222
- }
223
- function pathTo(node, id) {
224
- if (node.id === id)
225
- return [id];
226
- for (const child of node.evolvesTo) {
227
- const tail = pathTo(child, id);
228
- if (tail !== null)
229
- return [node.id, ...tail];
345
+ let active = null;
346
+ const storedActive = parsed.active;
347
+ if (storedActive !== null && storedActive !== undefined) {
348
+ if (!isRecord(storedActive))
349
+ return null;
350
+ const rarity = asRarity(storedActive.rarity);
351
+ if (rarity === null)
352
+ return null;
353
+ const path = Array.isArray(storedActive.plannedPath) ? storedActive.plannedPath.filter((id) => typeof id === "number" && id > 0) : [];
354
+ if (path.length === 0)
355
+ return null;
356
+ const nature = NATURES.includes(storedActive.nature) ? storedActive.nature : null;
357
+ active = {
358
+ baseId: asInt(storedActive.baseId, path[0]),
359
+ plannedPath: path,
360
+ stageIndex: Math.min(Math.max(0, asInt(storedActive.stageIndex, 0)), path.length - 1),
361
+ stageTimes: Array.isArray(storedActive.stageTimes) ? storedActive.stageTimes.map((at) => typeof at === "number" && at >= 0 && Number.isFinite(at) ? at : null) : [],
362
+ usedAtStage: Math.max(0, asInt(storedActive.usedAtStage, 0)),
363
+ rarity,
364
+ isShiny: storedActive.isShiny === true,
365
+ nature: nature ?? "hardy",
366
+ dittoDisguise: typeof storedActive.dittoDisguise === "number" ? storedActive.dittoDisguise : null,
367
+ dittoRevealed: storedActive.dittoRevealed === true,
368
+ everstone: storedActive.everstone === true,
369
+ soothe: storedActive.soothe === true,
370
+ soothedRaw: Math.max(0, asInt(storedActive.soothedRaw, 0))
371
+ };
230
372
  }
231
- return null;
232
- }
233
- function descendFirstBranch(node) {
234
- const rest = [];
235
- let current = node;
236
- while (current.evolvesTo.length > 0) {
237
- const next = current.evolvesTo[0];
238
- rest.push(next.id);
239
- current = next;
373
+ const storedPending = parsed.pendingHatch;
374
+ let pendingHatch = null;
375
+ if (isRecord(storedPending)) {
376
+ const rarity = asRarity(storedPending.rarity);
377
+ const path = Array.isArray(storedPending.path) ? storedPending.path.filter((id) => typeof id === "number" && id > 0) : [];
378
+ if (rarity !== null && path.length > 0) {
379
+ const nature = NATURES.includes(storedPending.nature) ? storedPending.nature : "hardy";
380
+ pendingHatch = {
381
+ speciesId: asInt(storedPending.speciesId, path[0]),
382
+ path,
383
+ rarity,
384
+ isShiny: storedPending.isShiny === true,
385
+ nature,
386
+ ditto: storedPending.ditto === true
387
+ };
388
+ }
240
389
  }
241
- return rest;
242
- }
243
- function lineThrough(root, id) {
244
- const prefix = pathTo(root, id);
245
- if (prefix === null)
390
+ const storedReveal = parsed.pendingReveal;
391
+ let pendingReveal = null;
392
+ if (isRecord(storedReveal)) {
393
+ const rarity = asRarity(storedReveal.rarity);
394
+ const path = Array.isArray(storedReveal.path) ? storedReveal.path.filter((id) => typeof id === "number" && id > 0) : [];
395
+ if (rarity !== null && path.length > 0)
396
+ pendingReveal = { path, rarity };
397
+ }
398
+ const storedConsumed = parsed.consumedTotal;
399
+ if (typeof storedConsumed !== "number" || !Number.isFinite(storedConsumed))
246
400
  return null;
247
- const self = prefix[prefix.length - 1];
248
- const node = nodeFor(root, self);
249
- return node === null ? prefix : [...prefix, ...descendFirstBranch(node)];
401
+ const eggTier = asRarity(parsed.eggTier);
402
+ return {
403
+ consumedTotal: Math.max(0, Math.trunc(storedConsumed)),
404
+ active,
405
+ eggUsage: Math.max(0, asInt(parsed.eggUsage, 0)),
406
+ eggTier: eggTier === null || eggTier === "legendary" ? null : eggTier,
407
+ pendingHatch,
408
+ pendingReveal,
409
+ lure: parsed.lure === true,
410
+ incense: parsed.incense === true,
411
+ repel: typeof parsed.repel === "number" && Number.isInteger(parsed.repel) && parsed.repel > 0 ? parsed.repel : null,
412
+ inventory
413
+ };
250
414
  }
251
- function nodeFor(node, id) {
252
- if (node.id === id)
253
- return node;
254
- for (const child of node.evolvesTo) {
255
- const found = nodeFor(child, id);
256
- if (found !== null)
257
- return found;
258
- }
259
- return null;
415
+ function serialiseState(state) {
416
+ return JSON.stringify(state);
260
417
  }
261
- function loadChain(deps, chainId, cache) {
262
- const inFlight = cache.get(chainId);
263
- if (inFlight !== undefined)
264
- return inFlight;
265
- const pending = (async () => {
266
- const path = chainPath(chainId);
267
- const cached = await readJson(deps, path);
268
- if (cached !== null) {
269
- const parsed2 = parseChainNode(cached);
270
- if (parsed2 !== null)
271
- return parsed2;
272
- }
273
- const fetched = await fetchJson(deps, `${POKEAPI_ORIGIN}/api/v2/evolution-chain/${chainId}`);
274
- if (fetched === null)
275
- return null;
276
- const root = asRecord(fetched)?.chain;
277
- const parsed = parseChainNode(root);
278
- if (parsed === null)
279
- return null;
280
- await writeJson(deps, path, { chain: root });
281
- return parsed;
282
- })();
283
- cache.set(chainId, pending);
284
- return pending;
418
+ function hasShinyCharm(state) {
419
+ return (state.inventory.shinyCharm ?? 0) > 0;
285
420
  }
286
- function parseNames(raw) {
287
- const names = {};
288
- for (const entry of asArray(raw) ?? []) {
289
- const record = asRecord(entry);
290
- if (record === null)
291
- continue;
292
- const language = asRecord(record.language)?.name;
293
- const name = record.name;
294
- if (typeof language === "string" && typeof name === "string")
295
- names[language] = name;
421
+
422
+ // src/store.ts
423
+ var MIGRATIONS = [
424
+ {
425
+ version: 1,
426
+ sql: `
427
+ CREATE TABLE {{companion}} (
428
+ api_key_id TEXT PRIMARY KEY,
429
+ state TEXT NOT NULL,
430
+ tokens_total INTEGER NOT NULL DEFAULT 0,
431
+ tokens_spent INTEGER NOT NULL DEFAULT 0,
432
+ created_at INTEGER NOT NULL,
433
+ updated_at INTEGER NOT NULL
434
+ )
435
+ `
436
+ },
437
+ {
438
+ version: 2,
439
+ sql: `
440
+ CREATE TABLE {{dex}} (
441
+ id TEXT PRIMARY KEY,
442
+ api_key_id TEXT NOT NULL,
443
+ base_id INTEGER NOT NULL,
444
+ final_id INTEGER NOT NULL,
445
+ chain_order TEXT NOT NULL,
446
+ rarity TEXT NOT NULL,
447
+ is_shiny INTEGER NOT NULL DEFAULT 0,
448
+ nature TEXT,
449
+ caught_at INTEGER NOT NULL
450
+ )
451
+ `
452
+ },
453
+ {
454
+ version: 3,
455
+ sql: `CREATE INDEX {{dex_by_key}} ON {{dex}} (api_key_id, caught_at DESC)`
456
+ },
457
+ {
458
+ version: 4,
459
+ sql: `
460
+ CREATE TABLE {{grants}} (
461
+ api_key_id TEXT NOT NULL,
462
+ window_key TEXT NOT NULL,
463
+ -- An instant, not a tier. A grant is rate-limited by the window's own
464
+ -- duration, because nothing tells this plugin when a window empties.
465
+ granted_at INTEGER NOT NULL,
466
+ PRIMARY KEY (api_key_id, window_key)
467
+ )
468
+ `
469
+ },
470
+ {
471
+ version: 5,
472
+ sql: `ALTER TABLE {{companion}} ADD COLUMN last_credit_at INTEGER`
473
+ },
474
+ {
475
+ version: 6,
476
+ sql: `ALTER TABLE {{dex}} ADD COLUMN stage_times TEXT`
477
+ },
478
+ {
479
+ version: 7,
480
+ sql: `
481
+ CREATE TABLE {{sightings}} (
482
+ api_key_id TEXT NOT NULL,
483
+ species_id INTEGER NOT NULL,
484
+ -- The line this individual was on, so a species seen but never
485
+ -- graduated still has a chain to draw. There is no Dex row to take one
486
+ -- from, and a record with no line is a sprite with nothing under it.
487
+ chain_order TEXT NOT NULL,
488
+ rarity TEXT NOT NULL,
489
+ is_shiny INTEGER NOT NULL DEFAULT 0,
490
+ seen_at INTEGER NOT NULL,
491
+ PRIMARY KEY (api_key_id, species_id)
492
+ )
493
+ `
296
494
  }
297
- return names;
495
+ ];
496
+ function wallet(row) {
497
+ return Math.max(0, row.tokensTotal - row.tokensSpent);
298
498
  }
299
- function parseCachedDetail(raw, id) {
300
- const record = asRecord(raw);
301
- if (record === null)
302
- return null;
303
- const captureRate = asFiniteNumber(record.captureRate);
304
- if (captureRate === null)
305
- return null;
306
- const chainRaw = asArray(record.chain);
307
- if (chainRaw === null || chainRaw.length === 0)
499
+ function readCompanion(storage, apiKeyId) {
500
+ const row = storage.get(`SELECT api_key_id, state, tokens_total, tokens_spent, last_credit_at
501
+ FROM {{companion}} WHERE api_key_id = ?`, [apiKeyId]);
502
+ if (row === null)
308
503
  return null;
309
- const chain = [];
310
- for (const entry of chainRaw) {
311
- if (typeof entry !== "number" || !isFetchableSpeciesId(entry))
312
- return null;
313
- chain.push(entry);
314
- }
315
504
  return {
316
- id,
317
- names: parseNames(record.names),
318
- captureRate,
319
- isLegendary: record.isLegendary === true,
320
- isMythical: record.isMythical === true,
321
- chain
505
+ apiKeyId: row.api_key_id,
506
+ state: parseState(row.state),
507
+ tokensTotal: row.tokens_total,
508
+ tokensSpent: row.tokens_spent,
509
+ lastCreditAt: row.last_credit_at
322
510
  };
323
511
  }
324
- function detailToCache(detail) {
325
- return {
326
- captureRate: detail.captureRate,
327
- isLegendary: detail.isLegendary,
328
- isMythical: detail.isMythical,
329
- chain: detail.chain,
330
- names: Object.entries(detail.names).map(([language, name]) => ({
331
- language: { name: language },
332
- name
333
- }))
334
- };
512
+ function listCompanions(storage) {
513
+ const rows = storage.all(`SELECT api_key_id, state, tokens_total, tokens_spent, last_credit_at
514
+ FROM {{companion}}
515
+ ORDER BY last_credit_at IS NULL, last_credit_at DESC, tokens_total DESC, api_key_id ASC`);
516
+ return rows.map((row) => ({
517
+ apiKeyId: row.api_key_id,
518
+ state: parseState(row.state),
519
+ tokensTotal: row.tokens_total,
520
+ tokensSpent: row.tokens_spent,
521
+ lastCreditAt: row.last_credit_at
522
+ }));
523
+ }
524
+ function creditTokens(storage, apiKeyId, tokens, now) {
525
+ if (tokens <= 0)
526
+ return;
527
+ storage.run(`INSERT INTO {{companion}} (api_key_id, state, tokens_total, tokens_spent, created_at, updated_at, last_credit_at)
528
+ VALUES (?, ?, ?, 0, ?, ?, ?)
529
+ ON CONFLICT(api_key_id) DO UPDATE SET
530
+ tokens_total = tokens_total + excluded.tokens_total,
531
+ updated_at = excluded.updated_at,
532
+ last_credit_at = excluded.last_credit_at`, [apiKeyId, serialiseState(freshState()), Math.trunc(tokens), now, now, now]);
533
+ }
534
+ function settle(storage, apiKeyId, now) {
535
+ const row = readCompanion(storage, apiKeyId);
536
+ if (row === null)
537
+ return null;
538
+ if (row.state === null)
539
+ return { row, events: [] };
540
+ const result = advance(row.state, row.tokensTotal, now);
541
+ if (result.events.length === 0 && result.state === row.state)
542
+ return { row, events: [] };
543
+ storage.run("UPDATE {{companion}} SET state = ?, updated_at = ? WHERE api_key_id = ?", [
544
+ serialiseState(result.state),
545
+ now,
546
+ apiKeyId
547
+ ]);
548
+ return { row: { ...row, state: result.state }, events: result.events };
549
+ }
550
+ function recordGraduation(storage, apiKeyId, entry, id) {
551
+ storage.run(`INSERT INTO {{dex}} (id, api_key_id, base_id, final_id, chain_order, stage_times, rarity, is_shiny, nature, caught_at)
552
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
553
+ id,
554
+ apiKeyId,
555
+ entry.baseId,
556
+ entry.finalId,
557
+ JSON.stringify(entry.chainOrder),
558
+ entry.stageTimes === null ? null : JSON.stringify(entry.stageTimes),
559
+ entry.rarity,
560
+ entry.isShiny ? 1 : 0,
561
+ entry.nature,
562
+ entry.caughtAt
563
+ ]);
335
564
  }
336
- async function loadDetail(deps, id, chains) {
337
- if (!isFetchableSpeciesId(id))
338
- return null;
339
- const path = speciesPath(id);
340
- const cached = parseCachedDetail(await readJson(deps, path), id);
341
- if (cached !== null)
342
- return cached;
343
- const raw = asRecord(await fetchJson(deps, `${POKEAPI_ORIGIN}/api/v2/pokemon-species/${id}`));
344
- if (raw === null)
345
- return null;
346
- const captureRate = asFiniteNumber(raw.capture_rate);
347
- if (captureRate === null)
565
+ function parseChain(raw) {
566
+ let parsed;
567
+ try {
568
+ parsed = JSON.parse(raw);
569
+ } catch {
348
570
  return null;
349
- const chainId = chainIdFromUrl(asRecord(raw.evolution_chain)?.url);
350
- if (chainId === null)
571
+ }
572
+ if (!Array.isArray(parsed))
351
573
  return null;
352
- const root = await loadChain(deps, chainId, chains);
353
- if (root === null)
574
+ const chain = parsed.filter((id) => typeof id === "number");
575
+ return chain.length === 0 ? null : chain;
576
+ }
577
+ function parseStageTimes(raw) {
578
+ if (raw === null)
354
579
  return null;
355
- const chain = lineThrough(root, id);
356
- if (chain === null || chain.length === 0)
580
+ let parsed;
581
+ try {
582
+ parsed = JSON.parse(raw);
583
+ } catch {
357
584
  return null;
358
- const detail = {
359
- id,
360
- names: parseNames(raw.names),
361
- captureRate,
362
- isLegendary: raw.is_legendary === true,
363
- isMythical: raw.is_mythical === true,
364
- chain
365
- };
366
- await writeJson(deps, path, detailToCache(detail));
367
- return detail;
368
- }
369
- function speciesDetail(deps, id) {
370
- return loadDetail(deps, id, new Map);
371
- }
372
- function speciesDetails(deps, ids) {
373
- const chains = new Map;
374
- return Promise.all(ids.map((id) => loadDetail(deps, id, chains)));
375
- }
376
- async function cachedSpeciesName(deps, id) {
377
- if (!isFetchableSpeciesId(id))
585
+ }
586
+ if (!Array.isArray(parsed))
378
587
  return null;
379
- const cached = parseCachedDetail(await readJson(deps, speciesPath(id)), id);
380
- return cached?.names.en ?? null;
588
+ return parsed.map((at) => typeof at === "number" && Number.isFinite(at) ? at : null);
381
589
  }
382
- function parseCachedIndex(raw) {
383
- const entries = asArray(raw);
384
- if (entries === null)
385
- return null;
386
- const candidates = [];
387
- for (const entry of entries) {
388
- const record = asRecord(entry);
389
- if (record === null)
390
- return null;
391
- const id = asFiniteNumber(record.id);
392
- const captureRate = asFiniteNumber(record.captureRate);
393
- const forms = asFiniteNumber(record.forms);
394
- const finalId = asFiniteNumber(record.finalId);
395
- if (id === null || captureRate === null || forms === null || finalId === null)
396
- return null;
397
- if (!isFetchableSpeciesId(id))
398
- return null;
399
- candidates.push({ id, captureRate, forms, finalId });
590
+ function readDex(storage, apiKeyId) {
591
+ const rows = storage.all(`SELECT id, base_id, final_id, chain_order, stage_times, rarity, is_shiny, nature, caught_at
592
+ FROM {{dex}} WHERE api_key_id = ? ORDER BY caught_at DESC`, [apiKeyId]);
593
+ const entries = [];
594
+ for (const row of rows) {
595
+ const chain = parseChain(row.chain_order);
596
+ if (chain === null)
597
+ continue;
598
+ entries.push({
599
+ id: row.id,
600
+ baseId: row.base_id,
601
+ finalId: row.final_id,
602
+ chainOrder: chain,
603
+ stageTimes: parseStageTimes(row.stage_times),
604
+ rarity: row.rarity,
605
+ isShiny: row.is_shiny === 1,
606
+ nature: row.nature,
607
+ caughtAt: row.caught_at
608
+ });
400
609
  }
401
- return candidates.length === 0 ? null : candidates;
610
+ return entries;
402
611
  }
403
- async function mapWithConcurrency(ids, limit, worker) {
404
- const results = new Array(ids.length);
405
- let next = 0;
406
- const runners = Array.from({ length: Math.min(limit, ids.length) }, async () => {
407
- while (next < ids.length) {
408
- const index = next++;
409
- results[index] = await worker(ids[index]);
410
- }
411
- });
412
- await Promise.all(runners);
413
- return results;
612
+ function recordSightings(storage, apiKeyId, reached, now) {
613
+ if (reached.disguised)
614
+ return;
615
+ const chain = JSON.stringify(reached.plannedPath);
616
+ const stages = reached.plannedPath.slice(0, reached.stageIndex + 1);
617
+ for (const [stage, speciesId] of stages.entries()) {
618
+ storage.run(`INSERT INTO {{sightings}} (api_key_id, species_id, chain_order, rarity, is_shiny, seen_at)
619
+ VALUES (?, ?, ?, ?, ?, ?)
620
+ ON CONFLICT(api_key_id, species_id) DO NOTHING`, [
621
+ apiKeyId,
622
+ speciesId,
623
+ chain,
624
+ reached.rarity,
625
+ reached.isShiny ? 1 : 0,
626
+ reached.stageTimes[stage] ?? now
627
+ ]);
628
+ }
414
629
  }
415
- async function speciesIndex(deps) {
416
- const cached = parseCachedIndex(await readJson(deps, INDEX_PATH));
417
- if (cached !== null)
418
- return cached;
419
- const ids = Array.from({ length: ANIMATED_SPECIES_MAX }, (_, i) => i + 1);
420
- const chains = new Map;
421
- const details = await mapWithConcurrency(ids, INDEX_FETCH_CONCURRENCY, (id) => loadDetail(deps, id, chains));
422
- const candidates = [];
423
- for (const detail of details) {
424
- if (detail === null)
425
- continue;
426
- if (detail.chain[0] !== detail.id)
630
+ function listSightings(storage, apiKeyId) {
631
+ const rows = storage.all(`SELECT species_id, chain_order, rarity, is_shiny, seen_at
632
+ FROM {{sightings}} WHERE api_key_id = ? ORDER BY species_id ASC`, [apiKeyId]);
633
+ const sightings = [];
634
+ for (const row of rows) {
635
+ const chain = parseChain(row.chain_order);
636
+ if (chain === null)
427
637
  continue;
428
- candidates.push({
429
- id: detail.id,
430
- captureRate: detail.captureRate,
431
- forms: detail.chain.length,
432
- finalId: detail.chain[detail.chain.length - 1] ?? detail.id
638
+ sightings.push({
639
+ speciesId: row.species_id,
640
+ chainOrder: chain,
641
+ rarity: row.rarity,
642
+ isShiny: row.is_shiny === 1,
643
+ seenAt: row.seen_at
433
644
  });
434
645
  }
435
- if (details.every((detail) => detail !== null))
436
- await writeJson(deps, INDEX_PATH, candidates);
437
- return candidates;
646
+ return sightings;
438
647
  }
439
- async function spriteBytes(deps, id, shiny) {
440
- if (!isFetchableSpeciesId(id))
441
- return null;
442
- const path = spritePath(id, shiny);
443
- try {
444
- const cached = await deps.files.read(path);
445
- if (cached !== null && cached.length > 0)
446
- return cached;
447
- } catch {}
448
- const url = shiny ? `${SPRITE_ORIGIN}${SPRITE_DIR}/shiny/${id}.gif` : `${SPRITE_ORIGIN}${SPRITE_DIR}/${id}.gif`;
449
- const bytes = await fetchBytes(deps, url);
450
- if (bytes === null)
451
- return null;
452
- await writeCache(deps, path, bytes);
453
- return bytes;
648
+ function lastGrantedAt(storage, apiKeyId, windowKey) {
649
+ const row = storage.get("SELECT granted_at FROM {{grants}} WHERE api_key_id = ? AND window_key = ?", [apiKeyId, windowKey]);
650
+ return row?.granted_at ?? null;
454
651
  }
455
- async function itemSpriteBytes(deps, item) {
456
- const name = ITEM_SPRITE_NAMES.get(item);
457
- if (name === undefined)
458
- return null;
459
- const path = itemSpritePath(name);
460
- try {
461
- const cached = await deps.files.read(path);
462
- if (cached !== null && cached.length > 0)
463
- return cached;
464
- } catch {}
465
- const bytes = await fetchBytes(deps, `${SPRITE_ORIGIN}${ITEM_SPRITE_DIR}/${name}.png`);
466
- if (bytes === null)
467
- return null;
468
- await writeCache(deps, path, bytes);
469
- return bytes;
652
+ function setGrantedAt(storage, apiKeyId, windowKey, at) {
653
+ storage.run(`INSERT INTO {{grants}} (api_key_id, window_key, granted_at) VALUES (?, ?, ?)
654
+ ON CONFLICT(api_key_id, window_key) DO UPDATE SET granted_at = excluded.granted_at`, [apiKeyId, windowKey, at]);
470
655
  }
471
-
472
- // src/roll.ts
473
- var NATURES = [
474
- "hardy",
475
- "lonely",
476
- "brave",
477
- "adamant",
478
- "naughty",
479
- "bold",
480
- "docile",
481
- "relaxed",
482
- "impish",
483
- "lax",
484
- "timid",
485
- "hasty",
486
- "serious",
487
- "jolly",
488
- "naive",
489
- "modest",
490
- "mild",
491
- "quiet",
492
- "bashful",
493
- "rash",
494
- "calm",
495
- "gentle",
496
- "sassy",
497
- "careful",
498
- "quirky"
499
- ];
500
- function mulberry32(seed) {
501
- let state = seed >>> 0;
502
- return () => {
503
- state = state + 1831565813 >>> 0;
504
- let t = state;
505
- t = Math.imul(t ^ t >>> 15, t | 1);
506
- t ^= t + Math.imul(t ^ t >>> 7, t | 61);
507
- return ((t ^ t >>> 14) >>> 0) / 4294967296;
508
- };
656
+ function shopPrice(entry) {
657
+ return entry.kind === "item" ? ITEM_PRICES[entry.item] : freshEggPrice(entry.tier);
509
658
  }
510
- var COLLECTED_WEIGHT = 0.25;
511
- var FORM_WEIGHT = 0.6;
512
- function roll(input) {
513
- const random = mulberry32(input.seed);
514
- const eligibleWith = (withLure) => input.candidates.filter((candidate) => {
515
- if (!hasAnimatedSprite(candidate.id))
516
- return false;
517
- if (candidate.id === DITTO_SPECIES_ID)
518
- return false;
519
- if (input.excludeFinal != null && candidate.finalId === input.excludeFinal)
520
- return false;
521
- if (withLure && input.collectedFinals.has(candidate.finalId))
522
- return false;
523
- if (input.guarantee === null)
524
- return true;
525
- const rarity2 = rarityFromCaptureRate(candidate.captureRate, false, false);
526
- return sortRank(rarity2) >= sortRank(input.guarantee);
527
- });
528
- const wanted = input.onlyUncollected === true;
529
- const lured = wanted ? eligibleWith(true) : [];
530
- const usedLure = wanted && lured.length > 0;
531
- const eligible = usedLure ? lured : eligibleWith(false);
532
- if (eligible.length === 0)
533
- return null;
534
- const weights = eligible.map((candidate) => {
535
- const base = Math.max(1, candidate.captureRate);
536
- const collected = input.collectedFinals.has(candidate.finalId) ? base * COLLECTED_WEIGHT : base;
537
- return input.preferLongLines === true ? collected * (1 + FORM_WEIGHT * (Math.max(1, candidate.forms) - 1)) : collected;
538
- });
539
- const total = weights.reduce((a, b) => a + b, 0);
540
- let target = random() * total;
541
- let index = 0;
542
- for (let i = 0;i < weights.length; i++) {
543
- target -= weights[i];
544
- if (target <= 0) {
545
- index = i;
546
- break;
659
+ function consume(storage, apiKeyId, item, applyToState, now) {
660
+ const row = readCompanion(storage, apiKeyId);
661
+ if (row === null)
662
+ return { ok: false, reason: "missing" };
663
+ if (row.state === null)
664
+ return { ok: false, reason: "unreadable" };
665
+ if ((row.state.inventory[item] ?? 0) <= 0)
666
+ return { ok: false, reason: "none-held" };
667
+ const outcome = applyToState(row.state);
668
+ if ("refused" in outcome)
669
+ return { ok: false, reason: outcome.refused };
670
+ const nextState = {
671
+ ...outcome.applied,
672
+ inventory: {
673
+ ...outcome.applied.inventory,
674
+ [item]: (outcome.applied.inventory[item] ?? 0) - 1
547
675
  }
676
+ };
677
+ storage.run("UPDATE {{companion}} SET state = ?, updated_at = ? WHERE api_key_id = ?", [
678
+ serialiseState(nextState),
679
+ now,
680
+ apiKeyId
681
+ ]);
682
+ return { ok: true, row: { ...row, state: nextState } };
683
+ }
684
+ function purchase(storage, apiKeyId, entry, applyToState, now) {
685
+ const price = shopPrice(entry);
686
+ {
687
+ const row = readCompanion(storage, apiKeyId);
688
+ if (row === null)
689
+ return { ok: false, reason: "missing" };
690
+ if (row.state === null)
691
+ return { ok: false, reason: "unreadable" };
692
+ if (wallet(row) < price)
693
+ return { ok: false, reason: "insufficient" };
694
+ const outcome = applyToState(row.state);
695
+ if ("refused" in outcome)
696
+ return { ok: false, reason: outcome.refused };
697
+ const nextState = outcome.applied;
698
+ storage.run("UPDATE {{companion}} SET state = ?, tokens_spent = tokens_spent + ?, updated_at = ? WHERE api_key_id = ?", [serialiseState(nextState), price, now, apiKeyId]);
699
+ return {
700
+ ok: true,
701
+ row: { ...row, state: nextState, tokensSpent: row.tokensSpent + price }
702
+ };
548
703
  }
549
- const chosen = eligible[index];
550
- const shinyDenominator = input.hasShinyCharm ? ODDS.shinyWithCharm : ODDS.shiny;
551
- const isShiny = random() < 1 / shinyDenominator;
552
- const nature = NATURES[Math.floor(random() * NATURES.length)];
553
- const rarity = rarityFromCaptureRate(chosen.captureRate, false, false);
554
- const dittoEligible = rarity === "common" && chosen.forms >= 2;
555
- const ditto = dittoEligible && random() < 1 / ODDS.dittoDisguise;
556
- return { speciesId: chosen.id, isShiny, nature, ditto, usedLure };
557
704
  }
558
705
 
559
- // src/state.ts
560
- function emptyInventory() {
561
- const inventory = {};
562
- for (const kind of ITEM_KINDS)
563
- inventory[kind] = 0;
564
- return inventory;
706
+ // src/collection.ts
707
+ function collect(entries, sightings = []) {
708
+ const bySpecies = new Map;
709
+ const contributions = [...entries.flatMap(contributionsOf), ...sightings.map(contributionOf)];
710
+ for (const { speciesId, stamp, exact, source, taken } of contributions) {
711
+ const found = bySpecies.get(speciesId);
712
+ if (found === undefined) {
713
+ bySpecies.set(speciesId, {
714
+ rarity: source.rarity,
715
+ isShiny: source.isShiny,
716
+ first: stamp,
717
+ firstExact: exact,
718
+ catches: taken === null ? [] : [taken],
719
+ lines: new Map([[lineKey(source.chainOrder), source.chainOrder]])
720
+ });
721
+ continue;
722
+ }
723
+ if (taken !== null)
724
+ found.catches.push(taken);
725
+ found.isShiny = found.isShiny || source.isShiny;
726
+ if (earlier(stamp, found.first)) {
727
+ found.rarity = source.rarity;
728
+ found.first = stamp;
729
+ found.firstExact = exact;
730
+ }
731
+ const key = lineKey(source.chainOrder);
732
+ if (!found.lines.has(key))
733
+ found.lines.set(key, source.chainOrder);
734
+ }
735
+ return [...bySpecies].map(([speciesId, found]) => ({
736
+ speciesId,
737
+ rarity: found.rarity,
738
+ isShiny: found.isShiny,
739
+ firstCaughtAt: found.first.caughtAt,
740
+ firstCaughtExact: found.firstExact,
741
+ lines: [...found.lines.values()],
742
+ catches: [...found.catches].sort((a, b) => (b.enteredAt ?? b.caughtAt) - (a.enteredAt ?? a.caughtAt) || byId(a.id, b.id))
743
+ })).sort((a, b) => a.speciesId - b.speciesId);
744
+ }
745
+ function readCollection(storage, apiKeyId) {
746
+ return collect(readDex(storage, apiKeyId), listSightings(storage, apiKeyId));
747
+ }
748
+ function contributionsOf(entry) {
749
+ return entry.chainOrder.map((speciesId) => {
750
+ const enteredAt = enteredAtOf(entry, speciesId);
751
+ return {
752
+ speciesId,
753
+ stamp: { id: entry.id, caughtAt: enteredAt ?? entry.caughtAt },
754
+ exact: enteredAt !== null,
755
+ source: entry,
756
+ taken: {
757
+ id: entry.id,
758
+ chainOrder: entry.chainOrder,
759
+ isShiny: entry.isShiny,
760
+ nature: entry.nature,
761
+ caughtAt: entry.caughtAt,
762
+ enteredAt
763
+ }
764
+ };
765
+ });
565
766
  }
566
- function freshState() {
767
+ function contributionOf(sighting) {
567
768
  return {
568
- consumedTotal: 0,
569
- active: null,
570
- eggUsage: 0,
571
- eggTier: null,
572
- pendingHatch: null,
573
- pendingReveal: null,
574
- lure: false,
575
- incense: false,
576
- repel: null,
577
- inventory: emptyInventory()
769
+ speciesId: sighting.speciesId,
770
+ stamp: { id: `seen-${sighting.speciesId}`, caughtAt: sighting.seenAt },
771
+ exact: true,
772
+ source: sighting,
773
+ taken: null
578
774
  };
579
775
  }
580
- function isRecord(value) {
581
- return typeof value === "object" && value !== null && !Array.isArray(value);
776
+ function lineKey(chainOrder) {
777
+ return chainOrder.join("-");
582
778
  }
583
- function asInt(value, fallback) {
584
- return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : fallback;
779
+ function enteredAtOf(entry, speciesId) {
780
+ if (entry.stageTimes === null)
781
+ return null;
782
+ const stage = entry.chainOrder.indexOf(speciesId);
783
+ if (stage < 0)
784
+ return null;
785
+ return entry.stageTimes[stage] ?? null;
585
786
  }
586
- function asRarity(value) {
587
- return RARITIES.includes(value) ? value : null;
787
+ function earlier(a, b) {
788
+ if (a.caughtAt !== b.caughtAt)
789
+ return a.caughtAt < b.caughtAt;
790
+ return byId(a.id, b.id) < 0;
588
791
  }
589
- function parseState(raw) {
590
- let parsed;
792
+ function byId(a, b) {
793
+ return a < b ? -1 : a > b ? 1 : 0;
794
+ }
795
+
796
+ // node_modules/@omnigateway/plugin-api/src/events.ts
797
+ var WINDOW_MS = {
798
+ "1m": 60000,
799
+ "5h": 5 * 60 * 60 * 1000,
800
+ "1w": 7 * 24 * 60 * 60 * 1000
801
+ };
802
+
803
+ // src/grants.ts
804
+ function windowKey(event) {
805
+ return `${event.dimension}:${event.window}`;
806
+ }
807
+ function grantSize(window) {
808
+ if (window === "1w")
809
+ return 5;
810
+ if (window === "5h")
811
+ return 1;
812
+ return 0;
813
+ }
814
+ function decideGrant(input) {
815
+ if (grantSize(input.window) === 0)
816
+ return { grant: false };
817
+ if (input.lastGrantedAt === null)
818
+ return { grant: false, seedAt: input.now };
819
+ if (input.now - input.lastGrantedAt < WINDOW_MS[input.window])
820
+ return { grant: false };
821
+ return { grant: true, count: grantSize(input.window), at: input.now };
822
+ }
823
+
824
+ // src/pokeapi.ts
825
+ var POKEAPI_ORIGIN = "https://pokeapi.co";
826
+ var SPRITE_ORIGIN = "https://raw.githubusercontent.com";
827
+ var SPRITE_DIR = "/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated";
828
+ var ITEM_SPRITE_DIR = "/PokeAPI/sprites/master/sprites/items";
829
+ var MAX_EVOLUTION_CHAIN_ID = 2000;
830
+ var INDEX_FETCH_CONCURRENCY = 8;
831
+ var INDEX_PATH = "species/index.json";
832
+ var speciesPath = (id) => `species/${id}.json`;
833
+ var chainPath = (chainId) => `species/chain-${chainId}.json`;
834
+ var spritePath = (id, shiny) => shiny ? `sprites/shiny/${id}.gif` : `sprites/${id}.gif`;
835
+ var itemSpritePath = (name) => `sprites/items/${name}.png`;
836
+ function isFetchableSpeciesId(id) {
837
+ return Number.isInteger(id) && hasAnimatedSprite(id);
838
+ }
839
+ function asRecord(value) {
840
+ if (typeof value !== "object" || value === null || Array.isArray(value))
841
+ return null;
842
+ return value;
843
+ }
844
+ function asArray(value) {
845
+ return Array.isArray(value) ? value : null;
846
+ }
847
+ function asFiniteNumber(value) {
848
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
849
+ }
850
+ var encoder = new TextEncoder;
851
+ var decoder = new TextDecoder;
852
+ async function readJson(deps, path) {
591
853
  try {
592
- parsed = JSON.parse(raw);
854
+ const bytes = await deps.files.read(path);
855
+ if (bytes === null)
856
+ return null;
857
+ return JSON.parse(decoder.decode(bytes));
593
858
  } catch {
594
859
  return null;
595
860
  }
596
- if (!isRecord(parsed))
861
+ }
862
+ async function writeCache(deps, path, bytes) {
863
+ try {
864
+ await deps.files.write(path, bytes);
865
+ } catch {}
866
+ }
867
+ async function writeJson(deps, path, value) {
868
+ await writeCache(deps, path, encoder.encode(JSON.stringify(value)));
869
+ }
870
+ async function fetchJson(deps, url) {
871
+ try {
872
+ const response = await deps.net(url);
873
+ if (!response.ok)
874
+ return null;
875
+ return JSON.parse(await response.text());
876
+ } catch {
597
877
  return null;
598
- const inventory = emptyInventory();
599
- const storedInventory = parsed.inventory;
600
- if (isRecord(storedInventory)) {
601
- for (const kind of ITEM_KINDS) {
602
- inventory[kind] = Math.max(0, asInt(storedInventory[kind], 0));
603
- }
604
878
  }
605
- let active = null;
606
- const storedActive = parsed.active;
607
- if (storedActive !== null && storedActive !== undefined) {
608
- if (!isRecord(storedActive))
609
- return null;
610
- const rarity = asRarity(storedActive.rarity);
611
- if (rarity === null)
612
- return null;
613
- const path = Array.isArray(storedActive.plannedPath) ? storedActive.plannedPath.filter((id) => typeof id === "number" && id > 0) : [];
614
- if (path.length === 0)
879
+ }
880
+ async function fetchBytes(deps, url) {
881
+ try {
882
+ const response = await deps.net(url);
883
+ if (!response.ok)
615
884
  return null;
616
- const nature = NATURES.includes(storedActive.nature) ? storedActive.nature : null;
617
- active = {
618
- baseId: asInt(storedActive.baseId, path[0]),
619
- plannedPath: path,
620
- stageIndex: Math.min(Math.max(0, asInt(storedActive.stageIndex, 0)), path.length - 1),
621
- usedAtStage: Math.max(0, asInt(storedActive.usedAtStage, 0)),
622
- rarity,
623
- isShiny: storedActive.isShiny === true,
624
- nature: nature ?? "hardy",
625
- dittoDisguise: typeof storedActive.dittoDisguise === "number" ? storedActive.dittoDisguise : null,
626
- dittoRevealed: storedActive.dittoRevealed === true,
627
- everstone: storedActive.everstone === true,
628
- soothe: storedActive.soothe === true,
629
- soothedRaw: Math.max(0, asInt(storedActive.soothedRaw, 0))
630
- };
885
+ const bytes = new Uint8Array(await response.arrayBuffer());
886
+ return bytes.length === 0 ? null : bytes;
887
+ } catch {
888
+ return null;
631
889
  }
632
- const storedPending = parsed.pendingHatch;
633
- let pendingHatch = null;
634
- if (isRecord(storedPending)) {
635
- const rarity = asRarity(storedPending.rarity);
636
- const path = Array.isArray(storedPending.path) ? storedPending.path.filter((id) => typeof id === "number" && id > 0) : [];
637
- if (rarity !== null && path.length > 0) {
638
- const nature = NATURES.includes(storedPending.nature) ? storedPending.nature : "hardy";
639
- pendingHatch = {
640
- speciesId: asInt(storedPending.speciesId, path[0]),
641
- path,
642
- rarity,
643
- isShiny: storedPending.isShiny === true,
644
- nature,
645
- ditto: storedPending.ditto === true
646
- };
647
- }
890
+ }
891
+ var CHAIN_URL_ID = /\/evolution-chain\/(\d+)\/?$/;
892
+ function chainIdFromUrl(url) {
893
+ if (typeof url !== "string")
894
+ return null;
895
+ const match = CHAIN_URL_ID.exec(url);
896
+ if (match === null)
897
+ return null;
898
+ const id = Number(match[1]);
899
+ if (!Number.isInteger(id) || id < 1 || id > MAX_EVOLUTION_CHAIN_ID)
900
+ return null;
901
+ return id;
902
+ }
903
+ var SPECIES_URL_ID = /\/pokemon-species\/(\d+)\/?$/;
904
+ function speciesIdFromUrl(url) {
905
+ if (typeof url !== "string")
906
+ return null;
907
+ const match = SPECIES_URL_ID.exec(url);
908
+ if (match === null)
909
+ return null;
910
+ const id = Number(match[1]);
911
+ return isFetchableSpeciesId(id) ? id : null;
912
+ }
913
+ function parseChainNode(raw) {
914
+ const node = asRecord(raw);
915
+ if (node === null)
916
+ return null;
917
+ const species = asRecord(node.species);
918
+ const id = speciesIdFromUrl(species?.url);
919
+ if (id === null)
920
+ return null;
921
+ const children = asArray(node.evolves_to) ?? [];
922
+ const evolvesTo = [];
923
+ for (const child of children) {
924
+ const parsed = parseChainNode(child);
925
+ if (parsed !== null)
926
+ evolvesTo.push(parsed);
648
927
  }
649
- const storedReveal = parsed.pendingReveal;
650
- let pendingReveal = null;
651
- if (isRecord(storedReveal)) {
652
- const rarity = asRarity(storedReveal.rarity);
653
- const path = Array.isArray(storedReveal.path) ? storedReveal.path.filter((id) => typeof id === "number" && id > 0) : [];
654
- if (rarity !== null && path.length > 0)
655
- pendingReveal = { path, rarity };
928
+ return { id, evolvesTo };
929
+ }
930
+ function pathTo(node, id) {
931
+ if (node.id === id)
932
+ return [id];
933
+ for (const child of node.evolvesTo) {
934
+ const tail = pathTo(child, id);
935
+ if (tail !== null)
936
+ return [node.id, ...tail];
656
937
  }
657
- const storedConsumed = parsed.consumedTotal;
658
- if (typeof storedConsumed !== "number" || !Number.isFinite(storedConsumed))
659
- return null;
660
- const eggTier = asRarity(parsed.eggTier);
661
- return {
662
- consumedTotal: Math.max(0, Math.trunc(storedConsumed)),
663
- active,
664
- eggUsage: Math.max(0, asInt(parsed.eggUsage, 0)),
665
- eggTier: eggTier === null || eggTier === "legendary" ? null : eggTier,
666
- pendingHatch,
667
- pendingReveal,
668
- lure: parsed.lure === true,
669
- incense: parsed.incense === true,
670
- repel: typeof parsed.repel === "number" && Number.isInteger(parsed.repel) && parsed.repel > 0 ? parsed.repel : null,
671
- inventory
672
- };
938
+ return null;
673
939
  }
674
- function serialiseState(state) {
675
- return JSON.stringify(state);
940
+ function descendFirstBranch(node) {
941
+ const rest = [];
942
+ let current = node;
943
+ while (current.evolvesTo.length > 0) {
944
+ const next = current.evolvesTo[0];
945
+ rest.push(next.id);
946
+ current = next;
947
+ }
948
+ return rest;
676
949
  }
677
- function hasShinyCharm(state) {
678
- return (state.inventory.shinyCharm ?? 0) > 0;
950
+ function lineThrough(root, id) {
951
+ const prefix = pathTo(root, id);
952
+ if (prefix === null)
953
+ return null;
954
+ const self = prefix[prefix.length - 1];
955
+ const node = nodeFor(root, self);
956
+ return node === null ? prefix : [...prefix, ...descendFirstBranch(node)];
679
957
  }
680
-
681
- // src/advance.ts
682
- var MAX_TRANSITIONS_PER_ADVANCE = 64;
683
- function advance(state, tokensTotal) {
684
- const gained = Math.max(0, Math.trunc(tokensTotal) - state.consumedTotal);
685
- const events = [];
686
- let next = { ...state, consumedTotal: Math.trunc(tokensTotal) };
687
- if (gained > 0) {
688
- const active = next.active;
689
- if (active === null) {
690
- next = { ...next, eggUsage: next.eggUsage + gained };
691
- } else if (!active.soothe) {
692
- next = { ...next, active: { ...active, usedAtStage: active.usedAtStage + gained } };
693
- } else {
694
- const raw = active.soothedRaw + gained;
695
- const owed = Math.floor(raw * SOOTHE_BONUS) - Math.floor(active.soothedRaw * SOOTHE_BONUS);
696
- next = {
697
- ...next,
698
- active: { ...active, soothedRaw: raw, usedAtStage: active.usedAtStage + gained + owed }
699
- };
700
- }
958
+ function nodeFor(node, id) {
959
+ if (node.id === id)
960
+ return node;
961
+ for (const child of node.evolvesTo) {
962
+ const found = nodeFor(child, id);
963
+ if (found !== null)
964
+ return found;
701
965
  }
702
- for (let step = 0;step < MAX_TRANSITIONS_PER_ADVANCE; step++) {
703
- if (next.active === null) {
704
- if (next.eggUsage < EGG_HATCH_THRESHOLD)
705
- break;
706
- if (next.pendingHatch === null)
707
- break;
708
- const hatch = next.pendingHatch;
709
- const active = {
710
- baseId: hatch.path[0] ?? hatch.speciesId,
711
- plannedPath: hatch.path,
712
- stageIndex: 0,
713
- usedAtStage: next.eggUsage - EGG_HATCH_THRESHOLD,
714
- rarity: hatch.rarity,
715
- isShiny: hatch.isShiny,
716
- nature: hatch.nature,
717
- dittoDisguise: hatch.ditto ? hatch.speciesId : null,
718
- dittoRevealed: false,
719
- everstone: false,
720
- soothe: false,
721
- soothedRaw: 0
722
- };
723
- events.push({
724
- kind: "hatched",
725
- speciesId: hatch.speciesId,
726
- isShiny: active.isShiny,
727
- ditto: active.dittoDisguise !== null
728
- });
729
- next = { ...next, active, eggUsage: 0, eggTier: null, pendingHatch: null };
730
- continue;
731
- }
732
- const mon = next.active;
733
- if (mon.everstone)
734
- break;
735
- const needed = phaseThreshold(mon.rarity, mon.plannedPath.length, mon.stageIndex);
736
- if (mon.usedAtStage < needed)
737
- break;
738
- const excess = mon.usedAtStage - needed;
739
- if (mon.dittoDisguise !== null && !mon.dittoRevealed) {
740
- if (next.pendingReveal === null)
741
- break;
742
- const reveal = next.pendingReveal;
743
- events.push({
744
- kind: "revealed",
745
- disguisedAs: mon.plannedPath[mon.stageIndex] ?? mon.baseId,
746
- speciesId: reveal.path[0]
747
- });
748
- next = {
749
- ...next,
750
- active: {
751
- ...mon,
752
- baseId: reveal.path[0],
753
- plannedPath: reveal.path,
754
- stageIndex: 0,
755
- usedAtStage: excess,
756
- rarity: reveal.rarity,
757
- dittoRevealed: true
758
- },
759
- pendingReveal: null
760
- };
761
- continue;
966
+ return null;
967
+ }
968
+ function loadChain(deps, chainId, cache) {
969
+ const inFlight = cache.get(chainId);
970
+ if (inFlight !== undefined)
971
+ return inFlight;
972
+ const pending = (async () => {
973
+ const path = chainPath(chainId);
974
+ const cached = await readJson(deps, path);
975
+ if (cached !== null) {
976
+ const parsed2 = parseChainNode(cached);
977
+ if (parsed2 !== null)
978
+ return parsed2;
762
979
  }
763
- if (mon.stageIndex < mon.plannedPath.length - 1) {
764
- events.push({
765
- kind: "evolved",
766
- from: mon.plannedPath[mon.stageIndex],
767
- to: mon.plannedPath[mon.stageIndex + 1]
768
- });
769
- next = { ...next, active: { ...mon, stageIndex: mon.stageIndex + 1, usedAtStage: excess } };
980
+ const fetched = await fetchJson(deps, `${POKEAPI_ORIGIN}/api/v2/evolution-chain/${chainId}`);
981
+ if (fetched === null)
982
+ return null;
983
+ const root = asRecord(fetched)?.chain;
984
+ const parsed = parseChainNode(root);
985
+ if (parsed === null)
986
+ return null;
987
+ await writeJson(deps, path, { chain: root });
988
+ return parsed;
989
+ })();
990
+ cache.set(chainId, pending);
991
+ return pending;
992
+ }
993
+ function parseNames(raw) {
994
+ const names = {};
995
+ for (const entry of asArray(raw) ?? []) {
996
+ const record = asRecord(entry);
997
+ if (record === null)
770
998
  continue;
771
- }
772
- events.push({
773
- kind: "graduated",
774
- baseId: mon.baseId,
775
- finalId: mon.plannedPath[mon.plannedPath.length - 1],
776
- chainOrder: mon.plannedPath,
777
- rarity: mon.rarity,
778
- isShiny: mon.isShiny,
779
- nature: mon.nature
780
- });
781
- next = { ...next, active: null, eggUsage: excess, eggTier: null, pendingHatch: null };
999
+ const language = asRecord(record.language)?.name;
1000
+ const name = record.name;
1001
+ if (typeof language === "string" && typeof name === "string")
1002
+ names[language] = name;
782
1003
  }
783
- return { state: next, events };
1004
+ return names;
784
1005
  }
785
-
786
- // src/store.ts
787
- var MIGRATIONS = [
788
- {
789
- version: 1,
790
- sql: `
791
- CREATE TABLE {{companion}} (
792
- api_key_id TEXT PRIMARY KEY,
793
- state TEXT NOT NULL,
794
- tokens_total INTEGER NOT NULL DEFAULT 0,
795
- tokens_spent INTEGER NOT NULL DEFAULT 0,
796
- created_at INTEGER NOT NULL,
797
- updated_at INTEGER NOT NULL
798
- )
799
- `
800
- },
801
- {
802
- version: 2,
803
- sql: `
804
- CREATE TABLE {{dex}} (
805
- id TEXT PRIMARY KEY,
806
- api_key_id TEXT NOT NULL,
807
- base_id INTEGER NOT NULL,
808
- final_id INTEGER NOT NULL,
809
- chain_order TEXT NOT NULL,
810
- rarity TEXT NOT NULL,
811
- is_shiny INTEGER NOT NULL DEFAULT 0,
812
- nature TEXT,
813
- caught_at INTEGER NOT NULL
814
- )
815
- `
816
- },
817
- {
818
- version: 3,
819
- sql: `CREATE INDEX {{dex_by_key}} ON {{dex}} (api_key_id, caught_at DESC)`
820
- },
821
- {
822
- version: 4,
823
- sql: `
824
- CREATE TABLE {{grants}} (
825
- api_key_id TEXT NOT NULL,
826
- window_key TEXT NOT NULL,
827
- -- An instant, not a tier. A grant is rate-limited by the window's own
828
- -- duration, because nothing tells this plugin when a window empties.
829
- granted_at INTEGER NOT NULL,
830
- PRIMARY KEY (api_key_id, window_key)
831
- )
832
- `
833
- },
834
- {
835
- version: 5,
836
- sql: `ALTER TABLE {{companion}} ADD COLUMN last_credit_at INTEGER`
1006
+ function parseCachedDetail(raw, id) {
1007
+ const record = asRecord(raw);
1008
+ if (record === null)
1009
+ return null;
1010
+ const captureRate = asFiniteNumber(record.captureRate);
1011
+ if (captureRate === null)
1012
+ return null;
1013
+ const chainRaw = asArray(record.chain);
1014
+ if (chainRaw === null || chainRaw.length === 0)
1015
+ return null;
1016
+ const chain = [];
1017
+ for (const entry of chainRaw) {
1018
+ if (typeof entry !== "number" || !isFetchableSpeciesId(entry))
1019
+ return null;
1020
+ chain.push(entry);
837
1021
  }
838
- ];
839
- function wallet(row) {
840
- return Math.max(0, row.tokensTotal - row.tokensSpent);
1022
+ return {
1023
+ id,
1024
+ names: parseNames(record.names),
1025
+ captureRate,
1026
+ isLegendary: record.isLegendary === true,
1027
+ isMythical: record.isMythical === true,
1028
+ chain
1029
+ };
841
1030
  }
842
- function readCompanion(storage, apiKeyId) {
843
- const row = storage.get(`SELECT api_key_id, state, tokens_total, tokens_spent, last_credit_at
844
- FROM {{companion}} WHERE api_key_id = ?`, [apiKeyId]);
845
- if (row === null)
846
- return null;
1031
+ function detailToCache(detail) {
847
1032
  return {
848
- apiKeyId: row.api_key_id,
849
- state: parseState(row.state),
850
- tokensTotal: row.tokens_total,
851
- tokensSpent: row.tokens_spent,
852
- lastCreditAt: row.last_credit_at
1033
+ captureRate: detail.captureRate,
1034
+ isLegendary: detail.isLegendary,
1035
+ isMythical: detail.isMythical,
1036
+ chain: detail.chain,
1037
+ names: Object.entries(detail.names).map(([language, name]) => ({
1038
+ language: { name: language },
1039
+ name
1040
+ }))
853
1041
  };
854
1042
  }
855
- function listCompanions(storage) {
856
- const rows = storage.all(`SELECT api_key_id, state, tokens_total, tokens_spent, last_credit_at
857
- FROM {{companion}}
858
- ORDER BY last_credit_at IS NULL, last_credit_at DESC, tokens_total DESC, api_key_id ASC`);
859
- return rows.map((row) => ({
860
- apiKeyId: row.api_key_id,
861
- state: parseState(row.state),
862
- tokensTotal: row.tokens_total,
863
- tokensSpent: row.tokens_spent,
864
- lastCreditAt: row.last_credit_at
865
- }));
1043
+ async function loadDetail(deps, id, chains) {
1044
+ if (!isFetchableSpeciesId(id))
1045
+ return null;
1046
+ const path = speciesPath(id);
1047
+ const cached = parseCachedDetail(await readJson(deps, path), id);
1048
+ if (cached !== null)
1049
+ return cached;
1050
+ const raw = asRecord(await fetchJson(deps, `${POKEAPI_ORIGIN}/api/v2/pokemon-species/${id}`));
1051
+ if (raw === null)
1052
+ return null;
1053
+ const captureRate = asFiniteNumber(raw.capture_rate);
1054
+ if (captureRate === null)
1055
+ return null;
1056
+ const chainId = chainIdFromUrl(asRecord(raw.evolution_chain)?.url);
1057
+ if (chainId === null)
1058
+ return null;
1059
+ const root = await loadChain(deps, chainId, chains);
1060
+ if (root === null)
1061
+ return null;
1062
+ const chain = lineThrough(root, id);
1063
+ if (chain === null || chain.length === 0)
1064
+ return null;
1065
+ const detail = {
1066
+ id,
1067
+ names: parseNames(raw.names),
1068
+ captureRate,
1069
+ isLegendary: raw.is_legendary === true,
1070
+ isMythical: raw.is_mythical === true,
1071
+ chain
1072
+ };
1073
+ await writeJson(deps, path, detailToCache(detail));
1074
+ return detail;
866
1075
  }
867
- function creditTokens(storage, apiKeyId, tokens, now) {
868
- if (tokens <= 0)
869
- return;
870
- storage.run(`INSERT INTO {{companion}} (api_key_id, state, tokens_total, tokens_spent, created_at, updated_at, last_credit_at)
871
- VALUES (?, ?, ?, 0, ?, ?, ?)
872
- ON CONFLICT(api_key_id) DO UPDATE SET
873
- tokens_total = tokens_total + excluded.tokens_total,
874
- updated_at = excluded.updated_at,
875
- last_credit_at = excluded.last_credit_at`, [apiKeyId, serialiseState(freshState()), Math.trunc(tokens), now, now, now]);
1076
+ function speciesDetail(deps, id) {
1077
+ return loadDetail(deps, id, new Map);
876
1078
  }
877
- function settle(storage, apiKeyId, now) {
878
- const row = readCompanion(storage, apiKeyId);
879
- if (row === null)
1079
+ function speciesDetails(deps, ids) {
1080
+ const chains = new Map;
1081
+ return Promise.all(ids.map((id) => loadDetail(deps, id, chains)));
1082
+ }
1083
+ async function cachedSpeciesName(deps, id) {
1084
+ if (!isFetchableSpeciesId(id))
880
1085
  return null;
881
- if (row.state === null)
882
- return { row, events: [] };
883
- const result = advance(row.state, row.tokensTotal);
884
- if (result.events.length === 0 && result.state === row.state)
885
- return { row, events: [] };
886
- storage.run("UPDATE {{companion}} SET state = ?, updated_at = ? WHERE api_key_id = ?", [
887
- serialiseState(result.state),
888
- now,
889
- apiKeyId
890
- ]);
891
- return { row: { ...row, state: result.state }, events: result.events };
1086
+ const cached = parseCachedDetail(await readJson(deps, speciesPath(id)), id);
1087
+ return cached?.names.en ?? null;
892
1088
  }
893
- function recordGraduation(storage, apiKeyId, entry, id) {
894
- storage.run(`INSERT INTO {{dex}} (id, api_key_id, base_id, final_id, chain_order, rarity, is_shiny, nature, caught_at)
895
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
896
- id,
897
- apiKeyId,
898
- entry.baseId,
899
- entry.finalId,
900
- JSON.stringify(entry.chainOrder),
901
- entry.rarity,
902
- entry.isShiny ? 1 : 0,
903
- entry.nature,
904
- entry.caughtAt
905
- ]);
1089
+ function parseCachedIndex(raw) {
1090
+ const entries = asArray(raw);
1091
+ if (entries === null)
1092
+ return null;
1093
+ const candidates = [];
1094
+ for (const entry of entries) {
1095
+ const record = asRecord(entry);
1096
+ if (record === null)
1097
+ return null;
1098
+ const id = asFiniteNumber(record.id);
1099
+ const captureRate = asFiniteNumber(record.captureRate);
1100
+ const forms = asFiniteNumber(record.forms);
1101
+ const finalId = asFiniteNumber(record.finalId);
1102
+ if (id === null || captureRate === null || forms === null || finalId === null)
1103
+ return null;
1104
+ if (!isFetchableSpeciesId(id))
1105
+ return null;
1106
+ candidates.push({ id, captureRate, forms, finalId });
1107
+ }
1108
+ return candidates.length === 0 ? null : candidates;
906
1109
  }
907
- function readDex(storage, apiKeyId) {
908
- const rows = storage.all(`SELECT id, base_id, final_id, chain_order, rarity, is_shiny, nature, caught_at
909
- FROM {{dex}} WHERE api_key_id = ? ORDER BY caught_at DESC`, [apiKeyId]);
910
- const entries = [];
911
- for (const row of rows) {
912
- let chainOrder;
913
- try {
914
- chainOrder = JSON.parse(row.chain_order);
915
- } catch {
916
- continue;
1110
+ async function mapWithConcurrency(ids, limit, worker) {
1111
+ const results = new Array(ids.length);
1112
+ let next = 0;
1113
+ const runners = Array.from({ length: Math.min(limit, ids.length) }, async () => {
1114
+ while (next < ids.length) {
1115
+ const index = next++;
1116
+ results[index] = await worker(ids[index]);
917
1117
  }
918
- if (!Array.isArray(chainOrder))
1118
+ });
1119
+ await Promise.all(runners);
1120
+ return results;
1121
+ }
1122
+ async function speciesIndex(deps) {
1123
+ const cached = parseCachedIndex(await readJson(deps, INDEX_PATH));
1124
+ if (cached !== null)
1125
+ return cached;
1126
+ const ids = Array.from({ length: ANIMATED_SPECIES_MAX }, (_, i) => i + 1);
1127
+ const chains = new Map;
1128
+ const details = await mapWithConcurrency(ids, INDEX_FETCH_CONCURRENCY, (id) => loadDetail(deps, id, chains));
1129
+ const candidates = [];
1130
+ for (const detail of details) {
1131
+ if (detail === null)
919
1132
  continue;
920
- const chain = chainOrder.filter((id) => typeof id === "number");
921
- if (chain.length === 0)
1133
+ if (detail.chain[0] !== detail.id)
922
1134
  continue;
923
- entries.push({
924
- id: row.id,
925
- baseId: row.base_id,
926
- finalId: row.final_id,
927
- chainOrder: chain,
928
- rarity: row.rarity,
929
- isShiny: row.is_shiny === 1,
930
- nature: row.nature,
931
- caughtAt: row.caught_at
1135
+ candidates.push({
1136
+ id: detail.id,
1137
+ captureRate: detail.captureRate,
1138
+ forms: detail.chain.length,
1139
+ finalId: detail.chain[detail.chain.length - 1] ?? detail.id
932
1140
  });
933
1141
  }
934
- return entries;
935
- }
936
- function lastGrantedAt(storage, apiKeyId, windowKey2) {
937
- const row = storage.get("SELECT granted_at FROM {{grants}} WHERE api_key_id = ? AND window_key = ?", [apiKeyId, windowKey2]);
938
- return row?.granted_at ?? null;
939
- }
940
- function setGrantedAt(storage, apiKeyId, windowKey2, at) {
941
- storage.run(`INSERT INTO {{grants}} (api_key_id, window_key, granted_at) VALUES (?, ?, ?)
942
- ON CONFLICT(api_key_id, window_key) DO UPDATE SET granted_at = excluded.granted_at`, [apiKeyId, windowKey2, at]);
943
- }
944
- function shopPrice(entry) {
945
- return entry.kind === "item" ? ITEM_PRICES[entry.item] : freshEggPrice(entry.tier);
1142
+ if (details.every((detail) => detail !== null))
1143
+ await writeJson(deps, INDEX_PATH, candidates);
1144
+ return candidates;
946
1145
  }
947
- function consume(storage, apiKeyId, item, applyToState, now) {
948
- const row = readCompanion(storage, apiKeyId);
949
- if (row === null)
950
- return { ok: false, reason: "missing" };
951
- if (row.state === null)
952
- return { ok: false, reason: "unreadable" };
953
- if ((row.state.inventory[item] ?? 0) <= 0)
954
- return { ok: false, reason: "none-held" };
955
- const outcome = applyToState(row.state);
956
- if ("refused" in outcome)
957
- return { ok: false, reason: outcome.refused };
958
- const nextState = {
959
- ...outcome.applied,
960
- inventory: {
961
- ...outcome.applied.inventory,
962
- [item]: (outcome.applied.inventory[item] ?? 0) - 1
963
- }
964
- };
965
- storage.run("UPDATE {{companion}} SET state = ?, updated_at = ? WHERE api_key_id = ?", [
966
- serialiseState(nextState),
967
- now,
968
- apiKeyId
969
- ]);
970
- return { ok: true, row: { ...row, state: nextState } };
1146
+ async function spriteBytes(deps, id, shiny) {
1147
+ if (!isFetchableSpeciesId(id))
1148
+ return null;
1149
+ const path = spritePath(id, shiny);
1150
+ try {
1151
+ const cached = await deps.files.read(path);
1152
+ if (cached !== null && cached.length > 0)
1153
+ return cached;
1154
+ } catch {}
1155
+ const url = shiny ? `${SPRITE_ORIGIN}${SPRITE_DIR}/shiny/${id}.gif` : `${SPRITE_ORIGIN}${SPRITE_DIR}/${id}.gif`;
1156
+ const bytes = await fetchBytes(deps, url);
1157
+ if (bytes === null)
1158
+ return null;
1159
+ await writeCache(deps, path, bytes);
1160
+ return bytes;
971
1161
  }
972
- function purchase(storage, apiKeyId, entry, applyToState, now) {
973
- const price = shopPrice(entry);
974
- {
975
- const row = readCompanion(storage, apiKeyId);
976
- if (row === null)
977
- return { ok: false, reason: "missing" };
978
- if (row.state === null)
979
- return { ok: false, reason: "unreadable" };
980
- if (wallet(row) < price)
981
- return { ok: false, reason: "insufficient" };
982
- const outcome = applyToState(row.state);
983
- if ("refused" in outcome)
984
- return { ok: false, reason: outcome.refused };
985
- const nextState = outcome.applied;
986
- storage.run("UPDATE {{companion}} SET state = ?, tokens_spent = tokens_spent + ?, updated_at = ? WHERE api_key_id = ?", [serialiseState(nextState), price, now, apiKeyId]);
987
- return {
988
- ok: true,
989
- row: { ...row, state: nextState, tokensSpent: row.tokensSpent + price }
990
- };
991
- }
1162
+ async function itemSpriteBytes(deps, item) {
1163
+ const name = ITEM_SPRITE_NAMES.get(item);
1164
+ if (name === undefined)
1165
+ return null;
1166
+ const path = itemSpritePath(name);
1167
+ try {
1168
+ const cached = await deps.files.read(path);
1169
+ if (cached !== null && cached.length > 0)
1170
+ return cached;
1171
+ } catch {}
1172
+ const bytes = await fetchBytes(deps, `${SPRITE_ORIGIN}${ITEM_SPRITE_DIR}/${name}.png`);
1173
+ if (bytes === null)
1174
+ return null;
1175
+ await writeCache(deps, path, bytes);
1176
+ return bytes;
992
1177
  }
993
1178
 
994
1179
  // src/server.ts
@@ -1077,6 +1262,17 @@ var server_default = definePlugin({
1077
1262
  const result = settle(storage, apiKeyId, ctx.now());
1078
1263
  if (result === null)
1079
1264
  return;
1265
+ const active = result.row.state?.active;
1266
+ if (active !== null && active !== undefined) {
1267
+ recordSightings(storage, apiKeyId, {
1268
+ plannedPath: active.plannedPath,
1269
+ stageIndex: active.stageIndex,
1270
+ stageTimes: active.stageTimes,
1271
+ rarity: active.rarity,
1272
+ isShiny: active.isShiny,
1273
+ disguised: active.dittoDisguise !== null && !active.dittoRevealed
1274
+ }, ctx.now());
1275
+ }
1080
1276
  for (const event of result.events) {
1081
1277
  if (event.kind !== "graduated")
1082
1278
  continue;
@@ -1084,6 +1280,7 @@ var server_default = definePlugin({
1084
1280
  baseId: event.baseId,
1085
1281
  finalId: event.finalId,
1086
1282
  chainOrder: event.chainOrder,
1283
+ stageTimes: event.stageTimes,
1087
1284
  rarity: event.rarity,
1088
1285
  isShiny: event.isShiny,
1089
1286
  nature: event.nature,
@@ -1253,13 +1450,15 @@ var server_default = definePlugin({
1253
1450
  if (row.state !== null)
1254
1451
  prefetchOnce(apiKeyId, row.state).catch(() => {});
1255
1452
  const active = row.state?.active ?? null;
1256
- const dex = readDex(storage, apiKeyId);
1257
1453
  const stageId = active === null ? null : active.plannedPath[active.stageIndex] ?? null;
1258
1454
  const stageName = await nameOf(stageId);
1259
- const named = await Promise.all(dex.map(async (entry) => ({ ...entry, name: await nameOf(entry.finalId) })));
1455
+ const named = await Promise.all(readCollection(storage, apiKeyId).map(async (record) => ({
1456
+ ...record,
1457
+ name: await nameOf(record.speciesId)
1458
+ })));
1260
1459
  warmNames([
1261
1460
  ...stageId !== null && stageName === null ? [stageId] : [],
1262
- ...named.filter((entry) => entry.name === null).map((entry) => entry.finalId)
1461
+ ...named.filter((record) => record.name === null).map((record) => record.speciesId)
1263
1462
  ]);
1264
1463
  return {
1265
1464
  json: {