From 528c9205066b6e241ee8e8c18a9e0853c5d92228 Mon Sep 17 00:00:00 2001 From: James Berry Date: Tue, 14 Jul 2026 17:32:34 +0100 Subject: [PATCH 1/7] docs: ReactMap consumer plan for /api/pokestop/available Co-Authored-By: Claude Opus 4.8 (1M context) --- ...14-pokestop-available-consumer-reactmap.md | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-pokestop-available-consumer-reactmap.md diff --git a/docs/superpowers/plans/2026-07-14-pokestop-available-consumer-reactmap.md b/docs/superpowers/plans/2026-07-14-pokestop-available-consumer-reactmap.md new file mode 100644 index 000000000..92a72c1a0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-pokestop-available-consumer-reactmap.md @@ -0,0 +1,203 @@ +# ReactMap consumer for Golbat `GET /api/pokestop/available` — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When a scanner source is a Golbat endpoint, have `Pokestop.getAvailable()` fetch `GET {mem}/api/pokestop/available` and map the structured tuples to the SAME `{ available: string[], conditions }` output it produces from SQL today — with SQL as the fallback (no `mem`, or endpoint failure/503). Mirrors the existing `Pokemon.getAvailable` path. + +**Architecture:** A pure mapper `mapAvailablePokestops(apiResponse, ctx)` reproduces the exact filter-key formulas of the SQL path; `Pokestop.getAvailable` gains a `mem` branch that calls the endpoint, detects failure, and falls back to the existing SQL block. The ~30-query SQL block is unchanged and becomes the fallback + the MAD path. + +**Tech Stack:** Node, Objection/Knex, the existing `evalQuery`/`fetchJson` HTTP helpers, Jest/vitest (match the repo's test runner). + +## Global Constraints + +- Branch `feat/pokestop-available-consumer` off `develop`. +- **The API-derived keys MUST byte-for-byte equal the SQL-derived keys.** A golden comparison is the acceptance gate for the mapper. +- MAD sources always have `mem: ''` → always SQL; gate the endpoint path strictly on `if (mem)` (truthy URL), exactly like `Pokemon.getAvailable` (`Pokemon.js:874`). +- `count` in the tuples is NOT used for pokestops (presence only) — unlike Pokémon rarity. +- Special cases stay in ReactMap (they already do): GoFest-2026-Mewtwo type-20 fallback (`Pokestop.js:22-33,1494-1519`), temp-evo type-20 in `parseRdmRewards` (`:1945-1962`). The mapper must reproduce the GoFest key (`m150-150`) — see Task 2. +- Golbat response (draft wire format): + ``` + { quests:[{with_ar,reward_type,item_id,amount,pokemon_id,form_id,title,target,count}], + invasions:[{character,display_type,confirmed,slot1_pokemon_id,slot1_form,count}], + lures:[{lure_id,count}], showcases:[{pokemon_id,form,type_id,count}] } + ``` +- Design reference: Golbat repo `docs/superpowers/specs/2026-07-14-pokestop-available-api-design.md` (§5 tuple contract). + +## File Structure + +- `packages/types/lib/server.d.ts` (modify) — add `AvailablePokestops` (+ per-category) types; add `httpAuth` to `DbContext`. +- `server/src/models/pokestopAvailableMapper.js` (create) — the pure mapper + its unit tests' target. +- `server/src/models/Pokestop.js` (modify) — `getAvailable`: destructure `mem/secret/httpAuth`, endpoint branch + failure→SQL fallback, call the mapper. +- Test files alongside (match repo convention — check for existing `*.test.js`/`__tests__`). + +--- + +### Task 1: Response types + `DbContext.httpAuth` + +**Files:** +- Modify: `packages/types/lib/server.d.ts` (`AvailablePokemon` at ~65; `DbContext` at ~29-55) + +**Interfaces:** +- Produces: `AvailablePokestops` and its member types; `DbContext.httpAuth?`. + +- [ ] **Step 1: Add the types** next to `AvailablePokemon`: + +```ts +export interface AvailablePokestopQuest { + with_ar: boolean; reward_type: number; item_id: number; amount: number + pokemon_id: number; form_id: number; title: string; target: number; count: number +} +export interface AvailablePokestopInvasion { + character: number; display_type: number; confirmed: boolean + slot1_pokemon_id: number; slot1_form: number; count: number +} +export interface AvailablePokestopLure { lure_id: number; count: number } +export interface AvailablePokestopShowcase { pokemon_id: number; form: number; type_id: number; count: number } +export interface AvailablePokestops { + quests: AvailablePokestopQuest[] + invasions: AvailablePokestopInvasion[] + lures: AvailablePokestopLure[] + showcases: AvailablePokestopShowcase[] +} +``` + +- [ ] **Step 2: Add `httpAuth` to `DbContext`** (it's set at `DbManager.js:272` but missing from the interface): `httpAuth?: { username: string; password: string } | null` (match the actual shape used by `evalQuery`/`fetchJson` — verify the real fields). + +- [ ] **Step 3: Typecheck + commit** + +Run the repo's type check (e.g. `yarn tsc --noEmit` / the packages/types build). Then: +```bash +git add packages/types/lib/server.d.ts +git commit -m "types: add AvailablePokestops + DbContext.httpAuth" +``` + +--- + +### Task 2: The pure mapper `mapAvailablePokestops` (the crux) + +**Files:** +- Create: `server/src/models/pokestopAvailableMapper.js` +- Test: `server/src/models/pokestopAvailableMapper.test.js` (match repo test convention) + +**Interfaces:** +- Produces: `mapAvailablePokestops(api: AvailablePokestops, ctx: { invasions: Record }): { available: string[], conditions: Record> }` +- `ctx.invasions` = the event invasion config used by the SQL rocket branch (`state.event.invasions`), needed to gate `a` keys. + +**Key formulas to reproduce EXACTLY** (from `Pokestop.js:1763-1932`; conditions via `process()` only for quest keys when `title` is truthy): + +| tuple | rule → key | +|---|---| +| quest reward_type 1 | `p${amount}` | +| 2 | `q${item_id}` | +| 3 | `d${amount}` | +| 4 | `c${pokemon_id}` | +| 7 | `form_id===0 ? \`${pokemon_id}\` : \`${pokemon_id}-${form_id}\`` (see §form) | +| 9 | `x${pokemon_id}` | +| 12 | `m${pokemon_id}-${amount}` | +| 20 (GoFest/temp-evo) | if `pokemon_id`>0 → `m${pokemon_id}-${amount}`; else `u20` (see §type20) | +| other | `u${reward_type}` | +| invasion, `character`>0 | `i${character}` | +| invasion, `character`===0 | `b${display_type}` | +| invasion confirmed | + `a${slot1_pokemon_id}-${slot1_form}` when `confirmed`, `slot1_pokemon_id`>0, `ctx.invasions[character]?.firstReward`, and `character` NOT in 41..44 | +| lure | `l${lure_id}` | +| showcase, `pokemon_id`>0 | `f${pokemon_id}-${form ?? 0}` | +| showcase, else `type_id`>0 | `h${type_id}` | + +**§form (top risk):** for reward_type 7, the SQL emits bare `${pokemon_id}` when RDM `form_id` was JSON-absent and `${pokemon_id}-${form}` when present (incl. 0). The endpoint always sends `form_id` as a number (0 for absent). Normalize **`form_id === 0` → bare key**; nonzero → `${pokemon_id}-${form_id}`. Document that a genuine explicit-form-0 pokémon reward (rare) would diverge; the golden test (Step 6) validates against real data. + +**§type20:** the SQL GoFest fallback emits `m150-150` and excludes empty-info type-20 rows from `u`-types. The endpoint conveys type-20 as a quest tuple. Emit `m${pokemon_id}-${amount}` when `pokemon_id`>0 (covers GoFest 150-150 and temp-evo mega energy); else `u20`. Flag for the golden test. + +**conditions:** for every QUEST key produced, if the tuple's `title` is truthy, add `conditions[key][\`${title}-${target}\`] = { title, target }`. Invasions/lures/showcases contribute nothing to conditions. Process BOTH `with_ar` true and false tuples into the same `available` Set + `conditions` (keys dedupe via the Set, exactly as the SQL merges quest + alternative_quest). + +- [ ] **Step 1: Write failing unit tests** — one assertion per key type + the edge cases. Cover: each reward_type→key; form_id 0 → bare ``, form_id 3 → `-3`; type 20 with pokemon_id 150 → `m150-150`; an unhandled reward_type (e.g. 8) → `u8`; invasion character 1 → `i1`, character 0 dt9 → `b9`; confirmed character 1 slot1 25 with `ctx.invasions[1].firstReward` → `a25-0`, and character 41 → NO `a` key; lure → `l501`; showcase pokemon → `f1-0`, showcase type-only → `h5`; conditions built for a quest with title/target and NOT for a lure. Assert `available` is a de-duplicated array and `conditions` shape matches. + +```js +// illustrative — expand to cover the whole table above +const { mapAvailablePokestops } = require('./pokestopAvailableMapper') +test('pokemon reward form normalization', () => { + const { available } = mapAvailablePokestops( + { quests: [ + { with_ar:false, reward_type:7, pokemon_id:150, form_id:0, item_id:0, amount:0, title:'', target:0, count:1 }, + { with_ar:false, reward_type:7, pokemon_id:151, form_id:3, item_id:0, amount:0, title:'', target:0, count:1 }, + ], invasions:[], lures:[], showcases:[] }, + { invasions:{} }, + ) + expect(available).toContain('150') // form_id 0 -> bare + expect(available).toContain('151-3') + expect(available).not.toContain('150-0') +}) +``` + +- [ ] **Step 2: Run → fail.** ` pokestopAvailableMapper` — FAIL (module missing). + +- [ ] **Step 3: Implement `mapAvailablePokestops`** per the table + §form + §type20 + conditions rules. Use a `Set` for `available`; a `process(key, title, target)` helper mirroring `Pokestop.js:1285-1294`. Return `{ available: [...set], conditions }`. + +- [ ] **Step 4: Run → pass.** All mapper unit tests green. + +- [ ] **Step 5: Self-review** the key table against `Pokestop.js:1763-1932` line by line — especially the `i` vs `b` split, the `a`-key gating (event config + 41-44 skip), and showcase `f` vs `h`. + +- [ ] **Step 6: Golden comparison test (acceptance gate).** Build a representative dataset and assert the mapper output equals the SQL `getAvailable` output for the equivalent data. If a live equivalent isn't scriptable in a unit test, at minimum add a fixture-based test that feeds the SQL path (via a seeded test DB or a hand-built `{available,conditions}` expectation derived from the same rewards) and diff. Record any key that diverges (esp. form and type-20) as a finding for the controller. + +- [ ] **Step 7: Commit** +```bash +git add server/src/models/pokestopAvailableMapper.js server/src/models/pokestopAvailableMapper.test.js +git commit -m "feat(pokestop): map Golbat /api/pokestop/available tuples to filter keys" +``` + +--- + +### Task 3: Wire `Pokestop.getAvailable` — endpoint branch + SQL fallback + +**Files:** +- Modify: `server/src/models/Pokestop.js` (`getAvailable` 1253-1938; import the mapper) +- Test: `server/src/models/Pokestop.getAvailable.test.js` (fallback behavior) + +**Interfaces:** +- Consumes: `mapAvailablePokestops` (Task 2), `evalQuery` (`Pokemon.js`/shared), `AvailablePokestops` (Task 1). + +- [ ] **Step 1: Write failing tests** for the branch logic (mock `evalQuery`): + - `mem` set + endpoint returns a valid `AvailablePokestops` object → returns the mapper's `{available, conditions}` (endpoint path taken, SQL not run). + - `mem` set + endpoint returns a non-array/`Response`-like object (503) OR throws → falls back to the SQL path and returns its `{available, conditions}`. + - `mem` falsy → SQL path (unchanged). + +- [ ] **Step 2: Run → fail.** + +- [ ] **Step 3: Implement.** Add `mem, secret, httpAuth` to the destructure (`Pokestop.js:1253`). At the top of the method: + +```js +if (mem) { + try { + const res = await this.evalQuery(`${mem}/api/pokestop/available`, undefined, 'GET', secret, httpAuth) + // fetchJson returns a Response object (not an array/object with `quests`) on 503/non-200 + if (res && Array.isArray(res.quests) && Array.isArray(res.invasions)) { + return mapAvailablePokestops(res, { invasions: state.event.invasions }) + } + // else fall through to SQL fallback below + log.warn(...) // endpoint unavailable (e.g. FortInMemory off) — falling back to SQL + } catch (e) { + log.warn(...) // endpoint error — falling back to SQL + } +} +// ...existing SQL block runs unchanged as the fallback / MAD / no-mem path... +``` +Verify the exact validity check against the real `fetchJson` failure shape (a `Response` object has no `.quests`), and the correct `state`/`log`/`evalQuery` accessors on the Pokestop model. Keep the entire existing SQL block intact as the fallback. + +- [ ] **Step 4: Run → pass** (all three branch tests + Task 2 mapper tests still green). + +- [ ] **Step 5: Self-review** — the failure detection must catch BOTH the 503 Response-object case and thrown errors; MAD (`mem:''`) must never enter the branch. + +- [ ] **Step 6: Commit** +```bash +git add server/src/models/Pokestop.js server/src/models/Pokestop.getAvailable.test.js +git commit -m "feat(pokestop): consume /api/pokestop/available with SQL fallback" +``` + +--- + +## Self-Review +- **Spec coverage:** types (T1) · exact key mapping incl. form/type20/conditions (T2) · endpoint branch + 503/no-mem SQL fallback + MAD-stays-SQL (T3). Golden comparison = acceptance gate (T2 Step 6). +- **Top risks flagged inline:** §form (`form_id 0 → bare`), §type20 (`m150-150` vs `u20`), 503 detection (Response object, not array). All have explicit resolutions + the golden test. +- **Placeholder scan:** the golden test (T2 S6) depends on the repo's testability of the SQL path — the implementer must adapt to the real test harness; if a true golden diff isn't feasible, the fixture-based expectation is the floor. + +## Follow-up +Phase 2 (out of scope): move pokestop map-data (`getPokestops`) to Golbat once the scan response carries incidents; then the `adv` title/target filtering can move server-side too. If the golden test shows the §form or §type20 divergence is real, the cleaner fix moves to Golbat (convey null form / a GoFest sentinel) — coordinate via PR #383. From 37a5d7cf69b0fd070fb373699bb8f07e932d1d83 Mon Sep 17 00:00:00 2001 From: James Berry Date: Tue, 14 Jul 2026 18:01:31 +0100 Subject: [PATCH 2/7] feat(pokestop): map Golbat /api/pokestop/available tuples to filter keys Pure, dependency-free mapper that reproduces Pokestop.js's SQL-derived { available, conditions } filter-key shape from the structured tuples returned by Golbat's GET /api/pokestop/available, so a future endpoint consumer can replace the SQL block key-for-key. --- server/src/models/pokestopAvailableMapper.js | 184 +++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 server/src/models/pokestopAvailableMapper.js diff --git a/server/src/models/pokestopAvailableMapper.js b/server/src/models/pokestopAvailableMapper.js new file mode 100644 index 000000000..4f49c7528 --- /dev/null +++ b/server/src/models/pokestopAvailableMapper.js @@ -0,0 +1,184 @@ +// @ts-check + +/** + * Pure mapper for Golbat's `GET /api/pokestop/available` response. + * + * Reproduces the filter-key formulas built by the SQL `getAvailable` block + * in `Pokestop.js` (~lines 1763-1932, `process()` helper ~lines 1285-1294) + * so that switching a pokestop source over to the Golbat endpoint yields the + * SAME `{ available, conditions }` shape the map UI already expects. + * + * Standalone by design: no requires, so it can run under plain `node` with + * no `node_modules` present. + * + * @typedef {object} AvailablePokestopQuest + * @property {boolean} with_ar + * @property {number} reward_type + * @property {number} item_id + * @property {number} amount + * @property {number} pokemon_id + * @property {number} form_id + * @property {string} title + * @property {number} target + * @property {number} count + * + * @typedef {object} AvailablePokestopInvasion + * @property {number} character + * @property {number} display_type + * @property {boolean} confirmed + * @property {number} slot1_pokemon_id + * @property {number} slot1_form + * @property {number} count + * + * @typedef {object} AvailablePokestopLure + * @property {number} lure_id + * @property {number} count + * + * @typedef {object} AvailablePokestopShowcase + * @property {number} pokemon_id + * @property {number} form + * @property {number} type_id + * @property {number} count + * + * @typedef {object} AvailablePokestops + * @property {AvailablePokestopQuest[]} quests + * @property {AvailablePokestopInvasion[]} invasions + * @property {AvailablePokestopLure[]} lures + * @property {AvailablePokestopShowcase[]} showcases + * + * @typedef {object} InvasionRewardConfig + * @property {boolean} [firstReward] + * @property {boolean} [secondReward] + * @property {boolean} [thirdReward] + * + * @typedef {object} MapAvailablePokestopsCtx + * @property {Record} invasions + * + * @typedef {{ title: number | string, target: number }} QuestCondition + * @typedef {Record>} QuestConditions + */ + +/** + * Builds the quest reward filter key for a single quest tuple, mirroring + * the `Pokestop.js:1763-1932` switch statement's per-reward-type branches. + * + * `reward_type` values are cross-checked against the SQL query definitions + * that feed that switch (not just its `questTypes.filter` bookkeeping, + * which references 9/12 in a way that looks swapped at a glance but is + * self-correcting because both branches always run together — see + * task-2-report.md): `candy` filters `quest_reward_type === 4`, `xlCandy` + * filters `=== 9`, `mega` filters `=== MEGA_RESOURCE_REWARD_TYPE` (`12`). + * + * @param {AvailablePokestopQuest} quest + * @returns {string} + */ +function questRewardKey(quest) { + const { reward_type, amount, item_id, pokemon_id, form_id } = quest + switch (reward_type) { + case 1: + return `p${amount}` + case 2: + return `q${item_id}` + case 3: + return `d${amount}` + case 4: + return `c${pokemon_id}` + case 7: + // §form: the SQL emits a bare `${pokemon_id}` when RDM's JSON + // `form_id` was absent, and `${pokemon_id}-${form}` (incl. `-0`) when + // present. The endpoint always sends `form_id` as a number (`0` for + // absent), so JSON-absence can't be distinguished from a genuine + // explicit form 0. Normalize `form_id === 0` to the bare key; a real + // explicit-form-0 reward (rare) would diverge from the SQL output. + return form_id === 0 ? `${pokemon_id}` : `${pokemon_id}-${form_id}` + case 9: + return `x${pokemon_id}` + case 12: + return `m${pokemon_id}-${amount}` + case 20: + // §type20: covers both the GoFest 2026 Mewtwo mega-energy fallback + // (`m150-150`) and generic temp-evo mega-energy rewards. Falls back + // to `u20` when no pokemon_id is conveyed. + return pokemon_id > 0 ? `m${pokemon_id}-${amount}` : 'u20' + default: + return `u${reward_type}` + } +} + +/** + * Maps Golbat's `GET /api/pokestop/available` response to ReactMap's + * `{ available, conditions }` filter-key shape, matching the SQL-derived + * output of `Pokestop.getAvailable` key-for-key. + * + * @param {AvailablePokestops} api + * @param {MapAvailablePokestopsCtx} ctx event invasion config (`state.event.invasions`), used to gate `a` keys + * @returns {{ available: string[], conditions: QuestConditions }} + */ +function mapAvailablePokestops(api, ctx) { + const available = new Set() + /** @type {QuestConditions} */ + const conditions = {} + + const process = ( + /** @type {string} */ key, + /** @type {number | string} */ title, + /** @type {number} */ target, + ) => { + if (title) { + if (key in conditions) { + conditions[key][`${title}-${target}`] = { title, target } + } else { + conditions[key] = { [`${title}-${target}`]: { title, target } } + } + } + available.add(key) + } + + // Quests: `with_ar` true/false tuples both feed the same Set/conditions, + // exactly as the SQL merges `quest` + `alternative_quest` columns. + const quests = api.quests || [] + quests.forEach((quest) => { + process(questRewardKey(quest), quest.title, quest.target) + }) + + // Invasions: `i`/`b` keys are unconditional; the `a` key additionally + // requires a confirmed slot1 reward the event config marks as a + // `firstReward`, excluding team leaders (41-43) and Giovanni (44) - + // mirrors the `invasions` and `rocketPokemon` SQL branches. + const invasions = api.invasions || [] + invasions.forEach((invasion) => { + const { character, display_type, confirmed, slot1_pokemon_id, slot1_form } = + invasion + available.add(character > 0 ? `i${character}` : `b${display_type}`) + + const isRocketLeaderOrGiovanni = character >= 41 && character <= 44 + if ( + confirmed && + slot1_pokemon_id > 0 && + !isRocketLeaderOrGiovanni && + ctx.invasions?.[character]?.firstReward + ) { + available.add(`a${slot1_pokemon_id}-${slot1_form}`) + } + }) + + // Lures contribute no conditions. + const lures = api.lures || [] + lures.forEach((lure) => { + available.add(`l${lure.lure_id}`) + }) + + // Showcases contribute no conditions. + const showcases = api.showcases || [] + showcases.forEach((showcase) => { + if (showcase.pokemon_id > 0) { + available.add(`f${showcase.pokemon_id}-${showcase.form ?? 0}`) + } else if (showcase.type_id > 0) { + available.add(`h${showcase.type_id}`) + } + }) + + return { available: [...available], conditions } +} + +module.exports = { mapAvailablePokestops, questRewardKey } From 24a7572b5849d882e857cde220b8301bc7d97090 Mon Sep 17 00:00:00 2001 From: James Berry Date: Tue, 14 Jul 2026 18:14:23 +0100 Subject: [PATCH 3/7] fix(pokestop): don't attach conditions to u-keys; drop amount<=0 xp/stardust Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/models/pokestopAvailableMapper.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/server/src/models/pokestopAvailableMapper.js b/server/src/models/pokestopAvailableMapper.js index 4f49c7528..bb1cfc13d 100644 --- a/server/src/models/pokestopAvailableMapper.js +++ b/server/src/models/pokestopAvailableMapper.js @@ -138,7 +138,23 @@ function mapAvailablePokestops(api, ctx) { // exactly as the SQL merges `quest` + `alternative_quest` columns. const quests = api.quests || [] quests.forEach((quest) => { - process(questRewardKey(quest), quest.title, quest.target) + // SQL filters reward_type 1 (xp) and 3 (stardust) tuples on + // `quest_reward_amount > 0`; a non-positive amount emits no key at all. + if ( + (quest.reward_type === 1 || quest.reward_type === 3) && + quest.amount <= 0 + ) { + return + } + const key = questRewardKey(quest) + // SQL builds `u`-prefixed fallback keys via `questTypes.map(t => + // `u${t}`)` and never runs them through the conditions-attaching helper, + // so fallback keys carry no conditions here either. + if (key[0] === 'u') { + available.add(key) + } else { + process(key, quest.title, quest.target) + } }) // Invasions: `i`/`b` keys are unconditional; the `a` key additionally From 983c4912a1e583108851330cb40173ef65855025 Mon Sep 17 00:00:00 2001 From: James Berry Date: Tue, 14 Jul 2026 18:18:13 +0100 Subject: [PATCH 4/7] feat(types): add AvailablePokestops + DbContext.httpAuth --- packages/types/lib/server.d.ts | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/types/lib/server.d.ts b/packages/types/lib/server.d.ts index 41e2c639c..565d1062b 100644 --- a/packages/types/lib/server.d.ts +++ b/packages/types/lib/server.d.ts @@ -52,6 +52,7 @@ export interface DbContext { hasPokemonBackground: boolean hasPokemonShinyStats?: boolean connection?: number + httpAuth?: { username: string; password: string } | null } export interface ExpressUser extends User { @@ -68,6 +69,46 @@ export interface AvailablePokemon { count: number } +export interface AvailablePokestopQuest { + with_ar: boolean + reward_type: number + item_id: number + amount: number + pokemon_id: number + form_id: number + title: string + target: number + count: number +} + +export interface AvailablePokestopInvasion { + character: number + display_type: number + confirmed: boolean + slot1_pokemon_id: number + slot1_form: number + count: number +} + +export interface AvailablePokestopLure { + lure_id: number + count: number +} + +export interface AvailablePokestopShowcase { + pokemon_id: number + form: number + type_id: number + count: number +} + +export interface AvailablePokestops { + quests: AvailablePokestopQuest[] + invasions: AvailablePokestopInvasion[] + lures: AvailablePokestopLure[] + showcases: AvailablePokestopShowcase[] +} + export interface Available { pokemon: ModelReturn gyms: ModelReturn From db1a23e4df13fa81dd1b6f07cf2774aea3eefeb4 Mon Sep 17 00:00:00 2001 From: James Berry Date: Tue, 14 Jul 2026 18:30:45 +0100 Subject: [PATCH 5/7] feat(pokestop): consume /api/pokestop/available with SQL fallback Wires Pokestop.getAvailable() to Golbat's GET /api/pokestop/available when a source has `mem` set (an endpoint source), using the Task 2 mapper to build the same {available, conditions} shape the SQL path produces. Falls back to the existing SQL block unchanged on a non-2xx response, a network/timeout error, or any thrown error; MAD/DB sources (mem: '') skip the branch entirely and always run SQL. Extracts the config-gated `fallbackRocketPokemonFiltering` a${id}-${form} backfill (previously inline in the SQL rocketPokemon case) into a shared applyRocketPokemonFallback() helper so both the endpoint and SQL paths stay byte-identical. Adds a Pokestop.evalQuery static mirroring Pokemon.evalQuery, since Pokestop extends Model directly and had no endpoint-fetch helper of its own. --- server/src/models/Pokestop.js | 187 +++++++++++++++++++++++++++------- 1 file changed, 152 insertions(+), 35 deletions(-) diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 9a679d47f..f35f595fb 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -3,16 +3,22 @@ /* eslint-disable no-continue */ const { Model, raw } = require('objection') const i18next = require('i18next') +const fs = require('fs') +const { resolve } = require('path') + const config = require('@rm/config') +const { log, TAGS } = require('@rm/logger') const { getAreaSql } = require('../utils/getAreaSql') const { applyManualIdFilter } = require('../utils/manualFilter') const { getUserMidnight } = require('../utils/getClientTime') +const { fetchJson } = require('../utils/fetchJson') const { state } = require('../services/state') const { isDualQuestLayerMode, resolveQuestLayerSelection, } = require('../utils/questLayerMode') +const { mapAvailablePokestops } = require('./pokestopAvailableMapper') const MEGA_RESOURCE_REWARD_TYPE = 12 const TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE = 20 @@ -32,6 +38,57 @@ const applyGoFest2026MewtwoRewardFallback = ( raw(`json_length(json_extract(${rewardsColumn}, "$[0].info")) = 0`), ) +// Team leaders (41-43) and Giovanni (44) never hand out a catchable rocket +// Pokemon, so both the confirmed-invasion branch and this config-derived +// fallback exclude them. +const ROCKET_LEADER_GRUNT_TYPE_MIN = 41 +const ROCKET_LEADER_GRUNT_TYPE_MAX = 44 + +/** + * Adds config-derived `a${id}-${form}` rocket-encounter fallback keys to + * `availableSet`, mirroring the SQL `rocketPokemon` case (originally inline + * here, now shared so the SQL path and the `/api/pokestop/available` + * endpoint path stay byte-identical). Gated on + * `map.misc.fallbackRocketPokemonFiltering` (default `true`, + * `config/default.json`). + * @param {Set} availableSet + */ +const applyRocketPokemonFallback = (availableSet) => { + if (!config.getSafe('map.misc.fallbackRocketPokemonFiltering')) return + // Always include potential rocket Pokemon from state.event.invasions as backup + Object.entries(state.event.invasions).forEach(([gruntType, invasionInfo]) => { + if (!invasionInfo) return + // Exclude team leaders (41-43) and Giovanni (44) + const gruntTypeNum = parseInt(gruntType, 10) + if ( + gruntTypeNum >= ROCKET_LEADER_GRUNT_TYPE_MIN && + gruntTypeNum <= ROCKET_LEADER_GRUNT_TYPE_MAX + ) + return + + // Add all potential first slot rewards + if (invasionInfo.firstReward && invasionInfo.encounters.first) { + invasionInfo.encounters.first.forEach((poke) => { + availableSet.add(`a${poke.id}-${poke.form}`) + }) + } + + // Add all potential second slot rewards + if (invasionInfo.secondReward && invasionInfo.encounters.second) { + invasionInfo.encounters.second.forEach((poke) => { + availableSet.add(`a${poke.id}-${poke.form}`) + }) + } + + // Add all potential third slot rewards + if (invasionInfo.thirdReward && invasionInfo.encounters.third) { + invasionInfo.encounters.third.forEach((poke) => { + availableSet.add(`a${poke.id}-${poke.form}`) + }) + } + }) +} + const questProps = { quest_type: true, quest_timestamp: true, @@ -1245,6 +1302,65 @@ class Pokestop extends Model { return Object.values(filtered) } + /** + * Mirrors `Pokemon.evalQuery`: fetches a Golbat scanner endpoint (when + * `mem` is set) with secret/httpAuth header handling, or evaluates a + * knex query builder / raw query directly otherwise. Pokestop currently + * only calls this with the `mem` branch (`/api/pokestop/available`), but + * keeps the same shape as Pokemon's for any future Golbat migrations of + * this model (see Phase 2 follow-up: `getPokestops`). + * @template T + * @param {string} mem + * @param {string | import("objection").QueryBuilder} query + * @param {'GET' | 'POST' | 'PATCH' | 'DELETE'} method + * @param {string} secret + * @param {{ username: string, password: string } | null} httpAuth + * @returns {Promise} + */ + static async evalQuery( + mem, + query, + method = 'POST', + secret = '', + httpAuth = null, + ) { + if (config.getSafe('devOptions.queryDebug')) { + if (!fs.existsSync(resolve(__dirname, './queries'))) { + fs.mkdirSync(resolve(__dirname, './queries'), { recursive: true }) + } + if (mem && typeof query === 'string') { + fs.writeFileSync( + resolve(__dirname, './queries', `${Date.now()}.json`), + query, + ) + } else if (typeof query === 'object') { + fs.writeFileSync( + resolve(__dirname, './queries', `${Date.now()}.sql`), + query.toKnexQuery().toString(), + ) + } + } + const results = await (mem + ? fetchJson(mem, { + method, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + // Support both secret-based and HTTP authentication + ...(secret ? { 'X-Golbat-Secret': secret } : {}), + ...(httpAuth + ? { + Authorization: `Basic ${Buffer.from(`${httpAuth.username}:${httpAuth.password}`).toString('base64')}`, + } + : {}), + }, + body: query, + }) + : query) + log.debug(TAGS.pokestops, 'raw result length', results?.length || 0) + return results || [] + } + /** * * @param {import("@rm/types").DbContext} param0 @@ -1261,7 +1377,42 @@ class Pokestop extends Model { hasShowcaseData, hasShowcaseForm, hasShowcaseType, + mem, + secret, + httpAuth, }) { + if (mem) { + try { + const res = await this.evalQuery( + `${mem}/api/pokestop/available`, + undefined, + 'GET', + secret, + httpAuth, + ) + // fetchJson returns a node-fetch Response object on a non-2xx + // response (e.g. 503 when FortInMemory is off) and evalQuery + // normalizes a network/timeout error to `[]` -- neither shape has + // a `.quests`/`.invasions` array, so both fall through to SQL. + if (res && Array.isArray(res.quests) && Array.isArray(res.invasions)) { + const result = mapAvailablePokestops(res, { + invasions: state.event.invasions, + }) + const availableSet = new Set(result.available) + applyRocketPokemonFallback(availableSet) + return { available: [...availableSet], conditions: result.conditions } + } + log.warn( + TAGS.pokestops, + '[POKESTOP] /api/pokestop/available unavailable (falling back to SQL)', + ) + } catch (e) { + log.warn( + TAGS.pokestops, + `[POKESTOP] /api/pokestop/available error (falling back to SQL): ${e}`, + ) + } + } const ts = Math.floor(Date.now() / 1000) const finalList = new Set() const conditions = {} @@ -1865,41 +2016,7 @@ class Pokestop extends Model { }) } - if (config.getSafe('map.misc.fallbackRocketPokemonFiltering')) { - // Always include potential rocket Pokemon from state.event.invasions as backup - Object.entries(state.event.invasions).forEach( - ([gruntType, invasionInfo]) => { - if (!invasionInfo) return - // Exclude team leaders (41-43) and Giovanni (44) - const gruntTypeNum = parseInt(gruntType, 10) - if (gruntTypeNum >= 41 && gruntTypeNum <= 44) return - - // Add all potential first slot rewards - if (invasionInfo.firstReward && invasionInfo.encounters.first) { - invasionInfo.encounters.first.forEach((poke) => { - finalList.add(`a${poke.id}-${poke.form}`) - }) - } - - // Add all potential second slot rewards - if ( - invasionInfo.secondReward && - invasionInfo.encounters.second - ) { - invasionInfo.encounters.second.forEach((poke) => { - finalList.add(`a${poke.id}-${poke.form}`) - }) - } - - // Add all potential third slot rewards - if (invasionInfo.thirdReward && invasionInfo.encounters.third) { - invasionInfo.encounters.third.forEach((poke) => { - finalList.add(`a${poke.id}-${poke.form}`) - }) - } - }, - ) - } + applyRocketPokemonFallback(finalList) break case 'showcase': if (hasShowcaseData) { From 1d94116a6f2ecce6cf25866ed9ef20c31103786f Mon Sep 17 00:00:00 2001 From: James Berry Date: Tue, 14 Jul 2026 18:54:19 +0100 Subject: [PATCH 6/7] fix(pokestop): endpoint sources return empty available on failure (no SQL fallback path) Endpoint sources (mem truthy) have no bound knex, so on /api/pokestop/available failure the previous fallthrough to the SQL block threw on this.query(), was swallowed by Promise.allSettled, and silently contributed zero filter keys. Endpoint sources are now endpoint-authoritative: on failure they return a clean empty { available: [], conditions: {} } instead of falling through, matching Pokemon.getAvailable. The SQL block is reached only for mem:'' (DB/MAD) sources. --- server/src/models/Pokestop.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index f35f595fb..229b14503 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -1381,6 +1381,14 @@ class Pokestop extends Model { secret, httpAuth, }) { + // Endpoint sources (mem truthy) have no bound knex -- DbManager binds + // the unbound Pokestop base class for endpoint sources precisely + // because they have no DB connection. They are endpoint-authoritative: + // on failure we return a clean empty result instead of falling + // through, since the SQL block below would throw on `this.query()` + // (swallowed by the caller's Promise.allSettled) and silently + // contribute zero filter keys. The SQL block is reached only for + // `mem: ''` (DB / MAD) sources. if (mem) { try { const res = await this.evalQuery( @@ -1393,7 +1401,7 @@ class Pokestop extends Model { // fetchJson returns a node-fetch Response object on a non-2xx // response (e.g. 503 when FortInMemory is off) and evalQuery // normalizes a network/timeout error to `[]` -- neither shape has - // a `.quests`/`.invasions` array, so both fall through to SQL. + // a `.quests`/`.invasions` array. if (res && Array.isArray(res.quests) && Array.isArray(res.invasions)) { const result = mapAvailablePokestops(res, { invasions: state.event.invasions, @@ -1404,14 +1412,16 @@ class Pokestop extends Model { } log.warn( TAGS.pokestops, - '[POKESTOP] /api/pokestop/available unavailable (falling back to SQL)', + '[POKESTOP] /api/pokestop/available unavailable (e.g. fort_in_memory off) — returning empty available for this endpoint source', ) } catch (e) { log.warn( TAGS.pokestops, - `[POKESTOP] /api/pokestop/available error (falling back to SQL): ${e}`, + `[POKESTOP] /api/pokestop/available error — returning empty available for this endpoint source: ${e}`, ) } + // Endpoint sources have no bound DB to fall back to. + return { available: [], conditions: {} } } const ts = Math.floor(Date.now() / 1000) const finalList = new Set() From 0ab3093a89ae7a4ace56b57e7b4056d17759a675 Mon Sep 17 00:00:00 2001 From: James Berry Date: Tue, 14 Jul 2026 21:00:11 +0100 Subject: [PATCH 7/7] feat(db): support dual endpoint+DB scanner sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A source with both a Golbat endpoint and DB creds now registers the endpoint AND builds a knex connection: migrated queries (Pokestop getAvailable) use the endpoint, un-migrated ones (getAll/getOne/search/ submissions) fall back to this.query() on the bound DB — so a Golbat pokestop endpoint no longer breaks map markers/popups/search. - DbManager: keep the knex connection for endpoint schemas that also carry DB creds; overlay mem/secret/httpAuth onto the schemaChecked context so isMad/has* flags survive. - Pokestop.getAvailable: on endpoint failure fall through to the SQL block (a dual source runs it on the bound knex; a pure-endpoint source throws and is dropped by allSettled) instead of returning empty. - types: ApiEndpoint.httpAuth typed; DbConnection gains optional endpoint/secret/httpAuth for the dual shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/types/lib/server.d.ts | 7 +++++++ server/src/models/Pokestop.js | 17 +++++++---------- server/src/services/DbManager.js | 18 +++++++++++++++++- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/types/lib/server.d.ts b/packages/types/lib/server.d.ts index 565d1062b..7286e0a52 100644 --- a/packages/types/lib/server.d.ts +++ b/packages/types/lib/server.d.ts @@ -122,6 +122,7 @@ export interface ApiEndpoint { type: string endpoint: string secret: string + httpAuth?: { username: string; password: string } | null useFor: Lowercase[] } @@ -132,6 +133,12 @@ export interface DbConnection { password: string database: string useFor: Lowercase[] + // Optional Golbat endpoint on the same source (dual): migrated queries use + // the endpoint, the rest fall back to this DB connection. + endpoint?: string + secret?: string + httpAuth?: { username: string; password: string } | null + type?: string } export type Schema = ApiEndpoint | DbConnection diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 229b14503..53cdb4174 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -1381,14 +1381,13 @@ class Pokestop extends Model { secret, httpAuth, }) { - // Endpoint sources (mem truthy) have no bound knex -- DbManager binds - // the unbound Pokestop base class for endpoint sources precisely - // because they have no DB connection. They are endpoint-authoritative: - // on failure we return a clean empty result instead of falling - // through, since the SQL block below would throw on `this.query()` - // (swallowed by the caller's Promise.allSettled) and silently - // contribute zero filter keys. The SQL block is reached only for - // `mem: ''` (DB / MAD) sources. + // A source with a Golbat endpoint (mem truthy) fetches the available list + // from the endpoint. On failure (503 when fort_in_memory is off, or a + // network error) it falls through to the SQL block below: a DUAL source + // (endpoint + DB) runs the SQL fallback on its bound knex, while a + // pure-endpoint source has no bound knex, so this.query() throws and the + // caller's Promise.allSettled drops it (contributing nothing). The SQL + // block also serves mem:'' (DB / MAD) sources directly. if (mem) { try { const res = await this.evalQuery( @@ -1420,8 +1419,6 @@ class Pokestop extends Model { `[POKESTOP] /api/pokestop/available error — returning empty available for this endpoint source: ${e}`, ) } - // Endpoint sources have no bound DB to fall back to. - return { available: [], conditions: {} } } const ts = Math.floor(Date.now() / 1000) const finalList = new Set() diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index c1c81fac0..ac51fa9c8 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -89,7 +89,13 @@ class DbManager extends Logger { }) if ('endpoint' in schema) { this.endpoints[i] = schema - return null + // Pure-endpoint source (no DB creds): no knex connection. A dual + // source (endpoint + host/…) registers the endpoint AND falls + // through to build knex below, so migrated queries use the endpoint + // while un-migrated ones fall back to the bound DB. + if (!('host' in schema)) { + return null + } } const { log } = new Logger('knex', schema.database) return knex({ @@ -273,6 +279,16 @@ class DbManager extends Logger { pvpV2: true, } + // Dual source (endpoint + DB): schemaCheck ran on the bound knex + // (giving isMad + has* flags) but returns mem:''/secret:''. Overlay + // the endpoint AFTER so migrated queries (getAvailable) use it while + // un-migrated ones fall back to this.query() on the bound DB. + if (schema && this.endpoints[i]) { + schemaContext.mem = this.endpoints[i].endpoint + schemaContext.secret = this.endpoints[i].secret + schemaContext.httpAuth = this.endpoints[i].httpAuth + } + Object.entries(this.models).forEach(([category, sources]) => { if (Array.isArray(sources)) { sources.forEach((source, j) => {