@omnigateway/pokemon 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Harismawan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # @omnigateway/pokemon
2
+
3
+ A Pokémon companion for [OmniGateway](https://github.com/harismawan/omnigateway).
4
+
5
+ Each gateway API key raises one companion. It hatches, evolves, and graduates
6
+ into a per-key Pokédex on the tokens that key spends, with a shop that spends a
7
+ wallet of those same tokens. It is strictly an observer: nothing it stores
8
+ affects routing, limits, or anything a request depends on.
9
+
10
+ ## Name
11
+
12
+ Unscoped, on purpose. `@omnigateway/*` is the project's own scope, and a
13
+ third-party plugin could never publish into it — so this repository is named the
14
+ way an external author's would be, `omnigateway-plugin-<name>`, the same
15
+ convention as `eslint-plugin-*`. It is the companion plugin, not a first-party
16
+ one, and the packaging should say so.
17
+
18
+ ## Install
19
+
20
+ The gateway resolves a plugin by name through npm, so the host needs no checkout
21
+ and no build toolchain:
22
+
23
+ ```bash
24
+ omni plugin install @omnigateway/pokemon
25
+ omni plugin verify pokemon && omni restart
26
+ ```
27
+
28
+ `verify` is the one to run before restarting a gateway that people are using: it
29
+ reaches the same verdict the next boot will, from the same code, without loading
30
+ the plugin.
31
+
32
+ Resolving a name through npm makes distribution easier; it does not make an
33
+ unknown plugin safer. Integrity checking proves you received the bytes the
34
+ registry advertised, and nothing about who wrote them or what they do once the
35
+ gateway imports them.
36
+
37
+ ### From a checkout
38
+
39
+ ```bash
40
+ bun install
41
+ bun run build # writes dist/pokemon
42
+ omni plugin install ./dist/pokemon
43
+ omni restart
44
+ ```
45
+
46
+ The built directory is `dist/pokemon` and the name matters: the installer takes
47
+ the installed directory name from the source and refuses a manifest whose `id`
48
+ disagrees with it, so a plugin cannot be installed under a name that is not its
49
+ own.
50
+
51
+ ## Capabilities
52
+
53
+ The manifest declares five, and each one is there for a reason a reader can
54
+ check:
55
+
56
+ | Capability | Why |
57
+ | --- | --- |
58
+ | `storage` | Three tables on the plugin's own migration track, named `plugin_pokemon_<name>` by the host: the companion row per key, the Dex, and the grant ledger. |
59
+ | `files` | The species index and cached sprites live in the plugin's scoped data directory, **not** in a table. That directory is excluded from database snapshots, exactly as `request_bodies/` is — the alternative would put tens of megabytes of artwork into every snapshot an operator downloads, and it re-fetches itself anyway. |
60
+ | `net:outbound` | Species data and sprites are fetched at runtime. The manifest also declares the origins, `https://pokeapi.co` and `https://raw.githubusercontent.com`; the host hands the plugin a `fetch` bound to that allowlist and refuses anything else. |
61
+ | `events:request` | Growth is credited from `RequestCompleted` — all four token classes, which are disjoint, so summing them double-counts nothing. |
62
+ | `events:limit` | A key parked at a `5h` or `1w` ceiling earns a rare candy, rated by the window's own length. A `1m` ceiling pays nothing: a minute is not a span in which work happened. |
63
+
64
+ Worth restating, because a plugin author reading a capability list will assume
65
+ otherwise: **this is a guardrail, not a sandbox.** A plugin shares the gateway's
66
+ process and can import past all of it. What the declaration buys is that
67
+ accidental overreach is impossible and that the plugin's intent is auditable
68
+ from one readable file. It constrains honest code and not hostile code.
69
+
70
+ The plugin degrades rather than failing when a capability is absent. With no
71
+ `net`, an incubating egg holds its progress instead of losing it, and the sprite
72
+ route answers `503`.
73
+
74
+ ## Nintendo and Game Freak intellectual property
75
+
76
+ The sprites, names, and evolution data are Nintendo and Game Freak intellectual
77
+ property, fetched at runtime from PokéAPI and the community sprite repository.
78
+ **Nothing is vendored** — not into this repository, not into the published npm
79
+ package, not into any built artifact. A running install contacts `pokeapi.co`
80
+ and `raw.githubusercontent.com` and caches what it gets in its own scoped data
81
+ directory.
82
+
83
+ This plugin ships separately, as something an operator chooses to fetch, rather
84
+ than inside OmniGateway's npm package or Docker image. Infrastructure other
85
+ people deploy is a different exposure profile from a personal application, and
86
+ that packaging decision follows from it. Recorded here so it stays a knowing
87
+ one.
88
+
89
+ ## Development
90
+
91
+ ```bash
92
+ bun install
93
+ bun run test # the plugin's own suites
94
+ bun run test:ui # the console panel, under happy-dom, separately
95
+ bun run typecheck
96
+ bun run lint
97
+ bun run build
98
+ ```
99
+
100
+ The UI suite runs on its own because registering a DOM mutates process-wide
101
+ globals, which would leak into every other file sharing the process.
102
+
103
+ This package builds against the published `@omnigateway/plugin-api` and
104
+ `@omnigateway/dashboard-sdk` and against nothing internal to the gateway. That
105
+ is deliberate: a plugin developed inside the monorepo can reach packages an
106
+ installed plugin cannot, and a build that only succeeds there proves nothing
107
+ about one that has to run anywhere else.
108
+
109
+ ## Licence
110
+
111
+ MIT. See [LICENSE](LICENSE). The licence covers this plugin's code and not the
112
+ third-party assets it fetches at runtime.
@@ -0,0 +1,17 @@
1
+ {
2
+ "id": "pokemon",
3
+ "name": "Pok\u00e9mon Companion",
4
+ "version": "1.0.0",
5
+ "api": 1,
6
+ "sdk": "^0.1.0",
7
+ "server": "server/index.js",
8
+ "ui": "ui/index.js",
9
+ "nav": {
10
+ "label": "Companion"
11
+ },
12
+ "capabilities": ["storage", "files", "net:outbound", "events:request", "events:limit"],
13
+ "origins": ["https://pokeapi.co", "https://raw.githubusercontent.com"],
14
+ "defaults": {
15
+ "multiplier": 1
16
+ }
17
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@omnigateway/pokemon",
3
+ "version": "1.0.0",
4
+ "description": "A Pokémon companion for OmniGateway. Each gateway key raises one that hatches, evolves, and graduates into a Pokédex on the tokens that key spends.",
5
+ "license": "MIT",
6
+ "author": "Harismawan <mail@harismawan.com>",
7
+ "homepage": "https://github.com/harismawan/omnigateway-plugin-pokemon",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/harismawan/omnigateway-plugin-pokemon.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/harismawan/omnigateway-plugin-pokemon/issues"
14
+ },
15
+ "keywords": [
16
+ "omnigateway",
17
+ "omnigateway-plugin",
18
+ "pokemon"
19
+ ],
20
+ "files": [
21
+ "omni-plugin.json",
22
+ "server",
23
+ "ui",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ }
30
+ }
@@ -0,0 +1,1134 @@
1
+ // @bun
2
+ // node_modules/@omnigateway/plugin-api/src/context.ts
3
+ function definePlugin(definition) {
4
+ return definition;
5
+ }
6
+
7
+ // src/balance.ts
8
+ var RARITIES = ["common", "uncommon", "rare", "legendary"];
9
+ function sortRank(rarity) {
10
+ return RARITIES.indexOf(rarity);
11
+ }
12
+ var CAPTURE_RATE_CEILING = {
13
+ rare: 45,
14
+ uncommon: 120,
15
+ common: 255
16
+ };
17
+ function rarityFromCaptureRate(captureRate, isLegendary, isMythical) {
18
+ if (isLegendary || isMythical)
19
+ return "legendary";
20
+ if (captureRate <= CAPTURE_RATE_CEILING.rare)
21
+ return "rare";
22
+ if (captureRate <= CAPTURE_RATE_CEILING.uncommon)
23
+ return "uncommon";
24
+ return "common";
25
+ }
26
+ var GRADUATION_TOTAL = {
27
+ common: 750000000,
28
+ uncommon: 1875000000,
29
+ rare: 3000000000,
30
+ legendary: 6000000000
31
+ };
32
+ function graduationTotal(rarity) {
33
+ return GRADUATION_TOTAL[rarity];
34
+ }
35
+ function phaseThreshold(rarity, totalForms, stageIndex) {
36
+ const forms = Math.max(1, totalForms);
37
+ const step = stageIndex + 1;
38
+ const denominator = forms * (forms + 1) / 2;
39
+ return Math.round(graduationTotal(rarity) * step / denominator);
40
+ }
41
+ var EGG_HATCH_THRESHOLD = 5000000;
42
+ var RARE_CANDY_XP = 1e8;
43
+ var ITEM_PRICES = {
44
+ rareCandy: 500000000,
45
+ mint: 1e8,
46
+ shinyCharm: 3000000000
47
+ };
48
+ var ITEM_KINDS = Object.keys(ITEM_PRICES);
49
+ var FRESH_EGG_BASE_PRICE = 1e9;
50
+ function freshEggPrice(tier) {
51
+ if (tier === null)
52
+ return FRESH_EGG_BASE_PRICE;
53
+ if (tier === "legendary") {
54
+ throw new Error("there is no legendary fresh egg; legendaries come from the upper tiers");
55
+ }
56
+ const multiplier = graduationTotal(tier) / graduationTotal("common");
57
+ return Math.round(FRESH_EGG_BASE_PRICE * multiplier);
58
+ }
59
+ var ODDS = {
60
+ shiny: 64,
61
+ shinyWithCharm: 48,
62
+ dittoDisguise: 128
63
+ };
64
+ var DITTO_SPECIES_ID = 132;
65
+ var ANIMATED_SPECIES_MAX = 649;
66
+ function hasAnimatedSprite(speciesId) {
67
+ return speciesId >= 1 && speciesId <= ANIMATED_SPECIES_MAX;
68
+ }
69
+
70
+ // node_modules/@omnigateway/plugin-api/src/events.ts
71
+ var WINDOW_MS = {
72
+ "1m": 60000,
73
+ "5h": 5 * 60 * 60 * 1000,
74
+ "1w": 7 * 24 * 60 * 60 * 1000
75
+ };
76
+
77
+ // src/grants.ts
78
+ function windowKey(event) {
79
+ return `${event.dimension}:${event.window}`;
80
+ }
81
+ function grantSize(window) {
82
+ if (window === "1w")
83
+ return 5;
84
+ if (window === "5h")
85
+ return 1;
86
+ return 0;
87
+ }
88
+ function decideGrant(input) {
89
+ if (grantSize(input.window) === 0)
90
+ return { grant: false };
91
+ if (input.lastGrantedAt === null)
92
+ return { grant: false, seedAt: input.now };
93
+ if (input.now - input.lastGrantedAt < WINDOW_MS[input.window])
94
+ return { grant: false };
95
+ return { grant: true, count: grantSize(input.window), at: input.now };
96
+ }
97
+
98
+ // src/pokeapi.ts
99
+ var POKEAPI_ORIGIN = "https://pokeapi.co";
100
+ var SPRITE_ORIGIN = "https://raw.githubusercontent.com";
101
+ var SPRITE_DIR = "/PokeAPI/sprites/master/sprites/pokemon/versions/generation-v/black-white/animated";
102
+ var MAX_EVOLUTION_CHAIN_ID = 2000;
103
+ var INDEX_FETCH_CONCURRENCY = 8;
104
+ var INDEX_PATH = "species/index.json";
105
+ var speciesPath = (id) => `species/${id}.json`;
106
+ var chainPath = (chainId) => `species/chain-${chainId}.json`;
107
+ var spritePath = (id, shiny) => shiny ? `sprites/shiny/${id}.gif` : `sprites/${id}.gif`;
108
+ function isFetchableSpeciesId(id) {
109
+ return Number.isInteger(id) && hasAnimatedSprite(id);
110
+ }
111
+ function asRecord(value) {
112
+ if (typeof value !== "object" || value === null || Array.isArray(value))
113
+ return null;
114
+ return value;
115
+ }
116
+ function asArray(value) {
117
+ return Array.isArray(value) ? value : null;
118
+ }
119
+ function asFiniteNumber(value) {
120
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
121
+ }
122
+ var encoder = new TextEncoder;
123
+ var decoder = new TextDecoder;
124
+ async function readJson(deps, path) {
125
+ try {
126
+ const bytes = await deps.files.read(path);
127
+ if (bytes === null)
128
+ return null;
129
+ return JSON.parse(decoder.decode(bytes));
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+ async function writeCache(deps, path, bytes) {
135
+ try {
136
+ await deps.files.write(path, bytes);
137
+ } catch {}
138
+ }
139
+ async function writeJson(deps, path, value) {
140
+ await writeCache(deps, path, encoder.encode(JSON.stringify(value)));
141
+ }
142
+ async function fetchJson(deps, url) {
143
+ try {
144
+ const response = await deps.net(url);
145
+ if (!response.ok)
146
+ return null;
147
+ return JSON.parse(await response.text());
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+ async function fetchBytes(deps, url) {
153
+ try {
154
+ const response = await deps.net(url);
155
+ if (!response.ok)
156
+ return null;
157
+ const bytes = new Uint8Array(await response.arrayBuffer());
158
+ return bytes.length === 0 ? null : bytes;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+ var CHAIN_URL_ID = /\/evolution-chain\/(\d+)\/?$/;
164
+ function chainIdFromUrl(url) {
165
+ if (typeof url !== "string")
166
+ return null;
167
+ const match = CHAIN_URL_ID.exec(url);
168
+ if (match === null)
169
+ return null;
170
+ const id = Number(match[1]);
171
+ if (!Number.isInteger(id) || id < 1 || id > MAX_EVOLUTION_CHAIN_ID)
172
+ return null;
173
+ return id;
174
+ }
175
+ var SPECIES_URL_ID = /\/pokemon-species\/(\d+)\/?$/;
176
+ function speciesIdFromUrl(url) {
177
+ if (typeof url !== "string")
178
+ return null;
179
+ const match = SPECIES_URL_ID.exec(url);
180
+ if (match === null)
181
+ return null;
182
+ const id = Number(match[1]);
183
+ return isFetchableSpeciesId(id) ? id : null;
184
+ }
185
+ function parseChainNode(raw) {
186
+ const node = asRecord(raw);
187
+ if (node === null)
188
+ return null;
189
+ const species = asRecord(node.species);
190
+ const id = speciesIdFromUrl(species?.url);
191
+ if (id === null)
192
+ return null;
193
+ const children = asArray(node.evolves_to) ?? [];
194
+ const evolvesTo = [];
195
+ for (const child of children) {
196
+ const parsed = parseChainNode(child);
197
+ if (parsed !== null)
198
+ evolvesTo.push(parsed);
199
+ }
200
+ return { id, evolvesTo };
201
+ }
202
+ function pathTo(node, id) {
203
+ if (node.id === id)
204
+ return [id];
205
+ for (const child of node.evolvesTo) {
206
+ const tail = pathTo(child, id);
207
+ if (tail !== null)
208
+ return [node.id, ...tail];
209
+ }
210
+ return null;
211
+ }
212
+ function descendFirstBranch(node) {
213
+ const rest = [];
214
+ let current = node;
215
+ while (current.evolvesTo.length > 0) {
216
+ const next = current.evolvesTo[0];
217
+ rest.push(next.id);
218
+ current = next;
219
+ }
220
+ return rest;
221
+ }
222
+ function lineThrough(root, id) {
223
+ const prefix = pathTo(root, id);
224
+ if (prefix === null)
225
+ return null;
226
+ const self = prefix[prefix.length - 1];
227
+ const node = nodeFor(root, self);
228
+ return node === null ? prefix : [...prefix, ...descendFirstBranch(node)];
229
+ }
230
+ function nodeFor(node, id) {
231
+ if (node.id === id)
232
+ return node;
233
+ for (const child of node.evolvesTo) {
234
+ const found = nodeFor(child, id);
235
+ if (found !== null)
236
+ return found;
237
+ }
238
+ return null;
239
+ }
240
+ function loadChain(deps, chainId, cache) {
241
+ const inFlight = cache.get(chainId);
242
+ if (inFlight !== undefined)
243
+ return inFlight;
244
+ const pending = (async () => {
245
+ const path = chainPath(chainId);
246
+ const cached = await readJson(deps, path);
247
+ if (cached !== null) {
248
+ const parsed2 = parseChainNode(cached);
249
+ if (parsed2 !== null)
250
+ return parsed2;
251
+ }
252
+ const fetched = await fetchJson(deps, `${POKEAPI_ORIGIN}/api/v2/evolution-chain/${chainId}`);
253
+ if (fetched === null)
254
+ return null;
255
+ const root = asRecord(fetched)?.chain;
256
+ const parsed = parseChainNode(root);
257
+ if (parsed === null)
258
+ return null;
259
+ await writeJson(deps, path, { chain: root });
260
+ return parsed;
261
+ })();
262
+ cache.set(chainId, pending);
263
+ return pending;
264
+ }
265
+ function parseNames(raw) {
266
+ const names = {};
267
+ for (const entry of asArray(raw) ?? []) {
268
+ const record = asRecord(entry);
269
+ if (record === null)
270
+ continue;
271
+ const language = asRecord(record.language)?.name;
272
+ const name = record.name;
273
+ if (typeof language === "string" && typeof name === "string")
274
+ names[language] = name;
275
+ }
276
+ return names;
277
+ }
278
+ function parseCachedDetail(raw, id) {
279
+ const record = asRecord(raw);
280
+ if (record === null)
281
+ return null;
282
+ const captureRate = asFiniteNumber(record.captureRate);
283
+ if (captureRate === null)
284
+ return null;
285
+ const chainRaw = asArray(record.chain);
286
+ if (chainRaw === null || chainRaw.length === 0)
287
+ return null;
288
+ const chain = [];
289
+ for (const entry of chainRaw) {
290
+ if (typeof entry !== "number" || !isFetchableSpeciesId(entry))
291
+ return null;
292
+ chain.push(entry);
293
+ }
294
+ return {
295
+ id,
296
+ names: parseNames(record.names),
297
+ captureRate,
298
+ isLegendary: record.isLegendary === true,
299
+ isMythical: record.isMythical === true,
300
+ chain
301
+ };
302
+ }
303
+ function detailToCache(detail) {
304
+ return {
305
+ captureRate: detail.captureRate,
306
+ isLegendary: detail.isLegendary,
307
+ isMythical: detail.isMythical,
308
+ chain: detail.chain,
309
+ names: Object.entries(detail.names).map(([language, name]) => ({
310
+ language: { name: language },
311
+ name
312
+ }))
313
+ };
314
+ }
315
+ async function loadDetail(deps, id, chains) {
316
+ if (!isFetchableSpeciesId(id))
317
+ return null;
318
+ const path = speciesPath(id);
319
+ const cached = parseCachedDetail(await readJson(deps, path), id);
320
+ if (cached !== null)
321
+ return cached;
322
+ const raw = asRecord(await fetchJson(deps, `${POKEAPI_ORIGIN}/api/v2/pokemon-species/${id}`));
323
+ if (raw === null)
324
+ return null;
325
+ const captureRate = asFiniteNumber(raw.capture_rate);
326
+ if (captureRate === null)
327
+ return null;
328
+ const chainId = chainIdFromUrl(asRecord(raw.evolution_chain)?.url);
329
+ if (chainId === null)
330
+ return null;
331
+ const root = await loadChain(deps, chainId, chains);
332
+ if (root === null)
333
+ return null;
334
+ const chain = lineThrough(root, id);
335
+ if (chain === null || chain.length === 0)
336
+ return null;
337
+ const detail = {
338
+ id,
339
+ names: parseNames(raw.names),
340
+ captureRate,
341
+ isLegendary: raw.is_legendary === true,
342
+ isMythical: raw.is_mythical === true,
343
+ chain
344
+ };
345
+ await writeJson(deps, path, detailToCache(detail));
346
+ return detail;
347
+ }
348
+ function speciesDetail(deps, id) {
349
+ return loadDetail(deps, id, new Map);
350
+ }
351
+ function parseCachedIndex(raw) {
352
+ const entries = asArray(raw);
353
+ if (entries === null)
354
+ return null;
355
+ const candidates = [];
356
+ for (const entry of entries) {
357
+ const record = asRecord(entry);
358
+ if (record === null)
359
+ return null;
360
+ const id = asFiniteNumber(record.id);
361
+ const captureRate = asFiniteNumber(record.captureRate);
362
+ const forms = asFiniteNumber(record.forms);
363
+ const finalId = asFiniteNumber(record.finalId);
364
+ if (id === null || captureRate === null || forms === null || finalId === null)
365
+ return null;
366
+ if (!isFetchableSpeciesId(id))
367
+ return null;
368
+ candidates.push({ id, captureRate, forms, finalId });
369
+ }
370
+ return candidates.length === 0 ? null : candidates;
371
+ }
372
+ async function mapWithConcurrency(ids, limit, worker) {
373
+ const results = new Array(ids.length);
374
+ let next = 0;
375
+ const runners = Array.from({ length: Math.min(limit, ids.length) }, async () => {
376
+ while (next < ids.length) {
377
+ const index = next++;
378
+ results[index] = await worker(ids[index]);
379
+ }
380
+ });
381
+ await Promise.all(runners);
382
+ return results;
383
+ }
384
+ async function speciesIndex(deps) {
385
+ const cached = parseCachedIndex(await readJson(deps, INDEX_PATH));
386
+ if (cached !== null)
387
+ return cached;
388
+ const ids = Array.from({ length: ANIMATED_SPECIES_MAX }, (_, i) => i + 1);
389
+ const chains = new Map;
390
+ const details = await mapWithConcurrency(ids, INDEX_FETCH_CONCURRENCY, (id) => loadDetail(deps, id, chains));
391
+ const candidates = [];
392
+ for (const detail of details) {
393
+ if (detail === null)
394
+ continue;
395
+ if (detail.chain[0] !== detail.id)
396
+ continue;
397
+ candidates.push({
398
+ id: detail.id,
399
+ captureRate: detail.captureRate,
400
+ forms: detail.chain.length,
401
+ finalId: detail.chain[detail.chain.length - 1] ?? detail.id
402
+ });
403
+ }
404
+ if (details.every((detail) => detail !== null))
405
+ await writeJson(deps, INDEX_PATH, candidates);
406
+ return candidates;
407
+ }
408
+ async function spriteBytes(deps, id, shiny) {
409
+ if (!isFetchableSpeciesId(id))
410
+ return null;
411
+ const path = spritePath(id, shiny);
412
+ try {
413
+ const cached = await deps.files.read(path);
414
+ if (cached !== null && cached.length > 0)
415
+ return cached;
416
+ } catch {}
417
+ const url = shiny ? `${SPRITE_ORIGIN}${SPRITE_DIR}/shiny/${id}.gif` : `${SPRITE_ORIGIN}${SPRITE_DIR}/${id}.gif`;
418
+ const bytes = await fetchBytes(deps, url);
419
+ if (bytes === null)
420
+ return null;
421
+ await writeCache(deps, path, bytes);
422
+ return bytes;
423
+ }
424
+
425
+ // src/roll.ts
426
+ var NATURES = [
427
+ "hardy",
428
+ "lonely",
429
+ "brave",
430
+ "adamant",
431
+ "naughty",
432
+ "bold",
433
+ "docile",
434
+ "relaxed",
435
+ "impish",
436
+ "lax",
437
+ "timid",
438
+ "hasty",
439
+ "serious",
440
+ "jolly",
441
+ "naive",
442
+ "modest",
443
+ "mild",
444
+ "quiet",
445
+ "bashful",
446
+ "rash",
447
+ "calm",
448
+ "gentle",
449
+ "sassy",
450
+ "careful",
451
+ "quirky"
452
+ ];
453
+ function mulberry32(seed) {
454
+ let state = seed >>> 0;
455
+ return () => {
456
+ state = state + 1831565813 >>> 0;
457
+ let t = state;
458
+ t = Math.imul(t ^ t >>> 15, t | 1);
459
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
460
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
461
+ };
462
+ }
463
+ var COLLECTED_WEIGHT = 0.25;
464
+ function roll(input) {
465
+ const random = mulberry32(input.seed);
466
+ const eligible = input.candidates.filter((candidate) => {
467
+ if (!hasAnimatedSprite(candidate.id))
468
+ return false;
469
+ if (candidate.id === DITTO_SPECIES_ID)
470
+ return false;
471
+ if (input.guarantee === null)
472
+ return true;
473
+ const rarity2 = rarityFromCaptureRate(candidate.captureRate, false, false);
474
+ return sortRank(rarity2) >= sortRank(input.guarantee);
475
+ });
476
+ if (eligible.length === 0)
477
+ return null;
478
+ const weights = eligible.map((candidate) => {
479
+ const base = Math.max(1, candidate.captureRate);
480
+ return input.collectedFinals.has(candidate.finalId) ? base * COLLECTED_WEIGHT : base;
481
+ });
482
+ const total = weights.reduce((a, b) => a + b, 0);
483
+ let target = random() * total;
484
+ let index = 0;
485
+ for (let i = 0;i < weights.length; i++) {
486
+ target -= weights[i];
487
+ if (target <= 0) {
488
+ index = i;
489
+ break;
490
+ }
491
+ }
492
+ const chosen = eligible[index];
493
+ const shinyDenominator = input.hasShinyCharm ? ODDS.shinyWithCharm : ODDS.shiny;
494
+ const isShiny = random() < 1 / shinyDenominator;
495
+ const nature = NATURES[Math.floor(random() * NATURES.length)];
496
+ const rarity = rarityFromCaptureRate(chosen.captureRate, false, false);
497
+ const dittoEligible = rarity === "common" && chosen.forms >= 2;
498
+ const ditto = dittoEligible && random() < 1 / ODDS.dittoDisguise;
499
+ return { speciesId: chosen.id, isShiny, nature, ditto };
500
+ }
501
+
502
+ // src/state.ts
503
+ function freshState() {
504
+ return {
505
+ consumedTotal: 0,
506
+ active: null,
507
+ eggUsage: 0,
508
+ eggTier: null,
509
+ pendingHatch: null,
510
+ inventory: { rareCandy: 0, mint: 0, shinyCharm: 0 }
511
+ };
512
+ }
513
+ function isRecord(value) {
514
+ return typeof value === "object" && value !== null && !Array.isArray(value);
515
+ }
516
+ function asInt(value, fallback) {
517
+ return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : fallback;
518
+ }
519
+ function asRarity(value) {
520
+ return RARITIES.includes(value) ? value : null;
521
+ }
522
+ function parseState(raw) {
523
+ let parsed;
524
+ try {
525
+ parsed = JSON.parse(raw);
526
+ } catch {
527
+ return null;
528
+ }
529
+ if (!isRecord(parsed))
530
+ return null;
531
+ const inventory = { rareCandy: 0, mint: 0, shinyCharm: 0 };
532
+ const storedInventory = parsed.inventory;
533
+ if (isRecord(storedInventory)) {
534
+ for (const kind of ITEM_KINDS) {
535
+ inventory[kind] = Math.max(0, asInt(storedInventory[kind], 0));
536
+ }
537
+ }
538
+ let active = null;
539
+ const storedActive = parsed.active;
540
+ if (storedActive !== null && storedActive !== undefined) {
541
+ if (!isRecord(storedActive))
542
+ return null;
543
+ const rarity = asRarity(storedActive.rarity);
544
+ if (rarity === null)
545
+ return null;
546
+ const path = Array.isArray(storedActive.plannedPath) ? storedActive.plannedPath.filter((id) => typeof id === "number" && id > 0) : [];
547
+ if (path.length === 0)
548
+ return null;
549
+ const nature = NATURES.includes(storedActive.nature) ? storedActive.nature : null;
550
+ active = {
551
+ baseId: asInt(storedActive.baseId, path[0]),
552
+ plannedPath: path,
553
+ stageIndex: Math.min(Math.max(0, asInt(storedActive.stageIndex, 0)), path.length - 1),
554
+ usedAtStage: Math.max(0, asInt(storedActive.usedAtStage, 0)),
555
+ rarity,
556
+ isShiny: storedActive.isShiny === true,
557
+ nature: nature ?? "hardy",
558
+ dittoDisguise: typeof storedActive.dittoDisguise === "number" ? storedActive.dittoDisguise : null,
559
+ dittoRevealed: storedActive.dittoRevealed === true
560
+ };
561
+ }
562
+ const storedPending = parsed.pendingHatch;
563
+ let pendingHatch = null;
564
+ if (isRecord(storedPending)) {
565
+ const rarity = asRarity(storedPending.rarity);
566
+ const path = Array.isArray(storedPending.path) ? storedPending.path.filter((id) => typeof id === "number" && id > 0) : [];
567
+ if (rarity !== null && path.length > 0) {
568
+ const nature = NATURES.includes(storedPending.nature) ? storedPending.nature : "hardy";
569
+ pendingHatch = {
570
+ speciesId: asInt(storedPending.speciesId, path[0]),
571
+ path,
572
+ rarity,
573
+ isShiny: storedPending.isShiny === true,
574
+ nature,
575
+ ditto: storedPending.ditto === true
576
+ };
577
+ }
578
+ }
579
+ const eggTier = asRarity(parsed.eggTier);
580
+ return {
581
+ consumedTotal: Math.max(0, asInt(parsed.consumedTotal, 0)),
582
+ active,
583
+ eggUsage: Math.max(0, asInt(parsed.eggUsage, 0)),
584
+ eggTier: eggTier === null || eggTier === "legendary" ? null : eggTier,
585
+ pendingHatch,
586
+ inventory
587
+ };
588
+ }
589
+ function serialiseState(state) {
590
+ return JSON.stringify(state);
591
+ }
592
+ function hasShinyCharm(state) {
593
+ return (state.inventory.shinyCharm ?? 0) > 0;
594
+ }
595
+
596
+ // src/advance.ts
597
+ var MAX_TRANSITIONS_PER_ADVANCE = 64;
598
+ function advance(state, tokensTotal) {
599
+ const gained = Math.max(0, Math.trunc(tokensTotal) - state.consumedTotal);
600
+ const events = [];
601
+ let next = { ...state, consumedTotal: Math.trunc(tokensTotal) };
602
+ if (gained > 0) {
603
+ next = next.active === null ? { ...next, eggUsage: next.eggUsage + gained } : { ...next, active: { ...next.active, usedAtStage: next.active.usedAtStage + gained } };
604
+ }
605
+ for (let step = 0;step < MAX_TRANSITIONS_PER_ADVANCE; step++) {
606
+ if (next.active === null) {
607
+ if (next.eggUsage < EGG_HATCH_THRESHOLD)
608
+ break;
609
+ if (next.pendingHatch === null)
610
+ break;
611
+ const hatch = next.pendingHatch;
612
+ const active = {
613
+ baseId: hatch.path[0] ?? hatch.speciesId,
614
+ plannedPath: hatch.path,
615
+ stageIndex: 0,
616
+ usedAtStage: next.eggUsage - EGG_HATCH_THRESHOLD,
617
+ rarity: hatch.rarity,
618
+ isShiny: hatch.isShiny,
619
+ nature: hatch.nature,
620
+ dittoDisguise: hatch.ditto ? hatch.speciesId : null,
621
+ dittoRevealed: false
622
+ };
623
+ events.push({
624
+ kind: "hatched",
625
+ speciesId: hatch.speciesId,
626
+ isShiny: active.isShiny,
627
+ ditto: active.dittoDisguise !== null
628
+ });
629
+ next = { ...next, active, eggUsage: 0, eggTier: null, pendingHatch: null };
630
+ continue;
631
+ }
632
+ const mon = next.active;
633
+ const needed = phaseThreshold(mon.rarity, mon.plannedPath.length, mon.stageIndex);
634
+ if (mon.usedAtStage < needed)
635
+ break;
636
+ const excess = mon.usedAtStage - needed;
637
+ if (mon.stageIndex < mon.plannedPath.length - 1) {
638
+ events.push({
639
+ kind: "evolved",
640
+ from: mon.plannedPath[mon.stageIndex],
641
+ to: mon.plannedPath[mon.stageIndex + 1]
642
+ });
643
+ next = { ...next, active: { ...mon, stageIndex: mon.stageIndex + 1, usedAtStage: excess } };
644
+ continue;
645
+ }
646
+ events.push({
647
+ kind: "graduated",
648
+ baseId: mon.baseId,
649
+ finalId: mon.plannedPath[mon.plannedPath.length - 1],
650
+ chainOrder: mon.plannedPath,
651
+ rarity: mon.rarity,
652
+ isShiny: mon.isShiny,
653
+ nature: mon.nature
654
+ });
655
+ next = { ...next, active: null, eggUsage: excess, eggTier: null, pendingHatch: null };
656
+ }
657
+ return { state: next, events };
658
+ }
659
+
660
+ // src/store.ts
661
+ var MIGRATIONS = [
662
+ {
663
+ version: 1,
664
+ sql: `
665
+ CREATE TABLE {{companion}} (
666
+ api_key_id TEXT PRIMARY KEY,
667
+ state TEXT NOT NULL,
668
+ tokens_total INTEGER NOT NULL DEFAULT 0,
669
+ tokens_spent INTEGER NOT NULL DEFAULT 0,
670
+ created_at INTEGER NOT NULL,
671
+ updated_at INTEGER NOT NULL
672
+ )
673
+ `
674
+ },
675
+ {
676
+ version: 2,
677
+ sql: `
678
+ CREATE TABLE {{dex}} (
679
+ id TEXT PRIMARY KEY,
680
+ api_key_id TEXT NOT NULL,
681
+ base_id INTEGER NOT NULL,
682
+ final_id INTEGER NOT NULL,
683
+ chain_order TEXT NOT NULL,
684
+ rarity TEXT NOT NULL,
685
+ is_shiny INTEGER NOT NULL DEFAULT 0,
686
+ nature TEXT,
687
+ caught_at INTEGER NOT NULL
688
+ )
689
+ `
690
+ },
691
+ {
692
+ version: 3,
693
+ sql: `CREATE INDEX {{dex_by_key}} ON {{dex}} (api_key_id, caught_at DESC)`
694
+ },
695
+ {
696
+ version: 4,
697
+ sql: `
698
+ CREATE TABLE {{grants}} (
699
+ api_key_id TEXT NOT NULL,
700
+ window_key TEXT NOT NULL,
701
+ -- An instant, not a tier. A grant is rate-limited by the window's own
702
+ -- duration, because nothing tells this plugin when a window empties.
703
+ granted_at INTEGER NOT NULL,
704
+ PRIMARY KEY (api_key_id, window_key)
705
+ )
706
+ `
707
+ },
708
+ {
709
+ version: 5,
710
+ sql: `ALTER TABLE {{companion}} ADD COLUMN last_credit_at INTEGER`
711
+ }
712
+ ];
713
+ function wallet(row) {
714
+ return Math.max(0, row.tokensTotal - row.tokensSpent);
715
+ }
716
+ function readCompanion(storage, apiKeyId) {
717
+ const row = storage.get(`SELECT api_key_id, state, tokens_total, tokens_spent, last_credit_at
718
+ FROM {{companion}} WHERE api_key_id = ?`, [apiKeyId]);
719
+ if (row === null)
720
+ return null;
721
+ return {
722
+ apiKeyId: row.api_key_id,
723
+ state: parseState(row.state),
724
+ tokensTotal: row.tokens_total,
725
+ tokensSpent: row.tokens_spent,
726
+ lastCreditAt: row.last_credit_at
727
+ };
728
+ }
729
+ function creditTokens(storage, apiKeyId, tokens, now) {
730
+ if (tokens <= 0)
731
+ return;
732
+ storage.run(`INSERT INTO {{companion}} (api_key_id, state, tokens_total, tokens_spent, created_at, updated_at, last_credit_at)
733
+ VALUES (?, ?, ?, 0, ?, ?, ?)
734
+ ON CONFLICT(api_key_id) DO UPDATE SET
735
+ tokens_total = tokens_total + excluded.tokens_total,
736
+ updated_at = excluded.updated_at,
737
+ last_credit_at = excluded.last_credit_at`, [apiKeyId, serialiseState(freshState()), Math.trunc(tokens), now, now, now]);
738
+ }
739
+ function settle(storage, apiKeyId, now) {
740
+ const row = readCompanion(storage, apiKeyId);
741
+ if (row === null)
742
+ return null;
743
+ if (row.state === null)
744
+ return { row, events: [] };
745
+ const result = advance(row.state, row.tokensTotal);
746
+ if (result.events.length === 0 && result.state === row.state)
747
+ return { row, events: [] };
748
+ storage.run("UPDATE {{companion}} SET state = ?, updated_at = ? WHERE api_key_id = ?", [
749
+ serialiseState(result.state),
750
+ now,
751
+ apiKeyId
752
+ ]);
753
+ return { row: { ...row, state: result.state }, events: result.events };
754
+ }
755
+ function recordGraduation(storage, apiKeyId, entry, id) {
756
+ storage.run(`INSERT INTO {{dex}} (id, api_key_id, base_id, final_id, chain_order, rarity, is_shiny, nature, caught_at)
757
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
758
+ id,
759
+ apiKeyId,
760
+ entry.baseId,
761
+ entry.finalId,
762
+ JSON.stringify(entry.chainOrder),
763
+ entry.rarity,
764
+ entry.isShiny ? 1 : 0,
765
+ entry.nature,
766
+ entry.caughtAt
767
+ ]);
768
+ }
769
+ function readDex(storage, apiKeyId) {
770
+ const rows = storage.all(`SELECT id, base_id, final_id, chain_order, rarity, is_shiny, nature, caught_at
771
+ FROM {{dex}} WHERE api_key_id = ? ORDER BY caught_at DESC`, [apiKeyId]);
772
+ const entries = [];
773
+ for (const row of rows) {
774
+ let chainOrder;
775
+ try {
776
+ chainOrder = JSON.parse(row.chain_order);
777
+ } catch {
778
+ continue;
779
+ }
780
+ if (!Array.isArray(chainOrder))
781
+ continue;
782
+ const chain = chainOrder.filter((id) => typeof id === "number");
783
+ if (chain.length === 0)
784
+ continue;
785
+ entries.push({
786
+ id: row.id,
787
+ baseId: row.base_id,
788
+ finalId: row.final_id,
789
+ chainOrder: chain,
790
+ rarity: row.rarity,
791
+ isShiny: row.is_shiny === 1,
792
+ nature: row.nature,
793
+ caughtAt: row.caught_at
794
+ });
795
+ }
796
+ return entries;
797
+ }
798
+ function lastGrantedAt(storage, apiKeyId, windowKey2) {
799
+ const row = storage.get("SELECT granted_at FROM {{grants}} WHERE api_key_id = ? AND window_key = ?", [apiKeyId, windowKey2]);
800
+ return row?.granted_at ?? null;
801
+ }
802
+ function setGrantedAt(storage, apiKeyId, windowKey2, at) {
803
+ storage.run(`INSERT INTO {{grants}} (api_key_id, window_key, granted_at) VALUES (?, ?, ?)
804
+ ON CONFLICT(api_key_id, window_key) DO UPDATE SET granted_at = excluded.granted_at`, [apiKeyId, windowKey2, at]);
805
+ }
806
+ function shopPrice(entry) {
807
+ return entry.kind === "item" ? ITEM_PRICES[entry.item] : freshEggPrice(entry.tier);
808
+ }
809
+ function consume(storage, apiKeyId, item, applyToState, now) {
810
+ const row = readCompanion(storage, apiKeyId);
811
+ if (row === null)
812
+ return { ok: false, reason: "missing" };
813
+ if (row.state === null)
814
+ return { ok: false, reason: "unreadable" };
815
+ if ((row.state.inventory[item] ?? 0) <= 0)
816
+ return { ok: false, reason: "none-held" };
817
+ const nextState = applyToState({
818
+ ...row.state,
819
+ inventory: { ...row.state.inventory, [item]: (row.state.inventory[item] ?? 0) - 1 }
820
+ });
821
+ storage.run("UPDATE {{companion}} SET state = ?, updated_at = ? WHERE api_key_id = ?", [
822
+ serialiseState(nextState),
823
+ now,
824
+ apiKeyId
825
+ ]);
826
+ return { ok: true, row: { ...row, state: nextState } };
827
+ }
828
+ function purchase(storage, apiKeyId, entry, applyToState, now) {
829
+ const price = shopPrice(entry);
830
+ {
831
+ const row = readCompanion(storage, apiKeyId);
832
+ if (row === null)
833
+ return { ok: false, reason: "missing" };
834
+ if (row.state === null)
835
+ return { ok: false, reason: "unreadable" };
836
+ if (wallet(row) < price)
837
+ return { ok: false, reason: "insufficient" };
838
+ const nextState = applyToState(row.state);
839
+ storage.run("UPDATE {{companion}} SET state = ?, tokens_spent = tokens_spent + ?, updated_at = ? WHERE api_key_id = ?", [serialiseState(nextState), price, now, apiKeyId]);
840
+ return {
841
+ ok: true,
842
+ row: { ...row, state: nextState, tokensSpent: row.tokensSpent + price }
843
+ };
844
+ }
845
+ }
846
+
847
+ // src/server.ts
848
+ var MAX_MULTIPLIER = 1000;
849
+ function multiplierFrom(config) {
850
+ const raw = config.multiplier;
851
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0)
852
+ return 1;
853
+ return Math.min(raw, MAX_MULTIPLIER);
854
+ }
855
+ var dexSequence = 0;
856
+ function dexId(apiKeyId, event, now) {
857
+ dexSequence += 1;
858
+ return `${apiKeyId}:${event.baseId}:${event.finalId}:${now}:${dexSequence}`;
859
+ }
860
+ var server_default = definePlugin({
861
+ migrations: MIGRATIONS,
862
+ setup(ctx) {
863
+ const storage = ctx.storage;
864
+ const events = ctx.events;
865
+ const net = ctx.net;
866
+ const files = ctx.files;
867
+ if (storage === undefined)
868
+ throw new Error("the companion needs the storage capability");
869
+ const multiplier = multiplierFrom(ctx.config);
870
+ const inFlight = new Map;
871
+ const prefetchOnce = (apiKeyId, state) => {
872
+ const existing = inFlight.get(apiKeyId);
873
+ if (existing !== undefined)
874
+ return existing;
875
+ const started = prefetchHatch(apiKeyId, state).finally(() => inFlight.delete(apiKeyId));
876
+ inFlight.set(apiKeyId, started);
877
+ return started;
878
+ };
879
+ const settleAndRecord = (apiKeyId) => {
880
+ const result = settle(storage, apiKeyId, ctx.now());
881
+ if (result === null)
882
+ return;
883
+ for (const event of result.events) {
884
+ if (event.kind !== "graduated")
885
+ continue;
886
+ recordGraduation(storage, apiKeyId, {
887
+ baseId: event.baseId,
888
+ finalId: event.finalId,
889
+ chainOrder: event.chainOrder,
890
+ rarity: event.rarity,
891
+ isShiny: event.isShiny,
892
+ nature: event.nature,
893
+ caughtAt: ctx.now()
894
+ }, dexId(apiKeyId, event, ctx.now()));
895
+ ctx.logger.info("companion graduated", { event: "companion.graduated", count: 1 });
896
+ }
897
+ };
898
+ const prefetchHatch = async (apiKeyId, state) => {
899
+ if (state.active !== null || state.pendingHatch !== null)
900
+ return;
901
+ if (net === undefined || files === undefined)
902
+ return;
903
+ const candidates = await speciesIndex({ net, files });
904
+ if (candidates.length === 0)
905
+ return;
906
+ const collected = new Set(readDex(storage, apiKeyId).map((entry) => entry.finalId));
907
+ const rolled = roll({
908
+ candidates,
909
+ seed: hashSeed(`${apiKeyId}:${state.consumedTotal}`),
910
+ guarantee: state.eggTier,
911
+ hasShinyCharm: hasShinyCharm(state),
912
+ collectedFinals: collected
913
+ });
914
+ if (rolled === null)
915
+ return;
916
+ const detail = await speciesDetail({ net, files }, rolled.speciesId);
917
+ if (detail === null)
918
+ return;
919
+ const path = detail.chain;
920
+ const rarity = rarityFromCaptureRate(detail.captureRate, detail.isLegendary, detail.isMythical);
921
+ const current = readCompanion(storage, apiKeyId);
922
+ if (current?.state == null || current.state.pendingHatch !== null)
923
+ return;
924
+ storage.run("UPDATE {{companion}} SET state = ?, updated_at = ? WHERE api_key_id = ?", [
925
+ JSON.stringify({
926
+ ...current.state,
927
+ pendingHatch: {
928
+ speciesId: rolled.speciesId,
929
+ path,
930
+ rarity,
931
+ isShiny: rolled.isShiny,
932
+ nature: rolled.nature,
933
+ ditto: rolled.ditto
934
+ }
935
+ }),
936
+ ctx.now(),
937
+ apiKeyId
938
+ ]);
939
+ };
940
+ if (events?.onRequestCompleted !== undefined) {
941
+ events.onRequestCompleted((event) => {
942
+ const tokens = event.tokens.input + event.tokens.output + event.tokens.cacheRead + event.tokens.cacheWrite;
943
+ creditTokens(storage, event.apiKeyId, Math.round(tokens * multiplier), ctx.now());
944
+ settleAndRecord(event.apiKeyId);
945
+ });
946
+ }
947
+ if (events?.onLimitReached !== undefined) {
948
+ events.onLimitReached((event) => {
949
+ const key = windowKey(event);
950
+ const row = readCompanion(storage, event.apiKeyId);
951
+ if (row?.state == null)
952
+ return;
953
+ const decision = decideGrant({
954
+ window: event.window,
955
+ lastGrantedAt: lastGrantedAt(storage, event.apiKeyId, key),
956
+ now: ctx.now()
957
+ });
958
+ if (!decision.grant) {
959
+ if (decision.seedAt !== undefined) {
960
+ setGrantedAt(storage, event.apiKeyId, key, decision.seedAt);
961
+ }
962
+ return;
963
+ }
964
+ setGrantedAt(storage, event.apiKeyId, key, decision.at);
965
+ storage.run("UPDATE {{companion}} SET state = ?, updated_at = ? WHERE api_key_id = ?", [
966
+ JSON.stringify({
967
+ ...row.state,
968
+ inventory: {
969
+ ...row.state.inventory,
970
+ rareCandy: row.state.inventory.rareCandy + decision.count
971
+ }
972
+ }),
973
+ ctx.now(),
974
+ event.apiKeyId
975
+ ]);
976
+ ctx.logger.info("companion candy granted", {
977
+ event: "companion.candy",
978
+ count: decision.count
979
+ });
980
+ });
981
+ }
982
+ const routes = [
983
+ {
984
+ method: "GET",
985
+ path: "/keys/:id",
986
+ handler: (request) => {
987
+ const apiKeyId = request.params.id ?? "";
988
+ settleAndRecord(apiKeyId);
989
+ const row = readCompanion(storage, apiKeyId);
990
+ if (row === null)
991
+ return { status: 404, json: { error: "no companion for that key" } };
992
+ if (row.state !== null)
993
+ prefetchOnce(apiKeyId, row.state).catch(() => {});
994
+ const active = row.state?.active ?? null;
995
+ return {
996
+ json: {
997
+ state: row.state,
998
+ tokensTotal: row.tokensTotal,
999
+ wallet: wallet(row),
1000
+ lastCreditAt: row.lastCreditAt,
1001
+ dex: readDex(storage, apiKeyId),
1002
+ shop: shopCatalogue(),
1003
+ nextThreshold: active === null ? EGG_HATCH_THRESHOLD : phaseThreshold(active.rarity, active.plannedPath.length, active.stageIndex),
1004
+ progress: active === null ? row.state?.eggUsage ?? 0 : active.usedAtStage
1005
+ }
1006
+ };
1007
+ }
1008
+ },
1009
+ {
1010
+ method: "GET",
1011
+ path: "/sprite/:species",
1012
+ handler: async (request) => {
1013
+ const raw = request.params.species ?? "";
1014
+ const speciesId = Number.parseInt(raw, 10);
1015
+ if (!Number.isInteger(speciesId))
1016
+ return { status: 400, json: { error: "bad species" } };
1017
+ if (net === undefined || files === undefined) {
1018
+ return { status: 503, json: { error: "sprites need the net and files capabilities" } };
1019
+ }
1020
+ const shiny = request.query.shiny === "1";
1021
+ const bytes = await spriteBytes({ net, files }, speciesId, shiny);
1022
+ if (bytes === null)
1023
+ return { status: 404, json: { error: "no sprite" } };
1024
+ return {
1025
+ bytes,
1026
+ contentType: "image/gif",
1027
+ cacheControl: "public, max-age=31536000, immutable"
1028
+ };
1029
+ }
1030
+ },
1031
+ {
1032
+ method: "POST",
1033
+ path: "/keys/:id/use",
1034
+ handler: (request) => {
1035
+ const apiKeyId = request.params.id ?? "";
1036
+ const item = parseHeldItem(request.body);
1037
+ if (item === null)
1038
+ return { status: 400, json: { error: "unknown item" } };
1039
+ const result = consume(storage, apiKeyId, item, (state) => useItem(state, item), ctx.now());
1040
+ if (!result.ok)
1041
+ return { status: 409, json: { error: result.reason } };
1042
+ settleAndRecord(apiKeyId);
1043
+ return { json: { ok: true } };
1044
+ }
1045
+ },
1046
+ {
1047
+ method: "POST",
1048
+ path: "/keys/:id/purchase",
1049
+ handler: (request) => {
1050
+ const apiKeyId = request.params.id ?? "";
1051
+ const entry = parseShopEntry(request.body);
1052
+ if (entry === null)
1053
+ return { status: 400, json: { error: "unknown shop entry" } };
1054
+ const result = purchase(storage, apiKeyId, entry, (state) => applyPurchase(state, entry), ctx.now());
1055
+ if (!result.ok)
1056
+ return { status: 409, json: { error: result.reason } };
1057
+ return { json: { ok: true, wallet: wallet(result.row) } };
1058
+ }
1059
+ }
1060
+ ];
1061
+ return { routes };
1062
+ }
1063
+ });
1064
+ function hashSeed(input) {
1065
+ let hash = 2166136261;
1066
+ for (let i = 0;i < input.length; i++) {
1067
+ hash ^= input.charCodeAt(i);
1068
+ hash = Math.imul(hash, 16777619);
1069
+ }
1070
+ return hash >>> 0;
1071
+ }
1072
+ function shopCatalogue() {
1073
+ return [
1074
+ ...ITEM_KINDS.map((item) => ({
1075
+ entry: { kind: "item", item },
1076
+ price: ITEM_PRICES[item]
1077
+ })),
1078
+ { entry: { kind: "egg", tier: null }, price: freshEggPrice(null) },
1079
+ {
1080
+ entry: { kind: "egg", tier: "uncommon" },
1081
+ price: freshEggPrice("uncommon")
1082
+ },
1083
+ { entry: { kind: "egg", tier: "rare" }, price: freshEggPrice("rare") }
1084
+ ];
1085
+ }
1086
+ function parseShopEntry(body) {
1087
+ if (typeof body !== "object" || body === null)
1088
+ return null;
1089
+ const record = body;
1090
+ if (record.kind === "item") {
1091
+ const item = record.item;
1092
+ return ITEM_KINDS.includes(item) ? { kind: "item", item } : null;
1093
+ }
1094
+ if (record.kind === "egg") {
1095
+ const tier = record.tier;
1096
+ if (tier === null || tier === undefined)
1097
+ return { kind: "egg", tier: null };
1098
+ if (tier === "uncommon" || tier === "rare")
1099
+ return { kind: "egg", tier };
1100
+ return null;
1101
+ }
1102
+ return null;
1103
+ }
1104
+ function applyPurchase(state, entry) {
1105
+ if (entry.kind === "egg") {
1106
+ return { ...state, active: null, eggUsage: 0, eggTier: entry.tier, pendingHatch: null };
1107
+ }
1108
+ return {
1109
+ ...state,
1110
+ inventory: { ...state.inventory, [entry.item]: (state.inventory[entry.item] ?? 0) + 1 }
1111
+ };
1112
+ }
1113
+ function parseHeldItem(body) {
1114
+ if (typeof body !== "object" || body === null)
1115
+ return null;
1116
+ const item = body.item;
1117
+ return item === "rareCandy" || item === "mint" ? item : null;
1118
+ }
1119
+ function useItem(state, item) {
1120
+ if (item === "mint") {
1121
+ if (state.active === null)
1122
+ return state;
1123
+ const index = NATURES.indexOf(state.active.nature);
1124
+ const nature = NATURES[(index + 1) % NATURES.length];
1125
+ return { ...state, active: { ...state.active, nature } };
1126
+ }
1127
+ return state.active === null ? { ...state, eggUsage: state.eggUsage + RARE_CANDY_XP } : {
1128
+ ...state,
1129
+ active: { ...state.active, usedAtStage: state.active.usedAtStage + RARE_CANDY_XP }
1130
+ };
1131
+ }
1132
+ export {
1133
+ server_default as default
1134
+ };
package/ui/index.js ADDED
@@ -0,0 +1,68 @@
1
+ function w(t){return t}import{useMutation as _,useQuery as K,useQueryClient as G}from"@tanstack/react-query";import{useState as f}from"react";import s from"styled-components";import{jsx as n,jsxs as r,Fragment as P}from"react/jsx-runtime";var c=s.section`
2
+ background: var(--panel);
3
+ border: 1px solid var(--rule);
4
+ border-radius: 6px;
5
+ padding: 16px;
6
+ color: var(--ink);
7
+ `,b=s.div`
8
+ display: flex;
9
+ gap: 16px;
10
+ align-items: center;
11
+ flex-wrap: wrap;
12
+ `,H=s.img`
13
+ width: 96px;
14
+ height: 96px;
15
+ image-rendering: pixelated;
16
+ background: var(--panel-sunk);
17
+ border-radius: 4px;
18
+ `,C=s.div`
19
+ background: var(--panel-sunk);
20
+ border-radius: 3px;
21
+ height: 8px;
22
+ overflow: hidden;
23
+ min-width: 200px;
24
+ `,N=s.div`
25
+ background: var(--accent);
26
+ height: 100%;
27
+ width: ${(t)=>Math.min(100,Math.max(0,t.$pct))}%;
28
+ `,a=s.span`
29
+ color: var(--ink-dim);
30
+ `,J=s.div`
31
+ display: grid;
32
+ grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
33
+ gap: 8px;
34
+ margin-top: 12px;
35
+ `,x=s.button`
36
+ background: var(--panel-raised);
37
+ border: 1px solid var(--rule);
38
+ border-radius: 4px;
39
+ color: var(--ink);
40
+ padding: 6px 10px;
41
+ cursor: pointer;
42
+ &:disabled {
43
+ color: var(--ink-faint);
44
+ cursor: not-allowed;
45
+ }
46
+ `,U=s.p`
47
+ color: var(--warn);
48
+ `,Q=s.figure`
49
+ margin: 0;
50
+ display: flex;
51
+ flex-direction: column;
52
+ align-items: center;
53
+ gap: 2px;
54
+ `,j=s.figcaption`
55
+ color: var(--ink-dim);
56
+ font-size: 11px;
57
+ text-align: center;
58
+ `,W=s.div`
59
+ display: flex;
60
+ align-items: center;
61
+ gap: 8px;
62
+ `,z=s.div`
63
+ width: 96px;
64
+ height: 96px;
65
+ border-radius: 50% 50% 45% 45%;
66
+ background: var(--panel-raised);
67
+ border: 2px solid var(--rule-strong);
68
+ `;function B(t,l,d){return`/api/plugins/${t}/sprite/${l}${d?"?shiny=1":""}`}function p(t){if(t>=1e9)return`${(t/1e9).toFixed(2)}B`;if(t>=1e6)return`${(t/1e6).toFixed(1)}M`;return t.toLocaleString()}function S(t){return t.replace(/([A-Z])/g," $1").toLowerCase()}function M(t){if(t.kind==="item")return S(t.item);return t.tier===null?"fresh egg":`fresh egg (${t.tier}+)`}var Y=["rareCandy","mint"],Z=[null,"common","uncommon","rare","legendary"],L=60000,O=60*L;function X(t,l,d){if(!t)return"egg";if(l===null)return"sleep";let g=d-l;if(g<5*L)return"working";if(g<O)return"idle";if(g<8*O)return"tired";return"sleep"}function ee({pluginId:t,api:l}){let[d,g]=f(""),[u,V]=f(""),[m,F]=f(null),A=G(),k=K({queryKey:["companion",u],queryFn:()=>l.get(`keys/${u}`),enabled:u!==""}),[T,h]=f(null),E=_({mutationFn:(e)=>l.post(`keys/${u}/purchase`,e),onMutate:()=>h(null),onError:(e)=>h(e instanceof Error?e.message:"the purchase was refused"),onSuccess:()=>A.invalidateQueries({queryKey:["companion",u]})}),v=_({mutationFn:(e)=>l.post(`keys/${u}/use`,{item:e}),onMutate:()=>h(null),onError:(e)=>h(e instanceof Error?e.message:"the item could not be used"),onSuccess:()=>A.invalidateQueries({queryKey:["companion",u]})});if(u==="")return r(c,{children:[n("h2",{children:"Companion"}),n("p",{children:n(a,{children:"Each API key raises its own Pokémon. Enter a key id to see it."})}),r("form",{onSubmit:(e)=>{e.preventDefault(),V(d.trim())},children:[n("input",{"aria-label":"API key id",onChange:(e)=>g(e.target.value),placeholder:"key id",value:d}),n(x,{type:"submit",children:"Show"})]})]});if(k.isPending)return n(c,{children:"Loading…"});if(k.isError)return n(c,{children:"No companion for that key yet."});let i=k.data;if(i.state===null)return r(c,{children:[n("h2",{children:"Companion"}),n(U,{children:"This key's save could not be read. It has been left untouched rather than replaced — nothing has been lost, but it needs looking at."})]});let{active:o}=i.state,y=o===null?null:o.plannedPath[o.stageIndex],R=X(o!==null,i.lastCreditAt,Date.now()),D=Object.entries(i.state.inventory).filter(([,e])=>e>0),I=m===null?i.dex:i.dex.filter((e)=>e.rarity===m);return r(c,{children:[n("h2",{children:"Companion"}),r(b,{children:[y===void 0||y===null?n(z,{"aria-label":"An egg, not yet hatched",role:"img"}):n(H,{alt:`Species ${y}${o?.isShiny===!0?", shiny":""}`,src:B(t,y,o?.isShiny===!0)}),r("div",{children:[o===null?r(P,{children:[r("div",{children:["Egg",i.state.eggTier===null?"":` (${i.state.eggTier}+ guaranteed)`]}),r(a,{children:[p(i.progress)," / ",p(i.nextThreshold)," tokens incubated"]}),n(C,{"aria-label":"Incubation",children:n(N,{$pct:i.progress/Math.max(1,i.nextThreshold)*100})})]}):r(P,{children:[r("div",{children:["Stage ",o.stageIndex+1," of ",o.plannedPath.length," · ",o.rarity,o.isShiny?" · shiny":"",o.dittoDisguise===null?"":" · ?"]}),n(a,{children:o.nature}),n(C,{"aria-label":"Growth to the next evolution",children:n(N,{$pct:i.progress/Math.max(1,i.nextThreshold)*100})}),r(a,{children:[p(i.progress)," / ",p(i.nextThreshold)," to the next stage"]})]}),n("div",{"aria-label":`Activity: ${R}`,role:"status",children:R})]})]}),n("p",{children:r(a,{children:[p(i.tokensTotal)," tokens earned · ",p(i.wallet)," to spend"]})}),n("h3",{children:"Shop"}),n(b,{children:i.shop.map((e)=>r(x,{disabled:i.wallet<e.price||E.isPending,onClick:()=>E.mutate(e.entry),type:"button",children:[M(e.entry)," · ",p(e.price)]},`${e.entry.kind}:${M(e.entry)}`))}),n("h3",{children:"Bag"}),D.length===0?n(a,{children:"Nothing in the bag."}):n(b,{children:D.map(([e,q])=>r(W,{children:[r("span",{children:[S(e)," · ",q]}),Y.includes(e)?r(x,{disabled:v.isPending,onClick:()=>v.mutate(e),type:"button",children:["Use ",S(e)]}):n(a,{children:"held"})]},e))}),T===null?null:n(U,{role:"alert",children:T}),n("h3",{children:"Pokédex"}),i.dex.length===0?n(a,{children:"Nothing graduated yet."}):r(P,{children:[n(b,{children:Z.map((e)=>n(x,{"aria-pressed":m===e,onClick:()=>F(e),type:"button",children:e??"all"},e??"all"))}),I.length===0?r(a,{children:["No ",m," graduates yet."]}):n(J,{children:I.map((e)=>r(Q,{children:[n("img",{alt:`${e.rarity}${e.isShiny?" shiny":""} species ${e.finalId}`,src:B(t,e.finalId,e.isShiny),style:{width:"64px",height:"64px",imageRendering:"pixelated"}}),e.nature===null?null:n(j,{children:e.nature})]},e.id))})]})]})}var ke=w({mount:ee});export{X as activityOf,ke as default};