@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 +21 -0
- package/README.md +112 -0
- package/omni-plugin.json +17 -0
- package/package.json +30 -0
- package/server/index.js +1134 -0
- package/ui/index.js +68 -0
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.
|
package/omni-plugin.json
ADDED
|
@@ -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
|
+
}
|
package/server/index.js
ADDED
|
@@ -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};
|