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