From f2d642b74d37ae97795ed4d1216c2cf6df28dd82 Mon Sep 17 00:00:00 2001 From: PJ0tter Date: Sun, 5 Jul 2026 20:04:17 +0200 Subject: [PATCH 01/38] fix(scanArea): prevent crash when area feature has no name/key (#1225) * fix(scanArea): prevent crash when area feature has no name/key Guard the scan area search filter against features missing a properties.key (which happens when a scan area polygon has no name set), instead of throwing TypeError: Cannot read properties of undefined (reading 'toLowerCase'). Also fixes a longstanding typo (geoJsonFilName / geoJsonFilname -> geoJsonFileName) in the multi-domain example config and docs. * fix: copilot comments --------- Co-authored-by: Mygod --- config/multi-domain-example/README.md | 4 ++-- config/multi-domain-example/local-applemap.json | 2 +- config/multi-domain-example/local-orangemap.json | 2 +- src/features/scanArea/ScanAreaTile.jsx | 7 ++++--- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/config/multi-domain-example/README.md b/config/multi-domain-example/README.md index 8a1985437..4327c83df 100644 --- a/config/multi-domain-example/README.md +++ b/config/multi-domain-example/README.md @@ -4,7 +4,7 @@ - This makes use of the `NODE_CONFIG_ENV` env variable to determine which `local.json` files to load - Loads `default.json` => `local.json` => `local-{NODE_CONFIG_ENV}`.json -- You set all of your base defaults in `local.json` still, then set things that are unique to those domains, such `geoJsonFilename` or authentication strategies in each of the domain specifics jsons +- You set all of your base defaults in `local.json` still, then set things that are unique to those domains, such as `geoJsonFileName` or authentication strategies in each of the domain specifics jsons - The `NODE_CONFIG_ENV` var names should not contain `/` or `.` ## File System @@ -20,7 +20,7 @@ local - orangemap.json - `local.json` is the base config file that all other configs will inherit from, it can also be its own map instance if do not set the `NODE_CONFIG_ENV` env variable - The other files will inherit everything you set in `local.json` and then override any values that are set in the domain specific file -- Such as in `local-applemap.json`, we have set a new title, a separate Discord strategy, and a different geoJsonFilename +- Such as in `local-applemap.json`, we have set a new title, a separate Discord strategy, and a different geoJsonFileName - Only config setting you must set in each file is the port, since separate instances of the app will be generated - In `local-orangemap.json`, we also set a different start Latitude and Longitude and have disabled some various features that we do not want on that map. In `local.json`, we had set `alwaysEnabledPerms = ["map"]`, however, for orangemap we have overridden that by providing an empty array. - The databases specified in `local.json` will be used in all 3 maps, as will all of the permissions. diff --git a/config/multi-domain-example/local-applemap.json b/config/multi-domain-example/local-applemap.json index 5a7d1f59a..eef6ac5a9 100644 --- a/config/multi-domain-example/local-applemap.json +++ b/config/multi-domain-example/local-applemap.json @@ -4,7 +4,7 @@ "general": { "title": "Apple Map", "headerTitle": "Apple Map PoGo", - "geoJsonFilName": "http://koji.map.com/api/v1/geofence/feature-collection/apple" + "geoJsonFileName": "http://koji.map.com/api/v1/geofence/feature-collection/apple" }, "links": { "discordInvite": "apple map invite", diff --git a/config/multi-domain-example/local-orangemap.json b/config/multi-domain-example/local-orangemap.json index ccd8c9b17..0bafbf251 100644 --- a/config/multi-domain-example/local-orangemap.json +++ b/config/multi-domain-example/local-orangemap.json @@ -6,7 +6,7 @@ "headerTitle": "Orange Map", "startLat": 67.2512, "startLon": -25.9667, - "geoJsonFilName": "http://koji.map.com/api/v1/geofence/feature-collection/orange" + "geoJsonFileName": "http://koji.map.com/api/v1/geofence/feature-collection/orange" }, "misc": { "enableMapJsFilter": false, diff --git a/src/features/scanArea/ScanAreaTile.jsx b/src/features/scanArea/ScanAreaTile.jsx index 1cbfcb03d..0cebe5478 100644 --- a/src/features/scanArea/ScanAreaTile.jsx +++ b/src/features/scanArea/ScanAreaTile.jsx @@ -14,7 +14,8 @@ import { getProperName } from '@utils/strings' * @returns */ function ScanArea(featureCollection) { - const search = useStorage((s) => s.filters.scanAreas?.filter?.search) + const rawSearch = useStorage((s) => s.filters.scanAreas?.filter?.search ?? '') + const search = rawSearch.toLowerCase() const tapToToggle = useStorage((s) => s.userSettings.scanAreas.tapToToggle) const alwaysShowLabels = useStorage( (s) => s.userSettings.scanAreas.alwaysShowLabels, @@ -23,12 +24,12 @@ function ScanArea(featureCollection) { return ( webhook || search === '' || - f.properties.key.toLowerCase().includes(search.toLowerCase()) + (f.properties?.key || '').toLowerCase().includes(search) } eventHandlers={{ click: ({ propagatedFrom: layer }) => { From 7d19efe698f556e4f10049367f0189815193f364 Mon Sep 17 00:00:00 2001 From: James Berry Date: Fri, 17 Jul 2026 15:37:39 +0100 Subject: [PATCH 02/38] feat(server): shared golbat scanner utils and dual-source plumbing Co-Authored-By: Claude Fable 5 --- packages/logger/lib/tags.js | 1 + packages/types/lib/server.d.ts | 48 ++++++++++++++++ server/src/services/DbManager.js | 18 +++++- server/src/utils/evalScannerQuery.js | 86 ++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 server/src/utils/evalScannerQuery.js diff --git a/packages/logger/lib/tags.js b/packages/logger/lib/tags.js index 75923a0a7..06191d7ab 100644 --- a/packages/logger/lib/tags.js +++ b/packages/logger/lib/tags.js @@ -38,6 +38,7 @@ const TAGS = /** @type {const} */ ({ pokemon: chalk.hex('#f44336')('[POKEMON]'), pokestops: chalk.hex('#e91e63')('[POKESTOPS]'), gyms: chalk.hex('#9c27b0')('[GYMS]'), + stations: chalk.hex('#00bcd4')('[STATIONS]'), weather: chalk.hex('#3f51b5')('[WEATHER]'), available: chalk.hex('#2196f3')('[AVAILABLE]'), scanAreas: chalk.hex('#00bcd4')('[SCAN AREAS]'), diff --git a/packages/types/lib/server.d.ts b/packages/types/lib/server.d.ts index 41e2c639c..7286e0a52 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 @@ -81,6 +122,7 @@ export interface ApiEndpoint { type: string endpoint: string secret: string + httpAuth?: { username: string; password: string } | null useFor: Lowercase[] } @@ -91,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/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) => { diff --git a/server/src/utils/evalScannerQuery.js b/server/src/utils/evalScannerQuery.js new file mode 100644 index 000000000..85668d68b --- /dev/null +++ b/server/src/utils/evalScannerQuery.js @@ -0,0 +1,86 @@ +// @ts-check +const fs = require('fs') +const { resolve } = require('path') + +const config = require('@rm/config') +const { log } = require('@rm/logger') +const { fetchJson } = require('./fetchJson') + +/** + * Endpoint-or-knex query evaluator shared by Golbat-backed scanner models. + * Mirrors Pokemon.evalQuery / Pokestop.evalQuery but is tag-parameterized so + * new consumers (Gym, Station) don't each re-copy it. + * @template T + * @param {import('@rm/logger').Tag} tag + * @param {string} mem endpoint base+path when set; falsy = evaluate `query` + * @param {string | import('objection').QueryBuilder} query JSON body (mem) or knex query + * @param {'GET' | 'POST' | 'PATCH' | 'DELETE'} [method] + * @param {string} [secret] + * @param {{ username: string, password: string } | null} [httpAuth] + * @returns {Promise} + */ +async function evalScannerQuery( + tag, + mem, + query, + method = 'POST', + secret = '', + httpAuth = null, +) { + if (config.getSafe('devOptions.queryDebug')) { + const dir = resolve(__dirname, '../models/queries') + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + if (mem && typeof query === 'string') { + fs.writeFileSync(resolve(dir, `${Date.now()}.json`), query) + } else if (typeof query === 'object' && query) { + fs.writeFileSync( + resolve(dir, `${Date.now()}.sql`), + query.toKnexQuery().toString(), + ) + } + } + const results = await (mem + ? fetchJson(mem, { + method, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(secret ? { 'X-Golbat-Secret': secret } : {}), + ...(httpAuth + ? { + Authorization: `Basic ${Buffer.from( + `${httpAuth.username}:${httpAuth.password}`, + ).toString('base64')}`, + } + : {}), + }, + body: query, + }) + : query) + log.debug(tag, 'raw result length', results?.length || 0) + return results +} + +/** + * Human-readable description of why a scanner endpoint response was not the + * expected shape, for diagnostic fallback logging. `fetchJson` returns the + * node-fetch `Response` (with a numeric `status`) on a non-2xx, `undefined` on a + * network/timeout error, or the parsed JSON on success. + * @param {any} res + * @returns {string} + */ +function describeScannerResponse(res) { + if (res === undefined || res === null) { + return 'no response (network error / timeout)' + } + if (typeof res.status === 'number') { + return `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''}` + } + if (typeof res === 'object') { + const keys = Object.keys(res) + return `unexpected body shape (keys: ${keys.length ? keys.join(', ') : 'none'})` + } + return `unexpected ${typeof res} response` +} + +module.exports = { evalScannerQuery, describeScannerResponse } From 5ec99c1d132a9d9a23700af5612f2f646757b8a9 Mon Sep 17 00:00:00 2001 From: James Berry Date: Fri, 17 Jul 2026 15:37:41 +0100 Subject: [PATCH 03/38] feat(server): pure mappers for golbat fort api responses Co-Authored-By: Claude Fable 5 --- server/src/models/gymAvailableMapper.js | 38 ++++ server/src/models/pokestopAvailableMapper.js | 210 +++++++++++++++++++ server/src/models/pokestopScanMapper.js | 144 +++++++++++++ server/src/models/stationAvailableMapper.js | 22 ++ 4 files changed, 414 insertions(+) create mode 100644 server/src/models/gymAvailableMapper.js create mode 100644 server/src/models/pokestopAvailableMapper.js create mode 100644 server/src/models/pokestopScanMapper.js create mode 100644 server/src/models/stationAvailableMapper.js diff --git a/server/src/models/gymAvailableMapper.js b/server/src/models/gymAvailableMapper.js new file mode 100644 index 000000000..7e6627d85 --- /dev/null +++ b/server/src/models/gymAvailableMapper.js @@ -0,0 +1,38 @@ +// @ts-check + +/** + * Pure mapper for Golbat's `GET /api/gym/available` response. Reproduces the + * key output of the SQL `Gym.getAvailable` (t/g/e/r + boss `-
`). + * Dependency-free so it can run under plain node for golden checks. + * @param {{ teams?: {team_id:number,available_slots:number,count:number}[], raids?: {raid_level:number,pokemon_id:number,form:number,count:number}[] }} api + * @returns {{ available: string[] }} + */ +function mapGymAvailable(api) { + const available = new Set() + + const teams = api.teams || [] + teams.forEach((t) => { + if (t.team_id === null || t.available_slots === null) return + available.add(`t${t.team_id}-0`) + available.add(`g${t.team_id}-${6 - t.available_slots}`) + }) + + const raids = api.raids || [] + const raidLevels = new Set() + raids.forEach((r) => { + if (!r.raid_level) return + raidLevels.add(r.raid_level) + if (r.pokemon_id > 0) { + available.add(`${r.pokemon_id}-${r.form}`) + } else { + available.add(`e${r.raid_level}`) + } + }) + ;[...raidLevels] + .sort((a, b) => a - b) + .forEach((level) => available.add(`r${level}`)) + + return { available: [...available] } +} + +module.exports = { mapGymAvailable } diff --git a/server/src/models/pokestopAvailableMapper.js b/server/src/models/pokestopAvailableMapper.js new file mode 100644 index 000000000..bdca83526 --- /dev/null +++ b/server/src/models/pokestopAvailableMapper.js @@ -0,0 +1,210 @@ +// @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 + * @property {boolean} [includeBaseQuests] include AR (`with_ar:true`) quests; default true + * @property {boolean} [includeAltQuests] include non-AR (`with_ar:false`) quests; default true + * + * @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 { includeBaseQuests = true, includeAltQuests = true } = 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) => { + // Honor questLayerMode: `with_ar:true` is the AR (base/`quest_*`) layer, + // `false` the non-AR (alt/`alternative_quest_*`) layer. Skip a layer the + // config excludes, matching the SQL `shouldIncludeBaseQuests`/ + // `shouldIncludeAltQuests` gating in `Pokestop.getAvailable`. + if (quest.with_ar ? !includeBaseQuests : !includeAltQuests) { + return + } + // 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 + // 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 } diff --git a/server/src/models/pokestopScanMapper.js b/server/src/models/pokestopScanMapper.js new file mode 100644 index 000000000..b9715c2ca --- /dev/null +++ b/server/src/models/pokestopScanMapper.js @@ -0,0 +1,144 @@ +// @ts-check + +/** + * Pure mapper for one pokestop from Golbat's `POST /api/pokestop/scan` + * (envelope `res.pokestops[]`) or `GET /api/pokestop/id/{id}` (bare object). + * + * Produces the SAME per-stop shape `Pokestop.mapRDM` emits from joined SQL + * rows, so `Pokestop.secondaryFilter` (and the `parseRdmRewards` it calls) run + * downstream completely unchanged — exactly how `Gym.getAll` reuses its own + * `secondaryFilter`. All filtering, reward expansion, `key` building, midnight/ + * layer gating, incident-blocker and `events[]` assembly stay in secondaryFilter. + * + * Golbat's ApiPokestopResult exposes the full pokestop record, including the + * generated quest columns (`quest_reward_type`, `quest_item_id`, + * `quest_pokemon_id`, …) — Golbat commit `ce54037` — so this is a straight + * field copy with no reward decoding. `quest_rewards` is native JSON (commit + * `1c86576`, `jsonRaw()`) and is passed through unchanged for `parseRdmRewards` + * to expand the per-type `info` (candy/xl/mega/xp/dust/form) that has no flat + * column. `quest_item_id`/`quest_pokemon_id` are copied explicitly because + * `secondaryFilter` builds reward-type filter keys from them and + * `parseRdmRewards` cannot reconstruct `quest_item_id` from the rewards JSON. + * + * Standalone by design (no requires) so it runs under plain `node` for golden + * checks with no `node_modules` present. + * + * @typedef {object} ApiPokestopIncident + * @property {number} character 0 for non-rocket (showcase/goldstop/kecleon) + * @property {number} expiration + * @property {number} display_type 7 goldstop, 8 kecleon, 9 showcase + * @property {boolean} confirmed + * @property {number} [slot_1_pokemon_id] + * @property {number} [slot_1_form] + * @property {number} [slot_2_pokemon_id] + * @property {number} [slot_2_form] + * @property {number} [slot_3_pokemon_id] + * @property {number} [slot_3_form] + */ + +/** + * Maps a Golbat invasion entry to ReactMap's `invasionProps` shape (the exact + * key set `Pokestop.mapRDM` puts on each `pokestop.invasions[]` entry). + * `character` → `grunt_type` and `expiration` → `incident_expire_timestamp` + * are the only renames; slots pass through by name. + * + * @param {ApiPokestopIncident} inc + */ +function mapInvasion(inc) { + return { + incident_expire_timestamp: inc.expiration, + grunt_type: inc.character, + display_type: inc.display_type, + confirmed: inc.confirmed, + slot_1_pokemon_id: inc.slot_1_pokemon_id, + slot_1_form: inc.slot_1_form, + slot_2_pokemon_id: inc.slot_2_pokemon_id, + slot_2_form: inc.slot_2_form, + slot_3_pokemon_id: inc.slot_3_pokemon_id, + slot_3_form: inc.slot_3_form, + } +} + +/** + * Builds one quest-layer object shaped like a `mapRDM` quest, or `null` when + * the layer has no active quest. Mirrors `mapRDM`'s `if (quest.quest_reward_type) + * push` — gated on the flat `quest_reward_type` column Golbat now exposes + * (commit `ce54037`), which is `null` when there is no quest, so no reward + * decoding or `JSON.parse` happens here. `quest_item_id`/`quest_pokemon_id` are + * copied straight from the record (RDM's generated columns) so + * `secondaryFilter`'s reward-type key switch resolves — `parseRdmRewards` cannot + * reproduce `quest_item_id`. The native `quest_rewards` array is passed through + * for `parseRdmRewards` to expand the remaining per-type `info` fields. + * + * @param {Record} api + * @param {'' | 'alternative_'} prefix + * @param {boolean} withAr + * @returns {Record | null} + */ +function buildQuestLayer(api, prefix, withAr) { + const questRewardType = api[`${prefix}quest_reward_type`] + if (!questRewardType) return null + return { + quest_type: api[`${prefix}quest_type`], + quest_timestamp: api[`${prefix}quest_timestamp`], + quest_target: api[`${prefix}quest_target`], + quest_conditions: api[`${prefix}quest_conditions`], + quest_rewards: api[`${prefix}quest_rewards`], + quest_reward_type: questRewardType, + quest_item_id: api[`${prefix}quest_item_id`], + quest_pokemon_id: api[`${prefix}quest_pokemon_id`], + quest_title: api[`${prefix}quest_title`], + with_ar: withAr, + } +} + +/** + * Maps one Golbat pokestop to the row shape `Pokestop.secondaryFilter` expects + * (i.e. one `mapRDM` output entry). Returns `null` for disabled/deleted stops, + * mirroring `mapRDM`'s `if (!result.enabled || result.deleted) continue`. + * + * `quest_*` = AR layer (`with_ar:true`), `alternative_quest_*` = non-AR layer + * (`with_ar:false`); each is pushed only when its reward type is derivable, so + * `quests` holds 0, 1, or 2 entries. Every returned incident (grunt AND + * showcase/goldstop/kecleon event rows) is mapped into `invasions`; + * `secondaryFilter` splits them into `invasions[]`/`events[]`/incident-blocker. + * Golbat prunes expired incidents server-side (`CollectPokestopIncidents` + * keeps `expiration > now`), so no expiry filter is applied here. + * + * @param {Record} api one ApiPokestopResult + * @returns {Record | null} + */ +function mapScanPokestop(api) { + if (!api.enabled || api.deleted) return null + const quests = [] + const base = buildQuestLayer(api, '', true) + if (base) quests.push(base) + const alt = buildQuestLayer(api, 'alternative_', false) + if (alt) quests.push(alt) + return { + id: api.id, + lat: api.lat, + lon: api.lon, + enabled: api.enabled, + url: api.url, + name: api.name, + last_modified_timestamp: api.last_modified_timestamp, + updated: api.updated, + ar_scan_eligible: api.ar_scan_eligible, + power_up_points: api.power_up_points, + power_up_level: api.power_up_level, + power_up_end_timestamp: api.power_up_end_timestamp, + lure_id: api.lure_id, + lure_expire_timestamp: api.lure_expire_timestamp, + showcase_expiry: api.showcase_expiry, + showcase_pokemon_id: api.showcase_pokemon_id, + showcase_pokemon_form_id: api.showcase_pokemon_form_id, + showcase_pokemon_type_id: api.showcase_pokemon_type_id, + showcase_ranking_standard: api.showcase_ranking_standard, + showcase_rankings: api.showcase_rankings, + quests, + invasions: (api.invasions || []).map(mapInvasion), + } +} + +module.exports = { mapScanPokestop, buildQuestLayer, mapInvasion } diff --git a/server/src/models/stationAvailableMapper.js b/server/src/models/stationAvailableMapper.js new file mode 100644 index 000000000..7da807e2e --- /dev/null +++ b/server/src/models/stationAvailableMapper.js @@ -0,0 +1,22 @@ +// @ts-check + +/** + * Pure mapper for Golbat's `GET /api/station/available` response. Reproduces the + * key output of the SQL `Station.getAvailable`: `j{level}` battle-tier keys and + * `-` battle-pokemon keys. Dependency-free (golden-testable + * under plain node). + * @param {{ battles?: {battle_level:number, pokemon_id:number, form:number, count:number}[] }} api + * @returns {{ available: string[] }} + */ +function mapStationAvailable(api) { + const available = new Set() + const battles = api.battles || [] + battles.forEach((b) => { + if (!b.battle_level) return + available.add(`${b.pokemon_id}-${b.form}`) + available.add(`j${b.battle_level}`) + }) + return { available: [...available] } +} + +module.exports = { mapStationAvailable } From 53b7e661298c85412d60cee06b1c12bedafa30e1 Mon Sep 17 00:00:00 2001 From: James Berry Date: Fri, 17 Jul 2026 15:37:42 +0100 Subject: [PATCH 04/38] feat(server): fort dnf filter backends with narrowing log Co-Authored-By: Claude Fable 5 --- .../src/filters/fort/describeDnfNarrowing.js | 41 +++ server/src/filters/fort/gym.js | 76 +++++ server/src/filters/fort/pokestop.js | 300 ++++++++++++++++++ server/src/filters/fort/station.js | 90 ++++++ 4 files changed, 507 insertions(+) create mode 100644 server/src/filters/fort/describeDnfNarrowing.js create mode 100644 server/src/filters/fort/gym.js create mode 100644 server/src/filters/fort/pokestop.js create mode 100644 server/src/filters/fort/station.js diff --git a/server/src/filters/fort/describeDnfNarrowing.js b/server/src/filters/fort/describeDnfNarrowing.js new file mode 100644 index 000000000..69cb5c533 --- /dev/null +++ b/server/src/filters/fort/describeDnfNarrowing.js @@ -0,0 +1,41 @@ +// @ts-check + +/** + * Builds the DNF observability log line. Leads with the meaningful metric — + * `returned` (forts of THIS type that DNF matched) narrowed to `final` by + * secondaryFilter — so a large residual drop flags where DNF is leaving + * narrowing on the table. `examined` is context only: Golbat's spatial scan + * counts EVERY fort in the viewport (gyms + stations + pokestops) before the + * per-type filter, so it is NOT a per-type "before DNF" number — don't read + * `examined - returned` as DNF's work. `clauses` = DNF clauses sent (0 = match-all). + * + * @param {string} label e.g. 'GYM' + * @param {object[]} clauses the DNF clause array sent to Golbat (empty = match-all) + * @param {number} examined all forts (every type) scanned in the viewport (res.examined) + * @param {number} returned forts of this type DNF returned (res..length) + * @param {number} final forts left after secondaryFilter + * @returns {string} + */ +function describeDnfNarrowing(label, clauses, examined, returned, final) { + const residual = returned - final + // Compact per-clause shape (field[listLen|value]) so a broad clause is + // visible — e.g. a quest_reward_type[1] with the exact amount dropped, or a + // quest_reward_pokemon[50] persisted filter, is the usual cause of a big residual. + const shape = clauses.length + ? clauses + .map((c) => + Object.entries(c) + .map(([k, v]) => { + if (Array.isArray(v)) return `${k}[${v.length}]` + if (v && typeof v === 'object') + return `${k}[${v.min ?? '?'}..${v.max ?? '?'}]` + return `${k}[${v}]` + }) + .join('+'), + ) + .join(' OR ') + : 'match-all' + return `[${label}] DNF(${clauses.length}): ${returned} matched -> ${final} after secondaryFilter (-${residual} residual) | ${shape} | ${examined} scanned (all types)` +} + +module.exports = { describeDnfNarrowing } diff --git a/server/src/filters/fort/gym.js b/server/src/filters/fort/gym.js new file mode 100644 index 000000000..0c4abbef7 --- /dev/null +++ b/server/src/filters/fort/gym.js @@ -0,0 +1,76 @@ +// @ts-check + +/** + * Translate a gym's `args.filters` into Golbat ApiFortDnfFilter[] clauses, + * gated on the layer toggles exactly like Gym.getAll's secondaryFilter: + * + * - Raid filters (egg tier `e`, raid boss `-`) only narrow when the + * raid layer (`onlyRaids`) is on; otherwise raids never show, so emitting a + * raid clause would over-fetch forts secondaryFilter then drops. + * - The gym/team layer's shown gyms are always covered by either the match-all + * poison (`onlyAllGyms`/`onlyExEligible`/`onlyInBattle`/badges) or the + * `is_ar_scan_eligible` clause (`onlyArEligible`), so team/slot (`t`/`g`) + * filters need no clause of their own — one would only enlarge the fetch. + * + * Gender and power-up stay residual. Returns [] (match-all) when an + * unexpressible category is active or nothing narrowable is on. + * + * @param {Record} filters args.filters + * @returns {object[]} + */ +function buildGymDnfFilters(filters) { + if (!filters || typeof filters !== 'object') return [] + const { + onlyAllGyms, + onlyExEligible, + onlyInBattle, + onlyArEligible, + onlyGymBadges, + onlyBadge, + onlyRaids, + } = filters + // Poison: badge gyms (ReactMap-local join) and the show-all/ex/in-battle + // toggles have no DNF expression -> fetch all. + if ( + onlyAllGyms || + onlyExEligible || + onlyInBattle || + onlyGymBadges || + onlyBadge + ) + return [] + + const clauses = [] + if (onlyRaids) { + const eggs = [] + const raidBosses = [] + Object.entries(filters).forEach(([key]) => { + if (typeof key !== 'string' || key.length === 0) return + if (key.charAt(0) === 'e') { + const tier = Number(key.slice(1)) + if (Number.isFinite(tier)) eggs.push(tier) + } else if (/^\d/.test(key)) { + // raid boss "-" (default case in Gym.getAll); gender residual + const [idPart, formPart] = key.split('-', 2) + const id = Number(idPart) + if (!Number.isFinite(id)) return + const pair = { pokemon_id: id } + if ( + formPart && + formPart !== 'null' && + Number.isFinite(Number(formPart)) + ) + pair.form = Number(formPart) + raidBosses.push(pair) + } + }) + // Golbat's tag is `raid_pokemon_id` (unlike other types' `*_pokemon`). + if (raidBosses.length) clauses.push({ raid_pokemon_id: raidBosses }) + if (eggs.length) clauses.push({ raid_level: eggs }) + } + if (onlyArEligible) clauses.push({ is_ar_scan_eligible: true }) + + return clauses.length ? clauses : [] +} + +module.exports = { buildGymDnfFilters } diff --git a/server/src/filters/fort/pokestop.js b/server/src/filters/fort/pokestop.js new file mode 100644 index 000000000..561e1fa47 --- /dev/null +++ b/server/src/filters/fort/pokestop.js @@ -0,0 +1,300 @@ +// @ts-check + +/** push {pokemon_id, form?} from a "[-]" key onto arr */ +function pushIdForm(arr, key, offset) { + const [idPart, formPart] = key.slice(offset).split('-', 2) + const id = Number(idPart) + if (!Number.isFinite(id)) return + const pair = { pokemon_id: id } + if (formPart && formPart !== 'null' && Number.isFinite(Number(formPart))) + pair.form = Number(formPart) + arr.push(pair) +} + +/** + * Grunt (incident) character ids whose *possible* rocket encounters include any + * of the requested reward pokemon ids. Mirrors the SQL path's + * `gruntTypesWithMatchingRewards` (`Pokestop.getAll`): iterate the event + * invasion map, skip team leaders/Giovanni (41-44), and match by pokemon id + * across the reward-gated first/second/third encounter slots. This is the DNF + * expression of the `a` rocket-reward filter — a superset of the real + * matches (both confirmed slots and unconfirmed grunts of these types), + * narrowed exactly by secondaryFilter. + * + * @param {Record} eventInvasions state.event.invasions + * @param {Set} pokemonIds + * @returns {number[]} + */ +function gruntTypesForRocketPokemon(eventInvasions, pokemonIds) { + const grunts = [] + Object.entries(eventInvasions || {}).forEach(([gruntStr, info]) => { + if (!info) return + const grunt = Number(gruntStr) + if (!Number.isFinite(grunt) || (grunt >= 41 && grunt <= 44)) return + const encounters = [ + ...(info.firstReward ? info.encounters?.first || [] : []), + ...(info.secondReward ? info.encounters?.second || [] : []), + ...(info.thirdReward ? info.encounters?.third || [] : []), + ] + if (encounters.some((poke) => pokemonIds.has(Number(poke.id)))) { + grunts.push(grunt) + } + }) + return grunts +} + +/** + * Translate a pokestop's `args.filters` into ApiFortDnfFilter[] clauses. + * + * CRITICAL — exact key semantics. secondaryFilter matches a stop's computed + * reward key EXACTLY against the enabled keys, so the translation must be + * equally exact or Golbat over-returns (users accumulate thousands of enabled + * keys from past rotations; any looseness matches today's stops those keys + * don't cover). Two rules follow: + * 1. One clause per reward TYPE (item 2, candy 4, encounter 7, xl 9, mega 12, + * plus a type-only clause for 1/3/u). Golbat ANDs sub-fields within a + * clause; merging types would let e.g. a candy pair match an encounter stop + * of the same species (cross-type over-return), and mixing item_id with + * pokemon sub-fields would match nothing (under-return). + * 2. Pokemon pairs are always FORM-EXACT — the pokemon-API pattern + * ({pokemon_id, form}; form set = exact, omitted = any-form wildcard). A + * bare ReactMap key means "reward carries no form_id" (encoded 0, like + * proto FORM_UNSET), NOT "any form", so it translates to form:0; an + * omitted-form wildcard was the accumulated-keys 997-stop over-return bug. + * Amounts are exact too where the key carries one: mega keys (`m-`) + * group into per-amount clauses with `quest_reward_amount {amt, amt}`, and + * stardust/xp keys (`d`/`p`) emit one amount-exact clause each + * (int16-overflow amounts fall back to type-level). `u` keys stay + * type-level by design. DNF is a superset narrow; secondaryFilter finalizes + * (quest title/target `adv`, invasion `confirmed` stay residual). Returns [] + * (match-all) when a match-all toggle is active or nothing is set. + * + * `a` rocket-reward keys are expanded to `incident_character` (the + * grunt types that can reward those pokemon) via `eventInvasions`; without that + * map (empty/unloaded) they poison to `[]` since they can't be expressed safely. + * + * @param {Record} filters args.filters + * @param {Record} [eventInvasions] state.event.invasions (grunt→reward map) + * @returns {object[]} + */ +function buildPokestopDnfFilters(filters, eventInvasions) { + if (!filters || typeof filters !== 'object') return [] + const { + onlyAllPokestops, + onlyArEligible, + onlyQuests, + onlyInvasions, + onlyLures, + onlyEventStops, + onlyExcludeGrunts, + onlyExcludeLeaders, + } = filters + if (onlyAllPokestops) return [] + // NOTE: no power_up_level. Like gyms, pokestop power-up filtering only applies + // in `onlyAllPokestops` mode (which poisons to [] above), so a power_up_level + // clause could only fire when the real filter does NOT restrict it — an + // under-return. Power-up stays residual. + + const INT16_MAX = 32767 + const itemIds = [] // 'q' -> quest reward type 2 + const candyPokemon = [] // 'c' -> type 4 (formless rewards -> form:0) + const xlPokemon = [] // 'x' -> type 9 (formless rewards -> form:0) + const megaByAmount = new Map() // 'm-' -> type 12, amount-exact groups + const megaLoose = [] // 'm' keys with no parseable amount (amount residual) + const encounterPokemon = [] // bare '[-]' -> type 7 (form-exact) + const dustAmounts = new Set() // 'd' -> type 3, amount-exact + const xpAmounts = new Set() // 'p' -> type 1, amount-exact + const typeOnly = new Set() // 'u' (+ overflow amounts) -> type-level + const lureId = [] + const incidentCharacter = new Set() // 'i' grunt ids + 'a'-derived grunt ids + const rocketPokemonIds = new Set() // 'a' reward ids + const incidentDisplayType = [] + const contestPokemon = [] + const contestPokemonType = [] + + Object.entries(filters).forEach(([key]) => { + if (typeof key !== 'string' || key.length === 0) return + const n = Number(key.slice(1)) + switch (key.charAt(0)) { + case 'o': + break + case 'l': + if (Number.isFinite(n)) lureId.push(n) + break + case 'q': + if (Number.isFinite(n)) itemIds.push(n) + break + case 'd': + // stardust key carries the exact amount; > int16 falls back to type-level + if (Number.isFinite(n) && n > 0 && n <= INT16_MAX) dustAmounts.add(n) + else typeOnly.add(3) + break + case 'p': + if (Number.isFinite(n) && n > 0 && n <= INT16_MAX) xpAmounts.add(n) + else typeOnly.add(1) + break + case 'u': + if (Number.isFinite(n)) typeOnly.add(n) + break + case 'c': + // The candy key has NO form component, so its match is form-agnostic — + // the exact translation is the form wildcard (form omitted). Pinning + // form:0 would under-return if a candy reward ever carried a form_id. + // Safe from over-return: the per-type clause only meets type-4 stops. + if (Number.isFinite(n)) candyPokemon.push({ pokemon_id: n }) + break + case 'x': + // form-agnostic key -> form wildcard (see 'c') + if (Number.isFinite(n)) xlPokemon.push({ pokemon_id: n }) + break + case 'm': { + // key is `m-` (NOT -): mega rewards are + // formless, and the key's amount is part of the exact match — group by + // amount so each clause carries quest_reward_amount {amt, amt}. + const [idPart, amtPart] = key.slice(1).split('-', 2) + const megaId = Number(idPart) + if (!Number.isFinite(megaId)) break + const amt = Number(amtPart) + // form-agnostic key -> form wildcard (see 'c') + if (Number.isFinite(amt) && amt > 0 && amt <= INT16_MAX) { + if (!megaByAmount.has(amt)) megaByAmount.set(amt, []) + megaByAmount.get(amt).push({ pokemon_id: megaId }) + } else { + megaLoose.push({ pokemon_id: megaId }) + } + break + } + case 'i': + if (Number.isFinite(n)) incidentCharacter.add(n) + break + case 'b': + if (Number.isFinite(n)) incidentDisplayType.push(n) + break + case 'a': { + // `a-` rocket reward: match by pokemon id (form ignored, + // as in the SQL grunt-reward expansion). Expanded after the loop. + const rocketId = Number(key.slice(1).split('-')[0]) + if (Number.isFinite(rocketId)) rocketPokemonIds.add(rocketId) + break + } + case 'f': + pushIdForm(contestPokemon, key, 1) + break + case 'h': + if (Number.isFinite(n)) contestPokemonType.push(n) + break + default: { + // "[-]" = quest reward type 7 (pokemon encounter). + // Form-exact (see rule 2 above): explicit form matches that form; a + // bare key means the reward carries no form_id -> form:0 (FortLookup + // encodes form-absent as 0). + // "-0" (historic key format; current code normalizes form-0 to a + // bare key) is DROPPED: it only matches a stop whose reward carries an + // EXPLICIT form_id 0, which the reward JSON does not produce (proto + // zero-fields are omitted -> column NULL -> bare key). Translating it + // as form:0 would collide with Golbat's NULL->0 collapse and match + // every formless stop of the species that the exact bare key does not + // cover (the -56 residual). Same accepted divergence class as the + // availableMapper's §form note. + const [idPart, formPart] = key.split('-', 2) + const id = Number(idPart) + if (!Number.isFinite(id)) break + if (formPart === '0') break + const form = + formPart && formPart !== 'null' && Number.isFinite(Number(formPart)) + ? Number(formPart) + : 0 + encounterPokemon.push({ pokemon_id: id, form }) + break + } + } + }) + + // Emit clauses ONLY for layers that are on — secondaryFilter processes each + // category only when its toggle is set (onlyQuests/onlyInvasions/onlyLures/ + // onlyEventStops), so a disabled category's forts never show. Emitting their + // clauses would over-fetch forts secondaryFilter then drops (the common case: + // persisted-but-disabled invasion/showcase/lure filters still in args.filters). + const clauses = [] + if (onlyQuests) { + if (itemIds.length) + clauses.push({ quest_reward_type: [2], quest_reward_item_id: itemIds }) + if (candyPokemon.length) + clauses.push({ + quest_reward_type: [4], + quest_reward_pokemon: candyPokemon, + }) + if (encounterPokemon.length) + clauses.push({ + quest_reward_type: [7], + quest_reward_pokemon: encounterPokemon, + }) + if (xlPokemon.length) + clauses.push({ quest_reward_type: [9], quest_reward_pokemon: xlPokemon }) + megaByAmount.forEach((pokes, amt) => + clauses.push({ + quest_reward_type: [12], + quest_reward_pokemon: pokes, + quest_reward_amount: { min: amt, max: amt }, + }), + ) + if (megaLoose.length) + clauses.push({ quest_reward_type: [12], quest_reward_pokemon: megaLoose }) + dustAmounts.forEach((amt) => + clauses.push({ + quest_reward_type: [3], + quest_reward_amount: { min: amt, max: amt }, + }), + ) + xpAmounts.forEach((amt) => + clauses.push({ + quest_reward_type: [1], + quest_reward_amount: { min: amt, max: amt }, + }), + ) + if (typeOnly.size) clauses.push({ quest_reward_type: [...typeOnly] }) + } + if (onlyLures && lureId.length) clauses.push({ lure_id: lureId }) + if (onlyInvasions) { + if (rocketPokemonIds.size) { + // Can't expand rocket-reward filters without the event map -> match-all so + // the residual (invasionMatchesFilters) can still surface them. + if (!eventInvasions || Object.keys(eventInvasions).length === 0) return [] + gruntTypesForRocketPokemon(eventInvasions, rocketPokemonIds).forEach( + (g) => incidentCharacter.add(g), + ) + } + if ( + incidentCharacter.size && + (onlyExcludeGrunts || onlyExcludeLeaders) && + eventInvasions + ) { + // secondaryFilter rejects excluded grunt classes BEFORE any other check + // (including rocket-reward matches), so subtract them from the clause — + // same classification EventManager.setInvasions uses for the id sets. + incidentCharacter.forEach((id) => { + const grunt = eventInvasions[id]?.grunt + if ( + (onlyExcludeGrunts && grunt === 'Grunt') || + (onlyExcludeLeaders && + (grunt === 'Executive' || grunt === 'Giovanni')) + ) + incidentCharacter.delete(id) + }) + } + if (incidentCharacter.size) + clauses.push({ incident_character: [...incidentCharacter] }) + if (incidentDisplayType.length) + clauses.push({ incident_display_type: incidentDisplayType }) + } + if (onlyEventStops) { + if (contestPokemon.length) clauses.push({ contest_pokemon: contestPokemon }) + if (contestPokemonType.length) + clauses.push({ contest_pokemon_type: contestPokemonType }) + } + if (onlyArEligible) clauses.push({ is_ar_scan_eligible: true }) + + return clauses.length ? clauses : [] +} + +module.exports = { buildPokestopDnfFilters } diff --git a/server/src/filters/fort/station.js b/server/src/filters/fort/station.js new file mode 100644 index 000000000..74107c608 --- /dev/null +++ b/server/src/filters/fort/station.js @@ -0,0 +1,90 @@ +// @ts-check + +/** + * Translate a station's `args.filters` into ApiFortDnfFilter[] clauses. + * DNF is a superset narrow; the station JS gate (passesTimeGate/ + * passesFilterGate) finalizes. + * + * Stations are the one ephemeral fort type — expired stations (end_time past) + * accumulate in Golbat's index, so a match-all scan ships mostly dead weight + * (observed 1330 returned / 174 live). Every mode except inactive-viewing only + * ever shows ACTIVE stations, so `station_active: true` is stamped into every + * clause (and IS the whole clause for All-Stations mode, which needs no other + * narrowing). The remaining now-relative pieces — the `updated > activeCutoff` + * config cutoff, upcoming windows, and the inactive mode's day-based cutoff — + * stay residual in the JS gate. + * + * Returns [] (match-all) only for `onlyInactiveStations`, which needs expired + * stations AND filtered actives (an OR the cutoffs keep residual). + * + * @param {Record} filters args.filters + * @returns {object[]} + */ +function buildStationDnfFilters(filters) { + if (!filters || typeof filters !== 'object') return [] + const { + onlyAllStations, + onlyInactiveStations, + onlyMaxBattles, + onlyBattleTier, + onlyGmaxStationed, + } = filters + // Inactive mode shows expired stations (day-based cutoff) OR filtered + // actives — the mix isn't expressible without the cutoffs -> fetch all. + if (onlyInactiveStations) return [] + // All-Stations mode = every ACTIVE station, no further narrowing. + if (onlyAllStations) return [{ station_active: true }] + + const clauses = [] + + if (onlyMaxBattles) { + const battleLevels = [] + const battlePokemon = [] + if (onlyBattleTier && onlyBattleTier !== 'all') { + const t = Number(onlyBattleTier) + if (Number.isFinite(t)) battleLevels.push(t) + } else { + // per-level multi-select + battle-combo keys + Object.entries(filters).forEach(([key]) => { + if (typeof key !== 'string' || key.length === 0) return + if (key.startsWith('j')) { + const lvl = Number(key.slice(1)) + if (Number.isFinite(lvl)) battleLevels.push(lvl) + } else if (/^\d/.test(key)) { + const [idPart, formPart] = key.split('-', 2) + const id = Number(idPart) + if (!Number.isFinite(id)) return + const pair = { pokemon_id: id } + if ( + formPart && + formPart !== 'null' && + Number.isFinite(Number(formPart)) + ) + pair.form = Number(formPart) + battlePokemon.push(pair) + } + }) + } + if (battleLevels.length || battlePokemon.length) { + // Separate OR clauses: the real filter's level-vs-pokemon AND/OR is + // unverified, so separate clauses are a safe superset either way + // (secondaryFilter's matchesStationBattleFilter narrows exactly). + if (battleLevels.length) + clauses.push({ battle_level: battleLevels, station_active: true }) + if (battlePokemon.length) + clauses.push({ battle_pokemon: battlePokemon, station_active: true }) + } else { + // onlyMaxBattles with no expressible battle condition = "any active + // battle" — the battle part stays residual, but liveness still narrows. + clauses.push({ station_active: true }) + } + } + + if (onlyGmaxStationed) + clauses.push({ stationed_gmax: true, station_active: true }) + + // Nothing narrowable set: still only active stations can ever render. + return clauses.length ? clauses : [{ station_active: true }] +} + +module.exports = { buildStationDnfFilters } From b69935876c6c635907cdcde90ebfe0ae457a500c Mon Sep 17 00:00:00 2001 From: James Berry Date: Fri, 17 Jul 2026 15:37:43 +0100 Subject: [PATCH 05/38] feat(gym): golbat-backed getall/getone/available with sql fallback Co-Authored-By: Claude Fable 5 --- server/src/models/Gym.js | 167 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 163 insertions(+), 4 deletions(-) diff --git a/server/src/models/Gym.js b/server/src/models/Gym.js index 005026f86..2cf9d68ef 100644 --- a/server/src/models/Gym.js +++ b/server/src/models/Gym.js @@ -5,12 +5,24 @@ const { Model, raw } = require('objection') const i18next = require('i18next') const config = require('@rm/config') +const { log, TAGS } = require('@rm/logger') const { getAreaSql } = require('../utils/getAreaSql') const { state } = require('../services/state') -const { applyManualIdFilter } = require('../utils/manualFilter') +const { + applyManualIdFilter, + normalizeManualId, +} = require('../utils/manualFilter') const { isDualQuestLayerMode } = require('../utils/questLayerMode') +const { + evalScannerQuery, + describeScannerResponse, +} = require('../utils/evalScannerQuery') +const { filterRTree } = require('../utils/filterRTree') +const { buildGymDnfFilters } = require('../filters/fort/gym') +const { describeDnfNarrowing } = require('../filters/fort/describeDnfNarrowing') +const { mapGymAvailable } = require('./gymAvailableMapper') const coreFields = [ 'id', @@ -111,7 +123,12 @@ class Gym extends Model { } } - static async getAll(perms, args, { isMad, availableSlotsCol }, userId) { + static async getAll( + perms, + args, + { isMad, availableSlotsCol, mem, secret, httpAuth }, + userId, + ) { const { gyms: gymPerms, raids: raidPerms, @@ -508,10 +525,132 @@ class Gym extends Model { }) return filteredResults } + + if (mem) { + try { + // /api/gym/scan returns an envelope { gyms, examined, skipped, total }, + // not a bare array — the matching gyms are on res.gyms. + const dnf = buildGymDnfFilters(args.filters) + const res = await evalScannerQuery( + TAGS.gyms, + `${mem}/api/gym/scan`, + JSON.stringify({ + min: { latitude: args.minLat, longitude: args.minLon }, + max: { latitude: args.maxLat, longitude: args.maxLon }, + limit: queryLimits.gyms, + filters: dnf, + }), + 'POST', + secret, + httpAuth, + ) + if (res && Array.isArray(res.gyms)) { + // Deep-link parity with SQL's `(bbox) OR id = manualId`: an + // off-viewport manually-selected gym joins the candidate set via the + // by-id endpoint; every later gate (active/area/secondaryFilter) + // still runs, exactly as it does for the SQL OR. + const manualId = normalizeManualId(args.filters.onlyManualId) + if ( + manualId !== null && + !res.gyms.some((g) => g && g.id === manualId) + ) { + try { + const one = await evalScannerQuery( + TAGS.gyms, + `${mem}/api/gym/id/${manualId}`, + undefined, + 'GET', + secret, + httpAuth, + ) + if ( + one && + typeof one === 'object' && + 'lat' in one && + 'lon' in one + ) + res.gyms.push(one) + } catch { + // by-id miss mirrors SQL finding no such row + } + } + const active = res.gyms.filter( + (gym) => + gym.enabled && + !gym.deleted && + (!hideOldGyms || gym.updated > ts - gymValidDataLimit * 86400) && + (!onlyAllGyms || + !onlyLevels || + onlyLevels === 'all' || + gym.power_up_level === Number(onlyLevels)) && + filterRTree(gym, areaRestrictions, onlyAreas), + ) + const final = secondaryFilter(active) + log.info( + TAGS.gyms, + describeDnfNarrowing( + 'GYM', + dnf, + res.examined, + res.gyms.length, + final.length, + ), + ) + return final + } + log.warn( + TAGS.gyms, + `[GYM] /api/gym/scan gave no gyms array — ${describeScannerResponse(res)} — falling back to SQL for this source`, + ) + } catch (e) { + log.warn( + TAGS.gyms, + `[GYM] /api/gym/scan error — falling back to SQL for this source: ${e}`, + ) + } + } return secondaryFilter(await query.limit(queryLimits.gyms)) } - static async getAvailable({ isMad, availableSlotsCol }) { + static async getAvailable({ + isMad, + availableSlotsCol, + mem, + secret, + httpAuth, + }) { + // Endpoint source: fetch the aggregate from Golbat; on 503/error fall + // through to the SQL below (dual source runs SQL on its bound knex; a + // pure-endpoint source's this.query() throws and is dropped upstream). + if (mem) { + try { + const res = await evalScannerQuery( + TAGS.gyms, + `${mem}/api/gym/available`, + undefined, + 'GET', + secret, + httpAuth, + ) + if (res && Array.isArray(res.teams) && Array.isArray(res.raids)) { + const { available } = mapGymAvailable(res) + log.info( + TAGS.gyms, + `[GYM] loaded available from Golbat endpoint ${mem}/api/gym/available — ${available.length} filter keys (${res.teams.length} team/slot, ${res.raids.length} raid options)`, + ) + return { available } + } + log.warn( + TAGS.gyms, + `[GYM] /api/gym/available gave no teams/raids — ${describeScannerResponse(res)} — returning empty available for this endpoint source`, + ) + } catch (e) { + log.warn( + TAGS.gyms, + `[GYM] /api/gym/available error — returning empty available for this endpoint source: ${e}`, + ) + } + } const ts = Math.floor(Date.now() / 1000) const results = await this.query() .select([ @@ -695,7 +834,27 @@ class Gym extends Model { .reverse() } - static getOne(id, { isMad }) { + static async getOne(id, { isMad, mem, secret, httpAuth }) { + if (mem) { + try { + const res = await evalScannerQuery( + TAGS.gyms, + `${mem}/api/gym/id/${id}`, + undefined, + 'GET', + secret, + httpAuth, + ) + if (res && typeof res === 'object' && 'lat' in res && 'lon' in res) { + return res + } + } catch (e) { + log.warn( + TAGS.gyms, + `[GYM] /api/gym/id error — falling back to SQL: ${e}`, + ) + } + } return this.query() .select([ isMad ? 'latitude AS lat' : 'lat', From 35772806ced61b3a7af2fa564fd336173e534352 Mon Sep 17 00:00:00 2001 From: James Berry Date: Fri, 17 Jul 2026 15:37:44 +0100 Subject: [PATCH 06/38] feat(station): golbat-backed getall/available with sql fallback Co-Authored-By: Claude Fable 5 --- server/src/models/Station.js | 212 ++++++++++++++++++++++++++++++++++- 1 file changed, 208 insertions(+), 4 deletions(-) diff --git a/server/src/models/Station.js b/server/src/models/Station.js index f269214fd..8f8c913be 100644 --- a/server/src/models/Station.js +++ b/server/src/models/Station.js @@ -6,10 +6,21 @@ const i18next = require('i18next') const { log, TAGS } = require('@rm/logger') const { getAreaSql } = require('../utils/getAreaSql') -const { applyManualIdFilter } = require('../utils/manualFilter') +const { + applyManualIdFilter, + normalizeManualId, +} = require('../utils/manualFilter') const { getEpoch } = require('../utils/getClientTime') const { state } = require('../services/state') const { getSharedPvpWrapper } = require('../services/PvpWrapper') +const { + evalScannerQuery, + describeScannerResponse, +} = require('../utils/evalScannerQuery') +const { filterRTree } = require('../utils/filterRTree') +const { buildStationDnfFilters } = require('../filters/fort/station') +const { describeDnfNarrowing } = require('../filters/fort/describeDnfNarrowing') +const { mapStationAvailable } = require('./stationAvailableMapper') const DEFAULT_IV = 15 const STATION_TABLE = 'station' @@ -588,10 +599,18 @@ class Station extends Model { static async getAll( perms, args, - { isMad, hasMultiBattles, hasStationedGmax, hasBattlePokemonStats }, + { + isMad, + hasMultiBattles, + hasStationedGmax, + hasBattlePokemonStats, + mem, + secret, + httpAuth, + }, ) { const { areaRestrictions } = perms - const { stationUpdateLimit, stationInactiveLimitDays } = + const { stationUpdateLimit, stationInactiveLimitDays, queryLimits } = config.getSafe('api') const { onlyAreas, @@ -679,6 +698,159 @@ class Station extends Model { const { includeUpcoming } = battleFilterOptions const shouldRestrictReturnedBattles = onlyMaxBattles && hasBattleConditions + if (mem) { + try { + // /api/station/scan returns an envelope { stations, examined, skipped, + // total } — the matching stations are on res.stations. + const dnf = buildStationDnfFilters(args.filters) + const res = await evalScannerQuery( + TAGS.stations, + `${mem}/api/station/scan`, + JSON.stringify({ + min: { latitude: args.minLat, longitude: args.minLon }, + max: { latitude: args.maxLat, longitude: args.maxLon }, + limit: queryLimits.stations, + filters: dnf, + }), + 'POST', + secret, + httpAuth, + ) + if (res && Array.isArray(res.stations)) { + // Deep-link parity with SQL's `(bbox) OR id = manualId` — see Gym. + const manualId = normalizeManualId(args.filters.onlyManualId) + if ( + manualId !== null && + !res.stations.some((s) => s && s.id === manualId) + ) { + try { + const one = await evalScannerQuery( + TAGS.stations, + `${mem}/api/station/id/${manualId}`, + undefined, + 'GET', + secret, + httpAuth, + ) + if ( + one && + typeof one === 'object' && + 'lat' in one && + 'lon' in one + ) + res.stations.push(one) + } catch { + // by-id miss mirrors SQL finding no such row + } + } + // CP estimation needs ohbem base stats, same as the SQL path. + // includeBattleData already implies perms.dynamax. + let pokemonData = null + if (includeBattleData) { + try { + pokemonData = await getSharedPvpWrapper().ensurePokemonData() + } catch (e) { + log.warn( + TAGS.fetch, + 'Unable to load ohbem basics for station CP estimation', + e, + ) + } + } + // Replicate the SQL WHERE that the endpoint (match-all) can't apply. + const passesFilterGate = (s) => { + if (onlyAllStations) return true + if (!perms.dynamax) return false + const battleMatch = + onlyMaxBattles && + hasBattleConditions && + (s.battles || []).some((b) => + matchesStationBattleFilter(b, battleFilterOptions), + ) + // Golbat always computes total_stationed_gmax on decode (from the + // bread-dough modes), so — unlike the SQL path — the endpoint needs + // no hasStationedGmax / stationed_pokemon JSON fallback here. + const gmaxMatch = + onlyGmaxStationed && Number(s.total_stationed_gmax || 0) > 0 + return battleMatch || gmaxMatch + } + const passesTimeGate = (s) => { + const active = + Number(s.end_time) > ts && Number(s.updated) > activeCutoff + if (onlyInactiveStations) { + const inactive = + Number(s.end_time) <= ts && Number(s.updated) > inactiveCutoff + return (active && passesFilterGate(s)) || inactive + } + return active && passesFilterGate(s) + } + const stations = res.stations + .filter( + (s) => + passesTimeGate(s) && + filterRTree(s, areaRestrictions, onlyAreas), + ) + .map((apiStation) => { + const station = { + ...apiStation, + battles: includeBattleData + ? (apiStation.battles || []).map((b) => + enrichStationBattle(b, pokemonData), + ) + : [], + } + // Mirror the SQL multi-battle tail (Station.js grouped-values map): + if (Number(station.end_time) <= ts) { + station.battles = [] + clearStationBattleFallback(station) + return finalizeStation(station, pokemonData, ts) + } + if (!includeUpcoming) { + const visible = getVisibleStationBattle(station.battles, ts) + station.battles = visible ? [visible] : [] + } + const hasMatchingReturnedBattle = station.battles.some((b) => + matchesStationBattleFilter(b, battleFilterOptions), + ) + if ( + !onlyAllStations && + shouldRestrictReturnedBattles && + !hasMatchingReturnedBattle && + !onlyGmaxStationed + ) { + return null + } + setStationBattleFields( + station, + getVisibleStationBattle(station.battles, ts), + ) + return finalizeStation(station, pokemonData, ts) + }) + .filter(Boolean) + log.info( + TAGS.stations, + describeDnfNarrowing( + 'STATION', + dnf, + res.examined, + res.stations.length, + stations.length, + ), + ) + return stations + } + log.warn( + TAGS.stations, + `[STATION] /api/station/scan gave no stations array — ${describeScannerResponse(res)} — falling back to SQL for this source`, + ) + } catch (e) { + log.warn( + TAGS.stations, + `[STATION] /api/station/scan error — falling back to SQL for this source: ${e}`, + ) + } + } + if (includeBattleData) { select.push( ...getStationSelect(['is_battle_available', 'total_stationed_pokemon']), @@ -979,7 +1151,39 @@ class Station extends Model { : result.stationed_pokemon || [] } - static async getAvailable({ hasMultiBattles }) { + static async getAvailable({ hasMultiBattles, mem, secret, httpAuth }) { + // Endpoint source: fetch the aggregate from Golbat; on 503/error fall + // through to the SQL below (dual source runs SQL on its bound knex; a + // pure-endpoint source's this.query() throws and is dropped upstream). + if (mem) { + try { + const res = await evalScannerQuery( + TAGS.stations, + `${mem}/api/station/available`, + undefined, + 'GET', + secret, + httpAuth, + ) + if (res && Array.isArray(res.battles)) { + const { available } = mapStationAvailable(res) + log.info( + TAGS.stations, + `[STATION] loaded available from Golbat endpoint ${mem}/api/station/available — ${available.length} filter keys (${res.battles.length} battle options)`, + ) + return { available } + } + log.warn( + TAGS.stations, + `[STATION] /api/station/available gave no battles — ${describeScannerResponse(res)} — returning empty available for this endpoint source`, + ) + } catch (e) { + log.warn( + TAGS.stations, + `[STATION] /api/station/available error — returning empty available for this endpoint source: ${e}`, + ) + } + } /** @type {import('@rm/types').FullStation[]} */ const ts = getEpoch() const { stationUpdateLimit } = config.getSafe('api') From 832c1332db68ac5c78c76c1661859ebbc9c4f945 Mon Sep 17 00:00:00 2001 From: James Berry Date: Fri, 17 Jul 2026 15:37:45 +0100 Subject: [PATCH 07/38] feat(pokestop): golbat-backed getall/getone with sql fallback and quest-layer availability fix Co-Authored-By: Claude Fable 5 --- server/src/models/Pokestop.js | 344 ++++++++++++++++++++++++++++++---- 1 file changed, 306 insertions(+), 38 deletions(-) diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 9a679d47f..30f3c48b8 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -3,16 +3,33 @@ /* 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 { + applyManualIdFilter, + normalizeManualId, +} = require('../utils/manualFilter') const { getUserMidnight } = require('../utils/getClientTime') +const { fetchJson } = require('../utils/fetchJson') +const { + evalScannerQuery, + describeScannerResponse, +} = require('../utils/evalScannerQuery') +const { filterRTree } = require('../utils/filterRTree') +const { mapScanPokestop } = require('./pokestopScanMapper') +const { buildPokestopDnfFilters } = require('../filters/fort/pokestop') +const { describeDnfNarrowing } = require('../filters/fort/describeDnfNarrowing') 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 +49,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, @@ -140,6 +208,9 @@ class Pokestop extends Model { hasLayerColumn, hasPowerUp, hasConfirmed, + mem, + secret, + httpAuth, }, ) { const { @@ -749,6 +820,101 @@ class Pokestop extends Model { } else if (onlyLevels !== 'all' && hasPowerUp) { query.andWhere('power_up_level', onlyLevels) } + // Endpoint-backed source: fetch the DNF-less match-all scan and map each + // Golbat row into the mapRDM shape secondaryFilter expects. Mirrors + // Gym.getAll — the same secondaryFilter runs for both SQL and endpoint + // rows. `with_incidents:true` makes Golbat attach invasions[] (grunts + + // showcase/goldstop/kecleon event rows). On any failure/bad-shape we log + // and fall through to the SQL block below. + if (mem) { + try { + const dnf = buildPokestopDnfFilters(args.filters, state.event.invasions) + const res = await evalScannerQuery( + TAGS.pokestops, + `${mem}/api/pokestop/scan`, + JSON.stringify({ + min: { latitude: args.minLat, longitude: args.minLon }, + max: { latitude: args.maxLat, longitude: args.maxLon }, + limit: queryLimits.pokestops, + filters: dnf, + with_incidents: true, + }), + 'POST', + secret, + httpAuth, + ) + if (res && Array.isArray(res.pokestops)) { + // Deep-link parity with SQL's `(bbox) OR id = manualId` — see Gym. + const manualId = normalizeManualId(args.filters.onlyManualId) + if ( + manualId !== null && + !res.pokestops.some((p) => p && p.id === manualId) + ) { + try { + const one = await evalScannerQuery( + TAGS.pokestops, + `${mem}/api/pokestop/id/${manualId}`, + undefined, + 'GET', + secret, + httpAuth, + ) + if ( + one && + typeof one === 'object' && + 'lat' in one && + 'lon' in one + ) + res.pokestops.push(one) + } catch { + // by-id miss mirrors SQL finding no such row + } + } + const mapped = res.pokestops + .map(mapScanPokestop) + .filter( + (stop) => stop && filterRTree(stop, areaRestrictions, onlyAreas), + ) + if (mapped.length > queryLimits.pokestops) { + mapped.length = queryLimits.pokestops + } + const final = this.secondaryFilter( + mapped, + args.filters, + false, + ts, + midnight, + perms, + hasMultiInvasions, + hasConfirmed, + effectiveOnlyArEligible, + effectiveQuestLayer, + ) + log.info( + TAGS.pokestops, + describeDnfNarrowing( + 'POKESTOP', + dnf, + res.examined, + res.pokestops.length, + final.length, + ), + ) + return final + } + log.warn( + TAGS.pokestops, + `[POKESTOP] /api/pokestop/scan gave no pokestops array — ${describeScannerResponse( + res, + )} — falling back to SQL for this source`, + ) + } catch (e) { + log.warn( + TAGS.pokestops, + `[POKESTOP] /api/pokestop/scan error — falling back to SQL for this source: ${e}`, + ) + } + } const results = await query const normalized = isMad @@ -1245,6 +1411,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 +1486,61 @@ class Pokestop extends Model { hasShowcaseData, hasShowcaseForm, hasShowcaseType, + mem, + secret, + httpAuth, }) { + // 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( + `${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. + if (res && Array.isArray(res.quests) && Array.isArray(res.invasions)) { + // The Golbat endpoint always returns both AR (`with_ar:true`) and + // non-AR quest tuples; honor `map.misc.questLayerMode` the same way + // the SQL path does so we don't advertise filters for a hidden layer. + const questLayer = resolveQuestLayerSelection('both', { + hasAltQuests: true, + }) + const result = mapAvailablePokestops(res, { + invasions: state.event.invasions, + includeBaseQuests: questLayer !== 'without_ar', + includeAltQuests: questLayer !== 'with_ar', + }) + const availableSet = new Set(result.available) + applyRocketPokemonFallback(availableSet) + log.info( + TAGS.pokestops, + `[POKESTOP] loaded available from Golbat endpoint ${mem}/api/pokestop/available — ${availableSet.size} filter keys (${res.quests.length} quests, ${res.invasions.length} invasions, ${(res.lures || []).length} lures, ${(res.showcases || []).length} showcases), ${Object.keys(result.conditions).length} reward conditions`, + ) + return { available: [...availableSet], conditions: result.conditions } + } + log.warn( + TAGS.pokestops, + '[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 — returning empty available for this endpoint source: ${e}`, + ) + } + } const ts = Math.floor(Date.now() / 1000) const finalList = new Set() const conditions = {} @@ -1865,41 +2144,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) { @@ -1939,7 +2184,10 @@ class Pokestop extends Model { static parseRdmRewards = (quest) => { if (quest.quest_reward_type) { - const rewards = JSON.parse(quest.quest_rewards) + const rewards = + typeof quest.quest_rewards === 'string' + ? JSON.parse(quest.quest_rewards) + : quest.quest_rewards let { info } = rewards[0] if ( quest.quest_reward_type === TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE && @@ -2368,7 +2616,27 @@ class Pokestop extends Model { : results } - static getOne(id, { isMad }) { + static async getOne(id, { isMad, mem, secret, httpAuth }) { + if (mem) { + try { + const res = await evalScannerQuery( + TAGS.pokestops, + `${mem}/api/pokestop/id/${id}`, + undefined, + 'GET', + secret, + httpAuth, + ) + if (res && typeof res === 'object' && 'lat' in res && 'lon' in res) { + return res + } + } catch (e) { + log.warn( + TAGS.pokestops, + `[POKESTOP] /api/pokestop/id error — falling back to SQL: ${e}`, + ) + } + } return this.query() .select([ isMad ? 'latitude AS lat' : 'lat', From af62c30d0200c08e2c9c849dc9a2011480d65a05 Mon Sep 17 00:00:00 2001 From: James Berry Date: Fri, 17 Jul 2026 15:37:46 +0100 Subject: [PATCH 08/38] docs: fort consumer implementation plans Co-Authored-By: Claude Fable 5 --- ...14-pokestop-available-consumer-reactmap.md | 260 ++++ .../plans/2026-07-16-fort-dnf-filtering.md | 1064 +++++++++++++++++ .../2026-07-16-reactmap-fort-consumer-gyms.md | 437 +++++++ ...-07-16-reactmap-fort-consumer-pokestops.md | 621 ++++++++++ ...6-07-16-reactmap-fort-consumer-stations.md | 356 ++++++ 5 files changed, 2738 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-pokestop-available-consumer-reactmap.md create mode 100644 docs/superpowers/plans/2026-07-16-fort-dnf-filtering.md create mode 100644 docs/superpowers/plans/2026-07-16-reactmap-fort-consumer-gyms.md create mode 100644 docs/superpowers/plans/2026-07-16-reactmap-fort-consumer-pokestops.md create mode 100644 docs/superpowers/plans/2026-07-16-reactmap-fort-consumer-stations.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..43310fc88 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-pokestop-available-consumer-reactmap.md @@ -0,0 +1,260 @@ +# 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. diff --git a/docs/superpowers/plans/2026-07-16-fort-dnf-filtering.md b/docs/superpowers/plans/2026-07-16-fort-dnf-filtering.md new file mode 100644 index 000000000..932d303a3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-fort-dnf-filtering.md @@ -0,0 +1,1064 @@ +# Fort DNF Filtering 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:** Push ReactMap's per-type fort filters into Golbat's `/api/{gym,pokestop,station}/scan` as DNF clauses so the rtree scan returns only matching forts, instead of fetching the whole viewport and filtering entirely in `secondaryFilter`. + +**Architecture:** Three pure ReactMap backends (`server/src/filters/fort/{gym,pokestop,station}.js`) translate `args.filters` into `ApiFortDnfFilter[]` clauses that replace the `filters: []` in each `getAll` `mem` branch. DNF is a best-effort **superset** narrow; the existing `secondaryFilter` stays untouched and finalizes exactness. One clean new Golbat DNF field (`stationed_gmax`) unblocks the station gmax narrow. An observability log shows both filter stages so the DNF gap is visible per query. + +**Tech Stack:** Node.js (ReactMap backends), Go 1.26 (Golbat `stationed_gmax`). From spec `docs/superpowers/specs/2026-07-16-fort-dnf-filtering-design.md`. + +## Global Constraints + +- **Superset invariant (the one hard rule):** a DNF clause set must **never be stricter** than the real filter — it may over-return (secondaryFilter drops the extras) but must never drop a fort that should show. When unsure whether a constraint is expressible, **omit it** (broader fetch) rather than guess. +- **Poisoning rule:** fort filters combine with OR. A backend emits narrowing clauses only if it can express **every** active category. If any active category is a match-all toggle (`onlyAllGyms`/`onlyAllPokestops`/`onlyAllStations`) or an unexpressible gap, return **`[]`** (Golbat treats an empty top-level `filters` array as **match-all**, not match-nothing). +- **`secondaryFilter` (and the station JS gate) is untouched.** It always runs after the fetch and guarantees exactness. Do not move residual logic out of it. +- **Clause shape** = plain JS objects matching `ApiFortDnfFilter` json tags: id lists as `number[]`; id+form pairs as `{ pokemon_id, form }` (omit `form` = any form); ranges as `{ min, max }` — **always send both bounds** (an omitted bound defaults to 0, so min-only never matches); bools as `true`. A field left unset = unconstrained. +- **Residual (stays in `secondaryFilter`, never a DNF clause; poison to `[]` when active):** quest **title/target** (`adv`), raid/battle/invasion **gender**, invasion **confirmed**, gym **ex-eligible / in-battle**, gym **badges** (`onlyGymBadges`/`onlyBadge` — ReactMap-local badge join), pokestop **rocket-reward `a` keys** (secondaryFilter matches UNCONFIRMED invasions by the grunt type's possible encounters, not the confirmed slot — inexpressible without the event reward→grunt map), station **active/inactive / upcoming** time windows. +- **No test runner** in ReactMap (maintainer opted out). Verify each backend with a throwaway `node` golden (run, confirm, delete — never `git add`), `npx eslint`, `npx prettier --check`, + reasoning. No test framework, no committed test files. Golbat uses `go test`. +- **Commit subjects lowercase** (commitlint). ReactMap pushes to `fork` (jfberry), PR #1228. Golbat pushes to `origin`, PR #385. +- **Scan body is unchanged otherwise:** `{ min:{latitude,longitude}, max:{...}, limit, filters:, with_incidents? }`. `res.examined` (envelope) = forts examined in viewport; `res..length` = DNF-returned; post-filter length = final. + +--- + +## File Structure + +- **Create `server/src/filters/fort/describeDnfNarrowing.js`** — shared log-string builder (Task 2). +- **Create `server/src/filters/fort/gym.js`** — `buildGymDnfFilters(filters)` (Task 2). +- **Create `server/src/filters/fort/pokestop.js`** — `buildPokestopDnfFilters(filters)` (Task 3). +- **Create `server/src/filters/fort/station.js`** — `buildStationDnfFilters(filters)` (Task 4). +- **Modify** `server/src/models/Gym.js`, `Pokestop.js`, `Station.js` — swap `filters: []` for the backend call + add the observability log in each `mem` branch. +- **Modify Golbat** `decoder/api_fort.go`, `decoder/fortRtree.go`, `decoder/station_battle.go`(if the battle-lookup builder needs it), `decoder/api_fort_test.go` — the `stationed_gmax` field (Task 1). + +--- + +### Task 1: Golbat `stationed_gmax` DNF field + +**Repo/branch:** Golbat `/Users/james/GolandProjects/Golbat-wt/pokestop-available-api`, branch `feat/fort-scan-map-data`. + +**Files:** + +- Modify: `decoder/api_fort.go` (`ApiFortDnfFilter` struct; `isFortDnfMatch` STATION case) +- Modify: `decoder/fortRtree.go` (`FortLookup` struct; `updateStationLookupWithBattles`) +- Test: `decoder/api_fort_dnf_gmax_test.go` (new) + +**Interfaces:** + +- Produces: `ApiFortDnfFilter.StationedGmax *bool` (json `stationed_gmax`) — when `true`, matches stations with `> 0` stationed Gigantamax pokemon. Task 4's `buildStationDnfFilters` emits `{ stationed_gmax: true }`. + +- [ ] **Step 1: Add the FortLookup field** + +In `decoder/fortRtree.go`, in the `FortLookup` struct's `// Station` section (next to `BattleLevel`/`BattlePokemonId`), add: + +```go + TotalStationedGmax int16 +``` + +- [ ] **Step 2: Populate it in the station lookup builder** + +In `decoder/fortRtree.go`, `updateStationLookupWithBattles`, add the field to the `FortLookup` literal: + +```go + lookup := FortLookup{ + FortType: STATION, + Lat: station.Lat, + Lon: station.Lon, + StationBattles: battles, + TotalStationedGmax: int16(station.TotalStationedGmax.ValueOrZero()), + } +``` + +- [ ] **Step 3: Add the filter field** + +In `decoder/api_fort.go`, in the `ApiFortDnfFilter` struct's `// Station` section (next to `BattleLevel`/`BattlePokemon`), add: + +```go + StationedGmax *bool `json:"stationed_gmax" required:"false" doc:"Station only: when true, only match stations with at least one stationed Gigantamax pokemon; null means no constraint."` +``` + +- [ ] **Step 4: Evaluate it in isFortDnfMatch** + +In `decoder/api_fort.go`, in `isFortDnfMatch`, at the **start** of the `case STATION:` block (before the `if filter.BattleLevel != nil || filter.BattlePokemon != nil {` line), add: + +```go + case STATION: + if filter.StationedGmax != nil && *filter.StationedGmax && fortLookup.TotalStationedGmax <= 0 { + return false + } + if filter.BattleLevel != nil || filter.BattlePokemon != nil { +``` + +(Only the two new lines are inserted; the existing battle block is unchanged.) + +- [ ] **Step 5: Write the unit test** + +Create `decoder/api_fort_dnf_gmax_test.go`: + +```go +package decoder + +import "testing" + +func TestIsFortDnfMatch_StationedGmax(t *testing.T) { + gmax := true + withGmax := FortLookup{FortType: STATION, TotalStationedGmax: 3} + noGmax := FortLookup{FortType: STATION, TotalStationedGmax: 0} + now := int64(1000) + + if !isFortDnfMatch(ApiFortDnfFilter{StationedGmax: &gmax}, withGmax, STATION, now) { + t.Error("station with stationed gmax should match stationed_gmax:true") + } + if isFortDnfMatch(ApiFortDnfFilter{StationedGmax: &gmax}, noGmax, STATION, now) { + t.Error("station without stationed gmax must not match stationed_gmax:true") + } + // null gmax filter is a wildcard — matches either + if !isFortDnfMatch(ApiFortDnfFilter{}, noGmax, STATION, now) { + t.Error("no stationed_gmax constraint should match any station") + } +} +``` + +Note: confirm `isFortDnfMatch`'s signature (arg order/type of `now`) from `api_fort.go` before running — adjust the call if it differs. + +- [ ] **Step 6: Build + test** + +Run: `cd /Users/james/GolandProjects/Golbat-wt/pokestop-available-api && gofmt -w decoder/*.go && go build -tags go_json ./decoder/ && go test ./decoder/ -run 'TestIsFortDnfMatch_StationedGmax|TestApiResultsExposeEveryDbColumn|Golden' -count=1` +Expected: build OK; tests PASS (the completeness/golden tests are unaffected — no `Api*Result` change). + +- [ ] **Step 7: Commit + push** + +```bash +git add decoder/api_fort.go decoder/fortRtree.go decoder/api_fort_dnf_gmax_test.go +git commit -m "feat(dnf): add stationed_gmax fort filter for stations" +git push origin feat/fort-scan-map-data +``` + +--- + +### Task 2: Gym DNF backend + wiring + observability log + +**Repo/branch:** ReactMap `/Users/james/dev/ReactMap`, branch `feat/fort-consumer`. + +**Files:** + +- Create: `server/src/filters/fort/describeDnfNarrowing.js` +- Create: `server/src/filters/fort/gym.js` +- Modify: `server/src/models/Gym.js` (the `mem` branch, ~524-566) + +**Interfaces:** + +- Produces: `buildGymDnfFilters(filters) → ApiFortDnfFilter[]` (consumed by `Gym.getAll`); `describeDnfNarrowing(label, clauses, examined, returned, final) → string` (consumed by all three model tasks). + +- [ ] **Step 1: Write the shared log helper** + +Create `server/src/filters/fort/describeDnfNarrowing.js`: + +```js +// @ts-check + +/** + * Builds the DNF observability log line showing both filter stages, so a large + * secondaryFilter (residual) drop flags where DNF is leaving narrowing on the + * table. `clauses` is the number of DNF clauses sent (0 = match-all). + * + * @param {string} label e.g. 'GYM' + * @param {number} clauses + * @param {number} examined forts examined in the viewport (res.examined) + * @param {number} returned forts Golbat returned after DNF (res..length) + * @param {number} final forts left after secondaryFilter + * @returns {string} + */ +function describeDnfNarrowing(label, clauses, examined, returned, final) { + const byDnf = examined - returned + const bySecondary = returned - final + return `[${label}] DNF(${clauses} clauses): ${examined} in viewport, -${byDnf} by DNF -> ${returned}, -${bySecondary} by secondaryFilter -> ${final} final` +} + +module.exports = { describeDnfNarrowing } +``` + +- [ ] **Step 2: Write the gym backend** + +Create `server/src/filters/fort/gym.js`. Mirrors the `Gym.getAll` key switch (`Gym.js:232-262`): `e`→`raid_level`, `t`→`team_id`, `g`→`team_id`+`available_slots` (slot base passed as a `slotCount` param = `baseGymSlotAmounts.length`), bare `-`→`raid_pokemon_id` (Golbat's tag for the raid boss pair — NOT `raid_pokemon`; gender residual). `r`/`o` ignored. Any of `onlyAllGyms`/`onlyExEligible`/`onlyInBattle` active ⇒ match-all (poison). `onlyArEligible`→`is_ar_scan_eligible:true` (its own clause). `onlyLevels` (power-up) stays **residual** — it only applies in `onlyAllGyms` mode (which poisons to `[]`), and the `getAll` `active` JS filter handles it; emitting `power_up_level` would under-return. + +```js +// @ts-check + +// Mirror of the gym per-slot layout the SQL path uses (Gym.getAll's `g` key +// computes available_slots = baseGymSlotAmounts.length - slotIndex). 6 slots. +const GYM_SLOT_COUNT = 6 + +/** + * Translate a gym's `args.filters` into Golbat ApiFortDnfFilter[] clauses. + * DNF is a superset narrow; secondaryFilter finalizes (gender, ex-eligible, + * in-battle, badges stay residual). Returns [] (match-all) when any active + * category can't be expressed. + * + * @param {Record} filters args.filters + * @returns {object[]} + */ +function buildGymDnfFilters(filters) { + if (!filters || typeof filters !== 'object') return [] + const { + onlyAllGyms, + onlyExEligible, + onlyInBattle, + onlyArEligible, + onlyLevels, + } = filters + // Poison: these categories have no DNF expression -> must fetch all. + if (onlyAllGyms || onlyExEligible || onlyInBattle) return [] + + const powerUp = + onlyLevels && onlyLevels !== 'all' && Number.isFinite(Number(onlyLevels)) + ? { min: Number(onlyLevels), max: Number(onlyLevels) } + : undefined + + const clauses = [] + const eggs = [] + const teams = [] + + Object.entries(filters).forEach(([key, value]) => { + if (typeof key !== 'string' || key.length === 0) return + switch (key.charAt(0)) { + case 'o': // onlyX toggles handled above / not per-item + case 'r': // dead raid-tier keys (unused by getAll) + break + case 'e': + eggs.push(Number(key.slice(1))) + break + case 't': + teams.push(Number(key.slice(1).split('-')[0])) + break + case 'g': { + const [team, slotIndex] = key.slice(1).split('-') + clauses.push({ + team_id: [Number(team)], + available_slots: { + min: GYM_SLOT_COUNT - Number(slotIndex), + max: GYM_SLOT_COUNT - Number(slotIndex), + }, + }) + break + } + default: { + // raid boss "-" (default case in Gym.getAll) + const [idPart, formPart] = key.split('-', 2) + const id = Number(idPart) + if (!Number.isFinite(id)) break + const pair = { pokemon_id: id } + if ( + formPart && + formPart !== 'null' && + Number.isFinite(Number(formPart)) + ) + pair.form = Number(formPart) + // Golbat's json tag is `raid_pokemon_id` (unlike other types' `*_pokemon`) + clauses.push({ raid_pokemon_id: [pair] }) + break + } + } + }) + + if (teams.length) clauses.push({ team_id: teams }) + if (eggs.length) clauses.push({ raid_level: eggs }) + if (onlyArEligible) clauses.push({ is_ar_scan_eligible: true }) + + // If nothing narrowable was active, match-all (e.g. onlyRaids/onlyGyms only). + if (clauses.length === 0) return [] + + // power_up_level is a base narrow ANDed into every clause. + if (powerUp) clauses.forEach((c) => (c.power_up_level = powerUp)) + return clauses +} + +module.exports = { buildGymDnfFilters } +``` + +- [ ] **Step 3: Golden-check the gym backend under node** + +Create `gym-dnf-golden.js` in the repo root (throwaway): + +```js +const { buildGymDnfFilters } = require('./server/src/filters/fort/gym') +const A = (c, m) => { + if (!c) throw new Error('FAIL ' + m) + console.log('ok ' + m) +} +const J = (o) => JSON.stringify(o) + +A( + J(buildGymDnfFilters({ onlyAllGyms: true, t123: {} })) === '[]', + 'onlyAllGyms poisons -> match-all', +) +A( + J(buildGymDnfFilters({ onlyExEligible: true })) === '[]', + 'onlyExEligible poisons', +) +A( + J(buildGymDnfFilters({ onlyInBattle: true, e5: {} })) === '[]', + 'onlyInBattle poisons', +) + +const teams = buildGymDnfFilters({ 't1-0': {}, 't2-0': {} }) +A(J(teams) === J([{ team_id: [1, 2] }]), 'team keys -> one team_id clause') + +const eggs = buildGymDnfFilters({ e5: {}, e6: {} }) +A(J(eggs) === J([{ raid_level: [5, 6] }]), 'egg keys -> raid_level clause') + +const slot = buildGymDnfFilters({ 'g2-1': {} }) +A( + J(slot) === J([{ team_id: [2], available_slots: { min: 5, max: 5 } }]), + 'g key -> team + slots', +) + +const boss = buildGymDnfFilters({ '150-0': { gender: 2 } }) +A( + J(boss) === J([{ raid_pokemon_id: [{ pokemon_id: 150, form: 0 }] }]), + 'raid boss -> raid_pokemon_id (correct Golbat tag), gender dropped (residual)', +) + +const ar = buildGymDnfFilters({ onlyArEligible: true, e5: {} }) +A( + J(ar) === J([{ raid_level: [5] }, { is_ar_scan_eligible: true }]), + 'onlyArEligible adds its own clause', +) + +const lvl = buildGymDnfFilters({ 't1-0': {}, onlyLevels: '3' }) +A( + J(lvl) === J([{ team_id: [1], power_up_level: { min: 3, max: 3 } }]), + 'onlyLevels ANDs power_up into each clause', +) + +A( + J(buildGymDnfFilters({ onlyRaids: true })) === '[]', + 'no narrowable category -> match-all', +) +console.log('\nALL PASS') +``` + +- [ ] **Step 4: Run the golden** + +Run: `node gym-dnf-golden.js` +Expected: all `ok` lines, ending `ALL PASS`. Fix the backend if any FAIL. + +- [ ] **Step 5: Wire the gym mem branch** + +In `server/src/models/Gym.js`, add the imports near the other util imports (after the `filterRTree` import): + +```js +const { buildGymDnfFilters } = require('../filters/fort/gym') +const { describeDnfNarrowing } = require('../filters/fort/describeDnfNarrowing') +``` + +Then in the `mem` branch, replace the scan call + result block. Change `filters: []` to the DNF clauses and log the narrowing before returning: + +```js +const dnf = buildGymDnfFilters(args.filters) +const res = await evalScannerQuery( + TAGS.gyms, + `${mem}/api/gym/scan`, + JSON.stringify({ + min: { latitude: args.minLat, longitude: args.minLon }, + max: { latitude: args.maxLat, longitude: args.maxLon }, + limit: queryLimits.gyms, + filters: dnf, + }), + 'POST', + secret, + httpAuth, +) +if (res && Array.isArray(res.gyms)) { + const active = res.gyms.filter( + (gym) => + gym.enabled && + !gym.deleted && + (!hideOldGyms || gym.updated > ts - gymValidDataLimit * 86400) && + (!onlyAllGyms || + !onlyLevels || + onlyLevels === 'all' || + gym.power_up_level === Number(onlyLevels)) && + filterRTree(gym, areaRestrictions, onlyAreas), + ) + const final = secondaryFilter(active) + log.info( + TAGS.gyms, + describeDnfNarrowing( + 'GYM', + dnf.length, + res.examined, + res.gyms.length, + final.length, + ), + ) + return final +} +``` + +(The `filterRTree`/`secondaryFilter` logic is unchanged — only `filters: dnf`, capturing `final`, and the log line are new.) + +- [ ] **Step 6: Lint** + +Run: `npx eslint server/src/filters/fort/describeDnfNarrowing.js server/src/filters/fort/gym.js server/src/models/Gym.js && npx prettier --check server/src/filters/fort/describeDnfNarrowing.js server/src/filters/fort/gym.js server/src/models/Gym.js` +Expected: clean (or `--write` then re-lint). + +- [ ] **Step 7: Delete golden + commit** + +```bash +rm gym-dnf-golden.js +git add server/src/filters/fort/describeDnfNarrowing.js server/src/filters/fort/gym.js server/src/models/Gym.js +git commit -m "feat(gym): dnf filter backend + narrowing log" +``` + +--- + +### Task 3: Pokestop DNF backend + wiring + +**Repo/branch:** ReactMap, branch `feat/fort-consumer`. + +**Files:** + +- Create: `server/src/filters/fort/pokestop.js` +- Modify: `server/src/models/Pokestop.js` (the `mem` branch, ~818-874) + +**Interfaces:** + +- Consumes: `describeDnfNarrowing` (Task 2). +- Produces: `buildPokestopDnfFilters(filters) → object[]` (consumed by `Pokestop.getAll`). + +- [ ] **Step 1: Write the pokestop backend** + +Create `server/src/filters/fort/pokestop.js`. Mirrors `Pokestop.getAll`'s reward-key switch + invasion/showcase keys. Reward keys map to **up to three separate OR clauses** by sub-field compatibility (Golbat ANDs the sub-fields WITHIN a clause, so merging incompatible reward families under-returns): **item** `q`→`{quest_reward_type:[2], quest_reward_item_id:[items]}`; **pokemon-family** `c`/`x`/`m`/bare `[-]`→`{quest_reward_type:[4/9/12/7…], quest_reward_pokemon:[…]}` (merging these types over-returns cross-type = safe superset); **type-only** `p`/`d`/`u`→`{quest_reward_type:[1/3/…]}` (exact amount is residual). Invasion: `i`→incident_character, `b`→incident_display_type, `a-`→incident_pokemon. Showcase: `f-`→contest_pokemon, `h`→contest_pokemon_type. Quest **title/target** (`adv`) is never in a clause (residual). `onlyAllPokestops` ⇒ match-all. `onlyArEligible`→is_ar_scan_eligible clause. `onlyLevels` (power-up) stays **residual** — like gyms it only applies in the `onlyAllPokestops` mode that poisons to `[]`, so emitting it would under-return. + +```js +// @ts-check + +/** push {pokemon_id, form?} from a "[-]" key onto arr */ +function pushIdForm(arr, key, offset) { + const [idPart, formPart] = key.slice(offset).split('-', 2) + const id = Number(idPart) + if (!Number.isFinite(id)) return + const pair = { pokemon_id: id } + if (formPart && formPart !== 'null' && Number.isFinite(Number(formPart))) + pair.form = Number(formPart) + arr.push(pair) +} + +/** + * Translate a pokestop's `args.filters` into ApiFortDnfFilter[] clauses. + * + * CRITICAL: Golbat ANDs the sub-fields WITHIN a clause, so different reward + * families must NOT share a clause — `{quest_reward_type:[2,4], + * quest_reward_item_id:[1], quest_reward_pokemon:[{25}]}` matches nothing (an + * item quest has no reward pokemon → under-return). Emit up to three separate + * OR'd quest clauses by sub-field compatibility: item (type 2 + item_id), + * pokemon-family (types 4/7/9/12 + pokemon — merging types over-returns + * cross-type, which is a safe superset), and type-only (types 1/3/u — amount is + * dropped to the residual). DNF is a superset narrow; secondaryFilter finalizes + * (quest title/target `adv`, invasion `confirmed`, exact amounts stay residual). + * Returns [] (match-all) when a match-all toggle is active or nothing is set. + * + * @param {Record} filters args.filters + * @returns {object[]} + */ +function buildPokestopDnfFilters(filters) { + if (!filters || typeof filters !== 'object') return [] + const { onlyAllPokestops, onlyArEligible } = filters + if (onlyAllPokestops) return [] + // NOTE: no power_up_level. Like gyms, pokestop power-up filtering only applies + // in `onlyAllPokestops` mode (which poisons to [] above), so a power_up_level + // clause could only fire when the real filter does NOT restrict it — an + // under-return. Power-up stays residual. + + const itemIds = [] // 'q' -> quest reward type 2 + const pokemonTypes = new Set() // 'c'/'x'/'m'/bare -> 4/9/12/7 + const pokemon = [] + const typeOnly = new Set() // 'p'/'d'/'u' -> 1/3/ (amount = residual) + const lureId = [] + const incidentCharacter = [] + const incidentDisplayType = [] + const incidentPokemon = [] + const contestPokemon = [] + const contestPokemonType = [] + + Object.entries(filters).forEach(([key]) => { + if (typeof key !== 'string' || key.length === 0) return + const n = Number(key.slice(1)) + switch (key.charAt(0)) { + case 'o': + break + case 'l': + if (Number.isFinite(n)) lureId.push(n) + break + case 'q': + if (Number.isFinite(n)) itemIds.push(n) + break + case 'd': + typeOnly.add(3) + break + case 'p': + typeOnly.add(1) + break + case 'u': + if (Number.isFinite(n)) typeOnly.add(n) + break + case 'c': + pokemonTypes.add(4) + pushIdForm(pokemon, key, 1) + break + case 'x': + pokemonTypes.add(9) + pushIdForm(pokemon, key, 1) + break + case 'm': { + // key is `m-` (NOT -): mega rewards have + // no form. Take the id only; the amount stays residual (secondaryFilter + // narrows via the m- key). Using pushIdForm here would treat + // the amount as a form and Golbat would return zero (under-return). + pokemonTypes.add(12) + const megaId = Number(key.slice(1).split('-')[0]) + if (Number.isFinite(megaId)) pokemon.push({ pokemon_id: megaId }) + break + } + case 'i': + if (Number.isFinite(n)) incidentCharacter.push(n) + break + case 'b': + if (Number.isFinite(n)) incidentDisplayType.push(n) + break + case 'a': + pushIdForm(incidentPokemon, key, 1) + break + case 'f': + pushIdForm(contestPokemon, key, 1) + break + case 'h': + if (Number.isFinite(n)) contestPokemonType.push(n) + break + default: { + // bare "[-]" = quest reward type 7 (pokemon encounter) + const [idPart] = key.split('-', 2) + if (Number.isFinite(Number(idPart))) { + pokemonTypes.add(7) + pushIdForm(pokemon, key, 0) + } + break + } + } + }) + + const clauses = [] + if (itemIds.length) + clauses.push({ quest_reward_type: [2], quest_reward_item_id: itemIds }) + if (pokemon.length) + clauses.push({ + quest_reward_type: [...pokemonTypes], + quest_reward_pokemon: pokemon, + }) + if (typeOnly.size) clauses.push({ quest_reward_type: [...typeOnly] }) + if (lureId.length) clauses.push({ lure_id: lureId }) + if (incidentCharacter.length) + clauses.push({ incident_character: incidentCharacter }) + if (incidentDisplayType.length) + clauses.push({ incident_display_type: incidentDisplayType }) + if (incidentPokemon.length) + clauses.push({ incident_pokemon: incidentPokemon }) + if (contestPokemon.length) clauses.push({ contest_pokemon: contestPokemon }) + if (contestPokemonType.length) + clauses.push({ contest_pokemon_type: contestPokemonType }) + if (onlyArEligible) clauses.push({ is_ar_scan_eligible: true }) + + return clauses.length ? clauses : [] +} + +module.exports = { buildPokestopDnfFilters } +``` + +- [ ] **Step 2: Golden-check the pokestop backend** + +Create `pokestop-dnf-golden.js` (throwaway): + +```js +const { + buildPokestopDnfFilters, +} = require('./server/src/filters/fort/pokestop') +const A = (c, m) => { + if (!c) throw new Error('FAIL ' + m) + console.log('ok ' + m) +} +const J = (o) => JSON.stringify(o) + +A( + J(buildPokestopDnfFilters({ onlyAllPokestops: true, q1: {} })) === '[]', + 'onlyAllPokestops -> match-all', +) +A(J(buildPokestopDnfFilters({ l501: {} })) === J([{ lure_id: [501] }]), 'lure') +A( + J(buildPokestopDnfFilters({ q1: {} })) === + J([{ quest_reward_type: [2], quest_reward_item_id: [1] }]), + 'item quest -> type2 + item', +) +A( + J(buildPokestopDnfFilters({ p1000: {} })) === J([{ quest_reward_type: [1] }]), + 'xp quest -> type1 only (amount is residual)', +) +A( + J(buildPokestopDnfFilters({ c25: {} })) === + J([{ quest_reward_type: [4], quest_reward_pokemon: [{ pokemon_id: 25 }] }]), + 'candy quest -> type4 + pokemon', +) +A( + J(buildPokestopDnfFilters({ '150-0': {} })) === + J([ + { + quest_reward_type: [7], + quest_reward_pokemon: [{ pokemon_id: 150, form: 0 }], + }, + ]), + 'pokemon-reward quest -> type7 + pokemon+form', +) +A( + J(buildPokestopDnfFilters({ i5: {} })) === J([{ incident_character: [5] }]), + 'invasion character', +) +A( + J(buildPokestopDnfFilters({ b8: {} })) === + J([{ incident_display_type: [8] }]), + 'invasion display type', +) +A( + J(buildPokestopDnfFilters({ 'f25-0': {} })) === + J([{ contest_pokemon: [{ pokemon_id: 25, form: 0 }] }]), + 'showcase pokemon', +) +A( + J(buildPokestopDnfFilters({ h3: {} })) === J([{ contest_pokemon_type: [3] }]), + 'showcase type', +) +A( + J(buildPokestopDnfFilters({ onlyArEligible: true, l1: {} })) === + J([{ lure_id: [1] }, { is_ar_scan_eligible: true }]), + 'ar-eligible own clause', +) +A( + J(buildPokestopDnfFilters({ q1: {}, onlyLevels: '2' })) === + J([{ quest_reward_type: [2], quest_reward_item_id: [1] }]), + 'onlyLevels does NOT emit power_up_level (residual — avoids under-return)', +) +// item + candy = TWO separate clauses (must NOT merge — different sub-fields) +const two = buildPokestopDnfFilters({ q1: {}, c25: {} }) +A( + J(two) === + J([ + { quest_reward_type: [2], quest_reward_item_id: [1] }, + { quest_reward_type: [4], quest_reward_pokemon: [{ pokemon_id: 25 }] }, + ]), + 'item + candy = two separate clauses (no under-return)', +) +// pokemon-family types merge into one pokemon clause (safe superset) +const pf = buildPokestopDnfFilters({ c25: {}, x50: {} }) +A( + pf.length === 1 && + J(pf[0].quest_reward_type) === J([4, 9]) && + pf[0].quest_reward_pokemon.length === 2, + 'candy + xl merge into one pokemon-family clause', +) +// mega key is m-: the amount must NOT become a form (under-return) +A( + J(buildPokestopDnfFilters({ 'm150-150': {} })) === + J([ + { quest_reward_type: [12], quest_reward_pokemon: [{ pokemon_id: 150 }] }, + ]), + 'mega m- -> pokemon id only, NO form (amount residual)', +) +console.log('\nALL PASS') +``` + +- [ ] **Step 3: Run the golden** + +Run: `node pokestop-dnf-golden.js` +Expected: all `ok`, `ALL PASS`. + +- [ ] **Step 4: Wire the pokestop mem branch** + +In `server/src/models/Pokestop.js`, add imports near the existing fort imports (after `mapScanPokestop`): + +```js +const { buildPokestopDnfFilters } = require('../filters/fort/pokestop') +const { describeDnfNarrowing } = require('../filters/fort/describeDnfNarrowing') +``` + +In the `mem` branch, change the scan body's `filters: []` to the DNF clauses and log before returning. Replace: + +```js +const res = await evalScannerQuery( + TAGS.pokestops, + `${mem}/api/pokestop/scan`, + JSON.stringify({ + min: { latitude: args.minLat, longitude: args.minLon }, + max: { latitude: args.maxLat, longitude: args.maxLon }, + limit: queryLimits.pokestops, + filters: [], + with_incidents: true, + }), + 'POST', + secret, + httpAuth, +) +if (res && Array.isArray(res.pokestops)) { + const mapped = res.pokestops + .map(mapScanPokestop) + .filter((stop) => stop && filterRTree(stop, areaRestrictions, onlyAreas)) + if (mapped.length > queryLimits.pokestops) { + mapped.length = queryLimits.pokestops + } + return this.secondaryFilter( + mapped, + args.filters, + false, + ts, + midnight, + perms, + hasMultiInvasions, + hasConfirmed, + effectiveOnlyArEligible, + effectiveQuestLayer, + ) +} +``` + +with: + +```js +const dnf = buildPokestopDnfFilters(args.filters) +const res = await evalScannerQuery( + TAGS.pokestops, + `${mem}/api/pokestop/scan`, + JSON.stringify({ + min: { latitude: args.minLat, longitude: args.minLon }, + max: { latitude: args.maxLat, longitude: args.maxLon }, + limit: queryLimits.pokestops, + filters: dnf, + with_incidents: true, + }), + 'POST', + secret, + httpAuth, +) +if (res && Array.isArray(res.pokestops)) { + const mapped = res.pokestops + .map(mapScanPokestop) + .filter((stop) => stop && filterRTree(stop, areaRestrictions, onlyAreas)) + if (mapped.length > queryLimits.pokestops) { + mapped.length = queryLimits.pokestops + } + const final = this.secondaryFilter( + mapped, + args.filters, + false, + ts, + midnight, + perms, + hasMultiInvasions, + hasConfirmed, + effectiveOnlyArEligible, + effectiveQuestLayer, + ) + log.info( + TAGS.pokestops, + describeDnfNarrowing( + 'POKESTOP', + dnf.length, + res.examined, + res.pokestops.length, + final.length, + ), + ) + return final +} +``` + +- [ ] **Step 5: Lint** + +Run: `npx eslint server/src/filters/fort/pokestop.js server/src/models/Pokestop.js && npx prettier --check server/src/filters/fort/pokestop.js server/src/models/Pokestop.js` +Expected: clean. + +- [ ] **Step 6: Delete golden + commit** + +```bash +rm pokestop-dnf-golden.js +git add server/src/filters/fort/pokestop.js server/src/models/Pokestop.js +git commit -m "feat(pokestop): dnf filter backend + narrowing log" +``` + +--- + +### Task 4: Station DNF backend + wiring + +**Repo/branch:** ReactMap, branch `feat/fort-consumer`. **Depends on Task 1** (`stationed_gmax`). + +**Files:** + +- Create: `server/src/filters/fort/station.js` +- Modify: `server/src/models/Station.js` (the `mem` branch, ~696-810) + +**Interfaces:** + +- Consumes: `describeDnfNarrowing` (Task 2); Golbat `stationed_gmax` (Task 1). +- Produces: `buildStationDnfFilters(filters) → object[]` (consumed by `Station.getAll`). + +- [ ] **Step 1: Write the station backend** + +Create `server/src/filters/fort/station.js`. Mirrors `Station.getAll`'s parsing: `onlyBattleTier !== 'all'`→`battle_level:[tier]`; `j` keys (when `onlyBattleTier === 'all'`)→`battle_level:[lvls]`; bare `-` (battle combo)→`battle_pokemon` (gender residual); `onlyGmaxStationed`→`stationed_gmax:true`. `onlyAllStations` or `onlyInactiveStations` ⇒ match-all (the active/inactive gate is a now-relative residual, and inactive-mode must return all inactive stations). `onlyMaxBattles` with no expressible battle condition ⇒ match-all (can't express "has any active battle"). + +```js +// @ts-check + +/** + * Translate a station's `args.filters` into ApiFortDnfFilter[] clauses. + * DNF is a superset narrow; the station JS gate (passesTimeGate/ + * passesFilterGate) finalizes. Active/inactive and upcoming are now-relative + * time windows and stay residual. Returns [] (match-all) when a match-all + * toggle is active or a battle intent can't be expressed. + * + * @param {Record} filters args.filters + * @returns {object[]} + */ +function buildStationDnfFilters(filters) { + if (!filters || typeof filters !== 'object') return [] + const { + onlyAllStations, + onlyInactiveStations, + onlyMaxBattles, + onlyBattleTier, + onlyGmaxStationed, + } = filters + // Now-relative / show-everything gates -> can't narrow server-side. + if (onlyAllStations || onlyInactiveStations) return [] + + const clauses = [] + + if (onlyMaxBattles) { + const battleLevels = [] + const battlePokemon = [] + if (onlyBattleTier && onlyBattleTier !== 'all') { + const t = Number(onlyBattleTier) + if (Number.isFinite(t)) battleLevels.push(t) + } else { + // per-level multi-select + battle-combo keys + Object.entries(filters).forEach(([key, value]) => { + if (typeof key !== 'string' || key.length === 0) return + if (key.startsWith('j')) { + const lvl = Number(key.slice(1)) + if (Number.isFinite(lvl)) battleLevels.push(lvl) + } else if (/^\d/.test(key)) { + const [idPart, formPart] = key.split('-', 2) + const id = Number(idPart) + if (!Number.isFinite(id)) return + const pair = { pokemon_id: id } + if ( + formPart && + formPart !== 'null' && + Number.isFinite(Number(formPart)) + ) + pair.form = Number(formPart) + battlePokemon.push(pair) + } + }) + } + if (battleLevels.length || battlePokemon.length) { + // Separate OR clauses: the real filter's level-vs-pokemon AND/OR is + // unverified, so separate clauses are a safe superset either way + // (secondaryFilter's matchesStationBattleFilter narrows exactly). + if (battleLevels.length) clauses.push({ battle_level: battleLevels }) + if (battlePokemon.length) clauses.push({ battle_pokemon: battlePokemon }) + } else { + // onlyMaxBattles but no expressible battle condition = "any active + // battle" — DNF can't say that, so match-all and let the JS gate narrow. + return [] + } + } + + if (onlyGmaxStationed) clauses.push({ stationed_gmax: true }) + + return clauses.length ? clauses : [] +} + +module.exports = { buildStationDnfFilters } +``` + +- [ ] **Step 2: Golden-check the station backend** + +Create `station-dnf-golden.js` (throwaway): + +```js +const { buildStationDnfFilters } = require('./server/src/filters/fort/station') +const A = (c, m) => { + if (!c) throw new Error('FAIL ' + m) + console.log('ok ' + m) +} +const J = (o) => JSON.stringify(o) + +A( + J(buildStationDnfFilters({ onlyAllStations: true })) === '[]', + 'onlyAllStations -> match-all', +) +A( + J( + buildStationDnfFilters({ + onlyInactiveStations: true, + onlyGmaxStationed: true, + }), + ) === '[]', + 'onlyInactiveStations poisons (time residual)', +) +A( + J(buildStationDnfFilters({ onlyMaxBattles: true, onlyBattleTier: '5' })) === + J([{ battle_level: [5] }]), + 'single tier -> battle_level', +) +A( + J( + buildStationDnfFilters({ + onlyMaxBattles: true, + onlyBattleTier: 'all', + j5: {}, + j6: {}, + }), + ) === J([{ battle_level: [5, 6] }]), + 'multi tier -> battle_level list', +) +A( + J( + buildStationDnfFilters({ + onlyMaxBattles: true, + onlyBattleTier: 'all', + '150-0': {}, + }), + ) === J([{ battle_pokemon: [{ pokemon_id: 150, form: 0 }] }]), + 'combo -> battle_pokemon', +) +A( + J( + buildStationDnfFilters({ + onlyMaxBattles: true, + onlyBattleTier: 'all', + j5: {}, + '150-0': {}, + }), + ) === + J([ + { battle_level: [5] }, + { battle_pokemon: [{ pokemon_id: 150, form: 0 }] }, + ]), + 'level + pokemon = separate OR clauses', +) +A( + J(buildStationDnfFilters({ onlyGmaxStationed: true })) === + J([{ stationed_gmax: true }]), + 'gmax -> stationed_gmax', +) +A( + J( + buildStationDnfFilters({ + onlyMaxBattles: true, + onlyBattleTier: '5', + onlyGmaxStationed: true, + }), + ) === J([{ battle_level: [5] }, { stationed_gmax: true }]), + 'battle OR gmax = two clauses', +) +A( + J(buildStationDnfFilters({ onlyMaxBattles: true, onlyBattleTier: 'all' })) === + '[]', + 'onlyMaxBattles with no condition -> match-all', +) +console.log('\nALL PASS') +``` + +- [ ] **Step 3: Run the golden** + +Run: `node station-dnf-golden.js` +Expected: all `ok`, `ALL PASS`. + +- [ ] **Step 4: Wire the station mem branch** + +In `server/src/models/Station.js`, add imports near the existing fort imports: + +```js +const { buildStationDnfFilters } = require('../filters/fort/station') +const { describeDnfNarrowing } = require('../filters/fort/describeDnfNarrowing') +``` + +In the `mem` branch, change `filters: []` to `filters: dnf` (compute `dnf` just before the `evalScannerQuery` call) and add the narrowing log just before `return stations`: + +```js +const dnf = buildStationDnfFilters(args.filters) +const res = await evalScannerQuery( + TAGS.stations, + `${mem}/api/station/scan`, + JSON.stringify({ + min: { latitude: args.minLat, longitude: args.minLon }, + max: { latitude: args.maxLat, longitude: args.maxLon }, + limit: queryLimits.stations, + filters: dnf, + }), + 'POST', + secret, + httpAuth, +) +``` + +and change the existing `return stations` (end of the `if (res && Array.isArray(res.stations))` block) to: + +```js +log.info( + TAGS.stations, + describeDnfNarrowing( + 'STATION', + dnf.length, + res.examined, + res.stations.length, + stations.length, + ), +) +return stations +``` + +(Everything between — the `passesFilterGate`/`passesTimeGate`/`.map` residual — is unchanged.) + +- [ ] **Step 5: Lint** + +Run: `npx eslint server/src/filters/fort/station.js server/src/models/Station.js && npx prettier --check server/src/filters/fort/station.js server/src/models/Station.js` +Expected: clean. + +- [ ] **Step 6: Delete golden + commit** + +```bash +rm station-dnf-golden.js +git add server/src/filters/fort/station.js server/src/models/Station.js +git commit -m "feat(station): dnf filter backend + narrowing log" +``` + +**Acceptance gate (deferred, user runs live vs a Golbat deployed at Task-1 HEAD, dual sources):** the **live parity gate** — for a viewport + representative filters, the DNF result after `secondaryFilter` must **equal** the match-all result (same markers). Exercise: a rare quest reward, a raid boss, an invasion type, a battle pokemon/tier, `onlyGmaxStationed`, and a poisoning case (a specific filter + a match-all toggle, and `onlyInactiveStations`). Watch the DNF log — a large "−N by secondaryFilter" on a case you expected to narrow is a gap to investigate; a divergence in marker set is a DNF **under-return** bug. + +--- + +## Self-Review + +**Spec coverage** (`2026-07-16-fort-dnf-filtering-design.md`): + +- Three pure backends under `server/src/filters/fort/` (§4) → Tasks 2-4. ✅ +- Wired into the existing mem branches, `secondaryFilter` untouched (§3) → Tasks 2-4. ✅ +- Poisoning rule (§3.1) → each backend returns `[]` on match-all toggles / gaps; golden-tested. ✅ +- Per-type translation (§4.1) → the three backends. ✅ +- Golbat `stationed_gmax` only (§5) → Task 1; `is_inactive` deliberately NOT filled (station backend poisons on `onlyInactiveStations`). ✅ +- Observability log (§6) → `describeDnfNarrowing`, used in all three branches. ✅ +- Residual stays JS (§3, §8): quest title/target, gender, ex/in-battle, invasion-confirmed, station time-windows — none emitted as clauses. ✅ +- Live parity gate (§7) → Task 4 acceptance. ✅ + +**Placeholder scan:** every code step has complete code; no TBD. ✅ + +**Type consistency:** `build{Gym,Pokestop,Station}DnfFilters(filters)` and `describeDnfNarrowing(label, clauses, examined, returned, final)` are consistent across their definition (Tasks 2-4) and the model wiring. Clause field names match `ApiFortDnfFilter` json tags (`quest_reward_type`, `quest_reward_item_id`, `quest_reward_pokemon`, `lure_id`, `incident_character`, `incident_display_type`, `incident_pokemon`, `contest_pokemon`, `contest_pokemon_type`, `team_id`, `available_slots`, `raid_level`, `raid_pokemon_id`, `battle_level`, `battle_pokemon`, `is_ar_scan_eligible`, `stationed_gmax`). ✅ (Gym's raid boss uses `raid_pokemon_id`, not `raid_pokemon` — a wrong key is silently ignored by Golbat.) + +**Superset-invariant spot checks:** gym raid-boss drops gender (residual) — superset ✓; pokestop splits quest reward families into separate OR clauses (item / pokemon-family / type-only) so no clause ANDs incompatible sub-fields, and drops title/target + exact amounts to the residual — superset, no under-return ✓; station emits battle_level and battle_pokemon as separate OR clauses (safe regardless of the real AND/OR) and drops upcoming/active-inactive — superset ✓; every "can't express" path returns `[]` (match-all), never a narrowing clause — no under-return. ✅ The one hard failure mode (a single clause ANDing sub-fields a real quest can't jointly satisfy) is explicitly avoided and golden-tested (`item + candy = two clauses`). ✅ diff --git a/docs/superpowers/plans/2026-07-16-reactmap-fort-consumer-gyms.md b/docs/superpowers/plans/2026-07-16-reactmap-fort-consumer-gyms.md new file mode 100644 index 000000000..db99c72dd --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-reactmap-fort-consumer-gyms.md @@ -0,0 +1,437 @@ +# ReactMap Fort Consumer — Gyms (match-all) 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:** Route `Gym.getAll` (markers/popups), `Gym.getOne`, and `Gym.getAvailable` through Golbat's fort endpoints when a scanner source has a Golbat `endpoint`, with SQL fallback — the first (pattern-proving, no-new-Golbat-code) slice of the ReactMap fort consumer. + +**Architecture:** Each method gains a `mem` branch (source has an endpoint) that fetches from Golbat and reuses the existing `secondaryFilter`/available logic, falling through to the existing SQL on 503/error (dual source). `getAll` sends a **match-all** scan (`filters: []`) and filters in JS — DNF narrowing is a later plan. `ApiGymResult`'s field names match ReactMap's, so `getAll` needs no row mapper; only `getAvailable` gets a small aggregate mapper. + +**Tech Stack:** Node, Objection/Knex, `fetchJson`, `filterRTree`, `@rm/logger`. **No test framework** (see Global Constraints). + +## Global Constraints + +- **No TDD / no test framework.** This repo has no test runner and the maintainer opted out of TDD (per Phase-1 #1227). Verify each task with: `npx eslint ` and `npx prettier --check ` (both clean), a **throwaway `node` golden script** for pure mappers (run with `node`, output eyeballed against hand-computed expected, then **deleted** — never committed), and explicit reasoning against the SQL path. Do **not** author committed test files. +- **Branch off `feat/pokestop-available-consumer`** (the Phase-1 ReactMap branch), NOT `develop` — this reuses Phase-1's dual-source `DbManager.getDbContext` overlay, `fetchJson`, and the `mem/secret/httpAuth` source context. (Corrects the spec's "off develop".) +- **`getAll` mem branch MUST apply `filterRTree(gym, perms.areaRestrictions, args.filters.onlyAreas)`** for area restriction — the Pokémon `getAll` template omits this (a latent gap); do not replicate the gap. `getAreaSql` is SQL-only. +- **`getAvailable` mem branch uses the `Pokestop.getAvailable` dual-source pattern:** `if (mem) { try { …; if (valid) return …; log.warn } catch { log.warn } }` then **fall through** to the existing SQL — a dual source runs SQL on its bound knex; a pure-endpoint source's `this.query()` throws and is dropped by `runScannerSources`'s `Promise.allSettled`. +- **No gym row mapper.** `ApiGymResult` JSON keys equal ReactMap's gym field names (`id,lat,lon,name,url,updated,last_modified_timestamp,team_id,available_slots,in_battle,guarding_pokemon_id,guarding_pokemon_display,defenders,total_cp,ar_scan_eligible,ex_raid_eligible,power_up_*,raid_*`), so endpoint rows feed the existing `secondaryFilter` unchanged. `guarding_pokemon_display`/`defenders` come back as JSON **strings** (Golbat types them `*string`), which `secondaryFilter`'s existing `typeof … === 'string'` `JSON.parse` already handles. +- **Client-side equivalents of SQL-only gates** (mem path only): filter rows to `enabled && !deleted` (the `onlyValid` WHERE), and when `hideOldGyms` filter rows to `updated > ts - gymValidDataLimit*86400`. `queryLimits.gyms` becomes the scan `limit` param. +- **Preserve badges unchanged.** `Badge` is a ReactMap-local table always bound to the ReactMap DB; the `userBadges`/`userBadgeObj` merge on `gym.id` runs identically on Golbat-sourced rows (they carry the same `id`). +- **`deDupeResults` keys by `id`, keeps larger `updated`** — `ApiGymResult` carries both, so dual DB+endpoint sources merge correctly. +- Endpoints: `POST {mem}/api/gym/scan` body `{min:{latitude,longitude},max:{latitude,longitude},limit,filters}`; `GET {mem}/api/gym/id/{id}`; `GET {mem}/api/gym/available`. Auth via `X-Golbat-Secret`/HTTP-Basic (handled by the shared eval util). +- Lint a single file with `npx eslint `; the husky `pre-commit` runs `lint-staged` (eslint --fix + prettier) automatically on commit. + +--- + +### Task 1: Shared `evalScannerQuery` util + +**Files:** + +- Create: `server/src/utils/evalScannerQuery.js` + +**Interfaces:** + +- Produces: `evalScannerQuery(tag, mem, query, method='POST', secret='', httpAuth=null)` — when `mem` is a URL string, POST/GET-fetches it via `fetchJson` with secret/HTTP-Basic headers and returns the parsed JSON (or a node-fetch `Response` on non-2xx, or `[]` on network error); when `mem` is falsy, awaits and returns the passed knex `query`. Consumed by Tasks 3–5. + +This extracts the endpoint-or-knex evaluator that `Pokemon.evalQuery`/`Pokestop.evalQuery` duplicate, parameterized by a log tag, so the new gym (and later station) methods don't add more copies. Pokémon/Pokestop are left untouched (out of scope). + +- [ ] **Step 1: Create the util** — `server/src/utils/evalScannerQuery.js`: + +```js +// @ts-check +const fs = require('fs') +const { resolve } = require('path') + +const config = require('@rm/config') +const { log } = require('@rm/logger') +const { fetchJson } = require('./fetchJson') + +/** + * Endpoint-or-knex query evaluator shared by Golbat-backed scanner models. + * Mirrors Pokemon.evalQuery / Pokestop.evalQuery but is tag-parameterized so + * new consumers (Gym, Station) don't each re-copy it. + * @template T + * @param {import('@rm/logger').Tag} tag + * @param {string} mem endpoint base+path when set; falsy = evaluate `query` + * @param {string | import('objection').QueryBuilder} query JSON body (mem) or knex query + * @param {'GET' | 'POST' | 'PATCH' | 'DELETE'} [method] + * @param {string} [secret] + * @param {{ username: string, password: string } | null} [httpAuth] + * @returns {Promise} + */ +async function evalScannerQuery( + tag, + mem, + query, + method = 'POST', + secret = '', + httpAuth = null, +) { + if (config.getSafe('devOptions.queryDebug')) { + const dir = resolve(__dirname, '../models/queries') + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + if (mem && typeof query === 'string') { + fs.writeFileSync(resolve(dir, `${Date.now()}.json`), query) + } else if (typeof query === 'object' && query) { + fs.writeFileSync( + resolve(dir, `${Date.now()}.sql`), + query.toKnexQuery().toString(), + ) + } + } + const results = await (mem + ? fetchJson(mem, { + method, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(secret ? { 'X-Golbat-Secret': secret } : {}), + ...(httpAuth + ? { + Authorization: `Basic ${Buffer.from( + `${httpAuth.username}:${httpAuth.password}`, + ).toString('base64')}`, + } + : {}), + }, + body: query, + }) + : query) + log.debug(tag, 'raw result length', results?.length || 0) + return results || [] +} + +module.exports = { evalScannerQuery } +``` + +- [ ] **Step 2: Verify** — `npx eslint server/src/utils/evalScannerQuery.js && npx prettier --check server/src/utils/evalScannerQuery.js` + Expected: clean (no output / "All matched files use Prettier code style"). Sanity-check `require('./fetchJson')` and the `@rm/logger` `Tag` type exist (they're used by `Pokemon.js`). + +- [ ] **Step 3: Commit** + +```bash +git add server/src/utils/evalScannerQuery.js +git commit -m "feat(scanner): shared evalScannerQuery endpoint-or-knex util" +``` + +--- + +### Task 2: `gymAvailableMapper.js` (pure aggregate mapper) + +**Files:** + +- Create: `server/src/models/gymAvailableMapper.js` + +**Interfaces:** + +- Produces: `mapGymAvailable(api)` where `api = { teams: [{team_id, available_slots, count}], raids: [{raid_level, pokemon_id, form, count}] }` → `{ available: string[] }`. Consumed by Task 3. + +Reproduces `Gym.getAvailable`'s SQL key output exactly (`Gym.js:542-573`): teams → `t{team}-0` + `g{team}-{6-slots}`; raids → boss `{id}-{form}` (pokemon_id>0), egg `e{level}` (pokemon_id===0), and `r{level}` for every distinct level. Standalone/require-free like `pokestopAvailableMapper.js` so it's golden-testable under plain `node`. + +- [ ] **Step 1: Create the mapper** — `server/src/models/gymAvailableMapper.js`: + +```js +// @ts-check + +/** + * Pure mapper for Golbat's `GET /api/gym/available` response. Reproduces the + * key output of the SQL `Gym.getAvailable` (t/g/e/r + boss `-`). + * Dependency-free so it can run under plain node for golden checks. + * @param {{ teams?: {team_id:number,available_slots:number,count:number}[], raids?: {raid_level:number,pokemon_id:number,form:number,count:number}[] }} api + * @returns {{ available: string[] }} + */ +function mapGymAvailable(api) { + const available = new Set() + + const teams = api.teams || [] + teams.forEach((t) => { + if (t.team_id === null || t.available_slots === null) return + available.add(`t${t.team_id}-0`) + available.add(`g${t.team_id}-${6 - t.available_slots}`) + }) + + const raids = api.raids || [] + const raidLevels = new Set() + raids.forEach((r) => { + if (!r.raid_level) return + raidLevels.add(r.raid_level) + if (r.pokemon_id > 0) { + available.add(`${r.pokemon_id}-${r.form}`) + } else { + available.add(`e${r.raid_level}`) + } + }) + ;[...raidLevels] + .sort((a, b) => a - b) + .forEach((level) => available.add(`r${level}`)) + + return { available: [...available] } +} + +module.exports = { mapGymAvailable } +``` + +- [ ] **Step 2: Golden check (throwaway `node`, then delete)** — verify against the SQL formulas. Run: + +```bash +node -e ' +const { mapGymAvailable } = require("./server/src/models/gymAvailableMapper"); +const out = mapGymAvailable({ + teams: [{team_id:1,available_slots:2,count:5},{team_id:2,available_slots:6,count:3}], + raids: [{raid_level:5,pokemon_id:150,form:0,count:2},{raid_level:3,pokemon_id:0,form:0,count:1}], +}).available.sort(); +console.log(JSON.stringify(out)); +// expected (sorted): ["150-0","e3","g1-4","g2-0","r3","r5","t1-0","t2-0"] +' +``` + +Expected printed: `["150-0","e3","g1-4","g2-0","r3","r5","t1-0","t2-0"]` — confirms `t{team}-0`, `g{team}-{6-slots}` (6-2=4, 6-6=0), boss `150-0`, egg `e3`, tiers `r3`/`r5`. If it matches, the mapper reproduces `Gym.js:542-573`. Do not commit this command. + +- [ ] **Step 3: Verify lint** — `npx eslint server/src/models/gymAvailableMapper.js && npx prettier --check server/src/models/gymAvailableMapper.js` + Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add server/src/models/gymAvailableMapper.js +git commit -m "feat(gym): pure mapper for /api/gym/available -> t/g/e/r keys" +``` + +--- + +### Task 3: `Gym.getAvailable` mem branch + +**Files:** + +- Modify: `server/src/models/Gym.js` (imports; `getAvailable` at `:514`) + +**Interfaces:** + +- Consumes: `evalScannerQuery` (Task 1), `mapGymAvailable` (Task 2). `getAvailable`'s source context gains `mem/secret/httpAuth` (already supplied by `DbManager` — `getDbContext` overlays them). + +- [ ] **Step 1: Add imports** to `server/src/models/Gym.js` (near the other requires at the top; match existing style): + +```js +const { evalScannerQuery } = require('../utils/evalScannerQuery') +const { mapGymAvailable } = require('./gymAvailableMapper') +``` + +(`log`, `TAGS`, `config`, `state` are already imported in `Gym.js` — confirm and don't duplicate.) + +- [ ] **Step 2: Add the mem branch** at the top of `getAvailable`, changing its signature to accept the endpoint context and falling through to the existing SQL on failure. Replace the method header `static async getAvailable({ isMad, availableSlotsCol }) {` with: + +```js + static async getAvailable({ isMad, availableSlotsCol, mem, secret, httpAuth }) { + // Endpoint source: fetch the aggregate from Golbat; on 503/error fall + // through to the SQL below (dual source runs SQL on its bound knex; a + // pure-endpoint source's this.query() throws and is dropped upstream). + if (mem) { + try { + const res = await evalScannerQuery( + TAGS.gyms, + `${mem}/api/gym/available`, + undefined, + 'GET', + secret, + httpAuth, + ) + if (res && Array.isArray(res.teams) && Array.isArray(res.raids)) { + const { available } = mapGymAvailable(res) + log.info( + TAGS.gyms, + `[GYM] loaded available from Golbat endpoint ${mem}/api/gym/available — ${available.length} filter keys (${res.teams.length} team/slot, ${res.raids.length} raid options)`, + ) + return { available } + } + log.warn( + TAGS.gyms, + '[GYM] /api/gym/available unavailable (e.g. fort_in_memory off) — returning empty available for this endpoint source', + ) + } catch (e) { + log.warn( + TAGS.gyms, + `[GYM] /api/gym/available error — returning empty available for this endpoint source: ${e}`, + ) + } + } + const ts = Math.floor(Date.now() / 1000) +``` + +(The rest of the existing `getAvailable` body — the two `this.query()` builders and the `return { available: [...] }` — is unchanged; the new code inserts before the existing `const ts = …` line, which is kept. Confirm `TAGS.gyms` exists in `@rm/logger`; if the tag is named differently — e.g. `TAGS.gym` — use the actual key.) + +- [ ] **Step 3: Verify** — `npx eslint server/src/models/Gym.js && npx prettier --check server/src/models/Gym.js`. Reasoning check: on `mem` success the endpoint keys equal the SQL keys (Task 2 golden); on `mem` unset or a non-`{teams,raids}` response, execution reaches the unchanged SQL path (dual source) — confirm there is no `return` between the `catch` and `const ts`. + Expected: lint clean. + +- [ ] **Step 4: Commit** + +```bash +git add server/src/models/Gym.js +git commit -m "feat(gym): getAvailable via /api/gym/available with SQL fallback" +``` + +--- + +### Task 4: `Gym.getAll` mem branch (match-all) + +**Files:** + +- Modify: `server/src/models/Gym.js` (imports for `filterRTree`; `getAll` at `:114`) + +**Interfaces:** + +- Consumes: `evalScannerQuery` (Task 1), `filterRTree` (`server/src/utils/filterRTree.js`). `getAll`'s source context gains `mem/secret/httpAuth`. + +The endpoint returns `ApiGymResult[]` whose keys match the fields `secondaryFilter` reads, so the fetched rows feed the **existing** `secondaryFilter` after client-side `onlyValid`/`hideOldGyms`/`filterRTree` filtering. Match-all sends `filters: []`. + +- [ ] **Step 1: Add the `filterRTree` import** (if not already present) near the top of `Gym.js`: + +```js +const { filterRTree } = require('../utils/filterRTree') +``` + +- [ ] **Step 2: Accept the endpoint context** — change the `getAll` signature to destructure the endpoint fields and `areaRestrictions` (already in `perms`). Replace `static async getAll(perms, args, { isMad, availableSlotsCol }, userId) {` with: + +```js + static async getAll(perms, args, { isMad, availableSlotsCol, mem, secret, httpAuth }, userId) { +``` + +- [ ] **Step 3: Insert the mem branch** immediately **before** the SQL query-building block. The insertion point is right after `finalSlots`/`finalTeams`/`userBadges` are computed and the "returns nothing if…" guards, and before `if (onlyAllGyms && onlyLevels !== 'all' …)` (around `Gym.js:262`) — i.e. after everything `secondaryFilter` closes over is set up. `secondaryFilter` is defined later in the method; JS hoists the `const secondaryFilter` only at its definition, so this branch must call it **after** its definition. To keep that ordering simple, place the branch as the **last** thing before `return secondaryFilter(await query.limit(queryLimits.gyms))` — i.e. replace that final return with: + +```js +if (mem) { + try { + const rows = await evalScannerQuery( + TAGS.gyms, + `${mem}/api/gym/scan`, + JSON.stringify({ + min: { latitude: args.minLat, longitude: args.minLon }, + max: { latitude: args.maxLat, longitude: args.maxLon }, + limit: queryLimits.gyms, + filters: [], + }), + 'POST', + secret, + httpAuth, + ) + if (Array.isArray(rows)) { + const active = rows.filter( + (gym) => + gym.enabled && + !gym.deleted && + (!hideOldGyms || gym.updated > ts - gymValidDataLimit * 86400) && + filterRTree(gym, areaRestrictions, onlyAreas), + ) + return secondaryFilter(active) + } + log.warn( + TAGS.gyms, + '[GYM] /api/gym/scan unavailable (e.g. fort_in_memory off) — falling back to SQL for this source', + ) + } catch (e) { + log.warn( + TAGS.gyms, + `[GYM] /api/gym/scan error — falling back to SQL for this source: ${e}`, + ) + } +} +return secondaryFilter(await query.limit(queryLimits.gyms)) +``` + +Notes: `hideOldGyms`, `gymValidDataLimit`, `queryLimits` are already destructured from `config.getSafe('api')` at the method top; `areaRestrictions` is from `perms`, `onlyAreas` from `args.filters`, `ts` is already computed. On endpoint failure the `try` falls through to the unchanged `return secondaryFilter(await query.limit(queryLimits.gyms))` (dual source runs the SQL query that was still built above; a pure-endpoint source's `this.query()` chain has no knex and its `getAll` promise is dropped by `runScannerSources`). The SQL query-building block above the branch is unchanged — it still runs on the fall-through path. + +- [ ] **Step 4: Verify** — `npx eslint server/src/models/Gym.js && npx prettier --check server/src/models/Gym.js`. Reasoning check against the SQL path: + + - Match-all `filters: []` → Golbat `/api/gym/scan` returns every gym in the bbox; `secondaryFilter` then applies the same `hasRaid`/`hasGym`/badge membership it applies to SQL rows, so the returned set matches the SQL WHERE-narrowed set. + - `onlyValid` (`enabled && !deleted`), `hideOldGyms`, and area (`filterRTree`) are applied client-side because the endpoint has no SQL WHERE / `getAreaSql`. + - `ApiGymResult` supplies every `coreFields`/`gymFields`/`raidFields` key by the same name; `guarding_pokemon_display`/`defenders` arrive as JSON strings and are parsed by the existing `typeof … === 'string'` branch. + - Badges: `userBadges` was computed above and `secondaryFilter` merges on `gym.id` — unchanged. + + Optional endpoint smoke (needs a Golbat deploy of #385's branch, so document as manual): with a dual gym source configured, load the map and confirm gym/raid markers render and popups show team/raid detail, matching the DB path. + Expected: lint clean. + +- [ ] **Step 5: Commit** + +```bash +git add server/src/models/Gym.js +git commit -m "feat(gym): getAll via /api/gym/scan (match-all) with filterRTree + SQL fallback" +``` + +--- + +### Task 5: `Gym.getOne` mem branch + +**Files:** + +- Modify: `server/src/models/Gym.js` (`getOne` at `:698`) + +**Interfaces:** + +- Consumes: `evalScannerQuery` (Task 1). `getOne`'s source context gains `mem/secret/httpAuth`. + +`getOne` is used by `gymsSingle` for recenter/deep-link; the client fragment reads only `lat`/`lon`. The by-id endpoint returns a full `ApiGymResult`; returning it whole is a harmless superset. + +- [ ] **Step 1: Add the mem branch** — replace `getOne` (`Gym.js:698-706`) with: + +```js + static async getOne(id, { isMad, mem, secret, httpAuth }) { + if (mem) { + try { + const res = await evalScannerQuery( + TAGS.gyms, + `${mem}/api/gym/id/${id}`, + undefined, + 'GET', + secret, + httpAuth, + ) + if (res && typeof res === 'object' && 'lat' in res && 'lon' in res) { + return res + } + } catch (e) { + log.warn(TAGS.gyms, `[GYM] /api/gym/id error — falling back to SQL: ${e}`) + } + } + return this.query() + .select([ + isMad ? 'latitude AS lat' : 'lat', + isMad ? 'longitude AS lon' : 'lon', + ]) + .where(isMad ? 'gym_id' : 'id', id) + .first() + } +``` + +Note: `DbManager.getOne(model, id)` calls `SubModel.getOne(id, source)` with the source context, and a 404 from the by-id endpoint returns a non-`{lat,lon}` shape → falls through to SQL (a dual source's knex answers; a pure-endpoint source returns undefined and is dropped by the caller's `.filter(Boolean)`). + +- [ ] **Step 2: Verify** — `npx eslint server/src/models/Gym.js && npx prettier --check server/src/models/Gym.js`. Reasoning: `mem` set + 2xx with lat/lon → endpoint record; 404/error/`mem` unset → SQL lat/lon. Return shape carries `lat`/`lon` in all cases, satisfying `GET_ONE_GYM`. + Expected: lint clean. + +- [ ] **Step 3: Commit** + +```bash +git add server/src/models/Gym.js +git commit -m "feat(gym): getOne via /api/gym/id/{id} with SQL fallback" +``` + +--- + +## Self-Review + +**Spec coverage** (design spec §8-§9/§11, gyms slice): `Gym.getAll` mem branch (match-all) → Task 4; `Gym.getOne` → Task 5; `Gym.getAvailable` → Task 3 (+ mapper Task 2); shared plumbing (`evalScannerQuery`) → Task 1; `filterRTree` area handling → Task 4; dual-source fallback → Tasks 3-5. DNF (`Backend`), stations, and pokestops are **out of this slice** (later plans on the same branch). No gap for gyms-match-all. + +**Placeholder scan:** every code step has complete code; the only manual verification (Task 4 endpoint smoke) is explicitly documented as needing a Golbat deploy, not a faked test — consistent with the no-test-framework constraint. + +**Type/name consistency:** `evalScannerQuery(tag, mem, query, method, secret, httpAuth)` is defined in Task 1 and called identically in Tasks 3-5; `mapGymAvailable(api) → {available}` defined in Task 2, consumed in Task 3; `TAGS.gyms` used consistently (the implementer must confirm the exact tag key in `@rm/logger` — it may be `TAGS.gym`; use whatever exists, consistently). `ApiGymResult` field names feed `secondaryFilter` unchanged (no mapper), per the field-mapping research. + +**Open items for the implementer to confirm (not gaps, but verify):** + +1. The exact `@rm/logger` tag key for gyms (`TAGS.gyms` vs `TAGS.gym`) — grep `@rm/logger`'s tags. +2. That `config.getSafe('api')` in `getAll` already yields `hideOldGyms`/`gymValidDataLimit`/`queryLimits` (it does per the research) — no new config read needed. +3. That `secondaryFilter` is defined before the final `return` where the mem branch is inserted (it is — the branch replaces the existing final return). + +## Follow-on plans (same branch/PR, not this slice) + +- **Stations (match-all):** `Station.getAll`/`getOne`/`getAvailable` — same pattern; needs a `stationAvailableMapper` (`{battles:[…]}` → `j{level}`/`-` keys) and a **station row mapper** (Golbat `ApiStationResult` uses pointer/nullable fields and a `battles[]` array — unlike gyms, its field shape may need light mapping; confirm during that plan). +- **Pokestops (match-all):** `Pokestop.getAll`/`getOne` — needs `with_incidents: true` in the scan body and a pokestop row mapper (quest/lure/invasion/showcase sub-objects); depends on the Golbat PR #385 being deployed. +- **DNF (all three):** a fort filter `Backend.buildApiFilter()` mirroring `PkmnBackend`, replacing `filters: []` with translated `ApiFortDnfFilter[]` — the payoff phase. +- **Shared-util cleanup:** migrate `Pokemon`/`Pokestop` `evalQuery` onto `evalScannerQuery` (deferred; avoids touching shipped code now). diff --git a/docs/superpowers/plans/2026-07-16-reactmap-fort-consumer-pokestops.md b/docs/superpowers/plans/2026-07-16-reactmap-fort-consumer-pokestops.md new file mode 100644 index 000000000..1198c879f --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-reactmap-fort-consumer-pokestops.md @@ -0,0 +1,621 @@ +# ReactMap Fort Consumer — Pokestops (match-all) 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:** Route `Pokestop.getAll` (map markers/popups) and `Pokestop.getOne` through Golbat's `POST /api/pokestop/scan` (`with_incidents:true`) and `GET /api/pokestop/id/{id}` when a source is endpoint-backed, falling back to SQL otherwise — completing the fort-consumer trio (gyms + stations already shipped; `getAvailable` shipped in Phase-1 #1227). + +**Architecture:** A pure row mapper (`pokestopScanMapper.js`) turns one Golbat `ApiPokestopResult` (+ its `invasions[]`) into the exact per-stop object shape `Pokestop.mapRDM` emits from joined SQL rows. `Pokestop.getAll`'s new `mem` branch fetches the scan, maps each stop, applies `filterRTree` area restriction, then runs the **existing, unchanged** `Pokestop.secondaryFilter` — identical to how `Gym.getAll` reuses its `secondaryFilter`. The only genuinely new logic is reading `quest_reward_type`, which Golbat has no flat column for, from the native `quest_rewards[0].type` array Golbat returns (commit `1c86576`) — no `JSON.parse`. + +**Tech Stack:** Node.js, Objection/Knex, `@rm/logger`, existing `evalScannerQuery`/`describeScannerResponse`/`filterRTree` utils. Golbat scan endpoints from spec `docs/superpowers/specs/2026-07-16-fort-scan-map-data-design.md`. + +## Global Constraints + +- **Branch:** work on `feat/fort-consumer` (already checked out). Diffs/reviews are against the branch HEAD before each task. +- **No test runner in this repo** (maintainer opted out of TDD). Verify each change with `npx eslint ` and `npx prettier --check `, plus a **throwaway `node` golden script** (run it, confirm output, then delete it — never `git add` it) and explicit reasoning. Do NOT add a test framework, and do NOT commit any test/spec file. +- **Commit subjects lowercase** (commitlint rejects start-case): `feat(pokestop): ...`. +- **Push to the `fork` remote** (`jfberry/ReactMap`); PR #1228 targets `WatWowMap:develop`. Do not push to `origin`/upstream. +- **Envelope shape:** `/api/pokestop/scan` returns `{ pokestops, examined, skipped, total }` — read `res.pokestops`, never `Array.isArray(res)`. `/api/pokestop/id/{id}` returns a **bare** `ApiPokestopResult`. +- **Reuse `secondaryFilter` verbatim** (as gyms reused `secondaryFilter`). The mapper's job is to produce `mapRDM`-shaped rows; all filtering, reward expansion, `key` building, midnight/layer gating, incident-blocker and `events[]` assembly stay inside the unchanged `secondaryFilter`. The ONE allowed edit to shared code is a single-line tolerance in `parseRdmRewards` so it accepts `quest_rewards` as either a native object (endpoint path) or a legacy JSON string (SQL path) — mirroring the `typeof … === 'string' ? JSON.parse : …` pattern `secondaryFilter` already uses for `showcase_rankings`. The SQL/MAD path always hits the string branch, so its behavior is unchanged. +- **Do NOT touch `getAvailable`** — it shipped in Phase-1 (#1227) and uses the older `this.evalQuery` helper. Leave it alone. +- **Scan body must include `with_incidents: true`** so Golbat attaches the `invasions[]` (grunts + showcase/goldstop/kecleon event rows). +- **Golbat facts verified against #385 source** (`decoder/api_pokestop.go`, `decoder/api_fort.go`): `ApiPokestopResult` has NO flat `quest_reward_type`; `quest_rewards`/`alternative_quest_rewards` are **native JSON** (arrays of `{type, info}`, or `null`) as of Golbat commit `1c86576` — read `quest_rewards[0].type`, do NOT `JSON.parse`; `quest_conditions`/`showcase_rankings` remain serialized JSON strings (opaque passthrough, the mapper never decodes them); each `invasions[]` entry has json fields `character` (0 for non-rocket), `expiration`, `display_type` (7 goldstop / 8 kecleon / 9 showcase), `confirmed`, `slot_1_pokemon_id`/`slot_1_form`/`slot_2_*`/`slot_3_*`; `CollectPokestopIncidents` returns ALL active incidents pruned to `expiration > now`. +- **`quest_rewards` is a dead wire field** — verified no client GraphQL fragment selects it (only `scanner.graphql:74` declares `quest_rewards: String`; it feeds server-side `parseRdmRewards` and is never sent to the client). So passing it through `secondaryFilter` as a native object is safe (GraphQL only serializes selected fields). + +--- + +## File Structure + +- **Create `server/src/models/pokestopScanMapper.js`** — pure, dependency-free module exporting `mapScanPokestop(api)` (→ mapRDM-shaped row or `null`), plus `buildQuestLayer` and `mapInvasion` helpers. Standalone so it runs under plain `node` for golden checks. +- **Modify `server/src/models/Pokestop.js`**: + - Add imports: `evalScannerQuery`, `describeScannerResponse`, `filterRTree`, `mapScanPokestop`. + - `getAll` — add `mem, secret, httpAuth` to the ctx destructure; insert a `mem` branch just before `const results = await query`. + - `getOne` — add `mem, secret, httpAuth` to the ctx destructure; insert a `mem` branch mirroring `Gym.getOne`. + +--- + +### Task 1: `pokestopScanMapper.js` — pure Golbat-row → mapRDM-shape mapper + +**Files:** + +- Create: `server/src/models/pokestopScanMapper.js` +- Verify: throwaway `node` golden (create in repo root, run, delete) + +**Interfaces:** + +- Consumes: one Golbat `ApiPokestopResult` object (see Global Constraints for its json fields) with an optional `invasions[]` array. +- Produces: + + - `mapScanPokestop(api) → object | null` — a per-stop row with keys `{ id, lat, lon, enabled, url, name, last_modified_timestamp, updated, ar_scan_eligible, power_up_points, power_up_level, power_up_end_timestamp, lure_id, lure_expire_timestamp, showcase_expiry, showcase_pokemon_id, showcase_pokemon_form_id, showcase_pokemon_type_id, showcase_ranking_standard, showcase_rankings, quests, invasions }`. Returns `null` when `!api.enabled || api.deleted`. + - `buildQuestLayer(api, prefix, withAr) → object | null` — one quest object `{ quest_type, quest_timestamp, quest_target, quest_conditions, quest_rewards, quest_reward_type, quest_title, with_ar }` or `null` when the layer has no active/parseable quest. + - `mapInvasion(inc) → object` — one invasion object with keys `{ incident_expire_timestamp, grunt_type, display_type, confirmed, slot_1_pokemon_id, slot_1_form, slot_2_pokemon_id, slot_2_form, slot_3_pokemon_id, slot_3_form }`. + - Task 2's `Pokestop.getAll` `mem` branch consumes `mapScanPokestop`; the mapped rows are fed to `secondaryFilter`, which computes `newQuest.key` and calls `parseRdmRewards` itself — the mapper must NOT compute `key` or expand rewards. + +- [ ] **Step 1: Write the mapper** + +Create `server/src/models/pokestopScanMapper.js`: + +```js +// @ts-check + +/** + * Pure mapper for one pokestop from Golbat's `POST /api/pokestop/scan` + * (envelope `res.pokestops[]`) or `GET /api/pokestop/id/{id}` (bare object). + * + * Produces the SAME per-stop shape `Pokestop.mapRDM` emits from joined SQL + * rows, so `Pokestop.secondaryFilter` (and the `parseRdmRewards` it calls) run + * downstream completely unchanged — exactly how `Gym.getAll` reuses its own + * `secondaryFilter`. All filtering, reward expansion, `key` building, midnight/ + * layer gating, incident-blocker and `events[]` assembly stay in secondaryFilter. + * + * The one piece of real work: Golbat's ApiPokestopResult has no flat + * `quest_reward_type` column (RDM's DB does), so we read it from the native + * `quest_rewards[0].type` array Golbat returns (commit `1c86576` made + * `quest_rewards`/`alternative_quest_rewards` native JSON via `jsonRaw()`, not + * escaped strings) — no `JSON.parse`. This matches how RDM's own quest_rewards + * JSON encodes it (`Pokestop.parseRdmRewards` reads `rewards[0].type`). + * + * Standalone by design (no requires) so it runs under plain `node` for golden + * checks with no `node_modules` present. + * + * @typedef {object} ApiPokestopIncident + * @property {number} character 0 for non-rocket (showcase/goldstop/kecleon) + * @property {number} expiration + * @property {number} display_type 7 goldstop, 8 kecleon, 9 showcase + * @property {boolean} confirmed + * @property {number} [slot_1_pokemon_id] + * @property {number} [slot_1_form] + * @property {number} [slot_2_pokemon_id] + * @property {number} [slot_2_form] + * @property {number} [slot_3_pokemon_id] + * @property {number} [slot_3_form] + */ + +/** + * Maps a Golbat invasion entry to ReactMap's `invasionProps` shape (the exact + * key set `Pokestop.mapRDM` puts on each `pokestop.invasions[]` entry). + * `character` → `grunt_type` and `expiration` → `incident_expire_timestamp` + * are the only renames; slots pass through by name. + * + * @param {ApiPokestopIncident} inc + */ +function mapInvasion(inc) { + return { + incident_expire_timestamp: inc.expiration, + grunt_type: inc.character, + display_type: inc.display_type, + confirmed: inc.confirmed, + slot_1_pokemon_id: inc.slot_1_pokemon_id, + slot_1_form: inc.slot_1_form, + slot_2_pokemon_id: inc.slot_2_pokemon_id, + slot_2_form: inc.slot_2_form, + slot_3_pokemon_id: inc.slot_3_pokemon_id, + slot_3_form: inc.slot_3_form, + } +} + +/** + * Builds one quest-layer object shaped like a `mapRDM` quest, or `null` when + * the layer has no active quest. Mirrors `mapRDM`'s `if (quest.quest_reward_type) + * push`. Golbat returns `quest_rewards` as native JSON (an array of + * `{type, info}`) or `null` — see Golbat commit `1c86576`, `jsonRaw()` — so we + * read the reward type directly with NO `JSON.parse`. The reward array is passed + * through unchanged as `quest_rewards`; `secondaryFilter`'s `parseRdmRewards` + * consumes it in-place to expand the per-type `info` fields (Task 2 makes it + * tolerant of an object as well as a legacy string). + * + * @param {Record} api + * @param {'' | 'alternative_'} prefix + * @param {boolean} withAr + * @returns {Record | null} + */ +function buildQuestLayer(api, prefix, withAr) { + const rewards = api[`${prefix}quest_rewards`] + if (!Array.isArray(rewards) || rewards.length === 0) return null + const questRewardType = rewards[0]?.type + if (!questRewardType) return null + return { + quest_type: api[`${prefix}quest_type`], + quest_timestamp: api[`${prefix}quest_timestamp`], + quest_target: api[`${prefix}quest_target`], + quest_conditions: api[`${prefix}quest_conditions`], + quest_rewards: rewards, + quest_reward_type: questRewardType, + quest_title: api[`${prefix}quest_title`], + with_ar: withAr, + } +} + +/** + * Maps one Golbat pokestop to the row shape `Pokestop.secondaryFilter` expects + * (i.e. one `mapRDM` output entry). Returns `null` for disabled/deleted stops, + * mirroring `mapRDM`'s `if (!result.enabled || result.deleted) continue`. + * + * `quest_*` = AR layer (`with_ar:true`), `alternative_quest_*` = non-AR layer + * (`with_ar:false`); each is pushed only when its reward type is derivable, so + * `quests` holds 0, 1, or 2 entries. Every returned incident (grunt AND + * showcase/goldstop/kecleon event rows) is mapped into `invasions`; + * `secondaryFilter` splits them into `invasions[]`/`events[]`/incident-blocker. + * Golbat prunes expired incidents server-side (`CollectPokestopIncidents` + * keeps `expiration > now`), so no expiry filter is applied here. + * + * @param {Record} api one ApiPokestopResult + * @returns {Record | null} + */ +function mapScanPokestop(api) { + if (!api.enabled || api.deleted) return null + const quests = [] + const base = buildQuestLayer(api, '', true) + if (base) quests.push(base) + const alt = buildQuestLayer(api, 'alternative_', false) + if (alt) quests.push(alt) + return { + id: api.id, + lat: api.lat, + lon: api.lon, + enabled: api.enabled, + url: api.url, + name: api.name, + last_modified_timestamp: api.last_modified_timestamp, + updated: api.updated, + ar_scan_eligible: api.ar_scan_eligible, + power_up_points: api.power_up_points, + power_up_level: api.power_up_level, + power_up_end_timestamp: api.power_up_end_timestamp, + lure_id: api.lure_id, + lure_expire_timestamp: api.lure_expire_timestamp, + showcase_expiry: api.showcase_expiry, + showcase_pokemon_id: api.showcase_pokemon_id, + showcase_pokemon_form_id: api.showcase_pokemon_form_id, + showcase_pokemon_type_id: api.showcase_pokemon_type_id, + showcase_ranking_standard: api.showcase_ranking_standard, + showcase_rankings: api.showcase_rankings, + quests, + invasions: (api.invasions || []).map(mapInvasion), + } +} + +module.exports = { mapScanPokestop, buildQuestLayer, mapInvasion } +``` + +- [ ] **Step 2: Golden-check the mapper under plain node** + +Create `pokestop-mapper-golden.js` in the repo root (throwaway — delete after): + +```js +const { mapScanPokestop } = require('./server/src/models/pokestopScanMapper') + +const api = { + id: 'abc.16', + lat: 1.5, + lon: 2.5, + enabled: true, + deleted: false, + url: 'http://x/img.png', + name: 'Test Stop', + last_modified_timestamp: 100, + updated: 200, + ar_scan_eligible: 1, + power_up_points: 50, + power_up_level: 1, + power_up_end_timestamp: 300, + lure_id: 501, + lure_expire_timestamp: 999, + showcase_expiry: 1234, + showcase_pokemon_id: 25, + showcase_pokemon_form_id: 0, + showcase_pokemon_type_id: 0, + showcase_ranking_standard: 1, + showcase_rankings: '{"total_entries":3}', + quest_type: 7, + quest_timestamp: 400, + quest_target: 3, + quest_conditions: '[{"type":1}]', + quest_title: 'catch', + quest_rewards: [{ type: 7, info: { pokemon_id: 25, form_id: 0 } }], + alternative_quest_type: 4, + alternative_quest_timestamp: 410, + alternative_quest_target: 5, + alternative_quest_conditions: '[]', + alternative_quest_title: 'spin', + alternative_quest_rewards: [{ type: 3, info: { amount: 1000 } }], + invasions: [ + { + id: 'i1', + character: 12, + expiration: 1500, + display_type: 1, + confirmed: true, + slot_1_pokemon_id: 63, + slot_1_form: 0, + }, + { + id: 'i2', + character: 0, + expiration: 1600, + display_type: 9, + confirmed: false, + }, + ], +} + +const out = mapScanPokestop(api) +const assert = (cond, msg) => { + if (!cond) throw new Error(`FAIL: ${msg}`) + console.log(`ok: ${msg}`) +} + +assert(out.id === 'abc.16' && out.lat === 1.5, 'core fields copied') +assert( + out.lure_id === 501 && out.showcase_expiry === 1234, + 'lure+showcase copied', +) +assert(out.quests.length === 2, 'two quest layers') +assert( + out.quests[0].with_ar === true && out.quests[0].quest_reward_type === 7, + 'AR layer type 7', +) +assert( + out.quests[1].with_ar === false && out.quests[1].quest_reward_type === 4, + 'non-AR layer type 4', +) +assert( + out.quests[0].quest_rewards === api.quest_rewards && + Array.isArray(out.quests[0].quest_rewards), + 'native rewards array passed through (not stringified)', +) +assert( + !('key' in out.quests[0]) && !('candy_pokemon_id' in out.quests[1]), + 'mapper does not compute key or expand info', +) +assert(out.invasions.length === 2, 'both incidents mapped') +assert( + out.invasions[0].grunt_type === 12 && + out.invasions[0].incident_expire_timestamp === 1500, + 'grunt renamed character/expiration', +) +assert( + out.invasions[1].grunt_type === 0 && out.invasions[1].display_type === 9, + 'showcase event row kept with grunt_type 0', +) + +assert(mapScanPokestop({ ...api, enabled: false }) === null, 'disabled -> null') +assert( + mapScanPokestop({ ...api, enabled: true, deleted: true }) === null, + 'deleted -> null', +) +const noAlt = mapScanPokestop({ ...api, alternative_quest_rewards: null }) +assert( + noAlt.quests.length === 1 && noAlt.quests[0].with_ar === true, + 'missing alt layer -> one quest', +) +const noType = mapScanPokestop({ + ...api, + quest_rewards: [{ info: {} }], + alternative_quest_rewards: null, +}) +assert( + noType.quests.length === 0, + 'rewards array without a type -> no quest, no throw', +) +const nonArray = mapScanPokestop({ + ...api, + quest_rewards: 'unexpected', + alternative_quest_rewards: null, +}) +assert(nonArray.quests.length === 0, 'non-array rewards -> no quest, no throw') + +console.log('\nALL GOLDEN CHECKS PASSED') +``` + +- [ ] **Step 3: Run the golden** + +Run: `node pokestop-mapper-golden.js` +Expected: every `ok:` line prints, ending with `ALL GOLDEN CHECKS PASSED`. If any `FAIL:` prints, fix the mapper and re-run. + +- [ ] **Step 4: Lint + format the mapper** + +Run: `npx eslint server/src/models/pokestopScanMapper.js && npx prettier --check server/src/models/pokestopScanMapper.js` +Expected: no eslint errors; prettier reports the file uses the correct style (if prettier reports a style diff, run `npx prettier --write server/src/models/pokestopScanMapper.js` and re-run eslint). + +- [ ] **Step 5: Delete the golden and commit** + +```bash +rm pokestop-mapper-golden.js +git add server/src/models/pokestopScanMapper.js +git commit -m "feat(pokestop): add pokestopScanMapper for Golbat scan rows" +``` + +--- + +### Task 2: `Pokestop.getAll` — `mem` branch via `/api/pokestop/scan` + +**Files:** + +- Modify: `server/src/models/Pokestop.js` (imports; ctx destructure ~188-201; insert branch before `const results = await query` at ~809; one-line `parseRdmRewards` tolerance ~2078) +- Verify: `npx eslint`/`npx prettier`, reasoning, and a **deferred LIVE golden** (see acceptance gate) + +**Interfaces:** + +- Consumes: `mapScanPokestop` from Task 1; existing in-scope vars `midnight`, `ts`, `queryLimits`, `areaRestrictions`, `onlyAreas`, `effectiveOnlyArEligible`, `effectiveQuestLayer`, `perms`, `hasMultiInvasions`, `hasConfirmed`; existing `this.secondaryFilter`. +- Produces: the same array-of-marker-objects `secondaryFilter` already returns; no signature change visible to callers (`resolvers.js` `pokestops` → `Db.query('Pokestop', 'getAll', perms, args)`). `DbManager.getDbContext` already overlays `mem`/`secret`/`httpAuth` onto each source (that's how `getAvailable` receives them), so only the destructure needs them added. + +- [ ] **Step 1: Add imports** + +In `server/src/models/Pokestop.js`, the imports currently include (line ~10) `const { log, TAGS } = require('@rm/logger')` and (line ~15) `const { fetchJson } = require('../utils/fetchJson')`. Add these three imports directly beneath the existing util imports (after line ~15): + +```js +const { + evalScannerQuery, + describeScannerResponse, +} = require('../utils/evalScannerQuery') +const { filterRTree } = require('../utils/filterRTree') +const { mapScanPokestop } = require('./pokestopScanMapper') +``` + +- [ ] **Step 2: Add `mem, secret, httpAuth` to the `getAll` ctx destructure** + +Change the `getAll` ctx destructure (lines ~191-201) from: + +```js + { + isMad, + hasAltQuests, + hasMultiInvasions, + multiInvasionMs, + hasRewardAmount, + hasLayerColumn, + hasPowerUp, + hasConfirmed, + }, +``` + +to (append the three fields): + +```js + { + isMad, + hasAltQuests, + hasMultiInvasions, + multiInvasionMs, + hasRewardAmount, + hasLayerColumn, + hasPowerUp, + hasConfirmed, + mem, + secret, + httpAuth, + }, +``` + +- [ ] **Step 3: Insert the `mem` branch before query execution** + +In `getAll`, find (line ~809): + +```js +const results = await query +``` + +Insert the following block **immediately before** that line (so on the endpoint happy path it returns before executing the SQL query; on failure it falls through to the unchanged SQL path — a dual source runs the SQL on its bound knex, a pure-endpoint source has no knex so `this.query()` already threw earlier and its result is dropped by the caller): + +```js +// Endpoint-backed source: fetch the DNF-less match-all scan and map each +// Golbat row into the mapRDM shape secondaryFilter expects. Mirrors +// Gym.getAll — the same secondaryFilter runs for both SQL and endpoint +// rows. `with_incidents:true` makes Golbat attach invasions[] (grunts + +// showcase/goldstop/kecleon event rows). On any failure/bad-shape we log +// and fall through to the SQL block below. +if (mem) { + try { + const res = await evalScannerQuery( + TAGS.pokestops, + `${mem}/api/pokestop/scan`, + JSON.stringify({ + min: { latitude: args.minLat, longitude: args.minLon }, + max: { latitude: args.maxLat, longitude: args.maxLon }, + limit: queryLimits.pokestops, + filters: [], + with_incidents: true, + }), + 'POST', + secret, + httpAuth, + ) + if (res && Array.isArray(res.pokestops)) { + const mapped = res.pokestops + .map(mapScanPokestop) + .filter( + (stop) => stop && filterRTree(stop, areaRestrictions, onlyAreas), + ) + if (mapped.length > queryLimits.pokestops) { + mapped.length = queryLimits.pokestops + } + return this.secondaryFilter( + mapped, + args.filters, + false, + ts, + midnight, + perms, + hasMultiInvasions, + hasConfirmed, + effectiveOnlyArEligible, + effectiveQuestLayer, + ) + } + log.warn( + TAGS.pokestops, + `[POKESTOP] /api/pokestop/scan gave no pokestops array — ${describeScannerResponse( + res, + )} — falling back to SQL for this source`, + ) + } catch (e) { + log.warn( + TAGS.pokestops, + `[POKESTOP] /api/pokestop/scan error — falling back to SQL for this source: ${e}`, + ) + } +} +``` + +- [ ] **Step 4: Make `parseRdmRewards` tolerant of native-object rewards** + +The mapper passes `quest_rewards` through as a native array (Golbat now returns it as native JSON). `parseRdmRewards` currently assumes a string and calls `JSON.parse`. Add a one-line tolerance so it accepts either form — the SQL/MAD path still passes a string and is unchanged; this mirrors the `typeof … === 'string' ? JSON.parse : …` pattern `secondaryFilter` already uses for `showcase_rankings`. + +Find (in `parseRdmRewards`, ~line 2078): + +```js +const rewards = JSON.parse(quest.quest_rewards) +``` + +Replace with: + +```js +const rewards = + typeof quest.quest_rewards === 'string' + ? JSON.parse(quest.quest_rewards) + : quest.quest_rewards +``` + +- [ ] **Step 5: Lint + format** + +Run: `npx eslint server/src/models/Pokestop.js && npx prettier --check server/src/models/Pokestop.js` +Expected: no eslint errors. If prettier reports a diff, run `npx prettier --write server/src/models/Pokestop.js` then re-run eslint. + +- [ ] **Step 6: Reasoning check (no runtime test possible without a live Golbat)** + +Confirm by reading the final diff: + +- `secondaryFilter` is called with `isMad=false` (the mapper only produces RDM-shaped rows). +- The mapped rows carry `quests` with `quest_reward_type` set (so `secondaryFilter`'s `if (quest.quest_reward_type ...)` gate works) and `quest_rewards` as the native array; `parseRdmRewards` (now object-tolerant, Step 4) expands the per-type `info` fields from it. `quest_rewards` is a dead wire field (not selected by any client query), so passing it through as an object is safe. +- The `filters:[]` match-all body means `secondaryFilter` applies all real filtering (as gyms/stations do). The `queryLimits.pokestops` cap mirrors the SQL path's `normalized.length = queryLimits.pokestops`. +- The area early-return `if (!getAreaSql(...)) return []` at ~284 still runs before this branch, so a no-visible-area user returns `[]` without an endpoint call. +- The SQL/MAD path is unchanged: `parseRdmRewards` still `JSON.parse`s the string it gets from the DB. + +- [ ] **Step 7: Commit** + +```bash +git add server/src/models/Pokestop.js +git commit -m "feat(pokestop): getAll via /api/pokestop/scan (match-all) with SQL fallback" +``` + +**Acceptance gate (deferred, run by the user against a deployed Golbat #385, `fort_in_memory` on, a dual pokestop source):** a LIVE golden comparing endpoint vs SQL for the same bbox — stop count, quest markers (both AR/non-AR layers, reward-type icons for xp/item/stardust/candy/pokemon/xl-candy/mega), invasion markers (grunt/leader/giovanni + confirmed lineups), showcase `events[]`, lure markers, `incident_blocker_*`, and `ar_scan_eligible` — must match. Exercise `onlyQuests`/`onlyInvasions`/`onlyLures`/`onlyEventStops`/`onlyArEligible` and a `questLayerMode` set to a single layer. This gate is the real parity proof; the mapper's node golden only covers the pure transform. + +--- + +### Task 3: `Pokestop.getOne` — `mem` branch via `/api/pokestop/id/{id}` + +**Files:** + +- Modify: `server/src/models/Pokestop.js` (`getOne`, ~2507-2515) +- Verify: `npx eslint`/`npx prettier`, reasoning + +**Interfaces:** + +- Consumes: `evalScannerQuery`, `TAGS`, `log` (imported in Task 2); the `source` object `DbManager.getOne` passes as the 2nd arg already carries `mem`/`secret`/`httpAuth`. +- Produces: same contract as today — an object with at least `lat`/`lon` (used by the `pokestopsSingle` resolver for recenter). Mirrors `Gym.getOne`. + +- [ ] **Step 1: Replace `getOne` with the `mem`-aware version** + +Change (lines ~2507-2515): + +```js + static getOne(id, { isMad }) { + return this.query() + .select([ + isMad ? 'latitude AS lat' : 'lat', + isMad ? 'longitude AS lon' : 'lon', + ]) + .where(isMad ? 'pokestop_id' : 'id', id) + .first() + } +``` + +to: + +```js + static async getOne(id, { isMad, mem, secret, httpAuth }) { + if (mem) { + try { + const res = await evalScannerQuery( + TAGS.pokestops, + `${mem}/api/pokestop/id/${id}`, + undefined, + 'GET', + secret, + httpAuth, + ) + if (res && typeof res === 'object' && 'lat' in res && 'lon' in res) { + return res + } + } catch (e) { + log.warn( + TAGS.pokestops, + `[POKESTOP] /api/pokestop/id error — falling back to SQL: ${e}`, + ) + } + } + return this.query() + .select([ + isMad ? 'latitude AS lat' : 'lat', + isMad ? 'longitude AS lon' : 'lon', + ]) + .where(isMad ? 'pokestop_id' : 'id', id) + .first() + } +``` + +- [ ] **Step 2: Lint + format** + +Run: `npx eslint server/src/models/Pokestop.js && npx prettier --check server/src/models/Pokestop.js` +Expected: no eslint errors; prettier clean (or `--write` then re-lint). + +- [ ] **Step 3: Reasoning check** + +Confirm the branch matches `Gym.getOne` exactly except for the endpoint path (`/api/pokestop/id/`) and the `TAGS.pokestops` tag: `mem` truthy → GET by id → return the object when it has `lat`/`lon`, else fall through to the unchanged SQL query. `DbManager.getOne` de-dupes across sources, so returning the whole `ApiPokestopResult` (a superset of `{lat, lon}`) is safe — downstream only reads `lat`/`lon`. + +- [ ] **Step 4: Commit** + +```bash +git add server/src/models/Pokestop.js +git commit -m "feat(pokestop): getOne via /api/pokestop/id with SQL fallback" +``` + +--- + +## Self-Review + +**Spec coverage** (against `2026-07-16-fort-scan-map-data-design.md` + the fort-consumer pattern): + +- `getAll` markers via `/api/pokestop/scan` (`with_incidents`, `res.pokestops` envelope) → Task 2. ✅ +- `getOne` via `/api/pokestop/id/{id}` (bare object) → Task 3. ✅ +- `getAvailable` — already shipped (#1227), untouched. ✅ +- Row mapper (flat `quest_*`/`alternative_quest_*` + `invasions[]` + showcase → `quests[]`/`invasions[]`/`events[]`) → Task 1, with the derive-`quest_reward_type` step; `events[]` + incident-blocker assembled by the unchanged `secondaryFilter`. ✅ +- Dual-source SQL fallback + `filterRTree` area restriction → Task 2. ✅ +- Reuse `secondaryFilter` verbatim + one-line `parseRdmRewards` object-tolerance (gym pattern) → Tasks 1-2. ✅ +- Golbat `quest_rewards`/`alternative_quest_rewards` native JSON (Golbat commit `1c86576`) so the mapper reads `quest_rewards[0].type` without `JSON.parse` → prerequisite done. ✅ + +**Placeholder scan:** no TBD/TODO; every code step carries complete code. ✅ + +**Type consistency:** `mapScanPokestop`/`buildQuestLayer`/`mapInvasion` names and the produced key set are consistent between Task 1's definition, its golden, and Task 2's consumer. `secondaryFilter` is called with the exact 10-arg signature from `Pokestop.js:816-827`. ✅ + +**Deliberate divergences documented:** (1) mapper skips a layer whose `quest_rewards` isn't a non-empty array with a `[0].type` (defensive; no throw). (2) no client-side incident-expiry filter — Golbat prunes server-side (`CollectPokestopIncidents` keeps `expiration > now`), matching how the stations slice delegated battle pruning. (3) `getAll` uses the shared `evalScannerQuery`/`describeScannerResponse` (like gyms/stations `getAll`) while `getAvailable` keeps its older `this.evalQuery` — intentional; migrating `getAvailable` is out of scope. (4) one-line `parseRdmRewards` object-tolerance is the sole shared-code edit; the SQL/MAD path still hits the string branch unchanged. diff --git a/docs/superpowers/plans/2026-07-16-reactmap-fort-consumer-stations.md b/docs/superpowers/plans/2026-07-16-reactmap-fort-consumer-stations.md new file mode 100644 index 000000000..28b0668b2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-reactmap-fort-consumer-stations.md @@ -0,0 +1,356 @@ +# ReactMap Fort Consumer — Stations (match-all) 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:** Route `Station.getAll` (Power Spot / Max-battle markers & popups) and `Station.getAvailable` (filter list) through Golbat's fort endpoints, with SQL fallback — the stations slice of the ReactMap fort consumer, mirroring the merged gyms slice. + +**Architecture:** Each method gains a `mem` branch (source has a Golbat `endpoint`) that fetches from Golbat and falls through to the existing SQL on 503/error (dual source). `getAll` sends a **match-all** scan (`filters:[]`), reads `res.stations` off the envelope, maps each `ApiStationResult` into ReactMap's station shape, and reuses `Station.js`'s existing pure post-processing helpers (`enrichStationBattle`/`finalizeStation`/`setStationBattleFields`/`matchesStationBattleFilter`/…) plus `filterRTree` for area restriction. `getAvailable` uses a small `stationAvailableMapper`. DNF narrowing is a later plan. + +**Tech Stack:** Node, Objection/Knex, `evalScannerQuery`, `filterRTree`, `@rm/logger`, ohbem (via `getSharedPvpWrapper`). **No test framework** (see Global Constraints). + +## Global Constraints + +- **No TDD / no test framework.** Verify each task with `npx eslint ` + `npx prettier --check ` (clean), a throwaway `node` golden script for the pure mapper (deleted, not committed), and reasoning against the SQL path. Do **not** author committed test files. +- **Branch:** `feat/fort-consumer` (the branch already holding the gyms slice). Reuses `server/src/utils/evalScannerQuery.js` (`evalScannerQuery`, `describeScannerResponse`) and `server/src/utils/filterRTree.js` from the gyms slice. +- **Scan endpoints return an envelope, not a bare array.** `POST /api/station/scan` → `{ stations:[], examined, skipped, total }` — read `res.stations` (per spec §9). `GET /api/station/available` → `{ battles:[{ battle_level, pokemon_id, form, count }] }`. `GET /api/station/id/{id}` → a bare `ApiStationResult`. +- **`getAll` MUST apply `filterRTree(station, perms.areaRestrictions, args.filters.onlyAreas)`** — `getAreaSql` is SQL-only; the endpoint can't know ReactMap's area polygons. +- **Reuse `Station.js`'s existing pure helpers** (`enrichStationBattle`, `estimateStationCp`, `getVisibleStationBattle`, `setStationBattleFields`, `clearStationBattleFallback`, `finalizeStation`, `matchesStationBattleFilter`, `isStationBattleActive`) — do NOT reimplement battle/CP/finalize logic. The endpoint path maps each `ApiStationResult` to `{ ...apiStation, battles: apiStation.battles.map(enrich) }` and runs the same post-processing the SQL multi-battle tail does. +- **Row mapper needed (unlike gyms):** `ApiStationResult` top level has no `battle_pokemon_stamina`/`battle_pokemon_cp_multiplier`/`battle_pokemon_estimated_cp` — those live inside `battles[]` (stamina/cp_multiplier) or are computed (estimated_cp). CP estimation needs ohbem `pokemonData` (`getSharedPvpWrapper().ensurePokemonData()`), same as the SQL path. +- **`stationed_pokemon` JSON blob is NOT needed for `getAll`** — Golbat supplies `total_stationed_gmax` pre-aggregated, so `finalizeStation`'s `JSON.parse(stationed_pokemon)` fallback never triggers for endpoint rows. +- **`getOne` is out of scope** — it's dead code (no `stationsSingle` resolver/typeDef exists; nothing calls `Db.getOne('Station', …)`). `getDynamaxMons`/`stationPokemon` also stay on SQL (separate concern; needs the `stationed_pokemon` blob). +- **`getAvailable`/`getAll` currently drop `mem/secret/httpAuth`** from their destructured ctx — add them. +- **`deDupeResults` keys by `id`, larger `updated`** — `ApiStationResult` carries both; dual DB+endpoint sources merge correctly. +- **Acceptance gate for `getAll` (mandatory):** because the station filter logic is intricate and there is no test suite, the getAll task is not "done" until a **live golden comparison** passes — station markers/popups from the endpoint match the SQL path on the same bbox (counts, active/inactive, battle detail, gmax, CP), against a Golbat deploy of #385. Document the result. +- Commitlint: lowercase commit subjects. + +--- + +### Task 1: `stations` logger tag + +**Files:** + +- Modify: `packages/logger/lib/tags.js` + +**Interfaces:** + +- Produces: `TAGS.stations` (renders `[STATIONS]`), consumed by Tasks 2–3. + +- [ ] **Step 1: Add the tag** — in `packages/logger/lib/tags.js`, next to `gyms:` (which is `gyms: chalk.hex('#9c27b0')('[GYMS]')`), add: + +```js + stations: chalk.hex('#00bcd4')('[STATIONS]'), +``` + +- [ ] **Step 2: Verify** — `npx eslint packages/logger/lib/tags.js && npx prettier --check packages/logger/lib/tags.js` + Expected: clean. Confirm `TAGS.stations` resolves (grep the file). + +- [ ] **Step 3: Commit** + +```bash +git add packages/logger/lib/tags.js +git commit -m "feat(logger): add stations tag" +``` + +--- + +### Task 2: `stationAvailableMapper.js` + `Station.getAvailable` mem branch + +**Files:** + +- Create: `server/src/models/stationAvailableMapper.js` +- Modify: `server/src/models/Station.js` (imports; `getAvailable` at `:982`) + +**Interfaces:** + +- Produces: `mapStationAvailable(api)` where `api = { battles:[{ battle_level, pokemon_id, form, count }] }` → `{ available: string[] }`. `getAvailable`'s ctx gains `mem/secret/httpAuth`. + +Reproduces the SQL `Station.getAvailable` key output: `j{battle_level}` + `{battle_pokemon_id}-{battle_pokemon_form}`. Note `/api/station/available` uses `pokemon_id`/`form` (not `battle_pokemon_id`/`battle_pokemon_form`). + +- [ ] **Step 1: Create the mapper** — `server/src/models/stationAvailableMapper.js`: + +```js +// @ts-check + +/** + * Pure mapper for Golbat's `GET /api/station/available` response. Reproduces the + * key output of the SQL `Station.getAvailable`: `j{level}` battle-tier keys and + * `-` battle-pokemon keys. Dependency-free (golden-testable + * under plain node). + * @param {{ battles?: {battle_level:number, pokemon_id:number, form:number, count:number}[] }} api + * @returns {{ available: string[] }} + */ +function mapStationAvailable(api) { + const available = new Set() + const battles = api.battles || [] + battles.forEach((b) => { + if (!b.battle_level) return + available.add(`${b.pokemon_id}-${b.form}`) + available.add(`j${b.battle_level}`) + }) + return { available: [...available] } +} + +module.exports = { mapStationAvailable } +``` + +- [ ] **Step 2: Golden check (throwaway `node`, delete after)** — Run: + +```bash +node -e ' +const { mapStationAvailable } = require("./server/src/models/stationAvailableMapper"); +const out = mapStationAvailable({ battles: [ + { battle_level: 3, pokemon_id: 150, form: 0, count: 2 }, + { battle_level: 5, pokemon_id: 384, form: 0, count: 1 }, + { battle_level: 0, pokemon_id: 1, form: 0, count: 9 }, // level 0 -> skipped +] }).available.sort(); +console.log(JSON.stringify(out)); // expect ["150-0","384-0","j3","j5"] +' +``` + +Expected printed: `["150-0","384-0","j3","j5"]` (level-0 excluded, matching SQL `!!battle_level`). + +- [ ] **Step 3: Add the mem branch** — in `server/src/models/Station.js`, add imports near the top (match existing style; `evalScannerQuery`/`describeScannerResponse` are the gyms-slice util; `log`/`TAGS` — confirm they're imported, Station.js already uses `log`/`TAGS.fetch`): + +```js +const { + evalScannerQuery, + describeScannerResponse, +} = require('../utils/evalScannerQuery') +const { mapStationAvailable } = require('./stationAvailableMapper') +``` + +Change the `getAvailable` signature and prepend the mem branch. Replace `static async getAvailable({ hasMultiBattles }) {` with: + +```js + static async getAvailable({ hasMultiBattles, mem, secret, httpAuth }) { + // Endpoint source: fetch the aggregate from Golbat; on 503/error fall + // through to the SQL below (dual source runs SQL on its bound knex; a + // pure-endpoint source's this.query() throws and is dropped upstream). + if (mem) { + try { + const res = await evalScannerQuery( + TAGS.stations, + `${mem}/api/station/available`, + undefined, + 'GET', + secret, + httpAuth, + ) + if (res && Array.isArray(res.battles)) { + const { available } = mapStationAvailable(res) + log.info( + TAGS.stations, + `[STATION] loaded available from Golbat endpoint ${mem}/api/station/available — ${available.length} filter keys (${res.battles.length} battle options)`, + ) + return { available } + } + log.warn( + TAGS.stations, + `[STATION] /api/station/available gave no battles — ${describeScannerResponse(res)} — returning empty available for this endpoint source`, + ) + } catch (e) { + log.warn( + TAGS.stations, + `[STATION] /api/station/available error — returning empty available for this endpoint source: ${e}`, + ) + } + } + /** @type {import('@rm/types').FullStation[]} */ + const ts = getEpoch() +``` + +(The rest of `getAvailable` — the two `this.query()` builders + the `return { available: [...] }` — is unchanged; the new code inserts before the existing `const ts = getEpoch()` line, which is kept. Confirm there is no `return` between the `catch` and `const ts`.) + +- [ ] **Step 4: Verify** — `npx eslint server/src/models/Station.js server/src/models/stationAvailableMapper.js && npx prettier --check server/src/models/Station.js server/src/models/stationAvailableMapper.js`. Reasoning: on `mem` success the endpoint keys equal the SQL keys (Step 2 golden); on `mem` unset / non-`{battles}` response, execution reaches the unchanged SQL path (dual source). + +- [ ] **Step 5: Commit** + +```bash +git add server/src/models/stationAvailableMapper.js server/src/models/Station.js +git commit -m "feat(station): getAvailable via /api/station/available with SQL fallback" +``` + +--- + +### Task 3: `Station.getAll` mem branch (match-all) + +**Files:** + +- Modify: `server/src/models/Station.js` (`filterRTree` import; `getAll` at `:581`) + +**Interfaces:** + +- Consumes: `evalScannerQuery`/`describeScannerResponse` (Task 2 imports), `filterRTree`, and the existing pure helpers `enrichStationBattle`/`getVisibleStationBattle`/`setStationBattleFields`/`clearStationBattleFallback`/`finalizeStation`/`matchesStationBattleFilter` (all already in `Station.js`). `getSharedPvpWrapper` (already imported for CP). `getAll`'s ctx gains `mem/secret/httpAuth`. + +The endpoint returns each station with `battles[]` embedded, so no `station_battle` grouping is needed — map, enrich battles with CP, then run the SAME per-station post-processing the SQL multi-battle tail does, with the SQL WHERE replicated as JS pre-filters. + +- [ ] **Step 1: Add the `filterRTree` import** (near the top of `Station.js`, if not present): + +```js +const { filterRTree } = require('../utils/filterRTree') +``` + +- [ ] **Step 2: Accept the endpoint context** — change the `getAll` signature. Replace `{ isMad, hasMultiBattles, hasStationedGmax, hasBattlePokemonStats }` with `{ isMad, hasMultiBattles, hasStationedGmax, hasBattlePokemonStats, mem, secret, httpAuth }`. + +- [ ] **Step 3: Insert the mem branch** immediately after the option/filter setup and BEFORE the SQL `select`/query building — specifically after `const shouldRestrictReturnedBattles = onlyMaxBattles && hasBattleConditions` and before `if (includeBattleData) {`. (`ts`, `activeCutoff`, `inactiveCutoff`, `battleFilterOptions`, `includeUpcoming`, `includeBattleData`, `shouldRestrictReturnedBattles`, `onlyAllStations`, `onlyInactiveStations`, `onlyMaxBattles`, `onlyGmaxStationed`, `hasBattleConditions`, `areaRestrictions`, `onlyAreas` are all in scope by then; `queryLimits` — confirm `queryLimits.stations` exists in `config.getSafe('api').queryLimits`; if not, use `queryLimits.gyms` as the cap or omit `limit`.) + +```js +if (mem) { + try { + // /api/station/scan returns an envelope { stations, examined, skipped, + // total } — the matching stations are on res.stations. + const res = await evalScannerQuery( + TAGS.stations, + `${mem}/api/station/scan`, + JSON.stringify({ + min: { latitude: args.minLat, longitude: args.minLon }, + max: { latitude: args.maxLat, longitude: args.maxLon }, + limit: queryLimits.stations, + filters: [], + }), + 'POST', + secret, + httpAuth, + ) + if (res && Array.isArray(res.stations)) { + // CP estimation needs ohbem base stats, same as the SQL path. + let pokemonData = null + if (perms.dynamax && includeBattleData) { + try { + pokemonData = await getSharedPvpWrapper().ensurePokemonData() + } catch (e) { + log.warn( + TAGS.fetch, + 'Unable to load ohbem basics for station CP estimation', + e, + ) + } + } + // Replicate the SQL WHERE that the endpoint (match-all) can't apply. + const passesFilterGate = (s) => { + if (onlyAllStations) return true + if (!perms.dynamax) return false + const battleMatch = + onlyMaxBattles && + hasBattleConditions && + (s.battles || []).some((b) => + matchesStationBattleFilter(b, battleFilterOptions), + ) + const gmaxMatch = + onlyGmaxStationed && Number(s.total_stationed_gmax || 0) > 0 + return battleMatch || gmaxMatch + } + const passesTimeGate = (s) => { + const active = + Number(s.end_time) > ts && Number(s.updated) > activeCutoff + if (onlyInactiveStations) { + const inactive = + Number(s.end_time) <= ts && Number(s.updated) > inactiveCutoff + return (active && passesFilterGate(s)) || inactive + } + return active && passesFilterGate(s) + } + const stations = res.stations + .filter( + (s) => + passesTimeGate(s) && filterRTree(s, areaRestrictions, onlyAreas), + ) + .map((apiStation) => { + const station = { + ...apiStation, + battles: includeBattleData + ? (apiStation.battles || []).map((b) => + enrichStationBattle(b, pokemonData), + ) + : [], + } + // Mirror the SQL multi-battle tail (Station.js grouped-values map): + if (Number(station.end_time) <= ts) { + station.battles = [] + clearStationBattleFallback(station) + return finalizeStation(station, pokemonData, ts) + } + if (!includeUpcoming) { + const visible = getVisibleStationBattle(station.battles, ts) + station.battles = visible ? [visible] : [] + } + const hasMatchingReturnedBattle = station.battles.some((b) => + matchesStationBattleFilter(b, battleFilterOptions), + ) + if ( + !onlyAllStations && + shouldRestrictReturnedBattles && + !hasMatchingReturnedBattle && + !onlyGmaxStationed + ) { + return null + } + setStationBattleFields( + station, + getVisibleStationBattle(station.battles, ts), + ) + return finalizeStation(station, pokemonData, ts) + }) + .filter(Boolean) + return stations + } + log.warn( + TAGS.stations, + `[STATION] /api/station/scan gave no stations array — ${describeScannerResponse(res)} — falling back to SQL for this source`, + ) + } catch (e) { + log.warn( + TAGS.stations, + `[STATION] /api/station/scan error — falling back to SQL for this source: ${e}`, + ) + } +} +``` + +On endpoint failure the `try` falls through to the unchanged SQL query building + await + post-processing below (dual source runs it; a pure-endpoint source's `this.query()` chain has no knex and its promise is dropped by `runScannerSources`). + +- [ ] **Step 4: Verify — lint + reasoning** — `npx eslint server/src/models/Station.js && npx prettier --check server/src/models/Station.js`. Reasoning against the SQL path, field by field: + + - Time gate = SQL `end_time > ts && updated > activeCutoff` (default) / the two-branch active-OR-inactive when `onlyInactiveStations`. + - `passesFilterGate` = SQL `applyStationFilters`: `onlyAllStations` → all; else `(onlyMaxBattles && hasBattleConditions && battle-match) || (onlyGmaxStationed && gmax>0)`. + - The `.map(...)` mirrors the SQL multi-battle tail verbatim (same `end_time<=ts` clear, `!includeUpcoming` visible-battle narrowing, `shouldRestrictReturnedBattles` drop, `setStationBattleFields` + `finalizeStation`). + - CP: `battles[]` carries `battle_pokemon_stamina`/`battle_pokemon_cp_multiplier`; `enrichStationBattle` computes `battle_pokemon_estimated_cp` per battle; `setStationBattleFields` mirrors the visible battle (incl. stamina/cp_multiplier/estimated_cp) to top level — matching SQL. + - Area via `filterRTree`; `deDupeResults` sees `id`+`updated`. + Expected: lint clean. + +- [ ] **Step 5: Acceptance gate — LIVE golden comparison (mandatory).** Against a Golbat deploy of `feat/fort-scan-map-data` (#385) with `fort_in_memory = true` and a dual station source configured, in a scanned area with active Power Spots/Max battles: + + 1. Endpoint on: load the map, capture the rendered stations (count + a few popups' battle detail/CP/gmax). + 2. Restart Golbat with `fort_in_memory = false` (ReactMap falls back to SQL): reload, capture the same. + 3. Confirm they match — station count, active vs inactive, per-battle pokemon/level/CP, `total_stationed_gmax`, `is_battle_available`. Also exercise the filters: `onlyMaxBattles` on a specific tier (`j5`) and a specific battle pokemon, `onlyGmaxStationed`, and `onlyInactiveStations`. + Record the comparison result in the task report. Any divergence is a defect to fix before marking Task 3 complete. (Rationale: the JS filter replication is the highest-risk part and the repo has no test suite.) + +- [ ] **Step 6: Commit** + +```bash +git add server/src/models/Station.js +git commit -m "feat(station): getAll via /api/station/scan (match-all) with filterRTree + SQL fallback" +``` + +--- + +## Self-Review + +**Spec coverage** (design spec §8-§9/§11, stations slice): `Station.getAvailable` → Task 2 (+ mapper); `Station.getAll` (match-all) → Task 3; envelope `res.stations` (§9 table) → Task 3; `filterRTree` area (D7) → Task 3; dual-source fallback → Tasks 2–3; `res.battles → j/-` → Task 2 mapper. `getOne` (dead code) + `getDynamaxMons` (needs the JSON blob) explicitly out of scope. DNF is a later plan. + +**Placeholder scan:** every code step has complete code; the only manual verification (Task 3 Step 5) is an explicitly-required live golden comparison, not a faked test — correct for the no-test-framework constraint, and load-bearing given the filter-replication risk. + +**Type/name consistency:** `mapStationAvailable(api) → {available}` (Task 2) is standalone; `TAGS.stations` (Task 1) used in Tasks 2–3; the pure helpers (`enrichStationBattle`/`finalizeStation`/`setStationBattleFields`/`matchesStationBattleFilter`/`getVisibleStationBattle`/`clearStationBattleFallback`) and `getSharedPvpWrapper` are existing `Station.js` module functions — the endpoint branch calls them by their existing names. + +**Open items for the implementer to confirm (verify, not gaps):** + +1. `TAGS.stations` vs `TAGS.station` — use the key added in Task 1, consistently. +2. `queryLimits.stations` exists in `config.getSafe('api').queryLimits`; if not, fall back to `queryLimits.gyms` or omit `limit`. +3. The mem-branch insertion point in `getAll` sees all the referenced locals in scope (they're computed above `if (includeBattleData) {`). +4. `log`/`TAGS` are already imported in `Station.js` (it uses `TAGS.fetch`); do not duplicate. + +## Follow-on plans (same branch/PR) + +- **Pokestops (match-all):** `Pokestop.getAll`/`getOne` — needs `with_incidents:true` in the scan body + a pokestop row mapper (quest/lure/invasion/showcase sub-objects); reads `res.pokestops`. +- **DNF (all three):** a fort filter `Backend.buildApiFilter()` mirroring `PkmnBackend` — replaces `filters:[]` with translated `ApiFortDnfFilter[]` (the payoff phase). +- **Station `getDynamaxMons`/`stationPokemon` + `getOne`:** deferred — needs the `stationed_pokemon` blob and (for getOne) a `stationsSingle` resolver that doesn't exist yet. From e8f26ecf61261a06a30d9f5e9a306ff6f067b970 Mon Sep 17 00:00:00 2001 From: James Berry Date: Fri, 17 Jul 2026 16:06:32 +0100 Subject: [PATCH 09/38] fix(dnf): raid tier override, event-key gating, endpoint quest layer, badge poison Review findings: - tier-override mode matches raid_level alone (curated boss/egg keys under-returned tier raids on endpoint sources) - b keys move to the onlyEventStops gate (secondaryFilter's events branch consumes them, not the invasions branch) - endpoint rows resolve the quest layer as dual-capable (pure-endpoint ctx flags made effectiveQuestLayer resolve to 'both') - poison on onlyGymBadges only: onlyBadge defaults to 'all' for every user with the gymBadges perm and was silently disabling gym DNF entirely Co-Authored-By: Claude Fable 5 --- server/src/filters/fort/gym.js | 84 +++++++++++++++-------------- server/src/filters/fort/pokestop.js | 7 ++- server/src/models/Pokestop.js | 11 +++- 3 files changed, 60 insertions(+), 42 deletions(-) diff --git a/server/src/filters/fort/gym.js b/server/src/filters/fort/gym.js index 0c4abbef7..f3dc68302 100644 --- a/server/src/filters/fort/gym.js +++ b/server/src/filters/fort/gym.js @@ -4,13 +4,21 @@ * Translate a gym's `args.filters` into Golbat ApiFortDnfFilter[] clauses, * gated on the layer toggles exactly like Gym.getAll's secondaryFilter: * - * - Raid filters (egg tier `e`, raid boss `-`) only narrow when the - * raid layer (`onlyRaids`) is on; otherwise raids never show, so emitting a - * raid clause would over-fetch forts secondaryFilter then drops. + * - Raid filters only narrow when the raid layer (`onlyRaids`) is on. In + * tier-override mode (`onlyRaidTier !== 'all'`) secondaryFilter accepts + * EVERY raid/egg of that level regardless of which boss/egg keys are + * enabled (`onlyRaidTier === gym.raid_level && (isRaid || isEgg)`), so the + * clause matches on level alone — deriving it from the enabled keys would + * under-return curated-key users. In 'all' mode the enabled egg (`e`) and + * boss (`-`) keys drive the clauses. * - The gym/team layer's shown gyms are always covered by either the match-all * poison (`onlyAllGyms`/`onlyExEligible`/`onlyInBattle`/badges) or the * `is_ar_scan_eligible` clause (`onlyArEligible`), so team/slot (`t`/`g`) * filters need no clause of their own — one would only enlarge the fetch. + * - Badge viewing (`onlyGymBadges`) poisons: badge gyms surface via a + * ReactMap-local badge join Golbat can't know about. `onlyBadge` alone is + * NOT a poison — it defaults to 'all' for every user with the gymBadges + * perm, and badges only surface when `onlyGymBadges` is on. * * Gender and power-up stay residual. Returns [] (match-all) when an * unexpressible category is active or nothing narrowable is on. @@ -26,47 +34,45 @@ function buildGymDnfFilters(filters) { onlyInBattle, onlyArEligible, onlyGymBadges, - onlyBadge, onlyRaids, + onlyRaidTier, } = filters - // Poison: badge gyms (ReactMap-local join) and the show-all/ex/in-battle - // toggles have no DNF expression -> fetch all. - if ( - onlyAllGyms || - onlyExEligible || - onlyInBattle || - onlyGymBadges || - onlyBadge - ) - return [] + // Poison: these categories have no DNF expression -> fetch all. + if (onlyAllGyms || onlyExEligible || onlyInBattle || onlyGymBadges) return [] const clauses = [] if (onlyRaids) { - const eggs = [] - const raidBosses = [] - Object.entries(filters).forEach(([key]) => { - if (typeof key !== 'string' || key.length === 0) return - if (key.charAt(0) === 'e') { - const tier = Number(key.slice(1)) - if (Number.isFinite(tier)) eggs.push(tier) - } else if (/^\d/.test(key)) { - // raid boss "-" (default case in Gym.getAll); gender residual - const [idPart, formPart] = key.split('-', 2) - const id = Number(idPart) - if (!Number.isFinite(id)) return - const pair = { pokemon_id: id } - if ( - formPart && - formPart !== 'null' && - Number.isFinite(Number(formPart)) - ) - pair.form = Number(formPart) - raidBosses.push(pair) - } - }) - // Golbat's tag is `raid_pokemon_id` (unlike other types' `*_pokemon`). - if (raidBosses.length) clauses.push({ raid_pokemon_id: raidBosses }) - if (eggs.length) clauses.push({ raid_level: eggs }) + const tierOverride = + onlyRaidTier && onlyRaidTier !== 'all' ? Number(onlyRaidTier) : NaN + if (Number.isFinite(tierOverride)) { + clauses.push({ raid_level: [tierOverride] }) + } else { + const eggs = [] + const raidBosses = [] + Object.entries(filters).forEach(([key]) => { + if (typeof key !== 'string' || key.length === 0) return + if (key.charAt(0) === 'e') { + const tier = Number(key.slice(1)) + if (Number.isFinite(tier)) eggs.push(tier) + } else if (/^\d/.test(key)) { + // raid boss "-" (default case in Gym.getAll); gender residual + const [idPart, formPart] = key.split('-', 2) + const id = Number(idPart) + if (!Number.isFinite(id)) return + const pair = { pokemon_id: id } + if ( + formPart && + formPart !== 'null' && + Number.isFinite(Number(formPart)) + ) + pair.form = Number(formPart) + raidBosses.push(pair) + } + }) + // Golbat's tag is `raid_pokemon_id` (unlike other types' `*_pokemon`). + if (raidBosses.length) clauses.push({ raid_pokemon_id: raidBosses }) + if (eggs.length) clauses.push({ raid_level: eggs }) + } } if (onlyArEligible) clauses.push({ is_ar_scan_eligible: true }) diff --git a/server/src/filters/fort/pokestop.js b/server/src/filters/fort/pokestop.js index 561e1fa47..c628204bc 100644 --- a/server/src/filters/fort/pokestop.js +++ b/server/src/filters/fort/pokestop.js @@ -284,10 +284,13 @@ function buildPokestopDnfFilters(filters, eventInvasions) { } if (incidentCharacter.size) clauses.push({ incident_character: [...incidentCharacter] }) - if (incidentDisplayType.length) - clauses.push({ incident_display_type: incidentDisplayType }) } if (onlyEventStops) { + // `b` keys (goldstop/kecleon/showcase incidents) are consumed + // by secondaryFilter's EVENTS branch (gated on onlyEventStops), not the + // invasions branch — grunt-less incidents never match invasionMatchesFilters. + if (incidentDisplayType.length) + clauses.push({ incident_display_type: incidentDisplayType }) if (contestPokemon.length) clauses.push({ contest_pokemon: contestPokemon }) if (contestPokemonType.length) clauses.push({ contest_pokemon_type: contestPokemonType }) diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 30f3c48b8..e8d63ce8f 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -829,6 +829,15 @@ class Pokestop extends Model { if (mem) { try { const dnf = buildPokestopDnfFilters(args.filters, state.event.invasions) + // Endpoint rows always carry BOTH quest layers, so resolve the layer + // selection as dual-capable (mirrors getAvailable's override). The SQL + // ctx flags are undefined for a pure-endpoint source, which would make + // effectiveQuestLayer resolve to 'both' even when questLayerMode + // restricts to a single layer. + const memQuestLayer = resolveQuestLayerSelection( + args.filters.onlyShowQuestSet, + { hasAltQuests: true }, + ) const res = await evalScannerQuery( TAGS.pokestops, `${mem}/api/pokestop/scan`, @@ -888,7 +897,7 @@ class Pokestop extends Model { hasMultiInvasions, hasConfirmed, effectiveOnlyArEligible, - effectiveQuestLayer, + memQuestLayer, ) log.info( TAGS.pokestops, From 7984d01ba4e258a47c5aab5766824726e135056e Mon Sep 17 00:00:00 2001 From: James Berry Date: Fri, 17 Jul 2026 16:24:41 +0100 Subject: [PATCH 10/38] feat(gym): team/slot dnf clauses for the gym layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the all-gyms/ex/in-battle poison with real narrowing: every gym-layer display requires the team/slot match (hasGym = enabler && (team || slot)), so team_id/available_slots clauses mirroring finalTeams/finalSlots are a tight superset for all four enablers — ex/ar/in-battle narrowing stays residual. The standalone is_ar_scan_eligible clause is subsumed (ar-shown gyms also need the team match). Badge viewing still poisons. Power-up narrowing is gone for good — power-ups are no longer in the game. Co-Authored-By: Claude Fable 5 --- server/src/filters/fort/gym.js | 94 ++++++++++++++++++++++++++++------ server/src/models/Gym.js | 2 +- 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/server/src/filters/fort/gym.js b/server/src/filters/fort/gym.js index f3dc68302..dd954e04b 100644 --- a/server/src/filters/fort/gym.js +++ b/server/src/filters/fort/gym.js @@ -1,32 +1,90 @@ // @ts-check +/** + * Team/slot clauses for the gym layer, mirroring Gym.getAll's + * finalTeams/finalSlots derivation exactly: + * - only teams with a `t-0` key participate (g-keys without a t-key do + * nothing, as in the model) + * - `all: true` on the t-key, team 0 (uncontested), or every slot enabled via + * g-keys -> the whole team (`team_id` list) + * - otherwise one clause per enabled slot VALUE (`g-` encodes + * available_slots = slotCount - idx), grouped across teams + * - `all: false` with no g-keys -> the team shows nothing (no clause) + * + * secondaryFilter requires the team/slot match for EVERY gym-layer display + * (`hasGym = (onlyAllGyms || ex || ar || inBattle) && (team || slot match)`), + * so these clauses are a correct superset for all of those modes — the + * ex/ar/in-battle halves stay residual. + * + * @param {Record} filters + * @param {number} slotCount baseGymSlotAmounts.length + * @returns {object[]} + */ +function buildGymTeamClauses(filters, slotCount) { + const fullTeams = [] + /** @type {Map} slot value -> teams */ + const bySlot = new Map() + Object.keys(filters).forEach((key) => { + if (typeof key !== 'string' || key.charAt(0) !== 't') return + const teamStr = key.slice(1).split('-')[0] + const team = Number(teamStr) + if (!Number.isFinite(team)) return + const all = filters[`t${teamStr}-0`]?.all + if (all || team === 0) { + fullTeams.push(team) + return + } + const slotVals = [] + Object.keys(filters).forEach((gk) => { + if (gk.charAt(0) !== 'g') return + const [gTeam, gIdx] = gk.slice(1).split('-') + if (gTeam !== teamStr) return + const v = slotCount - Number(gIdx) + if (Number.isFinite(v)) slotVals.push(v) + }) + if (slotVals.length >= slotCount) { + fullTeams.push(team) + return + } + slotVals.forEach((v) => { + if (!bySlot.has(v)) bySlot.set(v, []) + bySlot.get(v).push(team) + }) + }) + const clauses = [] + if (fullTeams.length) clauses.push({ team_id: fullTeams }) + bySlot.forEach((teams, v) => + clauses.push({ team_id: teams, available_slots: { min: v, max: v } }), + ) + return clauses +} + /** * Translate a gym's `args.filters` into Golbat ApiFortDnfFilter[] clauses, * gated on the layer toggles exactly like Gym.getAll's secondaryFilter: * - * - Raid filters only narrow when the raid layer (`onlyRaids`) is on. In - * tier-override mode (`onlyRaidTier !== 'all'`) secondaryFilter accepts - * EVERY raid/egg of that level regardless of which boss/egg keys are - * enabled (`onlyRaidTier === gym.raid_level && (isRaid || isEgg)`), so the - * clause matches on level alone — deriving it from the enabled keys would - * under-return curated-key users. In 'all' mode the enabled egg (`e`) and - * boss (`-`) keys drive the clauses. - * - The gym/team layer's shown gyms are always covered by either the match-all - * poison (`onlyAllGyms`/`onlyExEligible`/`onlyInBattle`/badges) or the - * `is_ar_scan_eligible` clause (`onlyArEligible`), so team/slot (`t`/`g`) - * filters need no clause of their own — one would only enlarge the fetch. + * - Raid layer (`onlyRaids`): tier-override mode (`onlyRaidTier !== 'all'`) + * matches on raid_level alone — secondaryFilter accepts EVERY raid/egg of + * that level regardless of the enabled boss/egg keys, so deriving from the + * keys would under-return. In 'all' mode the enabled egg (`e`) and boss + * (`-`) keys drive the clauses. + * - Gym layer (`onlyAllGyms`/`onlyExEligible`/`onlyInBattle`/`onlyArEligible`): + * team/slot clauses (see buildGymTeamClauses) — every gym-layer display + * requires the team/slot match, so they are a tight superset for all four + * enablers; ex/ar/in-battle narrowing stays residual. * - Badge viewing (`onlyGymBadges`) poisons: badge gyms surface via a * ReactMap-local badge join Golbat can't know about. `onlyBadge` alone is * NOT a poison — it defaults to 'all' for every user with the gymBadges * perm, and badges only surface when `onlyGymBadges` is on. * - * Gender and power-up stay residual. Returns [] (match-all) when an - * unexpressible category is active or nothing narrowable is on. + * Gender stays residual; power-ups are no longer in the game (no clause). + * Returns [] (match-all) when nothing narrowable is on. * * @param {Record} filters args.filters + * @param {number} [slotCount] baseGymSlotAmounts.length (open-slot base) * @returns {object[]} */ -function buildGymDnfFilters(filters) { +function buildGymDnfFilters(filters, slotCount = 6) { if (!filters || typeof filters !== 'object') return [] const { onlyAllGyms, @@ -37,8 +95,8 @@ function buildGymDnfFilters(filters) { onlyRaids, onlyRaidTier, } = filters - // Poison: these categories have no DNF expression -> fetch all. - if (onlyAllGyms || onlyExEligible || onlyInBattle || onlyGymBadges) return [] + // Poison: badge gyms come from a ReactMap-local join -> fetch all. + if (onlyGymBadges) return [] const clauses = [] if (onlyRaids) { @@ -74,7 +132,9 @@ function buildGymDnfFilters(filters) { if (eggs.length) clauses.push({ raid_level: eggs }) } } - if (onlyArEligible) clauses.push({ is_ar_scan_eligible: true }) + if (onlyAllGyms || onlyExEligible || onlyInBattle || onlyArEligible) { + clauses.push(...buildGymTeamClauses(filters, slotCount)) + } return clauses.length ? clauses : [] } diff --git a/server/src/models/Gym.js b/server/src/models/Gym.js index 2cf9d68ef..fbea45941 100644 --- a/server/src/models/Gym.js +++ b/server/src/models/Gym.js @@ -530,7 +530,7 @@ class Gym extends Model { try { // /api/gym/scan returns an envelope { gyms, examined, skipped, total }, // not a bare array — the matching gyms are on res.gyms. - const dnf = buildGymDnfFilters(args.filters) + const dnf = buildGymDnfFilters(args.filters, baseGymSlotAmounts.length) const res = await evalScannerQuery( TAGS.gyms, `${mem}/api/gym/scan`, From c10782820a0a1b08135a321c71b0e18b055cdaaa Mon Sep 17 00:00:00 2001 From: James Berry Date: Fri, 17 Jul 2026 18:04:01 +0100 Subject: [PATCH 11/38] feat(server): combined fort availability + setAvailable single-flight/ttl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Availability refresh fired a full Golbat fort-cache walk per fort type per trigger, and queryOnSessionInit triggers on EVERY page load — observed as duplicate ~110ms available-pokestops builds 200ms apart and gym rebuilds every 30-100s on a large instance. Two layers of fix: - EventManager.setAvailable gains single-flight (concurrent triggers share one refresh) and a TTL (api.availableRefreshSeconds, default 60): repeat session-init triggers within the window reuse the last result. Map markers never depend on availability — only the filter drawer's option list — so staleness is bounded and cosmetic. Scheduled intervals and the explicit /api/v1/available route force-refresh. - The three fort models' getAvailable share one GET /api/fort/available per endpoint per 30s window (Golbat builds all three sections in a single cache pass), falling back to the per-type endpoints when the combined one is unavailable. A refresh batch now costs one walk instead of three. Co-Authored-By: Claude Fable 5 --- config/default.json | 1 + server/src/models/Gym.js | 20 +++++++-- server/src/models/Pokestop.js | 18 ++++++-- server/src/models/Station.js | 18 ++++++-- server/src/routes/api/v1/available.js | 14 +++---- server/src/services/EventManager.js | 47 +++++++++++++++++---- server/src/utils/fortAvailable.js | 59 +++++++++++++++++++++++++++ 7 files changed, 151 insertions(+), 26 deletions(-) create mode 100644 server/src/utils/fortAvailable.js diff --git a/config/default.json b/config/default.json index 2740f1968..950563161 100644 --- a/config/default.json +++ b/config/default.json @@ -59,6 +59,7 @@ "historicalRarity": 6, "stations": 0.05 }, + "availableRefreshSeconds": 60, "queryOnSessionInit": { "pokemon": false, "quests": false, diff --git a/server/src/models/Gym.js b/server/src/models/Gym.js index fbea45941..e3fc6c352 100644 --- a/server/src/models/Gym.js +++ b/server/src/models/Gym.js @@ -20,6 +20,7 @@ const { describeScannerResponse, } = require('../utils/evalScannerQuery') const { filterRTree } = require('../utils/filterRTree') +const { getCombinedFortAvailable } = require('../utils/fortAvailable') const { buildGymDnfFilters } = require('../filters/fort/gym') const { describeDnfNarrowing } = require('../filters/fort/describeDnfNarrowing') const { mapGymAvailable } = require('./gymAvailableMapper') @@ -624,14 +625,25 @@ class Gym extends Model { // pure-endpoint source's this.query() throws and is dropped upstream). if (mem) { try { - const res = await evalScannerQuery( + // One combined /api/fort/available per endpoint serves all three fort + // models' refresh batch; falls back to the per-type endpoint when the + // combined one is unavailable (older Golbat). + const combined = await getCombinedFortAvailable( TAGS.gyms, - `${mem}/api/gym/available`, - undefined, - 'GET', + mem, secret, httpAuth, ) + const res = + combined?.gyms ?? + (await evalScannerQuery( + TAGS.gyms, + `${mem}/api/gym/available`, + undefined, + 'GET', + secret, + httpAuth, + )) if (res && Array.isArray(res.teams) && Array.isArray(res.raids)) { const { available } = mapGymAvailable(res) log.info( diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index e8d63ce8f..028f3d3a3 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -22,6 +22,7 @@ const { } = require('../utils/evalScannerQuery') const { filterRTree } = require('../utils/filterRTree') const { mapScanPokestop } = require('./pokestopScanMapper') +const { getCombinedFortAvailable } = require('../utils/fortAvailable') const { buildPokestopDnfFilters } = require('../filters/fort/pokestop') const { describeDnfNarrowing } = require('../filters/fort/describeDnfNarrowing') const { state } = require('../services/state') @@ -1508,13 +1509,22 @@ class Pokestop extends Model { // block also serves mem:'' (DB / MAD) sources directly. if (mem) { try { - const res = await this.evalQuery( - `${mem}/api/pokestop/available`, - undefined, - 'GET', + // Combined-first, per-type fallback — see Gym.getAvailable. + const combined = await getCombinedFortAvailable( + TAGS.pokestops, + mem, secret, httpAuth, ) + const res = + combined?.pokestops ?? + (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 diff --git a/server/src/models/Station.js b/server/src/models/Station.js index 8f8c913be..59cf61be8 100644 --- a/server/src/models/Station.js +++ b/server/src/models/Station.js @@ -18,6 +18,7 @@ const { describeScannerResponse, } = require('../utils/evalScannerQuery') const { filterRTree } = require('../utils/filterRTree') +const { getCombinedFortAvailable } = require('../utils/fortAvailable') const { buildStationDnfFilters } = require('../filters/fort/station') const { describeDnfNarrowing } = require('../filters/fort/describeDnfNarrowing') const { mapStationAvailable } = require('./stationAvailableMapper') @@ -1157,14 +1158,23 @@ class Station extends Model { // pure-endpoint source's this.query() throws and is dropped upstream). if (mem) { try { - const res = await evalScannerQuery( + // Combined-first, per-type fallback — see Gym.getAvailable. + const combined = await getCombinedFortAvailable( TAGS.stations, - `${mem}/api/station/available`, - undefined, - 'GET', + mem, secret, httpAuth, ) + const res = + combined?.stations ?? + (await evalScannerQuery( + TAGS.stations, + `${mem}/api/station/available`, + undefined, + 'GET', + secret, + httpAuth, + )) if (res && Array.isArray(res.battles)) { const { available } = mapStationAvailable(res) log.info( diff --git a/server/src/routes/api/v1/available.js b/server/src/routes/api/v1/available.js index c0cb7298f..7983e65e4 100644 --- a/server/src/routes/api/v1/available.js +++ b/server/src/routes/api/v1/available.js @@ -116,15 +116,15 @@ router.put('/:category', async (req, res) => { queryObj[resolveCategory(req.params.category)] || {} if (model && category) { - await state.event.setAvailable(category, model, state.db) + await state.event.setAvailable(category, model, state.db, true) } else { await Promise.all([ - state.event.setAvailable('pokemon', 'Pokemon', state.db), - state.event.setAvailable('pokestops', 'Pokestop', state.db), - state.event.setAvailable('gyms', 'Gym', state.db), - state.event.setAvailable('nests', 'Nest', state.db), - state.event.setAvailable('stations', 'Station', state.db), - state.event.setAvailable('tappables', 'Tappable', state.db), + state.event.setAvailable('pokemon', 'Pokemon', state.db, true), + state.event.setAvailable('pokestops', 'Pokestop', state.db, true), + state.event.setAvailable('gyms', 'Gym', state.db, true), + state.event.setAvailable('nests', 'Nest', state.db, true), + state.event.setAvailable('stations', 'Station', state.db, true), + state.event.setAvailable('tappables', 'Tappable', state.db, true), ]) } res diff --git a/server/src/services/EventManager.js b/server/src/services/EventManager.js index 356766ceb..a7a2b1bc6 100644 --- a/server/src/services/EventManager.js +++ b/server/src/services/EventManager.js @@ -41,6 +41,11 @@ class EventManager extends Logger { /** @type {Record void>} */ this.intervals = {} + /** @type {Record | undefined>} in-flight setAvailable per category */ + this.availablePending = {} + /** @type {Record} last successful setAvailable per category */ + this.availableUpdatedAt = {} + this.baseUrl = 'https://raw.githubusercontent.com/WatWowMap/wwm-uicons-webp/main' @@ -92,7 +97,34 @@ class EventManager extends Logger { * @param {import('../models').ScannerModelKeys} model * @param {import('./DbManager').DbManager} Db */ - async setAvailable(category, model, Db) { + async setAvailable(category, model, Db, force = false) { + // Single-flight + TTL: session-init triggers (queryOnSessionInit) fire on + // EVERY page load and can stampede — on endpoint-backed sources each + // refresh walks Golbat's whole fort cache. Concurrent calls share one + // in-flight promise; repeats within the TTL are served by the last result + // (map markers never depend on this — only the filter drawer options — + // so the staleness cost is bounded and cosmetic). Scheduled intervals and + // the explicit /api/v1/available route pass force=true. + if (this.availablePending[category]) return this.availablePending[category] + const ttlMs = (config.getSafe('api.availableRefreshSeconds') || 60) * 1000 + if ( + !force && + this.availableUpdatedAt[category] && + Date.now() - this.availableUpdatedAt[category] < ttlMs + ) { + return undefined + } + this.availablePending[category] = this.#refreshAvailable( + category, + model, + Db, + ).finally(() => { + delete this.availablePending[category] + }) + return this.availablePending[category] + } + + async #refreshAvailable(category, model, Db) { this.available[category] = await Db.getAvailable(model) /** @param {string} key */ @@ -131,6 +163,7 @@ class EventManager extends Logger { return 0 }) this.addAvailable(category) + this.availableUpdatedAt[category] = Date.now() } /** @@ -193,7 +226,7 @@ class EventManager extends Logger { if (!config.getSafe('api.queryOnSessionInit.raids')) { this.intervals.raidUpdate = setLongInterval( async () => { - await this.setAvailable('gyms', 'Gym', Db) + await this.setAvailable('gyms', 'Gym', Db, true) await this.chatLog('event', { description: 'Refreshed available raids', }) @@ -204,7 +237,7 @@ class EventManager extends Logger { if (!config.getSafe('api.queryOnSessionInit.nests')) { this.intervals.nestUpdate = setLongInterval( async () => { - await this.setAvailable('nests', 'Nest', Db) + await this.setAvailable('nests', 'Nest', Db, true) await this.chatLog('event', { description: 'Refreshed available nests', }) @@ -215,7 +248,7 @@ class EventManager extends Logger { if (!config.getSafe('api.queryOnSessionInit.pokemon')) { this.intervals.pokemonUpdate = setLongInterval( async () => { - await this.setAvailable('pokemon', 'Pokemon', Db) + await this.setAvailable('pokemon', 'Pokemon', Db, true) await this.chatLog('event', { description: 'Refreshed available pokemon', }) @@ -226,7 +259,7 @@ class EventManager extends Logger { if (!config.getSafe('api.queryOnSessionInit.quests')) { this.intervals.questUpdate = setLongInterval( async () => { - await this.setAvailable('pokestops', 'Pokestop', Db) + await this.setAvailable('pokestops', 'Pokestop', Db, true) await this.chatLog('event', { description: 'Refreshed available quests & invasions', }) @@ -237,7 +270,7 @@ class EventManager extends Logger { if (!config.getSafe('api.queryOnSessionInit.stations')) { this.intervals.stationUpdate = setLongInterval( async () => { - await this.setAvailable('stations', 'Station', Db) + await this.setAvailable('stations', 'Station', Db, true) await this.chatLog('event', { description: 'Refreshed available stations', }) @@ -388,7 +421,7 @@ class EventManager extends Logger { // Update available rocket Pokemon whenever invasions are refreshed if (Db) { - await this.setAvailable('pokestops', 'Pokestop', Db) + await this.setAvailable('pokestops', 'Pokestop', Db, true) } } } catch (e) { diff --git a/server/src/utils/fortAvailable.js b/server/src/utils/fortAvailable.js new file mode 100644 index 000000000..a0a38bde1 --- /dev/null +++ b/server/src/utils/fortAvailable.js @@ -0,0 +1,59 @@ +// @ts-check +const { evalScannerQuery } = require('./evalScannerQuery') + +/** + * The three fort models refresh availability together (session-init and the + * scheduled intervals fire them as a batch), and on Golbat each per-type + * /available call walks the ENTIRE fort cache. Share one combined + * GET /api/fort/available per endpoint within a short window so a refresh + * batch costs one cache pass instead of three. + */ +const CACHE_MS = 30_000 + +/** @type {Map }>} */ +const combinedCache = new Map() + +/** + * Fetches the combined fort availability, deduped per endpoint per window. + * Resolves null when the combined endpoint is unavailable (older Golbat or + * fort_in_memory off) — callers fall back to their per-type endpoint. The + * null is cached for the same window so an old Golbat isn't hammered. + * + * @param {import('@rm/logger').Tag} tag + * @param {string} mem endpoint base url + * @param {string} [secret] + * @param {{ username: string, password: string } | null} [httpAuth] + * @returns {Promise<{ pokestops: object, gyms: object, stations: object } | null>} + */ +function getCombinedFortAvailable(tag, mem, secret, httpAuth) { + const entry = combinedCache.get(mem) + if (entry && Date.now() - entry.ts < CACHE_MS) return entry.promise + const promise = (async () => { + try { + const res = await evalScannerQuery( + tag, + `${mem}/api/fort/available`, + undefined, + 'GET', + secret, + httpAuth, + ) + if ( + res && + typeof res === 'object' && + res.pokestops && + res.gyms && + res.stations + ) { + return res + } + } catch { + // unavailable -> per-type fallback + } + return null + })() + combinedCache.set(mem, { ts: Date.now(), promise }) + return promise +} + +module.exports = { getCombinedFortAvailable } From 8cddc380e99496e73eef3bb0bc26016109972a52 Mon Sep 17 00:00:00 2001 From: James Berry Date: Fri, 17 Jul 2026 19:29:55 +0100 Subject: [PATCH 12/38] chore(fort): log combined-available outcome (diagnostic) --- server/src/utils/fortAvailable.js | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/server/src/utils/fortAvailable.js b/server/src/utils/fortAvailable.js index a0a38bde1..af02a3643 100644 --- a/server/src/utils/fortAvailable.js +++ b/server/src/utils/fortAvailable.js @@ -1,5 +1,9 @@ // @ts-check -const { evalScannerQuery } = require('./evalScannerQuery') +const { log, TAGS } = require('@rm/logger') +const { + evalScannerQuery, + describeScannerResponse, +} = require('./evalScannerQuery') /** * The three fort models refresh availability together (session-init and the @@ -45,10 +49,21 @@ function getCombinedFortAvailable(tag, mem, secret, httpAuth) { res.gyms && res.stations ) { + log.info( + TAGS.gyms, + `[FORT] combined ${mem}/api/fort/available OK — one pass for all three types`, + ) return res } - } catch { - // unavailable -> per-type fallback + log.warn( + TAGS.gyms, + `[FORT] combined ${mem}/api/fort/available unusable — ${describeScannerResponse(res)} — falling back to per-type`, + ) + } catch (e) { + log.warn( + TAGS.gyms, + `[FORT] combined ${mem}/api/fort/available error — falling back to per-type: ${e}`, + ) } return null })() From 71e977335ea2504894862b10f2e56495264a7273 Mon Sep 17 00:00:00 2001 From: James Berry Date: Fri, 17 Jul 2026 19:39:26 +0100 Subject: [PATCH 13/38] refactor(fort): availability from combined endpoint only, drop per-type fallback ReactMap always runs against a current Golbat that serves /api/fort/available, so the per-type /api/{gym,pokestop,station}/available fallback is dead weight. Each model's getAvailable now reads its slice of the combined result directly; a combined failure still falls through to the SQL block (the real degradation path for dual sources). Also drops the combined-OK diagnostic log now that the Golbat side emits one clean "available-forts built" line. Co-Authored-By: Claude Fable 5 --- server/src/models/Gym.js | 19 +++++++------------ server/src/models/Pokestop.js | 15 ++++----------- server/src/models/Station.js | 16 ++++------------ server/src/utils/fortAvailable.js | 4 ---- 4 files changed, 15 insertions(+), 39 deletions(-) diff --git a/server/src/models/Gym.js b/server/src/models/Gym.js index e3fc6c352..2416bb0a3 100644 --- a/server/src/models/Gym.js +++ b/server/src/models/Gym.js @@ -628,33 +628,28 @@ class Gym extends Model { // One combined /api/fort/available per endpoint serves all three fort // models' refresh batch; falls back to the per-type endpoint when the // combined one is unavailable (older Golbat). + // Availability comes from the combined /api/fort/available (one Golbat + // cache pass for all three fort types, deduped across the models). No + // per-type fallback: ReactMap always runs against a current Golbat that + // serves it. A combined failure falls through to the SQL block below. const combined = await getCombinedFortAvailable( TAGS.gyms, mem, secret, httpAuth, ) - const res = - combined?.gyms ?? - (await evalScannerQuery( - TAGS.gyms, - `${mem}/api/gym/available`, - undefined, - 'GET', - secret, - httpAuth, - )) + const res = combined?.gyms if (res && Array.isArray(res.teams) && Array.isArray(res.raids)) { const { available } = mapGymAvailable(res) log.info( TAGS.gyms, - `[GYM] loaded available from Golbat endpoint ${mem}/api/gym/available — ${available.length} filter keys (${res.teams.length} team/slot, ${res.raids.length} raid options)`, + `[GYM] loaded available from ${mem}/api/fort/available — ${available.length} filter keys (${res.teams.length} team/slot, ${res.raids.length} raid options)`, ) return { available } } log.warn( TAGS.gyms, - `[GYM] /api/gym/available gave no teams/raids — ${describeScannerResponse(res)} — returning empty available for this endpoint source`, + `[GYM] combined /api/fort/available had no gyms section — returning empty available for this endpoint source`, ) } catch (e) { log.warn( diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 028f3d3a3..cfdba8c38 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -1509,22 +1509,15 @@ class Pokestop extends Model { // block also serves mem:'' (DB / MAD) sources directly. if (mem) { try { - // Combined-first, per-type fallback — see Gym.getAvailable. + // From the combined /api/fort/available (see Gym.getAvailable) — no + // per-type fallback; a combined failure falls through to SQL below. const combined = await getCombinedFortAvailable( TAGS.pokestops, mem, secret, httpAuth, ) - const res = - combined?.pokestops ?? - (await this.evalQuery( - `${mem}/api/pokestop/available`, - undefined, - 'GET', - secret, - httpAuth, - )) + const res = combined?.pokestops // 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 @@ -1545,7 +1538,7 @@ class Pokestop extends Model { applyRocketPokemonFallback(availableSet) log.info( TAGS.pokestops, - `[POKESTOP] loaded available from Golbat endpoint ${mem}/api/pokestop/available — ${availableSet.size} filter keys (${res.quests.length} quests, ${res.invasions.length} invasions, ${(res.lures || []).length} lures, ${(res.showcases || []).length} showcases), ${Object.keys(result.conditions).length} reward conditions`, + `[POKESTOP] loaded available from ${mem}/api/fort/available — ${availableSet.size} filter keys (${res.quests.length} quests, ${res.invasions.length} invasions, ${(res.lures || []).length} lures, ${(res.showcases || []).length} showcases), ${Object.keys(result.conditions).length} reward conditions`, ) return { available: [...availableSet], conditions: result.conditions } } diff --git a/server/src/models/Station.js b/server/src/models/Station.js index 59cf61be8..d671f43ba 100644 --- a/server/src/models/Station.js +++ b/server/src/models/Station.js @@ -1158,28 +1158,20 @@ class Station extends Model { // pure-endpoint source's this.query() throws and is dropped upstream). if (mem) { try { - // Combined-first, per-type fallback — see Gym.getAvailable. + // From the combined /api/fort/available (see Gym.getAvailable) — no + // per-type fallback; a combined failure falls through to SQL below. const combined = await getCombinedFortAvailable( TAGS.stations, mem, secret, httpAuth, ) - const res = - combined?.stations ?? - (await evalScannerQuery( - TAGS.stations, - `${mem}/api/station/available`, - undefined, - 'GET', - secret, - httpAuth, - )) + const res = combined?.stations if (res && Array.isArray(res.battles)) { const { available } = mapStationAvailable(res) log.info( TAGS.stations, - `[STATION] loaded available from Golbat endpoint ${mem}/api/station/available — ${available.length} filter keys (${res.battles.length} battle options)`, + `[STATION] loaded available from ${mem}/api/fort/available — ${available.length} filter keys (${res.battles.length} battle options)`, ) return { available } } diff --git a/server/src/utils/fortAvailable.js b/server/src/utils/fortAvailable.js index af02a3643..0850ceffd 100644 --- a/server/src/utils/fortAvailable.js +++ b/server/src/utils/fortAvailable.js @@ -49,10 +49,6 @@ function getCombinedFortAvailable(tag, mem, secret, httpAuth) { res.gyms && res.stations ) { - log.info( - TAGS.gyms, - `[FORT] combined ${mem}/api/fort/available OK — one pass for all three types`, - ) return res } log.warn( From ab1754915693cc3799a9343af4c34d8b7602c134 Mon Sep 17 00:00:00 2001 From: James Berry Date: Fri, 17 Jul 2026 20:26:12 +0100 Subject: [PATCH 14/38] refactor(gym): drop team/slot from availability consumption Golbat no longer aggregates gym team/slot (every combination exists on a live instance and a claimed gym always has >=1 defender). buildGyms already generates every t/g filter key statically from the masterfile, so nothing is lost: the mapper and builder now take only the dynamic raid keys. Co-Authored-By: Claude Fable 5 --- server/src/filters/builder/gym.js | 5 ++--- server/src/models/Gym.js | 4 ++-- server/src/models/gymAvailableMapper.js | 14 ++++---------- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/server/src/filters/builder/gym.js b/server/src/filters/builder/gym.js index 1ad0fd7d5..ad055081d 100644 --- a/server/src/filters/builder/gym.js +++ b/server/src/filters/builder/gym.js @@ -28,10 +28,9 @@ function buildGyms(perms, defaults) { } }) } + // Team/slot (t/g) keys are generated statically above — availability only + // contributes the dynamic raid keys (e/r + boss `-`). state.event.getAvailable('gyms').forEach((avail) => { - if (perms.gyms && (avail.startsWith('t') || avail.startsWith('g'))) { - gymFilters[avail] = new BaseFilter(defaults.allGyms) - } if (perms.raids) { if (avail.startsWith('e')) { gymFilters[avail] = new BaseFilter(defaults.eggs) diff --git a/server/src/models/Gym.js b/server/src/models/Gym.js index 2416bb0a3..951b42e94 100644 --- a/server/src/models/Gym.js +++ b/server/src/models/Gym.js @@ -639,11 +639,11 @@ class Gym extends Model { httpAuth, ) const res = combined?.gyms - if (res && Array.isArray(res.teams) && Array.isArray(res.raids)) { + if (res && Array.isArray(res.raids)) { const { available } = mapGymAvailable(res) log.info( TAGS.gyms, - `[GYM] loaded available from ${mem}/api/fort/available — ${available.length} filter keys (${res.teams.length} team/slot, ${res.raids.length} raid options)`, + `[GYM] loaded available from ${mem}/api/fort/available — ${available.length} filter keys (${res.raids.length} raid options)`, ) return { available } } diff --git a/server/src/models/gymAvailableMapper.js b/server/src/models/gymAvailableMapper.js index 7e6627d85..c6812bd26 100644 --- a/server/src/models/gymAvailableMapper.js +++ b/server/src/models/gymAvailableMapper.js @@ -1,22 +1,16 @@ // @ts-check /** - * Pure mapper for Golbat's `GET /api/gym/available` response. Reproduces the - * key output of the SQL `Gym.getAvailable` (t/g/e/r + boss `-`). + * Pure mapper for Golbat's gym availability. Reproduces the dynamic raid keys + * of the SQL `Gym.getAvailable` (e/r + boss `-`); team/slot (t/g) + * keys are generated statically by buildGyms, so Golbat no longer returns them. * Dependency-free so it can run under plain node for golden checks. - * @param {{ teams?: {team_id:number,available_slots:number,count:number}[], raids?: {raid_level:number,pokemon_id:number,form:number,count:number}[] }} api + * @param {{ raids?: {raid_level:number,pokemon_id:number,form:number,count:number}[] }} api * @returns {{ available: string[] }} */ function mapGymAvailable(api) { const available = new Set() - const teams = api.teams || [] - teams.forEach((t) => { - if (t.team_id === null || t.available_slots === null) return - available.add(`t${t.team_id}-0`) - available.add(`g${t.team_id}-${6 - t.available_slots}`) - }) - const raids = api.raids || [] const raidLevels = new Set() raids.forEach((r) => { From f161a9673deac56f46e16f9fb1caf9999d73265d Mon Sep 17 00:00:00 2001 From: James Berry Date: Fri, 17 Jul 2026 22:58:47 +0100 Subject: [PATCH 15/38] fix(fort): apply endpoint-source parity gaps flagged in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two endpoint-backed-source gaps the SQL path handled but the endpoint path did not: - Pokestop getAll (mem): the endpoint path mapped rows + filterRTree only, so the SQL freshness gate (hideOldPokestops) and onlyLevels/power_up_level gate were never applied and secondaryFilter has no equivalent — endpoint sources rendered stale/wrong-level stops the SQL source suppresses. Gyms already mirror this (Gym.js secondaryFilter push); pokestops now do too. (power-ups are out of the game, so the onlyLevels mirror is vestigial but exact.) - Station.getDynamaxMons always ran this.query().findById, which throws on a pure-endpoint (unbound) station source, rejecting the dynamax popup. It now reads stationed_pokemon from the whole-record by-id endpoint when the source has one, falling back to SQL for dual/SQL sources. Co-Authored-By: Claude Fable 5 --- server/src/models/Pokestop.js | 21 +++++++++++++----- server/src/models/Station.js | 42 ++++++++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index cfdba8c38..8eceae1ab 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -880,11 +880,22 @@ class Pokestop extends Model { // by-id miss mirrors SQL finding no such row } } - const mapped = res.pokestops - .map(mapScanPokestop) - .filter( - (stop) => stop && filterRTree(stop, areaRestrictions, onlyAreas), - ) + const mapped = res.pokestops.map(mapScanPokestop).filter( + (stop) => + stop && + // Mirror the SQL-only gates: the endpoint path never applied + // them and secondaryFilter has no equivalent, so stale/wrong + // stops would render where the SQL source suppresses them. + // Freshness (hideOldPokestops): + (!hideOldPokestops || + stop.updated > ts - stopValidDataLimit * 86400) && + // Power-up level (onlyLevels): power-ups are out of the game + // (also why power_up_level is dropped from the DNF); the gate is + // vestigial but mirrored for exact endpoint↔SQL parity. + (onlyLevels === 'all' || + Number(stop.power_up_level) === Number(onlyLevels)) && + filterRTree(stop, areaRestrictions, onlyAreas), + ) if (mapped.length > queryLimits.pokestops) { mapped.length = queryLimits.pokestops } diff --git a/server/src/models/Station.js b/server/src/models/Station.js index d671f43ba..2744d23f6 100644 --- a/server/src/models/Station.js +++ b/server/src/models/Station.js @@ -1141,15 +1141,41 @@ class Station extends Model { * @returns {Promise} */ // eslint-disable-next-line no-unused-vars - static async getDynamaxMons(id, _ctx) { - /** @type {import('@rm/types').FullStation} */ - const result = await this.query().findById(id).select('stationed_pokemon') - if (!result) { - return [] + static async getDynamaxMons(id, ctx = {}) { + const { mem, secret, httpAuth } = ctx + let stationedPokemon + if (mem) { + // Endpoint source: the whole-record by-id response carries + // stationed_pokemon, so a pure-endpoint station source (unbound model, + // where this.query() would throw) can still serve the dynamax popup. + const one = await evalScannerQuery( + TAGS.stations, + `${mem}/api/station/id/${id}`, + undefined, + 'GET', + secret, + httpAuth, + ).catch(() => null) + if (one && typeof one === 'object') { + stationedPokemon = one.stationed_pokemon + } + } + if (stationedPokemon === undefined) { + // SQL (dual source or SQL-only). A pure-endpoint source whose fetch + // failed has no bound knex, so this.query() throws -> empty list. + try { + /** @type {import('@rm/types').FullStation} */ + const result = await this.query() + .findById(id) + .select('stationed_pokemon') + stationedPokemon = result?.stationed_pokemon + } catch { + return [] + } } - return typeof result.stationed_pokemon === 'string' - ? JSON.parse(result.stationed_pokemon) - : result.stationed_pokemon || [] + return typeof stationedPokemon === 'string' + ? JSON.parse(stationedPokemon) + : stationedPokemon || [] } static async getAvailable({ hasMultiBattles, mem, secret, httpAuth }) { From b97c03503587e4e3cd07ec52f7f20d3563e81ae3 Mon Sep 17 00:00:00 2001 From: James Berry Date: Fri, 17 Jul 2026 23:52:24 +0100 Subject: [PATCH 16/38] refactor(fort): simplify DNF builders, share fort-by-id fetch, drop dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete unused Pokestop.evalQuery and its now-orphaned fs/path/fetchJson imports (was speculative, never called). - Extract fetchFortById() — the repeated GET-by-id + lat/lon validation used by 5 gym/pokestop/station call sites; helper does not catch, so each caller keeps its own swallow (manual-id miss) vs log.warn (getOne) behaviour. - Extract parseIdFormPair() shared by the three DNF builders' wildcard-form parses (gym raid boss, station battle pokemon, pokestop contest). - Fix stale post-refactor comments/logs: the removed per-type availability fallback (callers fall through to SQL, not per-type) and getAvailable log strings that named /api/{gym,pokestop,station}/available instead of the combined /api/fort/available. - Minor: Object.keys over Object.entries([key]); return clauses over clauses.length ? clauses : []. Behaviour-preserving; eslint clean, filter builders verified by direct exercise. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/filters/fort/gym.js | 18 ++--- server/src/filters/fort/parseIdForm.js | 27 ++++++++ server/src/filters/fort/pokestop.js | 22 ++---- server/src/filters/fort/station.js | 16 ++--- server/src/models/Gym.js | 23 ++----- server/src/models/Pokestop.js | 94 +++----------------------- server/src/models/Station.js | 17 ++--- server/src/utils/evalScannerQuery.js | 29 +++++++- server/src/utils/fortAvailable.js | 9 +-- 9 files changed, 97 insertions(+), 158 deletions(-) create mode 100644 server/src/filters/fort/parseIdForm.js diff --git a/server/src/filters/fort/gym.js b/server/src/filters/fort/gym.js index dd954e04b..420f46fc1 100644 --- a/server/src/filters/fort/gym.js +++ b/server/src/filters/fort/gym.js @@ -1,4 +1,5 @@ // @ts-check +const { parseIdFormPair } = require('./parseIdForm') /** * Team/slot clauses for the gym layer, mirroring Gym.getAll's @@ -107,24 +108,15 @@ function buildGymDnfFilters(filters, slotCount = 6) { } else { const eggs = [] const raidBosses = [] - Object.entries(filters).forEach(([key]) => { + Object.keys(filters).forEach((key) => { if (typeof key !== 'string' || key.length === 0) return if (key.charAt(0) === 'e') { const tier = Number(key.slice(1)) if (Number.isFinite(tier)) eggs.push(tier) } else if (/^\d/.test(key)) { // raid boss "-" (default case in Gym.getAll); gender residual - const [idPart, formPart] = key.split('-', 2) - const id = Number(idPart) - if (!Number.isFinite(id)) return - const pair = { pokemon_id: id } - if ( - formPart && - formPart !== 'null' && - Number.isFinite(Number(formPart)) - ) - pair.form = Number(formPart) - raidBosses.push(pair) + const pair = parseIdFormPair(key) + if (pair) raidBosses.push(pair) } }) // Golbat's tag is `raid_pokemon_id` (unlike other types' `*_pokemon`). @@ -136,7 +128,7 @@ function buildGymDnfFilters(filters, slotCount = 6) { clauses.push(...buildGymTeamClauses(filters, slotCount)) } - return clauses.length ? clauses : [] + return clauses } module.exports = { buildGymDnfFilters } diff --git a/server/src/filters/fort/parseIdForm.js b/server/src/filters/fort/parseIdForm.js new file mode 100644 index 000000000..b04f3ce08 --- /dev/null +++ b/server/src/filters/fort/parseIdForm.js @@ -0,0 +1,27 @@ +// @ts-check + +/** + * Parse a "[-]" key into `{ pokemon_id, form? }`, or `null` when the + * id isn't finite. Form is a wildcard (omitted) unless the key carries a + * finite, non-"null" form segment. Shared by the pokestop contest (`f`), gym + * raid-boss, and station battle-pokemon DNF builders — all form-WILDCARD parses. + * + * NOTE: the pokestop quest-encounter default case is deliberately NOT this — a + * bare key there means form:0 (formless reward) and "-0" is dropped. Only + * the wildcard-form variant is shared here. + * + * @param {string} key + * @param {number} [offset] chars to skip before the id (e.g. 1 past an `f` prefix) + * @returns {{ pokemon_id: number, form?: number } | null} + */ +function parseIdFormPair(key, offset = 0) { + const [idPart, formPart] = key.slice(offset).split('-', 2) + const id = Number(idPart) + if (!Number.isFinite(id)) return null + const pair = { pokemon_id: id } + if (formPart && formPart !== 'null' && Number.isFinite(Number(formPart))) + pair.form = Number(formPart) + return pair +} + +module.exports = { parseIdFormPair } diff --git a/server/src/filters/fort/pokestop.js b/server/src/filters/fort/pokestop.js index c628204bc..93f7da708 100644 --- a/server/src/filters/fort/pokestop.js +++ b/server/src/filters/fort/pokestop.js @@ -1,15 +1,5 @@ // @ts-check - -/** push {pokemon_id, form?} from a "[-]" key onto arr */ -function pushIdForm(arr, key, offset) { - const [idPart, formPart] = key.slice(offset).split('-', 2) - const id = Number(idPart) - if (!Number.isFinite(id)) return - const pair = { pokemon_id: id } - if (formPart && formPart !== 'null' && Number.isFinite(Number(formPart))) - pair.form = Number(formPart) - arr.push(pair) -} +const { parseIdFormPair } = require('./parseIdForm') /** * Grunt (incident) character ids whose *possible* rocket encounters include any @@ -112,7 +102,7 @@ function buildPokestopDnfFilters(filters, eventInvasions) { const contestPokemon = [] const contestPokemonType = [] - Object.entries(filters).forEach(([key]) => { + Object.keys(filters).forEach((key) => { if (typeof key !== 'string' || key.length === 0) return const n = Number(key.slice(1)) switch (key.charAt(0)) { @@ -177,9 +167,11 @@ function buildPokestopDnfFilters(filters, eventInvasions) { if (Number.isFinite(rocketId)) rocketPokemonIds.add(rocketId) break } - case 'f': - pushIdForm(contestPokemon, key, 1) + case 'f': { + const pair = parseIdFormPair(key, 1) + if (pair) contestPokemon.push(pair) break + } case 'h': if (Number.isFinite(n)) contestPokemonType.push(n) break @@ -297,7 +289,7 @@ function buildPokestopDnfFilters(filters, eventInvasions) { } if (onlyArEligible) clauses.push({ is_ar_scan_eligible: true }) - return clauses.length ? clauses : [] + return clauses } module.exports = { buildPokestopDnfFilters } diff --git a/server/src/filters/fort/station.js b/server/src/filters/fort/station.js index 74107c608..6a977e2e1 100644 --- a/server/src/filters/fort/station.js +++ b/server/src/filters/fort/station.js @@ -1,4 +1,5 @@ // @ts-check +const { parseIdFormPair } = require('./parseIdForm') /** * Translate a station's `args.filters` into ApiFortDnfFilter[] clauses. @@ -45,23 +46,14 @@ function buildStationDnfFilters(filters) { if (Number.isFinite(t)) battleLevels.push(t) } else { // per-level multi-select + battle-combo keys - Object.entries(filters).forEach(([key]) => { + Object.keys(filters).forEach((key) => { if (typeof key !== 'string' || key.length === 0) return if (key.startsWith('j')) { const lvl = Number(key.slice(1)) if (Number.isFinite(lvl)) battleLevels.push(lvl) } else if (/^\d/.test(key)) { - const [idPart, formPart] = key.split('-', 2) - const id = Number(idPart) - if (!Number.isFinite(id)) return - const pair = { pokemon_id: id } - if ( - formPart && - formPart !== 'null' && - Number.isFinite(Number(formPart)) - ) - pair.form = Number(formPart) - battlePokemon.push(pair) + const pair = parseIdFormPair(key) + if (pair) battlePokemon.push(pair) } }) } diff --git a/server/src/models/Gym.js b/server/src/models/Gym.js index 951b42e94..3c53ad444 100644 --- a/server/src/models/Gym.js +++ b/server/src/models/Gym.js @@ -18,6 +18,7 @@ const { isDualQuestLayerMode } = require('../utils/questLayerMode') const { evalScannerQuery, describeScannerResponse, + fetchFortById, } = require('../utils/evalScannerQuery') const { filterRTree } = require('../utils/filterRTree') const { getCombinedFortAvailable } = require('../utils/fortAvailable') @@ -556,21 +557,13 @@ class Gym extends Model { !res.gyms.some((g) => g && g.id === manualId) ) { try { - const one = await evalScannerQuery( + const one = await fetchFortById( TAGS.gyms, `${mem}/api/gym/id/${manualId}`, - undefined, - 'GET', secret, httpAuth, ) - if ( - one && - typeof one === 'object' && - 'lat' in one && - 'lon' in one - ) - res.gyms.push(one) + if (one) res.gyms.push(one) } catch { // by-id miss mirrors SQL finding no such row } @@ -654,7 +647,7 @@ class Gym extends Model { } catch (e) { log.warn( TAGS.gyms, - `[GYM] /api/gym/available error — returning empty available for this endpoint source: ${e}`, + `[GYM] /api/fort/available error — returning empty available for this endpoint source: ${e}`, ) } } @@ -844,17 +837,13 @@ class Gym extends Model { static async getOne(id, { isMad, mem, secret, httpAuth }) { if (mem) { try { - const res = await evalScannerQuery( + const one = await fetchFortById( TAGS.gyms, `${mem}/api/gym/id/${id}`, - undefined, - 'GET', secret, httpAuth, ) - if (res && typeof res === 'object' && 'lat' in res && 'lon' in res) { - return res - } + if (one) return one } catch (e) { log.warn( TAGS.gyms, diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 8eceae1ab..365c8fba3 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -3,8 +3,6 @@ /* 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') @@ -15,10 +13,10 @@ const { normalizeManualId, } = require('../utils/manualFilter') const { getUserMidnight } = require('../utils/getClientTime') -const { fetchJson } = require('../utils/fetchJson') const { evalScannerQuery, describeScannerResponse, + fetchFortById, } = require('../utils/evalScannerQuery') const { filterRTree } = require('../utils/filterRTree') const { mapScanPokestop } = require('./pokestopScanMapper') @@ -861,21 +859,13 @@ class Pokestop extends Model { !res.pokestops.some((p) => p && p.id === manualId) ) { try { - const one = await evalScannerQuery( + const one = await fetchFortById( TAGS.pokestops, `${mem}/api/pokestop/id/${manualId}`, - undefined, - 'GET', secret, httpAuth, ) - if ( - one && - typeof one === 'object' && - 'lat' in one && - 'lon' in one - ) - res.pokestops.push(one) + if (one) res.pokestops.push(one) } catch { // by-id miss mirrors SQL finding no such row } @@ -1432,65 +1422,6 @@ 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 @@ -1529,10 +1460,9 @@ class Pokestop extends Model { httpAuth, ) const res = combined?.pokestops - // 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. + // getCombinedFortAvailable resolves null when the endpoint is + // unavailable (e.g. 503 when fort_in_memory is off, or a network + // error), so res is undefined and this guard falls through to SQL. if (res && Array.isArray(res.quests) && Array.isArray(res.invasions)) { // The Golbat endpoint always returns both AR (`with_ar:true`) and // non-AR quest tuples; honor `map.misc.questLayerMode` the same way @@ -1555,12 +1485,12 @@ class Pokestop extends Model { } log.warn( TAGS.pokestops, - '[POKESTOP] /api/pokestop/available unavailable (e.g. fort_in_memory off) — returning empty available for this endpoint source', + '[POKESTOP] /api/fort/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 — returning empty available for this endpoint source: ${e}`, + `[POKESTOP] /api/fort/available error — returning empty available for this endpoint source: ${e}`, ) } } @@ -2642,17 +2572,13 @@ class Pokestop extends Model { static async getOne(id, { isMad, mem, secret, httpAuth }) { if (mem) { try { - const res = await evalScannerQuery( + const one = await fetchFortById( TAGS.pokestops, `${mem}/api/pokestop/id/${id}`, - undefined, - 'GET', secret, httpAuth, ) - if (res && typeof res === 'object' && 'lat' in res && 'lon' in res) { - return res - } + if (one) return one } catch (e) { log.warn( TAGS.pokestops, diff --git a/server/src/models/Station.js b/server/src/models/Station.js index 2744d23f6..b3aa856e1 100644 --- a/server/src/models/Station.js +++ b/server/src/models/Station.js @@ -16,6 +16,7 @@ const { getSharedPvpWrapper } = require('../services/PvpWrapper') const { evalScannerQuery, describeScannerResponse, + fetchFortById, } = require('../utils/evalScannerQuery') const { filterRTree } = require('../utils/filterRTree') const { getCombinedFortAvailable } = require('../utils/fortAvailable') @@ -725,21 +726,13 @@ class Station extends Model { !res.stations.some((s) => s && s.id === manualId) ) { try { - const one = await evalScannerQuery( + const one = await fetchFortById( TAGS.stations, `${mem}/api/station/id/${manualId}`, - undefined, - 'GET', secret, httpAuth, ) - if ( - one && - typeof one === 'object' && - 'lat' in one && - 'lon' in one - ) - res.stations.push(one) + if (one) res.stations.push(one) } catch { // by-id miss mirrors SQL finding no such row } @@ -1203,12 +1196,12 @@ class Station extends Model { } log.warn( TAGS.stations, - `[STATION] /api/station/available gave no battles — ${describeScannerResponse(res)} — returning empty available for this endpoint source`, + `[STATION] /api/fort/available gave no battles — ${describeScannerResponse(res)} — returning empty available for this endpoint source`, ) } catch (e) { log.warn( TAGS.stations, - `[STATION] /api/station/available error — returning empty available for this endpoint source: ${e}`, + `[STATION] /api/fort/available error — returning empty available for this endpoint source: ${e}`, ) } } diff --git a/server/src/utils/evalScannerQuery.js b/server/src/utils/evalScannerQuery.js index 85668d68b..61c293a86 100644 --- a/server/src/utils/evalScannerQuery.js +++ b/server/src/utils/evalScannerQuery.js @@ -83,4 +83,31 @@ function describeScannerResponse(res) { return `unexpected ${typeof res} response` } -module.exports = { evalScannerQuery, describeScannerResponse } +/** + * Fetch a single fort by id from a Golbat endpoint (GET) and validate the + * response looks like a fort record (an object carrying lat/lon). Returns the + * record or null on a non-fort/unexpected shape. Does NOT catch — the caller + * decides whether to swallow (manual-id miss mirrors an empty SQL lookup) or + * log and fall back to SQL (getOne). + * + * @param {import('@rm/logger').Tag} tag + * @param {string} url endpoint base + `/api//id/` + * @param {string} [secret] + * @param {{ username: string, password: string } | null} [httpAuth] + * @returns {Promise} + */ +async function fetchFortById(tag, url, secret, httpAuth) { + const one = await evalScannerQuery( + tag, + url, + undefined, + 'GET', + secret, + httpAuth, + ) + return one && typeof one === 'object' && 'lat' in one && 'lon' in one + ? one + : null +} + +module.exports = { evalScannerQuery, describeScannerResponse, fetchFortById } diff --git a/server/src/utils/fortAvailable.js b/server/src/utils/fortAvailable.js index 0850ceffd..7fc4dd1fc 100644 --- a/server/src/utils/fortAvailable.js +++ b/server/src/utils/fortAvailable.js @@ -20,8 +20,9 @@ const combinedCache = new Map() /** * Fetches the combined fort availability, deduped per endpoint per window. * Resolves null when the combined endpoint is unavailable (older Golbat or - * fort_in_memory off) — callers fall back to their per-type endpoint. The - * null is cached for the same window so an old Golbat isn't hammered. + * fort_in_memory off) — callers then fall through to their SQL path (or an + * empty result for a pure-endpoint source). The null is cached for the same + * window so an old Golbat isn't hammered. * * @param {import('@rm/logger').Tag} tag * @param {string} mem endpoint base url @@ -53,12 +54,12 @@ function getCombinedFortAvailable(tag, mem, secret, httpAuth) { } log.warn( TAGS.gyms, - `[FORT] combined ${mem}/api/fort/available unusable — ${describeScannerResponse(res)} — falling back to per-type`, + `[FORT] combined ${mem}/api/fort/available unusable — ${describeScannerResponse(res)} — callers fall through to SQL`, ) } catch (e) { log.warn( TAGS.gyms, - `[FORT] combined ${mem}/api/fort/available error — falling back to per-type: ${e}`, + `[FORT] combined ${mem}/api/fort/available error — callers fall through to SQL: ${e}`, ) } return null From 812ff92ad7f269372c0decf14d33d354c3ce6618 Mon Sep 17 00:00:00 2001 From: James Berry Date: Mon, 20 Jul 2026 08:16:47 +0100 Subject: [PATCH 17/38] =?UTF-8?q?fix(fort):=20address=20Copilot=20review?= =?UTF-8?q?=20=E2=80=94=20cache=20key,=20log=20tags,=20defensive=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getCombinedFortAvailable: key the dedup cache by endpoint URL AND credentials, so two sources sharing a URL with different secret/httpAuth don't share a response or a cached auth failure. - Log the combined-availability warnings under the caller's tag instead of a hardcoded TAGS.gyms (pokestop/station failures no longer show as gyms). - parseRdmRewards: guard against a malformed endpoint row carrying quest_reward_type with no rewards array (was an unguarded rewards[0]). - Reword the getAvailable 'returning empty' warnings: on a dual source the code falls through to SQL, so it is empty only for a pure-endpoint source. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/models/Gym.js | 4 ++-- server/src/models/Pokestop.js | 7 +++++-- server/src/models/Station.js | 4 ++-- server/src/utils/fortAvailable.js | Bin 2282 -> 2915 bytes 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/server/src/models/Gym.js b/server/src/models/Gym.js index 3c53ad444..babcc027f 100644 --- a/server/src/models/Gym.js +++ b/server/src/models/Gym.js @@ -642,12 +642,12 @@ class Gym extends Model { } log.warn( TAGS.gyms, - `[GYM] combined /api/fort/available had no gyms section — returning empty available for this endpoint source`, + `[GYM] combined /api/fort/available had no gyms section — falling through to SQL (empty only for a pure-endpoint source)`, ) } catch (e) { log.warn( TAGS.gyms, - `[GYM] /api/fort/available error — returning empty available for this endpoint source: ${e}`, + `[GYM] /api/fort/available error — falling through to SQL (empty only for a pure-endpoint source): ${e}`, ) } } diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 365c8fba3..248222c38 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -1485,12 +1485,12 @@ class Pokestop extends Model { } log.warn( TAGS.pokestops, - '[POKESTOP] /api/fort/available unavailable (e.g. fort_in_memory off) — returning empty available for this endpoint source', + '[POKESTOP] /api/fort/available unavailable (e.g. fort_in_memory off) — falling through to SQL (empty only for a pure-endpoint source)', ) } catch (e) { log.warn( TAGS.pokestops, - `[POKESTOP] /api/fort/available error — returning empty available for this endpoint source: ${e}`, + `[POKESTOP] /api/fort/available error — falling through to SQL (empty only for a pure-endpoint source): ${e}`, ) } } @@ -2141,6 +2141,9 @@ class Pokestop extends Model { typeof quest.quest_rewards === 'string' ? JSON.parse(quest.quest_rewards) : quest.quest_rewards + // Defensive: a malformed endpoint row could carry quest_reward_type with + // no rewards array; don't let one bad stop throw and break the whole query. + if (!Array.isArray(rewards) || rewards.length === 0) return quest let { info } = rewards[0] if ( quest.quest_reward_type === TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE && diff --git a/server/src/models/Station.js b/server/src/models/Station.js index b3aa856e1..a66a157d3 100644 --- a/server/src/models/Station.js +++ b/server/src/models/Station.js @@ -1196,12 +1196,12 @@ class Station extends Model { } log.warn( TAGS.stations, - `[STATION] /api/fort/available gave no battles — ${describeScannerResponse(res)} — returning empty available for this endpoint source`, + `[STATION] /api/fort/available gave no battles — ${describeScannerResponse(res)} — falling through to SQL (empty only for a pure-endpoint source)`, ) } catch (e) { log.warn( TAGS.stations, - `[STATION] /api/fort/available error — returning empty available for this endpoint source: ${e}`, + `[STATION] /api/fort/available error — falling through to SQL (empty only for a pure-endpoint source): ${e}`, ) } } diff --git a/server/src/utils/fortAvailable.js b/server/src/utils/fortAvailable.js index 7fc4dd1fca34355905adccfca004c090bac55638..d1d1d27f07a69b38d3edb64e3a90b495d3b34799 100644 GIT binary patch delta 587 zcmYk3F>lmB5QX93#K{r`4N$>DIdsWk?kbu?5ky3x2%H}W|fm-?knap#ttN}-? z=I4d?R|%@RfhG!JU?tDJ5QdsCj_Al+gV8OV9Aw6JHqcz z-(YOaqt;Q(a3NCA>P9CHf*k{1E~Kg~VOKy`O6_5h8@mnA$@W!U7{w@3XQrNo!RMv0 zVuiKyR_bK~25#f)*`0ZmS332QLu_r2>Ga6h-S+12!6umUR{O+whGe|%t69Z?G;gh0 z{C8Oj=Qu^yBv@cFiNGO!WqCv6HCr}U#_Mj6I6LF_PzRSLiJB;yPp1L(h&<TS7MCO1LPG;gx1OVKY8NC1i From cade9f686a62d4050c9c9e7d20c81e4dcbddefec Mon Sep 17 00:00:00 2001 From: James Berry Date: Mon, 20 Jul 2026 11:20:10 +0100 Subject: [PATCH 18/38] feat(pokestop): treat quest reward type 20 as mega, retire GoFest stopgap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Golbat #382 now decodes TEMP_EVO_BRANCH_RESOURCE (reward type 20 — temp-evo branch = mega energy) into info.pokemon_id/amount, filling the generated quest_pokemon_id/quest_reward_amount columns exactly like a MEGA_RESOURCE (type 12) reward. So type 20 is now handled as mega everywhere: - SQL getAll: mega matching broadened to quest_reward_type IN (12, 20). - SQL getAvailable: queries.mega/megaAlt advertise (12, 20); type 20 excluded from the u fallback set; megaBranchFallback machinery removed. - SQL search: a 'mega' reward-type search also matches type 20. - parseRdmRewards: any type-20 reward carrying info.pokemon_id is normalized to type 12 so the mega branch keys it as m- (sourceQuestRewardType still preserves a u20 filter). - Endpoint DNF (buildPokestopDnfFilters): mega clauses emit quest_reward_type [12, 20] so Golbat returns type-20 mega stops. The empty-info GoFest 2026 Mewtwo stopgap (applyGoFest2026MewtwoRewardFallback + hardcoded m150-150) is retired — it required empty info and stops matching once Golbat #382 populates it. Requires deployed Golbat >= PR #382. Addresses Mygod review comment #3 (type-20 mega retrievability). Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/filters/fort/pokestop.js | 8 +- server/src/models/Pokestop.js | 189 +++++++--------------------- 2 files changed, 54 insertions(+), 143 deletions(-) diff --git a/server/src/filters/fort/pokestop.js b/server/src/filters/fort/pokestop.js index 93f7da708..2c8040e0e 100644 --- a/server/src/filters/fort/pokestop.js +++ b/server/src/filters/fort/pokestop.js @@ -223,15 +223,19 @@ function buildPokestopDnfFilters(filters, eventInvasions) { }) if (xlPokemon.length) clauses.push({ quest_reward_type: [9], quest_reward_pokemon: xlPokemon }) + // type 20 (temp-evo branch resource) is mega energy too, so match both. megaByAmount.forEach((pokes, amt) => clauses.push({ - quest_reward_type: [12], + quest_reward_type: [12, 20], quest_reward_pokemon: pokes, quest_reward_amount: { min: amt, max: amt }, }), ) if (megaLoose.length) - clauses.push({ quest_reward_type: [12], quest_reward_pokemon: megaLoose }) + clauses.push({ + quest_reward_type: [12, 20], + quest_reward_pokemon: megaLoose, + }) dustAmounts.forEach((amt) => clauses.push({ quest_reward_type: [3], diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 248222c38..05c97e73d 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -32,21 +32,14 @@ const { mapAvailablePokestops } = require('./pokestopAvailableMapper') const MEGA_RESOURCE_REWARD_TYPE = 12 const TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE = 20 -const GO_FEST_2026_MEWTWO_ID = 150 -const GO_FEST_2026_MEWTWO_ENERGY_AMOUNT = 150 - -const applyGoFest2026MewtwoRewardFallback = ( - query, - { rewardTypeColumn, rewardsColumn }, -) => - query - .where(rewardTypeColumn, TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE) - .andWhere( - raw(`json_type(json_extract(${rewardsColumn}, "$[0].info")) = 'OBJECT'`), - ) - .andWhere( - raw(`json_length(json_extract(${rewardsColumn}, "$[0].info")) = 0`), - ) +// Temp-evo branch resource (type 20) is mega energy in a different wrapper; +// Golbat (PR #382) decodes its info.pokemon_id/amount into the same shape as a +// MEGA_RESOURCE reward, so both are treated as mega throughout — matched, +// advertised, and keyed as `m-`. +const MEGA_REWARD_TYPES = [ + MEGA_RESOURCE_REWARD_TYPE, + TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE, +] // Team leaders (41-43) and Giovanni (44) never hand out a catchable rocket // Pokemon, so both the confirmed-invasion branch and this config-derived @@ -481,7 +474,7 @@ class Pokestop extends Model { if (hasRewardAmount) { questTypes.orWhere((mega) => { mega - .where('quest_reward_type', MEGA_RESOURCE_REWARD_TYPE) + .whereIn('quest_reward_type', MEGA_REWARD_TYPES) .andWhere( isMad ? 'quest_item_amount' : 'quest_reward_amount', amount, @@ -491,9 +484,9 @@ class Pokestop extends Model { if (hasAltQuests) { questTypes.orWhere((altMega) => { altMega - .where( + .whereIn( 'alternative_quest_reward_type', - MEGA_RESOURCE_REWARD_TYPE, + MEGA_REWARD_TYPES, ) .andWhere('alternative_quest_reward_amount', amount) .andWhere('alternative_quest_pokemon_id', pokeId) @@ -501,7 +494,7 @@ class Pokestop extends Model { } } else { questTypes.orWhere((mega) => { - mega.where('quest_reward_type', MEGA_RESOURCE_REWARD_TYPE) + mega.whereIn('quest_reward_type', MEGA_REWARD_TYPES) if (hasRewardAmount) { mega .andWhere('quest_reward_amount', amount) @@ -530,9 +523,9 @@ class Pokestop extends Model { }) if (hasAltQuests) { questTypes.orWhere((altMega) => { - altMega.where( + altMega.whereIn( 'alternative_quest_reward_type', - MEGA_RESOURCE_REWARD_TYPE, + MEGA_REWARD_TYPES, ) if (hasRewardAmount) { altMega @@ -554,26 +547,6 @@ class Pokestop extends Model { }) } } - if ( - !isMad && - Number(pokeId) === GO_FEST_2026_MEWTWO_ID && - Number(amount) === GO_FEST_2026_MEWTWO_ENERGY_AMOUNT - ) { - questTypes.orWhere((fallback) => - applyGoFest2026MewtwoRewardFallback(fallback, { - rewardTypeColumn: 'quest_reward_type', - rewardsColumn: 'quest_rewards', - }), - ) - if (hasAltQuests) { - questTypes.orWhere((fallback) => - applyGoFest2026MewtwoRewardFallback(fallback, { - rewardTypeColumn: 'alternative_quest_reward_type', - rewardsColumn: 'alternative_quest_rewards', - }), - ) - } - } }) if (hasRewardAmount) { questTypes @@ -1665,7 +1638,7 @@ class Pokestop extends Model { // mega queries.mega = this.query() .from(isMad ? 'trs_quest' : 'pokestop') - .where('quest_reward_type', MEGA_RESOURCE_REWARD_TYPE) + .whereIn('quest_reward_type', MEGA_REWARD_TYPES) if (hasRewardAmount) { queries.mega .select('quest_title', 'quest_target') @@ -1692,9 +1665,9 @@ class Pokestop extends Model { ) } if (hasAltQuests) { - queries.megaAlt = this.query().where( + queries.megaAlt = this.query().whereIn( 'alternative_quest_reward_type', - MEGA_RESOURCE_REWARD_TYPE, + MEGA_REWARD_TYPES, ) if (hasRewardAmount) { queries.megaAlt @@ -1722,33 +1695,6 @@ class Pokestop extends Model { ) } } - if (!isMad) { - queries.megaBranchFallback = this.query() - .select('quest_title', 'quest_target') - .distinct( - raw(`${GO_FEST_2026_MEWTWO_ID}`).as('id'), - raw(`${GO_FEST_2026_MEWTWO_ENERGY_AMOUNT}`).as('amount'), - ) - applyGoFest2026MewtwoRewardFallback(queries.megaBranchFallback, { - rewardTypeColumn: 'quest_reward_type', - rewardsColumn: 'quest_rewards', - }) - if (hasAltQuests) { - queries.megaBranchFallbackAlt = this.query() - .select( - 'alternative_quest_title AS quest_title', - 'alternative_quest_target AS quest_target', - ) - .distinct( - raw(`${GO_FEST_2026_MEWTWO_ID}`).as('id'), - raw(`${GO_FEST_2026_MEWTWO_ENERGY_AMOUNT}`).as('amount'), - ) - applyGoFest2026MewtwoRewardFallback(queries.megaBranchFallbackAlt, { - rewardTypeColumn: 'alternative_quest_reward_type', - rewardsColumn: 'alternative_quest_rewards', - }) - } - } // mega // candy @@ -1926,25 +1872,18 @@ class Pokestop extends Model { } // showcase - ;[ - 'items', - 'stardust', - 'xp', - 'mega', - 'megaBranchFallback', - 'candy', - 'xlCandy', - 'pokemon', - ].forEach((key) => { - if (!shouldIncludeBaseQuests) { - delete queries[key] - } else if (queries[key]) { - applyMadQuestLayer(queries[key]) - } - if (!shouldIncludeAltQuests) { - delete queries[`${key}Alt`] - } - }) + ;['items', 'stardust', 'xp', 'mega', 'candy', 'xlCandy', 'pokemon'].forEach( + (key) => { + if (!shouldIncludeBaseQuests) { + delete queries[key] + } else if (queries[key]) { + applyMadQuestLayer(queries[key]) + } + if (!shouldIncludeAltQuests) { + delete queries[`${key}Alt`] + } + }, + ) const resolved = Object.fromEntries( await Promise.all( @@ -1961,11 +1900,9 @@ class Pokestop extends Model { .whereNotNull('quest_reward_type'), ) if (!isMad) { - questTypeQuery.whereNot((fallback) => - applyGoFest2026MewtwoRewardFallback(fallback, { - rewardTypeColumn: 'quest_reward_type', - rewardsColumn: 'quest_rewards', - }), + questTypeQuery.whereNot( + 'quest_reward_type', + TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE, ) } questTypeQueries.push( @@ -1978,11 +1915,9 @@ class Pokestop extends Model { const questTypeQuery = this.query() .distinct('alternative_quest_reward_type') .whereNotNull('alternative_quest_reward_type') - .whereNot((fallback) => - applyGoFest2026MewtwoRewardFallback(fallback, { - rewardTypeColumn: 'alternative_quest_reward_type', - rewardsColumn: 'alternative_quest_rewards', - }), + .whereNot( + 'alternative_quest_reward_type', + TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE, ) questTypeQueries.push( questTypeQuery.then((results) => @@ -2016,16 +1951,6 @@ class Pokestop extends Model { ) questTypes = questTypes.filter((x) => x !== 2) break - case 'megaBranchFallbackAlt': - case 'megaBranchFallback': - rewards.forEach((reward) => - process( - `m${reward.id}-${reward.amount}`, - reward.quest_title, - reward.quest_target, - ), - ) - break case 'megaAlt': case 'mega': rewards.forEach((reward) => @@ -2144,23 +2069,19 @@ class Pokestop extends Model { // Defensive: a malformed endpoint row could carry quest_reward_type with // no rewards array; don't let one bad stop throw and break the whole query. if (!Array.isArray(rewards) || rewards.length === 0) return quest - let { info } = rewards[0] + const { info } = rewards[0] + // Temp-evo branch resource (type 20) is mega energy; Golbat decodes its + // pokemon_id/amount into the same `info` shape as a MEGA_RESOURCE reward, + // so normalize it to type 12 and let the mega branch below expand it into + // `mega_*` fields (→ `m-` key). The original type is tracked by + // the caller (sourceQuestRewardType) so a `u20` filter still matches. if ( quest.quest_reward_type === TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE && info && !Array.isArray(info) && - Object.keys(info).length === 0 + info.pokemon_id ) { - // GO Fest 2026 fallback until Golbat exposes branch-specific details. - info = { - amount: GO_FEST_2026_MEWTWO_ENERGY_AMOUNT, - pokemon_id: GO_FEST_2026_MEWTWO_ID, - } - rewards[0] = { - ...rewards[0], - info, - type: MEGA_RESOURCE_REWARD_TYPE, - } + rewards[0] = { ...rewards[0], type: MEGA_RESOURCE_REWARD_TYPE } quest.quest_rewards = JSON.stringify(rewards) quest.quest_reward_type = MEGA_RESOURCE_REWARD_TYPE } @@ -2292,9 +2213,11 @@ class Pokestop extends Model { .toLowerCase() .includes(search), ) - const searchIncludesGoFest2026MewtwoReward = - pokemonIds.includes(`${GO_FEST_2026_MEWTWO_ID}`) || - rewardTypes.includes(`${MEGA_RESOURCE_REWARD_TYPE}`) + // A "mega" reward-type search should also surface temp-evo branch (type 20) + // mega-energy quests, which now carry real pokemon/amount. + if (rewardTypes.includes(`${MEGA_RESOURCE_REWARD_TYPE}`)) { + rewardTypes.push(`${TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE}`) + } if (!pokemonIds.length && !itemIds.length && !rewardTypes.length) { return [] @@ -2337,14 +2260,6 @@ class Pokestop extends Model { } else if (rewardTypes.length > 1) { quests.orWhereIn('quest_reward_type', rewardTypes) } - if (!isMad && searchIncludesGoFest2026MewtwoReward) { - quests.orWhere((fallback) => - applyGoFest2026MewtwoRewardFallback(fallback, { - rewardTypeColumn: 'quest_reward_type', - rewardsColumn: 'quest_rewards', - }), - ) - } }) .limit(config.getSafe('api.searchResultsLimit')) .orderBy('distance') @@ -2402,14 +2317,6 @@ class Pokestop extends Model { } else if (rewardTypes.length > 1) { quests.orWhereIn('alternative_quest_reward_type', rewardTypes) } - if (searchIncludesGoFest2026MewtwoReward) { - quests.orWhere((fallback) => - applyGoFest2026MewtwoRewardFallback(fallback, { - rewardTypeColumn: 'alternative_quest_reward_type', - rewardsColumn: 'alternative_quest_rewards', - }), - ) - } }) .limit(searchResultsLimit) .orderBy('distance') From 15d82178cb74b5b341be47932aaa693bad8019ab Mon Sep 17 00:00:00 2001 From: James Berry Date: Mon, 20 Jul 2026 12:05:48 +0100 Subject: [PATCH 19/38] fix(fort): enforce strict area restrictions on endpoint scans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint (in-memory) getAll paths filter rows with filterRTree, which returns true for empty area inputs — so unlike the SQL path's getAreaSql it did NOT deny a user who has no assigned areas while strictAreaRestrictions is on and restrictions are configured. Such a user received every fort in the viewport (access-control bypass). Add areaRestrictionsDenyAll (mirroring getAreaSql's strict-deny and empty-consolidated deny) and short-circuit each fort model's endpoint branch to [] before accepting rows. Addresses Mygod review comment #1 (P1). Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/models/Gym.js | 6 +++++- server/src/models/Pokestop.js | 6 +++++- server/src/models/Station.js | 6 +++++- server/src/utils/getAreaSql.js | 26 +++++++++++++++++++++++++- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/server/src/models/Gym.js b/server/src/models/Gym.js index babcc027f..476efefc9 100644 --- a/server/src/models/Gym.js +++ b/server/src/models/Gym.js @@ -7,7 +7,7 @@ const i18next = require('i18next') const config = require('@rm/config') const { log, TAGS } = require('@rm/logger') -const { getAreaSql } = require('../utils/getAreaSql') +const { getAreaSql, areaRestrictionsDenyAll } = require('../utils/getAreaSql') const { state } = require('../services/state') const { @@ -529,6 +529,10 @@ class Gym extends Model { } if (mem) { + // filterRTree below allow-alls on empty area inputs, unlike the SQL + // getAreaSql — so enforce the strict-area denial before accepting any + // endpoint rows (else a no-area user under strict mode sees everything). + if (areaRestrictionsDenyAll(areaRestrictions, onlyAreas)) return [] try { // /api/gym/scan returns an envelope { gyms, examined, skipped, total }, // not a bare array — the matching gyms are on res.gyms. diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 05c97e73d..d2b88ad6d 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -7,7 +7,7 @@ const i18next = require('i18next') const config = require('@rm/config') const { log, TAGS } = require('@rm/logger') -const { getAreaSql } = require('../utils/getAreaSql') +const { getAreaSql, areaRestrictionsDenyAll } = require('../utils/getAreaSql') const { applyManualIdFilter, normalizeManualId, @@ -799,6 +799,10 @@ class Pokestop extends Model { // showcase/goldstop/kecleon event rows). On any failure/bad-shape we log // and fall through to the SQL block below. if (mem) { + // filterRTree below allow-alls on empty area inputs, unlike the SQL + // getAreaSql — so enforce the strict-area denial before accepting any + // endpoint rows (else a no-area user under strict mode sees everything). + if (areaRestrictionsDenyAll(areaRestrictions, onlyAreas)) return [] try { const dnf = buildPokestopDnfFilters(args.filters, state.event.invasions) // Endpoint rows always carry BOTH quest layers, so resolve the layer diff --git a/server/src/models/Station.js b/server/src/models/Station.js index a66a157d3..be04c308d 100644 --- a/server/src/models/Station.js +++ b/server/src/models/Station.js @@ -5,7 +5,7 @@ const i18next = require('i18next') const { log, TAGS } = require('@rm/logger') -const { getAreaSql } = require('../utils/getAreaSql') +const { getAreaSql, areaRestrictionsDenyAll } = require('../utils/getAreaSql') const { applyManualIdFilter, normalizeManualId, @@ -701,6 +701,10 @@ class Station extends Model { const shouldRestrictReturnedBattles = onlyMaxBattles && hasBattleConditions if (mem) { + // filterRTree below allow-alls on empty area inputs, unlike the SQL + // getAreaSql — so enforce the strict-area denial before accepting any + // endpoint rows (else a no-area user under strict mode sees everything). + if (areaRestrictionsDenyAll(areaRestrictions, onlyAreas)) return [] try { // /api/station/scan returns an envelope { stations, examined, skipped, // total } — the matching stations are on res.stations. diff --git a/server/src/utils/getAreaSql.js b/server/src/utils/getAreaSql.js index 7174cff15..d992ada72 100644 --- a/server/src/utils/getAreaSql.js +++ b/server/src/utils/getAreaSql.js @@ -71,4 +71,28 @@ function getAreaSql( return true } -module.exports = { getAreaSql } +/** + * The deny half of getAreaSql, for the in-memory (filterRTree) path. filterRTree + * returns true (allow-all) for empty inputs, so on its own it BYPASSES strict + * area restrictions — a user with no assigned areas would see every fort. This + * mirrors getAreaSql's two deny cases so an endpoint source can short-circuit to + * an empty result exactly as the SQL query returns no rows: + * 1. strict mode on, restrictions configured, and this user has none; or + * 2. the user's areas don't resolve to any polygon. + * @param {string[]} areaRestrictions + * @param {string[]} onlyAreas + * @returns {boolean} true when the request must yield no results + */ +function areaRestrictionsDenyAll(areaRestrictions = [], onlyAreas = []) { + const authentication = config.getSafe('authentication') + if ( + authentication.strictAreaRestrictions && + authentication.areaRestrictions.length && + !areaRestrictions.length + ) + return true + if (!areaRestrictions.length && !onlyAreas.length) return false + return !consolidateAreas(areaRestrictions, onlyAreas).size +} + +module.exports = { getAreaSql, areaRestrictionsDenyAll } From f51f662ecc660b544c998ba170ebdeb5c2c3b398 Mon Sep 17 00:00:00 2001 From: James Berry Date: Mon, 20 Jul 2026 12:06:02 +0100 Subject: [PATCH 20/38] fix(fort): endpoint confirmed-capability, dual-source rarity, getOne resilience Three endpoint-source correctness fixes from the Mygod review: - #4 (P1): a pure-endpoint pokestop source now reports hasConfirmed:true. The Golbat scan always returns confirmed incident data, but no DB schema check runs for a knex-less source, so onlyConfirmed and confirmed rocket-reward (a) filters were silently degrading to the grunt possible-encounter pool. - #6 (P2): historicalRarity now skips only pure-endpoint sources (no bound knex), not dual sources. Testing source.mem alone dropped a dual source DB and cleared the historical rarity map on every refresh. - #8 (P2): DbManager.getOne uses Promise.allSettled so a pure-endpoint source whose by-id fetch misses (falling through to an unbound this.query() that throws) no longer fails the whole single-fort lookup. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/services/DbManager.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index ac51fa9c8..18473086e 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -277,6 +277,12 @@ class DbManager extends Logger { secret: this.endpoints[i].secret, httpAuth: this.endpoints[i].httpAuth, pvpV2: true, + // No DB schema check runs for a pure-endpoint source, but the + // Golbat scan always returns confirmed incident data (confirmed + // flag + lineup slots), so it IS confirmed-capable. Without this, + // onlyConfirmed is ineffective and confirmed `a` reward filters + // fall back to the grunt's possible-encounter pool. + hasConfirmed: true, } // Dual source (endpoint + DB): schemaCheck ran on the bound knex @@ -349,7 +355,11 @@ class DbManager extends Logger { try { const results = await Promise.all( (this.models.Pokemon ?? []).map(async (source) => - source.isMad || source.mem + // Skip MAD (no pokemon_stats) and pure-endpoint sources (no bound + // knex to query). A dual source (endpoint + DB) keeps its knex, so it + // still serves historical rarity — testing source.mem alone would + // wrongly drop it and clear the rarity map on refresh. + source.isMad || !this.connections[source.connection] ? [] : source.SubModel.query() .select('pokemon_id', raw('SUM(count) as total')) @@ -518,11 +528,18 @@ class DbManager extends Logger { * @returns {Promise} */ async getOne(model, id) { - const data = await Promise.all( + // allSettled, not all: a pure-endpoint source whose by-id fetch misses + // falls through to this.query() on an unbound model, which throws. With + // Promise.all that one rejection would fail the whole single-fort lookup; + // here it just contributes no match. + const settled = await Promise.allSettled( this.models[model].map(async ({ SubModel, ...source }) => SubModel.getOne(id, source), ), ) + const data = settled + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value) const cleaned = DbManager.deDupeResults(data.filter(Boolean)) return cleaned || {} } From fe24b481d8c7e4080a00f4bddacfee52c1d9b192 Mon Sep 17 00:00:00 2001 From: James Berry Date: Mon, 20 Jul 2026 12:11:10 +0100 Subject: [PATCH 21/38] feat(pokestop): advertise confirmed rocket rewards from slots 2 and 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Golbat now exposes confirmed invasion slots 2/3 in /api/fort/available. Read them in the availability mapper, adding an a- key per slot the event config marks as a reward (second/thirdReward) — matching the SQL path, which already advertises all three confirmed slots. Leaders/Giovanni (41-44) stay excluded. Addresses Mygod review comment #9 (P2). Requires Golbat with the slots-2/3 availability change. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/models/pokestopAvailableMapper.js | 37 +++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/server/src/models/pokestopAvailableMapper.js b/server/src/models/pokestopAvailableMapper.js index bdca83526..3810daf89 100644 --- a/server/src/models/pokestopAvailableMapper.js +++ b/server/src/models/pokestopAvailableMapper.js @@ -28,6 +28,10 @@ * @property {boolean} confirmed * @property {number} slot1_pokemon_id * @property {number} slot1_form + * @property {number} slot2_pokemon_id + * @property {number} slot2_form + * @property {number} slot3_pokemon_id + * @property {number} slot3_form * @property {number} count * * @typedef {object} AvailablePokestopLure @@ -173,18 +177,33 @@ function mapAvailablePokestops(api, ctx) { // 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 + const { + character, + display_type, + confirmed, + slot1_pokemon_id, + slot1_form, + slot2_pokemon_id, + slot2_form, + slot3_pokemon_id, + slot3_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}`) + if (confirmed && !isRocketLeaderOrGiovanni) { + // Each slot the event config marks as a reward contributes an `a` key, + // mirroring the SQL path which reads confirmed slots 1/2/3. + const cfg = ctx.invasions?.[character] + if (slot1_pokemon_id > 0 && cfg?.firstReward) { + available.add(`a${slot1_pokemon_id}-${slot1_form}`) + } + if (slot2_pokemon_id > 0 && cfg?.secondReward) { + available.add(`a${slot2_pokemon_id}-${slot2_form}`) + } + if (slot3_pokemon_id > 0 && cfg?.thirdReward) { + available.add(`a${slot3_pokemon_id}-${slot3_form}`) + } } }) From 45a25e9d8e7281fc3702056f773f03ca93f561b4 Mon Sep 17 00:00:00 2001 From: James Berry Date: Wed, 22 Jul 2026 13:40:03 +0100 Subject: [PATCH 22/38] Revert "feat(pokestop): treat quest reward type 20 as mega, retire GoFest stopgap" This reverts commit cade9f686a62d4050c9c9e7d20c81e4dcbddefec. --- server/src/filters/fort/pokestop.js | 8 +- server/src/models/Pokestop.js | 189 +++++++++++++++++++++------- 2 files changed, 143 insertions(+), 54 deletions(-) diff --git a/server/src/filters/fort/pokestop.js b/server/src/filters/fort/pokestop.js index 2c8040e0e..93f7da708 100644 --- a/server/src/filters/fort/pokestop.js +++ b/server/src/filters/fort/pokestop.js @@ -223,19 +223,15 @@ function buildPokestopDnfFilters(filters, eventInvasions) { }) if (xlPokemon.length) clauses.push({ quest_reward_type: [9], quest_reward_pokemon: xlPokemon }) - // type 20 (temp-evo branch resource) is mega energy too, so match both. megaByAmount.forEach((pokes, amt) => clauses.push({ - quest_reward_type: [12, 20], + quest_reward_type: [12], quest_reward_pokemon: pokes, quest_reward_amount: { min: amt, max: amt }, }), ) if (megaLoose.length) - clauses.push({ - quest_reward_type: [12, 20], - quest_reward_pokemon: megaLoose, - }) + clauses.push({ quest_reward_type: [12], quest_reward_pokemon: megaLoose }) dustAmounts.forEach((amt) => clauses.push({ quest_reward_type: [3], diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index d2b88ad6d..a4b2affcb 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -32,14 +32,21 @@ const { mapAvailablePokestops } = require('./pokestopAvailableMapper') const MEGA_RESOURCE_REWARD_TYPE = 12 const TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE = 20 -// Temp-evo branch resource (type 20) is mega energy in a different wrapper; -// Golbat (PR #382) decodes its info.pokemon_id/amount into the same shape as a -// MEGA_RESOURCE reward, so both are treated as mega throughout — matched, -// advertised, and keyed as `m-`. -const MEGA_REWARD_TYPES = [ - MEGA_RESOURCE_REWARD_TYPE, - TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE, -] +const GO_FEST_2026_MEWTWO_ID = 150 +const GO_FEST_2026_MEWTWO_ENERGY_AMOUNT = 150 + +const applyGoFest2026MewtwoRewardFallback = ( + query, + { rewardTypeColumn, rewardsColumn }, +) => + query + .where(rewardTypeColumn, TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE) + .andWhere( + raw(`json_type(json_extract(${rewardsColumn}, "$[0].info")) = 'OBJECT'`), + ) + .andWhere( + 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 @@ -474,7 +481,7 @@ class Pokestop extends Model { if (hasRewardAmount) { questTypes.orWhere((mega) => { mega - .whereIn('quest_reward_type', MEGA_REWARD_TYPES) + .where('quest_reward_type', MEGA_RESOURCE_REWARD_TYPE) .andWhere( isMad ? 'quest_item_amount' : 'quest_reward_amount', amount, @@ -484,9 +491,9 @@ class Pokestop extends Model { if (hasAltQuests) { questTypes.orWhere((altMega) => { altMega - .whereIn( + .where( 'alternative_quest_reward_type', - MEGA_REWARD_TYPES, + MEGA_RESOURCE_REWARD_TYPE, ) .andWhere('alternative_quest_reward_amount', amount) .andWhere('alternative_quest_pokemon_id', pokeId) @@ -494,7 +501,7 @@ class Pokestop extends Model { } } else { questTypes.orWhere((mega) => { - mega.whereIn('quest_reward_type', MEGA_REWARD_TYPES) + mega.where('quest_reward_type', MEGA_RESOURCE_REWARD_TYPE) if (hasRewardAmount) { mega .andWhere('quest_reward_amount', amount) @@ -523,9 +530,9 @@ class Pokestop extends Model { }) if (hasAltQuests) { questTypes.orWhere((altMega) => { - altMega.whereIn( + altMega.where( 'alternative_quest_reward_type', - MEGA_REWARD_TYPES, + MEGA_RESOURCE_REWARD_TYPE, ) if (hasRewardAmount) { altMega @@ -547,6 +554,26 @@ class Pokestop extends Model { }) } } + if ( + !isMad && + Number(pokeId) === GO_FEST_2026_MEWTWO_ID && + Number(amount) === GO_FEST_2026_MEWTWO_ENERGY_AMOUNT + ) { + questTypes.orWhere((fallback) => + applyGoFest2026MewtwoRewardFallback(fallback, { + rewardTypeColumn: 'quest_reward_type', + rewardsColumn: 'quest_rewards', + }), + ) + if (hasAltQuests) { + questTypes.orWhere((fallback) => + applyGoFest2026MewtwoRewardFallback(fallback, { + rewardTypeColumn: 'alternative_quest_reward_type', + rewardsColumn: 'alternative_quest_rewards', + }), + ) + } + } }) if (hasRewardAmount) { questTypes @@ -1642,7 +1669,7 @@ class Pokestop extends Model { // mega queries.mega = this.query() .from(isMad ? 'trs_quest' : 'pokestop') - .whereIn('quest_reward_type', MEGA_REWARD_TYPES) + .where('quest_reward_type', MEGA_RESOURCE_REWARD_TYPE) if (hasRewardAmount) { queries.mega .select('quest_title', 'quest_target') @@ -1669,9 +1696,9 @@ class Pokestop extends Model { ) } if (hasAltQuests) { - queries.megaAlt = this.query().whereIn( + queries.megaAlt = this.query().where( 'alternative_quest_reward_type', - MEGA_REWARD_TYPES, + MEGA_RESOURCE_REWARD_TYPE, ) if (hasRewardAmount) { queries.megaAlt @@ -1699,6 +1726,33 @@ class Pokestop extends Model { ) } } + if (!isMad) { + queries.megaBranchFallback = this.query() + .select('quest_title', 'quest_target') + .distinct( + raw(`${GO_FEST_2026_MEWTWO_ID}`).as('id'), + raw(`${GO_FEST_2026_MEWTWO_ENERGY_AMOUNT}`).as('amount'), + ) + applyGoFest2026MewtwoRewardFallback(queries.megaBranchFallback, { + rewardTypeColumn: 'quest_reward_type', + rewardsColumn: 'quest_rewards', + }) + if (hasAltQuests) { + queries.megaBranchFallbackAlt = this.query() + .select( + 'alternative_quest_title AS quest_title', + 'alternative_quest_target AS quest_target', + ) + .distinct( + raw(`${GO_FEST_2026_MEWTWO_ID}`).as('id'), + raw(`${GO_FEST_2026_MEWTWO_ENERGY_AMOUNT}`).as('amount'), + ) + applyGoFest2026MewtwoRewardFallback(queries.megaBranchFallbackAlt, { + rewardTypeColumn: 'alternative_quest_reward_type', + rewardsColumn: 'alternative_quest_rewards', + }) + } + } // mega // candy @@ -1876,18 +1930,25 @@ class Pokestop extends Model { } // showcase - ;['items', 'stardust', 'xp', 'mega', 'candy', 'xlCandy', 'pokemon'].forEach( - (key) => { - if (!shouldIncludeBaseQuests) { - delete queries[key] - } else if (queries[key]) { - applyMadQuestLayer(queries[key]) - } - if (!shouldIncludeAltQuests) { - delete queries[`${key}Alt`] - } - }, - ) + ;[ + 'items', + 'stardust', + 'xp', + 'mega', + 'megaBranchFallback', + 'candy', + 'xlCandy', + 'pokemon', + ].forEach((key) => { + if (!shouldIncludeBaseQuests) { + delete queries[key] + } else if (queries[key]) { + applyMadQuestLayer(queries[key]) + } + if (!shouldIncludeAltQuests) { + delete queries[`${key}Alt`] + } + }) const resolved = Object.fromEntries( await Promise.all( @@ -1904,9 +1965,11 @@ class Pokestop extends Model { .whereNotNull('quest_reward_type'), ) if (!isMad) { - questTypeQuery.whereNot( - 'quest_reward_type', - TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE, + questTypeQuery.whereNot((fallback) => + applyGoFest2026MewtwoRewardFallback(fallback, { + rewardTypeColumn: 'quest_reward_type', + rewardsColumn: 'quest_rewards', + }), ) } questTypeQueries.push( @@ -1919,9 +1982,11 @@ class Pokestop extends Model { const questTypeQuery = this.query() .distinct('alternative_quest_reward_type') .whereNotNull('alternative_quest_reward_type') - .whereNot( - 'alternative_quest_reward_type', - TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE, + .whereNot((fallback) => + applyGoFest2026MewtwoRewardFallback(fallback, { + rewardTypeColumn: 'alternative_quest_reward_type', + rewardsColumn: 'alternative_quest_rewards', + }), ) questTypeQueries.push( questTypeQuery.then((results) => @@ -1955,6 +2020,16 @@ class Pokestop extends Model { ) questTypes = questTypes.filter((x) => x !== 2) break + case 'megaBranchFallbackAlt': + case 'megaBranchFallback': + rewards.forEach((reward) => + process( + `m${reward.id}-${reward.amount}`, + reward.quest_title, + reward.quest_target, + ), + ) + break case 'megaAlt': case 'mega': rewards.forEach((reward) => @@ -2073,19 +2148,23 @@ class Pokestop extends Model { // Defensive: a malformed endpoint row could carry quest_reward_type with // no rewards array; don't let one bad stop throw and break the whole query. if (!Array.isArray(rewards) || rewards.length === 0) return quest - const { info } = rewards[0] - // Temp-evo branch resource (type 20) is mega energy; Golbat decodes its - // pokemon_id/amount into the same `info` shape as a MEGA_RESOURCE reward, - // so normalize it to type 12 and let the mega branch below expand it into - // `mega_*` fields (→ `m-` key). The original type is tracked by - // the caller (sourceQuestRewardType) so a `u20` filter still matches. + let { info } = rewards[0] if ( quest.quest_reward_type === TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE && info && !Array.isArray(info) && - info.pokemon_id + Object.keys(info).length === 0 ) { - rewards[0] = { ...rewards[0], type: MEGA_RESOURCE_REWARD_TYPE } + // GO Fest 2026 fallback until Golbat exposes branch-specific details. + info = { + amount: GO_FEST_2026_MEWTWO_ENERGY_AMOUNT, + pokemon_id: GO_FEST_2026_MEWTWO_ID, + } + rewards[0] = { + ...rewards[0], + info, + type: MEGA_RESOURCE_REWARD_TYPE, + } quest.quest_rewards = JSON.stringify(rewards) quest.quest_reward_type = MEGA_RESOURCE_REWARD_TYPE } @@ -2217,11 +2296,9 @@ class Pokestop extends Model { .toLowerCase() .includes(search), ) - // A "mega" reward-type search should also surface temp-evo branch (type 20) - // mega-energy quests, which now carry real pokemon/amount. - if (rewardTypes.includes(`${MEGA_RESOURCE_REWARD_TYPE}`)) { - rewardTypes.push(`${TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE}`) - } + const searchIncludesGoFest2026MewtwoReward = + pokemonIds.includes(`${GO_FEST_2026_MEWTWO_ID}`) || + rewardTypes.includes(`${MEGA_RESOURCE_REWARD_TYPE}`) if (!pokemonIds.length && !itemIds.length && !rewardTypes.length) { return [] @@ -2264,6 +2341,14 @@ class Pokestop extends Model { } else if (rewardTypes.length > 1) { quests.orWhereIn('quest_reward_type', rewardTypes) } + if (!isMad && searchIncludesGoFest2026MewtwoReward) { + quests.orWhere((fallback) => + applyGoFest2026MewtwoRewardFallback(fallback, { + rewardTypeColumn: 'quest_reward_type', + rewardsColumn: 'quest_rewards', + }), + ) + } }) .limit(config.getSafe('api.searchResultsLimit')) .orderBy('distance') @@ -2321,6 +2406,14 @@ class Pokestop extends Model { } else if (rewardTypes.length > 1) { quests.orWhereIn('alternative_quest_reward_type', rewardTypes) } + if (searchIncludesGoFest2026MewtwoReward) { + quests.orWhere((fallback) => + applyGoFest2026MewtwoRewardFallback(fallback, { + rewardTypeColumn: 'alternative_quest_reward_type', + rewardsColumn: 'alternative_quest_rewards', + }), + ) + } }) .limit(searchResultsLimit) .orderBy('distance') From fd35e9e70097162c4cbdda2ae0218629892f1a4e Mon Sep 17 00:00:00 2001 From: James Berry Date: Wed, 22 Jul 2026 13:44:08 +0100 Subject: [PATCH 23/38] feat(pokestop): re-apply endpoint DNF type-20 mega after develop merge develop's canonical type-20 handling (9acf867 + reward-definition refactor) superseded my SQL-side type-20 work, which was reverted before the merge. The endpoint DNF builder is ReactMap-fort-consumer-only (not in develop), so re-apply the mega clause quest_reward_type [12, 20] so Golbat returns type-20 (temp-evo branch) mega stops for the in-memory scan path. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/filters/fort/pokestop.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/src/filters/fort/pokestop.js b/server/src/filters/fort/pokestop.js index 93f7da708..2c8040e0e 100644 --- a/server/src/filters/fort/pokestop.js +++ b/server/src/filters/fort/pokestop.js @@ -223,15 +223,19 @@ function buildPokestopDnfFilters(filters, eventInvasions) { }) if (xlPokemon.length) clauses.push({ quest_reward_type: [9], quest_reward_pokemon: xlPokemon }) + // type 20 (temp-evo branch resource) is mega energy too, so match both. megaByAmount.forEach((pokes, amt) => clauses.push({ - quest_reward_type: [12], + quest_reward_type: [12, 20], quest_reward_pokemon: pokes, quest_reward_amount: { min: amt, max: amt }, }), ) if (megaLoose.length) - clauses.push({ quest_reward_type: [12], quest_reward_pokemon: megaLoose }) + clauses.push({ + quest_reward_type: [12, 20], + quest_reward_pokemon: megaLoose, + }) dustAmounts.forEach((amt) => clauses.push({ quest_reward_type: [3], From ef9c1908bb33e468c21b8fdaae60efb3225694c1 Mon Sep 17 00:00:00 2001 From: James Berry Date: Thu, 23 Jul 2026 07:57:22 +0100 Subject: [PATCH 24/38] fix(fort): address second Mygod review (resultLimit, filter-context, +) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [P1] Endpoint Pokestop scan passed no resultLimit to secondaryFilter, whose loop runs while filteredResults.length < resultLimit — so undefined returned zero markers. Pass queryLimits.pokestops (mirroring the SQL call) and drop the pre-truncation, which also fixes [P2] dropping the appended off-viewport manual-id row before filtering. - [P1] getFilterContext ran this.query() on an unbound (pure-endpoint) model when fallbackRocketPokemonFiltering is off and hasConfirmed is set, rejecting at startup. Recognize mem and return endpoint capability without SQL. - [P2] Dual endpoint sources now marked hasConfirmed:true even when the bound DB lacks the confirmed column (getAll uses Golbat rows with confirmation). - [P2] Availability mapper advertises type-20 mega only when both pokemon_id and amount are present (never u20 or m-0), matching what secondaryFilter keys. - [P2] Cache-key separators are the \0 escape instead of literal NUL bytes, so git no longer classifies fortAvailable.js as binary. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/models/Pokestop.js | 15 +++++++++++---- server/src/models/pokestopAvailableMapper.js | 13 +++++++++---- server/src/services/DbManager.js | 5 +++++ server/src/utils/fortAvailable.js | Bin 2915 -> 2917 bytes 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 8255f4e10..9133a25e5 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -926,9 +926,10 @@ class Pokestop extends Model { Number(stop.power_up_level) === Number(onlyLevels)) && filterRTree(stop, areaRestrictions, onlyAreas), ) - if (mapped.length > queryLimits.pokestops) { - mapped.length = queryLimits.pokestops - } + // Mirror the SQL path: pass the result cap to secondaryFilter (its + // loop runs while filteredResults.length < resultLimit — omitting it + // returns zero markers) rather than pre-truncating, which would drop + // the appended off-viewport manual-id row before filtering. const final = this.secondaryFilter( mapped, args.filters, @@ -940,6 +941,7 @@ class Pokestop extends Model { hasConfirmed, effectiveOnlyArEligible, memQuestLayer, + queryLimits.pokestops, ) log.info( TAGS.pokestops, @@ -2577,7 +2579,7 @@ class Pokestop extends Model { * @param {import('@rm/types').DbContext} ctx * @returns {Promise<{ hasConfirmedInvasions: boolean }>} */ - static async getFilterContext({ isMad, hasConfirmed }) { + static async getFilterContext({ isMad, hasConfirmed, mem }) { // Check if rocket Pokemon filtering should be forced via config const fallback = config.getSafe('map.misc.fallbackRocketPokemonFiltering') @@ -2587,6 +2589,11 @@ class Pokestop extends Model { return { hasConfirmedInvasions: true } } + // Endpoint source: Golbat scan rows always carry confirmed + lineup slots, + // so it is confirmed-capable without an SQL probe — and a pure-endpoint + // model has no bound knex, so this.query() below would throw at startup. + if (mem) return { hasConfirmedInvasions: true } + // Use original behavior when config is disabled if (isMad || !hasConfirmed) return { hasConfirmedInvasions: false } const result = await this.query() diff --git a/server/src/models/pokestopAvailableMapper.js b/server/src/models/pokestopAvailableMapper.js index 3810daf89..7282684d1 100644 --- a/server/src/models/pokestopAvailableMapper.js +++ b/server/src/models/pokestopAvailableMapper.js @@ -102,10 +102,12 @@ function questRewardKey(quest) { 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' + // §type20: temp-evo branch mega energy. secondaryFilter keys type 20 as a + // dedicated mega reward ONLY when both pokemon_id and amount are present + // (else no key), and never emits `u20` (type 20 has a dedicated filter). + // So advertise `m-` only when complete — `u20`/`m-0` would be + // a filter no marker can satisfy. + return pokemon_id > 0 && amount > 0 ? `m${pokemon_id}-${amount}` : '' default: return `u${reward_type}` } @@ -161,6 +163,9 @@ function mapAvailablePokestops(api, ctx) { return } const key = questRewardKey(quest) + // An incomplete reward (e.g. type-20 missing pokemon_id/amount) yields no + // key — advertise nothing rather than a filter no marker can satisfy. + if (!key) return // 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. diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index 18473086e..90c848799 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -293,6 +293,11 @@ class DbManager extends Logger { schemaContext.mem = this.endpoints[i].endpoint schemaContext.secret = this.endpoints[i].secret schemaContext.httpAuth = this.endpoints[i].httpAuth + // getAll uses Golbat rows (confirmed + lineup slots) when the + // endpoint is active, so mark the source confirmed-capable even if + // the bound DB lacks the confirmed column (schemaCheck left it + // false). Endpoint capability is authoritative while mem is set. + schemaContext.hasConfirmed = true } Object.entries(this.models).forEach(([category, sources]) => { diff --git a/server/src/utils/fortAvailable.js b/server/src/utils/fortAvailable.js index d1d1d27f07a69b38d3edb64e3a90b495d3b34799..b8ccd98cca4a979ac3cc490d79e1bd6d82ee1c01 100644 GIT binary patch delta 32 ocmaDX_Ec=cJQm>?1C{FH)a0Vn5`~%?1$Fh>7=z90S+=kN0Ln`Xx&QzG delta 30 mcmaDV_E>DgJQhI)mFnWu Date: Thu, 23 Jul 2026 09:20:59 +0100 Subject: [PATCH 25/38] fix(fort): address whole-PR review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: - getOne (gym/pokestop) endpoint now projects to {lat, lon} like the SQL path, instead of returning the raw Golbat record. The client controls the GraphQL selection, so returning the full record leaked raid/team/lure/detail past the sub-perm split and area restrictions for any fort id. - Pokestop endpoint onlyLevels power-up gate now guarded on !onlyAllPokestops (like the gym sibling). SQL only applies it under onlyAllPokestops, so the endpoint was under-returning the entire quest/invasion/lure layer for a user with a non-all levels filter. P2: - DbManager.search / submissionCells use Promise.allSettled: the fort search/getSubmissions methods have no endpoint branch, so a pure-endpoint source rejected the whole batch (crashing search/submissions) instead of degrading. Mirrors the getOne fix. - fetchJson redacts Authorization / X-Golbat-Secret before the debug log and the failed-request payload dump, so credentials are no longer written to disk. - EventManager only arms the availability TTL on a non-empty refresh, so a failed (empty) endpoint refresh no longer suppresses recovery for the window. P3: - encodeURIComponent on all fort by-id fetch URLs (getOne, manual-id, getDynamaxMons) — no path traversal via id/onlyManualId. - historicalRarity uses Promise.allSettled so a dual source whose DB lacks pokemon_stats no longer fails the whole batch and blanks every rarity map. (Skipped review P3: force does not bypass the 30s combined-availability cache — that cache deliberately caches failures to avoid hammering; the TTL fix already cuts the drawer-empty window to <=30s, and force-bypass needs invasive threading for a minor gain.) Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/models/Gym.js | 9 ++++-- server/src/models/Pokestop.js | 17 ++++++++--- server/src/models/Station.js | 4 +-- server/src/services/DbManager.js | 47 +++++++++++++++++++---------- server/src/services/EventManager.js | 8 ++++- server/src/utils/fetchJson.js | 25 +++++++++++++-- 6 files changed, 81 insertions(+), 29 deletions(-) diff --git a/server/src/models/Gym.js b/server/src/models/Gym.js index 476efefc9..899597c82 100644 --- a/server/src/models/Gym.js +++ b/server/src/models/Gym.js @@ -563,7 +563,7 @@ class Gym extends Model { try { const one = await fetchFortById( TAGS.gyms, - `${mem}/api/gym/id/${manualId}`, + `${mem}/api/gym/id/${encodeURIComponent(manualId)}`, secret, httpAuth, ) @@ -843,11 +843,14 @@ class Gym extends Model { try { const one = await fetchFortById( TAGS.gyms, - `${mem}/api/gym/id/${id}`, + `${mem}/api/gym/id/${encodeURIComponent(id)}`, secret, httpAuth, ) - if (one) return one + // Match the SQL projection ({lat, lon} only). Returning the raw Golbat + // record would leak raid/team/detail fields past the raids sub-perm + // split and area restrictions — a deep link only needs centering. + if (one) return { lat: one.lat, lon: one.lon } } catch (e) { log.warn( TAGS.gyms, diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 9133a25e5..8bb1e6ba5 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -901,7 +901,7 @@ class Pokestop extends Model { try { const one = await fetchFortById( TAGS.pokestops, - `${mem}/api/pokestop/id/${manualId}`, + `${mem}/api/pokestop/id/${encodeURIComponent(manualId)}`, secret, httpAuth, ) @@ -921,8 +921,12 @@ class Pokestop extends Model { stop.updated > ts - stopValidDataLimit * 86400) && // Power-up level (onlyLevels): power-ups are out of the game // (also why power_up_level is dropped from the DNF); the gate is - // vestigial but mirrored for exact endpoint↔SQL parity. - (onlyLevels === 'all' || + // vestigial but mirrored for exact endpoint↔SQL parity. SQL only + // applies it under onlyAllPokestops (the `else` of `if + // (!onlyAllPokestops)`), so guard on it like the gym sibling — + // else the whole quest/invasion/lure layer under-returns. + (!onlyAllPokestops || + onlyLevels === 'all' || Number(stop.power_up_level) === Number(onlyLevels)) && filterRTree(stop, areaRestrictions, onlyAreas), ) @@ -2510,11 +2514,14 @@ class Pokestop extends Model { try { const one = await fetchFortById( TAGS.pokestops, - `${mem}/api/pokestop/id/${id}`, + `${mem}/api/pokestop/id/${encodeURIComponent(id)}`, secret, httpAuth, ) - if (one) return one + // Match the SQL projection ({lat, lon} only). Returning the raw Golbat + // record would leak lure/power-up/detail fields past the sub-perm gates + // and area restrictions — a deep link only needs centering. + if (one) return { lat: one.lat, lon: one.lon } } catch (e) { log.warn( TAGS.pokestops, diff --git a/server/src/models/Station.js b/server/src/models/Station.js index be04c308d..eb5049042 100644 --- a/server/src/models/Station.js +++ b/server/src/models/Station.js @@ -732,7 +732,7 @@ class Station extends Model { try { const one = await fetchFortById( TAGS.stations, - `${mem}/api/station/id/${manualId}`, + `${mem}/api/station/id/${encodeURIComponent(manualId)}`, secret, httpAuth, ) @@ -1147,7 +1147,7 @@ class Station extends Model { // where this.query() would throw) can still serve the dynamax popup. const one = await evalScannerQuery( TAGS.stations, - `${mem}/api/station/id/${id}`, + `${mem}/api/station/id/${encodeURIComponent(id)}`, undefined, 'GET', secret, diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index 90c848799..1cb4aac4d 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -358,12 +358,12 @@ class DbManager extends Logger { async historicalRarity() { this.log.info('Setting historical rarity stats') try { - const results = await Promise.all( + // allSettled: skip MAD (no pokemon_stats) and pure-endpoint sources (no + // bound knex). A dual source (endpoint + DB) keeps its knex and still + // serves rarity, but if one such DB lacks pokemon_stats its query must + // not fail the whole batch and blank every source's rarity map. + const settled = await Promise.allSettled( (this.models.Pokemon ?? []).map(async (source) => - // Skip MAD (no pokemon_stats) and pure-endpoint sources (no bound - // knex to query). A dual source (endpoint + DB) keeps its knex, so it - // still serves historical rarity — testing source.mem alone would - // wrongly drop it and clear the rarity map on refresh. source.isMad || !this.connections[source.connection] ? [] : source.SubModel.query() @@ -372,6 +372,9 @@ class DbManager extends Logger { .groupBy('pokemon_id'), ), ) + const results = settled + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value) this.setRarity( results.map((result) => Object.fromEntries( @@ -589,7 +592,11 @@ class DbManager extends Logger { const loopTime = Date.now() count += 1 const bbox = getBboxFromCenter(args.lat, args.lon, distance) - const data = await Promise.all( + // allSettled: the fort search methods have no endpoint branch, so a + // pure-endpoint source hits an unbound this.query() and rejects. Degrade + // that source to no results rather than failing the whole search batch + // (a co-configured SQL source still contributes). + const settled = await Promise.allSettled( this.models[model].map(async ({ SubModel, ...source }) => SubModel[method]( perms, @@ -600,6 +607,9 @@ class DbManager extends Logger { ), ), ) + const data = settled + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value) const results = DbManager.deDupeResults(data) if (results.length > deDuped.length) { deDuped = results @@ -658,16 +668,21 @@ class DbManager extends Logger { * ]>} */ async submissionCells(perms, args) { - const stopData = await Promise.all( - this.models.Pokestop.map(async ({ SubModel, ...source }) => - SubModel.getSubmissions(perms, args, source), - ), - ) - const gymData = await Promise.all( - this.models.Gym.map(async ({ SubModel, ...source }) => - SubModel.getSubmissions(perms, args, source), - ), - ) + // allSettled: getSubmissions has no endpoint branch, so a pure-endpoint + // source rejects on an unbound this.query(); degrade it to no cells rather + // than failing the whole submission overlay. + const collect = async (sources) => + ( + await Promise.allSettled( + sources.map(async ({ SubModel, ...source }) => + SubModel.getSubmissions(perms, args, source), + ), + ) + ) + .filter((r) => r.status === 'fulfilled') + .map((r) => r.value) + const stopData = await collect(this.models.Pokestop) + const gymData = await collect(this.models.Gym) return [DbManager.deDupeResults(stopData), DbManager.deDupeResults(gymData)] } diff --git a/server/src/services/EventManager.js b/server/src/services/EventManager.js index e9ba33df2..6aa3d7837 100644 --- a/server/src/services/EventManager.js +++ b/server/src/services/EventManager.js @@ -169,7 +169,13 @@ class EventManager extends Logger { }) this.available[category] = available this.addAvailable(category) - this.availableUpdatedAt[category] = Date.now() + // Only arm the TTL when the refresh returned options. A failed endpoint + // refresh (esp. pure-endpoint) comes back empty; arming the TTL on it would + // suppress the retry for the whole window and keep the drawer empty until it + // expires — leaving it unstamped lets the next setAvailable recover. + if (available.length) { + this.availableUpdatedAt[category] = Date.now() + } } /** diff --git a/server/src/utils/fetchJson.js b/server/src/utils/fetchJson.js index 6073f9feb..884ee6993 100644 --- a/server/src/utils/fetchJson.js +++ b/server/src/utils/fetchJson.js @@ -9,6 +9,27 @@ const { log, TAGS } = require('@rm/logger') const { setLongTimeout } = require('./setLongTimeout') +const REDACTED_HEADERS = new Set(['authorization', 'x-golbat-secret']) + +/** + * Returns a shallow copy of the fetch options with credential headers masked, + * so debug logs and the failed-request payload dump never persist the + * X-Golbat-Secret or Basic auth to disk. + * @param {import('node-fetch').RequestInit} [options] + */ +function redactOptions(options) { + const headers = options && /** @type {any} */ (options).headers + if (!headers || typeof headers !== 'object') return options + return { + ...options, + headers: Object.fromEntries( + Object.entries(headers).map(([k, v]) => + REDACTED_HEADERS.has(k.toLowerCase()) ? [k, ''] : [k, v], + ), + ), + } +} + /** * fetch wrapper with timeout and error handling * @param {string} url @@ -23,7 +44,7 @@ async function fetchJson(url, options = undefined) { }, config.getSafe('api.fetchTimeoutMs')) try { - log.debug(TAGS.fetch, url, options || '') + log.debug(TAGS.fetch, url, options ? redactOptions(options) : '') const response = await fetch(url, { ...options, signal: controller.signal }) if (!response.ok) { throw new Error(`${response.status} (${response.statusText})`, { @@ -47,7 +68,7 @@ async function fetchJson(url, options = undefined) { fs.mkdirSync(logsDir, { recursive: true }) fs.writeFileSync( resolve(logsDir, fileName), - JSON.stringify(options, null, 2), + JSON.stringify(redactOptions(options), null, 2), ) } catch (writeError) { log.warn( From 4b278a311aa0399e12a7ec095a0b3a4d8964524f Mon Sep 17 00:00:00 2001 From: James Berry Date: Fri, 24 Jul 2026 21:50:21 +0100 Subject: [PATCH 26/38] fix(fort): address review round (endpoint-only, no query broadening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four ReactMap-side regressions from Mygod review; the three that would modify Golbat or broaden the Golbat queries are held (see PR discussion). - Availability mapper runs generic u quest keys through process() so they carry their title/target conditions, matching develop SQL genericQuests (endpoint deployments were losing the advanced quest-condition selector). - EventManager #refreshAvailable returns early on an empty result: a failed (empty) refresh no longer replaces the last-good drawer + conditions or arms the TTL — it retains the cache and retries next call. - DbManager no longer forces hasConfirmed:true on a dual source. That flag gates the SQL fallback confirmed-column query, so a dual DB lacking confirmed would reject on fallback. The endpoint scan branch marks itself confirmed-capable locally instead, keeping endpoint capability separate from the fallback flag. - historicalRarity retains the last-good rarity map when every eligible pokemon_stats query fails (total DB outage) instead of clearing it — the allSettled change had lost Promise.all catch-path retention. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/models/Pokestop.js | 5 ++- server/src/models/pokestopAvailableMapper.js | 13 +++---- server/src/services/DbManager.js | 41 ++++++++++++-------- server/src/services/EventManager.js | 14 +++---- 4 files changed, 41 insertions(+), 32 deletions(-) diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 8bb1e6ba5..835d4d5f2 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -942,7 +942,10 @@ class Pokestop extends Model { midnight, perms, hasMultiInvasions, - hasConfirmed, + // The endpoint scan always returns confirmed lineup data, so treat + // it as confirmed-capable regardless of the source's schema flag + // (which the SQL fallback below still relies on). + true, effectiveOnlyArEligible, memQuestLayer, queryLimits.pokestops, diff --git a/server/src/models/pokestopAvailableMapper.js b/server/src/models/pokestopAvailableMapper.js index 7282684d1..a93168982 100644 --- a/server/src/models/pokestopAvailableMapper.js +++ b/server/src/models/pokestopAvailableMapper.js @@ -166,14 +166,11 @@ function mapAvailablePokestops(api, ctx) { // An incomplete reward (e.g. type-20 missing pokemon_id/amount) yields no // key — advertise nothing rather than a filter no marker can satisfy. if (!key) return - // 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) - } + // Every key — including generic `u` fallbacks — carries its + // title/target conditions, matching the SQL path's + // `genericQuests.forEach(process)`. Otherwise endpoint deployments lose the + // advanced quest-condition selector for generic rewards. + process(key, quest.title, quest.target) }) // Invasions: `i`/`b` keys are unconditional; the `a` key additionally diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index 1cb4aac4d..75688fe6f 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -293,11 +293,11 @@ class DbManager extends Logger { schemaContext.mem = this.endpoints[i].endpoint schemaContext.secret = this.endpoints[i].secret schemaContext.httpAuth = this.endpoints[i].httpAuth - // getAll uses Golbat rows (confirmed + lineup slots) when the - // endpoint is active, so mark the source confirmed-capable even if - // the bound DB lacks the confirmed column (schemaCheck left it - // false). Endpoint capability is authoritative while mem is set. - schemaContext.hasConfirmed = true + // NB: leave schema-derived capability flags (hasConfirmed, etc.) + // untouched — the SQL fallback path relies on them (a dual source + // whose DB lacks the `confirmed` column must NOT query it). The + // endpoint always provides confirmed data, so the endpoint scan + // branch marks itself confirmed-capable locally instead. } Object.entries(this.models).forEach(([category, sources]) => { @@ -358,23 +358,32 @@ class DbManager extends Logger { async historicalRarity() { this.log.info('Setting historical rarity stats') try { - // allSettled: skip MAD (no pokemon_stats) and pure-endpoint sources (no - // bound knex). A dual source (endpoint + DB) keeps its knex and still - // serves rarity, but if one such DB lacks pokemon_stats its query must - // not fail the whole batch and blank every source's rarity map. + // Only DB-backed, non-MAD sources have pokemon_stats. A dual source + // (endpoint + DB) keeps its knex and still serves rarity. + const eligible = (this.models.Pokemon ?? []).filter( + (source) => !source.isMad && this.connections[source.connection], + ) + // allSettled: if one dual-source DB lacks pokemon_stats its query must not + // fail the whole batch and blank every source's rarity map. const settled = await Promise.allSettled( - (this.models.Pokemon ?? []).map(async (source) => - source.isMad || !this.connections[source.connection] - ? [] - : source.SubModel.query() - .select('pokemon_id', raw('SUM(count) as total')) - .from('pokemon_stats') - .groupBy('pokemon_id'), + eligible.map((source) => + source.SubModel.query() + .select('pokemon_id', raw('SUM(count) as total')) + .from('pokemon_stats') + .groupBy('pokemon_id'), ), ) const results = settled .filter((r) => r.status === 'fulfilled') .map((r) => r.value) + // Every eligible query rejected (e.g. the sole dual-source DB is down): + // retain the last-good rarity map rather than clearing it to empty. + if (eligible.length && results.length === 0) { + this.log.warn( + 'Historical rarity: all sources failed; retaining last-good stats', + ) + return + } this.setRarity( results.map((result) => Object.fromEntries( diff --git a/server/src/services/EventManager.js b/server/src/services/EventManager.js index 6aa3d7837..ecbb25fe7 100644 --- a/server/src/services/EventManager.js +++ b/server/src/services/EventManager.js @@ -131,6 +131,12 @@ class EventManager extends Logger { */ async #refreshAvailable(category, model, Db) { const available = await Db.getAvailable(model) + // A failed refresh (esp. a pure-endpoint /api/fort/available request) comes + // back empty. Don't replace the last-good drawer + conditions with it or arm + // the TTL — keep serving the previous options and retry on the next call. (A + // genuinely-empty category just re-scans next session; this list is + // drawer-only, so that is harmless.) + if (!available.length) return /** @param {string} key */ const parseKey = (key) => { @@ -169,13 +175,7 @@ class EventManager extends Logger { }) this.available[category] = available this.addAvailable(category) - // Only arm the TTL when the refresh returned options. A failed endpoint - // refresh (esp. pure-endpoint) comes back empty; arming the TTL on it would - // suppress the retry for the whole window and keep the drawer empty until it - // expires — leaving it unstamped lets the next setAvailable recover. - if (available.length) { - this.availableUpdatedAt[category] = Date.now() - } + this.availableUpdatedAt[category] = Date.now() } /** From f8c29173625ac9b1afb5831374da894ce58bb83b Mon Sep 17 00:00:00 2001 From: James Berry Date: Sun, 26 Jul 2026 14:03:39 +0100 Subject: [PATCH 27/38] fix(fort): address review round (endpoint-only refinements) Five ReactMap-side fixes; the other two (scan-limit-before-residual is a repeat addressed via Golbat max_fort_results, and the rocket-reward superset would need query broadening or a Golbat predicate) are held per the no-Golbat/no-broaden constraint -- see PR. - Serialize endpoint quest_rewards / quest_conditions to String so the GraphQL String fields coerce (they are raw JSON-string columns on the SQL path); parseRdmRewards already handles the string. - Prepend the manual by-id fort instead of appending, so an off-viewport onlyManualId deep link survives secondaryFilter resultLimit cap in a dense viewport (gym/pokestop/station). - loadLocalContexts forces setAvailable so a hot config reload (fresh manager) is not short-circuited by the category-keyed availability TTL. The per-session stampede path stays non-forced. - getAvailable no longer overwrites manager-owned metadata (quest conditions, rarity) on a total-failure empty result -- retains last-good. - getOne / search / submissionCells route through runScannerSources (allSettled + logs rejections) rather than a raw allSettled that silently swallowed genuine DB-source failures. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/models/Gym.js | 4 +- server/src/models/Pokestop.js | 5 +- server/src/models/Station.js | 4 +- server/src/models/pokestopScanMapper.js | 12 ++++- server/src/services/DbManager.js | 67 +++++++++++-------------- server/src/services/state.js | 17 ++++--- 6 files changed, 60 insertions(+), 49 deletions(-) diff --git a/server/src/models/Gym.js b/server/src/models/Gym.js index 899597c82..7db90ab72 100644 --- a/server/src/models/Gym.js +++ b/server/src/models/Gym.js @@ -567,7 +567,9 @@ class Gym extends Model { secret, httpAuth, ) - if (one) res.gyms.push(one) + // Prepend so the off-viewport deep-link survives secondaryFilter's + // resultLimit cap in a dense viewport (see Pokestop.getAll). + if (one) res.gyms.unshift(one) } catch { // by-id miss mirrors SQL finding no such row } diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 835d4d5f2..9c165b89f 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -905,7 +905,10 @@ class Pokestop extends Model { secret, httpAuth, ) - if (one) res.pokestops.push(one) + // Prepend, not append: secondaryFilter stops at resultLimit, so + // an off-viewport deep-link appended last would be skipped in a + // dense viewport that already fills the cap. + if (one) res.pokestops.unshift(one) } catch { // by-id miss mirrors SQL finding no such row } diff --git a/server/src/models/Station.js b/server/src/models/Station.js index eb5049042..fa4ce0102 100644 --- a/server/src/models/Station.js +++ b/server/src/models/Station.js @@ -736,7 +736,9 @@ class Station extends Model { secret, httpAuth, ) - if (one) res.stations.push(one) + // Prepend so the off-viewport deep-link survives the resultLimit + // cap in a dense viewport (see Pokestop.getAll). + if (one) res.stations.unshift(one) } catch { // by-id miss mirrors SQL finding no such row } diff --git a/server/src/models/pokestopScanMapper.js b/server/src/models/pokestopScanMapper.js index b9715c2ca..a265e884e 100644 --- a/server/src/models/pokestopScanMapper.js +++ b/server/src/models/pokestopScanMapper.js @@ -75,6 +75,11 @@ function mapInvasion(inc) { * @param {boolean} withAr * @returns {Record | null} */ +/** Serialize a parsed JSON value back to a string (SQL shape); pass strings + * and null/undefined through unchanged. */ +const jsonString = (v) => + typeof v === 'string' || v == null ? v : JSON.stringify(v) + function buildQuestLayer(api, prefix, withAr) { const questRewardType = api[`${prefix}quest_reward_type`] if (!questRewardType) return null @@ -82,8 +87,11 @@ function buildQuestLayer(api, prefix, withAr) { quest_type: api[`${prefix}quest_type`], quest_timestamp: api[`${prefix}quest_timestamp`], quest_target: api[`${prefix}quest_target`], - quest_conditions: api[`${prefix}quest_conditions`], - quest_rewards: api[`${prefix}quest_rewards`], + // SQL exposes these as raw JSON strings (`quest_condition`/`quest_reward` + // columns); Golbat returns them parsed. Stringify so the GraphQL `String` + // field coerces and parseRdmRewards (which handles a string) still parses. + quest_conditions: jsonString(api[`${prefix}quest_conditions`]), + quest_rewards: jsonString(api[`${prefix}quest_rewards`]), quest_reward_type: questRewardType, quest_item_id: api[`${prefix}quest_item_id`], quest_pokemon_id: api[`${prefix}quest_pokemon_id`], diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index 75688fe6f..6c1727dc0 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -545,18 +545,14 @@ class DbManager extends Logger { * @returns {Promise} */ async getOne(model, id) { - // allSettled, not all: a pure-endpoint source whose by-id fetch misses - // falls through to this.query() on an unbound model, which throws. With - // Promise.all that one rejection would fail the whole single-fort lookup; - // here it just contributes no match. - const settled = await Promise.allSettled( - this.models[model].map(async ({ SubModel, ...source }) => - SubModel.getOne(id, source), - ), + // runScannerSources (allSettled + logs rejections): a pure-endpoint source + // whose by-id fetch misses falls through to an unbound this.query() and + // rejects. Isolating it keeps one miss from failing the whole single-fort + // lookup, while a genuine SQL error from a DB source is still logged. + const data = await this.runScannerSources( + model, + ({ SubModel, ...source }) => SubModel.getOne(id, source), ) - const data = settled - .filter((r) => r.status === 'fulfilled') - .map((r) => r.value) const cleaned = DbManager.deDupeResults(data.filter(Boolean)) return cleaned || {} } @@ -601,12 +597,14 @@ class DbManager extends Logger { const loopTime = Date.now() count += 1 const bbox = getBboxFromCenter(args.lat, args.lon, distance) - // allSettled: the fort search methods have no endpoint branch, so a - // pure-endpoint source hits an unbound this.query() and rejects. Degrade - // that source to no results rather than failing the whole search batch - // (a co-configured SQL source still contributes). - const settled = await Promise.allSettled( - this.models[model].map(async ({ SubModel, ...source }) => + // runScannerSources (allSettled + logs rejections): the fort search + // methods have no endpoint branch, so a pure-endpoint source hits an + // unbound this.query() and rejects. Isolating it degrades that source to + // no results rather than failing the whole batch, while a genuine SQL + // error from a DB source stays visible in the logs. + const data = await this.runScannerSources( + model, + ({ SubModel, ...source }) => SubModel[method]( perms, args, @@ -614,11 +612,7 @@ class DbManager extends Logger { DbManager.getDistance(args, source.isMad), bbox, ), - ), ) - const data = settled - .filter((r) => r.status === 'fulfilled') - .map((r) => r.value) const results = DbManager.deDupeResults(data) if (results.length > deDuped.length) { deDuped = results @@ -677,21 +671,14 @@ class DbManager extends Logger { * ]>} */ async submissionCells(perms, args) { - // allSettled: getSubmissions has no endpoint branch, so a pure-endpoint - // source rejects on an unbound this.query(); degrade it to no cells rather - // than failing the whole submission overlay. - const collect = async (sources) => - ( - await Promise.allSettled( - sources.map(async ({ SubModel, ...source }) => - SubModel.getSubmissions(perms, args, source), - ), - ) - ) - .filter((r) => r.status === 'fulfilled') - .map((r) => r.value) - const stopData = await collect(this.models.Pokestop) - const gymData = await collect(this.models.Gym) + // runScannerSources (allSettled + logs rejections): getSubmissions has no + // endpoint branch, so a pure-endpoint source rejects on an unbound + // this.query(); isolate it to no cells rather than failing the whole + // overlay, while a genuine DB error stays visible in the logs. + const handler = ({ SubModel, ...source }) => + SubModel.getSubmissions(perms, args, source) + const stopData = await this.runScannerSources('Pokestop', handler) + const gymData = await this.runScannerSources('Gym', handler) return [DbManager.deDupeResults(stopData), DbManager.deDupeResults(gymData)] } @@ -737,7 +724,11 @@ class DbManager extends Logger { async ({ SubModel, ...source }) => SubModel.getAvailable(source), ) this.log.info(`Setting available for ${model}`) - if (model === 'Pokestop') { + // runScannerSources returns only fulfilled sources, so an empty `results` + // means every source failed. Don't overwrite manager-owned metadata + // (quest conditions / rarity) with that failure-derived emptiness — + // retain the last-good so a transient outage doesn't blank the drawer. + if (results.length && model === 'Pokestop') { const newQuestConditions = {} results.forEach((result) => { if ('conditions' in result) { @@ -751,7 +742,7 @@ class DbManager extends Logger { ]), ) } - if (model === 'Pokemon') { + if (results.length && model === 'Pokemon') { this.setRarity(results, false) } if (results.length === 1) return results[0].available diff --git a/server/src/services/state.js b/server/src/services/state.js index 28c4c0544..3ede0b1c1 100644 --- a/server/src/services/state.js +++ b/server/src/services/state.js @@ -100,14 +100,19 @@ const state = { if (!reloadReport || reloadReport.historical) { promises.push(this.db.historicalRarity()) } + // force=true: startup/reload must query the current manager. On a hot + // reload this.db is a fresh manager, so the availability TTL (keyed by + // category only) must not short-circuit and keep the old source's data. + // The per-session stampede path (rootRouter queryOnSessionInit) stays + // non-forced and TTL-protected. promises.push( this.db.getFilterContext(), - this.event.setAvailable('gyms', 'Gym', this.db), - this.event.setAvailable('pokestops', 'Pokestop', this.db), - this.event.setAvailable('pokemon', 'Pokemon', this.db), - this.event.setAvailable('nests', 'Nest', this.db), - this.event.setAvailable('stations', 'Station', this.db), - this.event.setAvailable('tappables', 'Tappable', this.db), + this.event.setAvailable('gyms', 'Gym', this.db, true), + this.event.setAvailable('pokestops', 'Pokestop', this.db, true), + this.event.setAvailable('pokemon', 'Pokemon', this.db, true), + this.event.setAvailable('nests', 'Nest', this.db, true), + this.event.setAvailable('stations', 'Station', this.db, true), + this.event.setAvailable('tappables', 'Tappable', this.db, true), ) } await Promise.all(promises) From e95142befe744087c12be1eab07f1c0e542acd40 Mon Sep 17 00:00:00 2001 From: James Berry Date: Sun, 26 Jul 2026 14:18:57 +0100 Subject: [PATCH 28/38] fix(pokestop): narrow rocket-reward DNF by display type, not reward Per maintainer: incident display type is reliable (sourced from the GMO, always present); the reward pokemon is a slot-1-only value populated by an optional invasion check, so it must not be narrowed at Golbat -- a reward-derived incident_character can drop a stop secondaryFilter would accept (e.g. across a rotation), an under-return. The `a` rocket-reward filter now emits incident_display_type [1,2,3,4] (all rocket incidents -- a safe superset) instead of the metadata-derived incident_character, and secondaryFilter confirms the specific reward from its slot/metadata data. Removes the now-dead gruntTypesForRocketPokemon helper and the empty-event-map []-poison (no longer needed -- display types don't depend on the event map). Addresses Mygod review "keep rocket-reward DNF a true superset". Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/filters/fort/pokestop.js | 61 ++++++++++------------------- 1 file changed, 20 insertions(+), 41 deletions(-) diff --git a/server/src/filters/fort/pokestop.js b/server/src/filters/fort/pokestop.js index 2c8040e0e..fd1d6790a 100644 --- a/server/src/filters/fort/pokestop.js +++ b/server/src/filters/fort/pokestop.js @@ -1,37 +1,11 @@ // @ts-check const { parseIdFormPair } = require('./parseIdForm') -/** - * Grunt (incident) character ids whose *possible* rocket encounters include any - * of the requested reward pokemon ids. Mirrors the SQL path's - * `gruntTypesWithMatchingRewards` (`Pokestop.getAll`): iterate the event - * invasion map, skip team leaders/Giovanni (41-44), and match by pokemon id - * across the reward-gated first/second/third encounter slots. This is the DNF - * expression of the `a` rocket-reward filter — a superset of the real - * matches (both confirmed slots and unconfirmed grunts of these types), - * narrowed exactly by secondaryFilter. - * - * @param {Record} eventInvasions state.event.invasions - * @param {Set} pokemonIds - * @returns {number[]} - */ -function gruntTypesForRocketPokemon(eventInvasions, pokemonIds) { - const grunts = [] - Object.entries(eventInvasions || {}).forEach(([gruntStr, info]) => { - if (!info) return - const grunt = Number(gruntStr) - if (!Number.isFinite(grunt) || (grunt >= 41 && grunt <= 44)) return - const encounters = [ - ...(info.firstReward ? info.encounters?.first || [] : []), - ...(info.secondReward ? info.encounters?.second || [] : []), - ...(info.thirdReward ? info.encounters?.third || [] : []), - ] - if (encounters.some((poke) => pokemonIds.has(Number(poke.id)))) { - grunts.push(grunt) - } - }) - return grunts -} +// Incident display types for Team Rocket grunt invasions (the only invasions +// that carry a catchable reward). Sourced from the GMO, so reliable — unlike +// the reward pokemon (see the `a` rocket-reward handling below). Golbat doc: +// display_type 1-4 = rocket, 7 goldstop, 8 kecleon, 9 showcase. +const ROCKET_INCIDENT_DISPLAY_TYPES = [1, 2, 3, 4] /** * Translate a pokestop's `args.filters` into ApiFortDnfFilter[] clauses. @@ -59,12 +33,13 @@ function gruntTypesForRocketPokemon(eventInvasions, pokemonIds) { * (quest title/target `adv`, invasion `confirmed` stay residual). Returns [] * (match-all) when a match-all toggle is active or nothing is set. * - * `a` rocket-reward keys are expanded to `incident_character` (the - * grunt types that can reward those pokemon) via `eventInvasions`; without that - * map (empty/unloaded) they poison to `[]` since they can't be expressed safely. + * `a` rocket-reward keys narrow ONLY by rocket incident display type + * (reliable, from the GMO), never by the reward pokemon (a slot-1-only value + * from an optional invasion check that no Golbat clause can safely track); + * secondaryFilter confirms the specific reward. * * @param {Record} filters args.filters - * @param {Record} [eventInvasions] state.event.invasions (grunt→reward map) + * @param {Record} [eventInvasions] state.event.invasions (grunt→reward map, used for grunt-class exclusion) * @returns {object[]} */ function buildPokestopDnfFilters(filters, eventInvasions) { @@ -253,12 +228,16 @@ function buildPokestopDnfFilters(filters, eventInvasions) { if (onlyLures && lureId.length) clauses.push({ lure_id: lureId }) if (onlyInvasions) { if (rocketPokemonIds.size) { - // Can't expand rocket-reward filters without the event map -> match-all so - // the residual (invasionMatchesFilters) can still surface them. - if (!eventInvasions || Object.keys(eventInvasions).length === 0) return [] - gruntTypesForRocketPokemon(eventInvasions, rocketPokemonIds).forEach( - (g) => incidentCharacter.add(g), - ) + // Rocket-reward `a` filters narrow by incident DISPLAY TYPE only, never by + // the reward pokemon. Display type is reliable (it comes from the GMO and + // is always present); the reward is a slot-1-only value populated by an + // optional invasion check, so a reward-derived incident_character could + // drop a stop secondaryFilter would accept (e.g. across a rotation). + // Restrict to rocket incidents (safe superset); secondaryFilter confirms + // the specific reward from its slot/metadata data. + clauses.push({ + incident_display_type: [...ROCKET_INCIDENT_DISPLAY_TYPES], + }) } if ( incidentCharacter.size && From 0ad5883d7fe3f174631011329f0e032804d93303 Mon Sep 17 00:00:00 2001 From: James Berry Date: Sun, 26 Jul 2026 14:21:10 +0100 Subject: [PATCH 29/38] docs(pokestop): fix stale incidentCharacter comment The a-derived grunts no longer feed incidentCharacter (rocket rewards now narrow by display type), so it holds only i-filter grunt character ids. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/filters/fort/pokestop.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/filters/fort/pokestop.js b/server/src/filters/fort/pokestop.js index fd1d6790a..ec42e8130 100644 --- a/server/src/filters/fort/pokestop.js +++ b/server/src/filters/fort/pokestop.js @@ -71,7 +71,7 @@ function buildPokestopDnfFilters(filters, eventInvasions) { const xpAmounts = new Set() // 'p' -> type 1, amount-exact const typeOnly = new Set() // 'u' (+ overflow amounts) -> type-level const lureId = [] - const incidentCharacter = new Set() // 'i' grunt ids + 'a'-derived grunt ids + const incidentCharacter = new Set() // 'i' grunt character ids const rocketPokemonIds = new Set() // 'a' reward ids const incidentDisplayType = [] const contestPokemon = [] From 2cd14701f53da65deb84294ba8b703fe441bed55 Mon Sep 17 00:00:00 2001 From: James Berry Date: Sun, 26 Jul 2026 17:21:26 +0100 Subject: [PATCH 30/38] fix(fort): address review round (station projection, empty-snapshot, env TTL) Three ReactMap-side fixes. - [P1] Station endpoint no longer leaks total_stationed_pokemon / total_stationed_gmax to non-dynamax callers. The endpoint spreads the raw Golbat record, so those totals rode along regardless of permission; the SQL path selects them only under includeBattleData. finalizeEndpointStation now strips them when battle data is not included, so a GraphQL selection cannot read stationed/gmax counts without the dynamax perm. - [P2] Distinguish a total-source-failure availability refresh from a genuine empty snapshot. getAvailable returns null when every source failed (caller retains last-good); a successful empty result returns [] and clears the category, so a drawer whose last option disappears no longer shows stale filters forever. EventManager retains only on null; the /api/v1/available route guards its sorts with || []. - [P2] Map api.availableRefreshSeconds to API_AVAILABLE_REFRESH_SECONDS in custom-environment-variables.json so container deployments can override the refresh throttle, matching every other scalar api default. Co-Authored-By: Claude Opus 4.8 (1M context) --- config/custom-environment-variables.json | 4 ++++ server/src/models/Station.js | 16 ++++++++++++++-- server/src/routes/api/v1/available.js | 10 +++++----- server/src/services/DbManager.js | 10 ++++++++++ server/src/services/EventManager.js | 12 ++++++------ 5 files changed, 39 insertions(+), 13 deletions(-) diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index 8e1bf0264..fbb9a62fe 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -60,6 +60,10 @@ "__name": "API_COOKIE_AGE_DAYS", "__format": "number" }, + "availableRefreshSeconds": { + "__name": "API_AVAILABLE_REFRESH_SECONDS", + "__format": "number" + }, "rateLimit": { "time": { "__name": "API_RATE_LIMIT_TIME", diff --git a/server/src/models/Station.js b/server/src/models/Station.js index fa4ce0102..064635cf3 100644 --- a/server/src/models/Station.js +++ b/server/src/models/Station.js @@ -784,6 +784,18 @@ class Station extends Model { } return active && passesFilterGate(s) } + // Match the SQL projection: total_stationed_* are selected only under + // includeBattleData (dynamax perm). The endpoint spreads the raw + // Golbat record, so strip them when battle data isn't included, else a + // non-dynamax GraphQL selection could read the stationed/gmax counts. + const finalizeEndpointStation = (s) => { + const out = finalizeStation(s, pokemonData, ts) + if (!includeBattleData) { + delete out.total_stationed_pokemon + delete out.total_stationed_gmax + } + return out + } const stations = res.stations .filter( (s) => @@ -803,7 +815,7 @@ class Station extends Model { if (Number(station.end_time) <= ts) { station.battles = [] clearStationBattleFallback(station) - return finalizeStation(station, pokemonData, ts) + return finalizeEndpointStation(station) } if (!includeUpcoming) { const visible = getVisibleStationBattle(station.battles, ts) @@ -824,7 +836,7 @@ class Station extends Model { station, getVisibleStationBattle(station.battles, ts), ) - return finalizeStation(station, pokemonData, ts) + return finalizeEndpointStation(station) }) .filter(Boolean) log.info( diff --git a/server/src/routes/api/v1/available.js b/server/src/routes/api/v1/available.js index 7983e65e4..f755b2226 100644 --- a/server/src/routes/api/v1/available.js +++ b/server/src/routes/api/v1/available.js @@ -54,7 +54,7 @@ const getAll = async (compare) => { state.event.available.tappables, ] return Object.fromEntries( - Object.keys(queryObj).map((key, i) => [key, available[i]]), + Object.keys(queryObj).map((key, i) => [key, available[i] || []]), ) } @@ -66,16 +66,16 @@ router.get(['/', '/:category'], async (req, res) => { if (model && category) { const available = - current !== undefined + (current !== undefined ? await state.db.getAvailable(model) - : state.event.available[category] + : state.event.available[category]) || [] available.sort((a, b) => a.localeCompare(b)) if (equal !== undefined) { const compare = - current !== undefined + (current !== undefined ? state.event.available[category] - : await state.db.getAvailable(model) + : await state.db.getAvailable(model)) || [] compare.sort((a, b) => a.localeCompare(b)) res.status(200).json(available.every((item, i) => item === compare[i])) } else { diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index 6c1727dc0..93040f5c6 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -723,6 +723,16 @@ class DbManager extends Logger { model, async ({ SubModel, ...source }) => SubModel.getAvailable(source), ) + // Total failure: sources exist but every one rejected. Return null so the + // caller retains its last-good data. A genuine empty snapshot (sources + // succeeded, no active options) falls through and returns [], which + // legitimately clears the category. + if (this.models[model].length && results.length === 0) { + this.log.warn( + `Available for ${model}: all sources failed, retaining last-good`, + ) + return null + } this.log.info(`Setting available for ${model}`) // runScannerSources returns only fulfilled sources, so an empty `results` // means every source failed. Don't overwrite manager-owned metadata diff --git a/server/src/services/EventManager.js b/server/src/services/EventManager.js index ecbb25fe7..fb1799a1a 100644 --- a/server/src/services/EventManager.js +++ b/server/src/services/EventManager.js @@ -131,12 +131,12 @@ class EventManager extends Logger { */ async #refreshAvailable(category, model, Db) { const available = await Db.getAvailable(model) - // A failed refresh (esp. a pure-endpoint /api/fort/available request) comes - // back empty. Don't replace the last-good drawer + conditions with it or arm - // the TTL — keep serving the previous options and retry on the next call. (A - // genuinely-empty category just re-scans next session; this list is - // drawer-only, so that is harmless.) - if (!available.length) return + // null = total source failure (getAvailable): keep the last-good drawer + + // conditions and retry on the next call. A successful snapshot — even an + // empty one, when a category's last active option disappears — falls + // through and commits, so the drawer clears instead of showing stale + // filters forever. + if (available == null) return /** @param {string} key */ const parseKey = (key) => { From be254651805fd24ccb105daf704bc948791282db Mon Sep 17 00:00:00 2001 From: James Berry Date: Mon, 27 Jul 2026 08:33:27 +0100 Subject: [PATCH 31/38] fix(fort): forced availability refresh busts snapshot cache; align types Two ReactMap-side fixes (the third review item, scan-limit-before-residual, is the recurring point already handled by Golbat max_fort_results). - [P2] Forced availability refreshes now start a fresh combined-snapshot generation. force=true previously bypassed only EventManager's TTL, not the fortAvailable combined cache, so a config reload or PUT /api/v1/available within the window reused a stale snapshot (or cached failure). loadLocalContexts and the PUT route now call bustCombinedFortCache() before the forced batch; the concurrent fort refreshes still coalesce via the repopulated window. The combined-cache window now tracks api.availableRefreshSeconds instead of a fixed 30s, so sub-30s API_AVAILABLE_REFRESH_SECONDS values take effect for forts. - [P3] Align the exported availability types (server.d.ts) and the mapper JSDoc with the Golbat payload: AvailablePokestopInvasion gains slot2/slot3 fields and drops count; AvailablePokestopLure / AvailablePokestopShowcase drop count (quest keeps count, which Golbat does return). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/types/lib/server.d.ts | 7 ++++--- server/src/models/pokestopAvailableMapper.js | 3 --- server/src/routes/api/v1/available.js | 5 +++++ server/src/services/state.js | 5 +++++ server/src/utils/fortAvailable.js | 22 ++++++++++++++++---- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/types/lib/server.d.ts b/packages/types/lib/server.d.ts index 7286e0a52..997d72af6 100644 --- a/packages/types/lib/server.d.ts +++ b/packages/types/lib/server.d.ts @@ -87,19 +87,20 @@ export interface AvailablePokestopInvasion { confirmed: boolean slot1_pokemon_id: number slot1_form: number - count: number + slot2_pokemon_id: number + slot2_form: number + slot3_pokemon_id: number + slot3_form: 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 { diff --git a/server/src/models/pokestopAvailableMapper.js b/server/src/models/pokestopAvailableMapper.js index a93168982..b3caf81cd 100644 --- a/server/src/models/pokestopAvailableMapper.js +++ b/server/src/models/pokestopAvailableMapper.js @@ -32,17 +32,14 @@ * @property {number} slot2_form * @property {number} slot3_pokemon_id * @property {number} slot3_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 diff --git a/server/src/routes/api/v1/available.js b/server/src/routes/api/v1/available.js index f755b2226..75b06338a 100644 --- a/server/src/routes/api/v1/available.js +++ b/server/src/routes/api/v1/available.js @@ -3,6 +3,7 @@ const router = require('express').Router() const { log, TAGS } = require('@rm/logger') const { state } = require('../../../services/state') +const { bustCombinedFortCache } = require('../../../utils/fortAvailable') const queryObj = /** @type {const} */ ({ pokemon: { model: 'Pokemon', category: 'pokemon' }, @@ -115,6 +116,10 @@ router.put('/:category', async (req, res) => { const { model, category } = queryObj[resolveCategory(req.params.category)] || {} + // Explicit refresh: bust the combined fort snapshot cache so the forced + // setAvailable calls below fetch fresh instead of reusing a cached window. + bustCombinedFortCache() + if (model && category) { await state.event.setAvailable(category, model, state.db, true) } else { diff --git a/server/src/services/state.js b/server/src/services/state.js index 3ede0b1c1..06c098e27 100644 --- a/server/src/services/state.js +++ b/server/src/services/state.js @@ -9,6 +9,7 @@ const { DbManager } = require('./DbManager') const { EventManager } = require('./EventManager') const { getSharedPvpWrapper } = require('./PvpWrapper') const { setCache } = require('./cache') +const { bustCombinedFortCache } = require('../utils/fortAvailable') const { migrate } = require('../db/migrate') const { Stats } = require('./Stats') @@ -97,6 +98,10 @@ const state = { async loadLocalContexts(reloadReport) { const promises = [this.event.cleanupTrials()] if (!reloadReport || reloadReport.database) { + // A reload can swap the db manager / endpoint config, so drop the combined + // fort snapshot cache — the forced setAvailable batch below then fetches + // fresh (and still coalesces via the repopulated window). + bustCombinedFortCache() if (!reloadReport || reloadReport.historical) { promises.push(this.db.historicalRarity()) } diff --git a/server/src/utils/fortAvailable.js b/server/src/utils/fortAvailable.js index b8ccd98cc..655df311b 100644 --- a/server/src/utils/fortAvailable.js +++ b/server/src/utils/fortAvailable.js @@ -1,4 +1,5 @@ // @ts-check +const config = require('@rm/config') const { log } = require('@rm/logger') const { evalScannerQuery, @@ -10,13 +11,26 @@ const { * scheduled intervals fire them as a batch), and on Golbat each per-type * /available call walks the ENTIRE fort cache. Share one combined * GET /api/fort/available per endpoint within a short window so a refresh - * batch costs one cache pass instead of three. + * batch costs one cache pass instead of three. The window tracks + * api.availableRefreshSeconds so it never floors a shorter configured throttle; + * a forced refresh (config reload / PUT /api/v1/available) busts the cache so it + * starts a fresh generation (see bustCombinedFortCache). */ -const CACHE_MS = 30_000 +const cacheMs = () => + (config.getSafe('api.availableRefreshSeconds') || 60) * 1000 /** @type {Map }>} */ const combinedCache = new Map() +/** + * Drop all cached snapshots so the next fetch starts a fresh generation. Called + * before a forced refresh; the concurrent fort batch that follows still shares + * one Golbat request via the repopulated entry's window. + */ +function bustCombinedFortCache() { + combinedCache.clear() +} + /** * Cache key = endpoint URL AND credentials. Two sources can point at the same * Golbat URL with different secret/httpAuth; keying on `mem` alone would let one @@ -47,7 +61,7 @@ function cacheKeyFor(mem, secret, httpAuth) { function getCombinedFortAvailable(tag, mem, secret, httpAuth) { const cacheKey = cacheKeyFor(mem, secret, httpAuth) const entry = combinedCache.get(cacheKey) - if (entry && Date.now() - entry.ts < CACHE_MS) return entry.promise + if (entry && Date.now() - entry.ts < cacheMs()) return entry.promise const promise = (async () => { try { const res = await evalScannerQuery( @@ -83,4 +97,4 @@ function getCombinedFortAvailable(tag, mem, secret, httpAuth) { return promise } -module.exports = { getCombinedFortAvailable } +module.exports = { getCombinedFortAvailable, bustCombinedFortCache } From a8564fbd31bbfbea68332d8edeb2aeb9134d2d01 Mon Sep 17 00:00:00 2001 From: James Berry Date: Mon, 27 Jul 2026 08:39:27 +0100 Subject: [PATCH 32/38] fix(fort): decouple scan request limit from display limit The fort scan requests sent to Golbat passed limit: queryLimits., which Golbat applies as a hard traversal cap BEFORE ReactMap's local gates (filterRTree, freshness, secondaryFilter) run. A viewport with more forts than the display limit could therefore drop valid forts: rejected forts earlier in the scan consumed the cap, leaving valid ones past it unseen. Send limit: 0 instead, so Golbat traverses up to its own server-side max_fort_results backstop, and apply queryLimits. as a display cap AFTER the local gates: - pokestop: already capped via secondaryFilter's resultLimit - gym: final.slice(0, queryLimits.gyms) - station: stations.slice(0, queryLimits.stations) This mirrors the SQL path, where .limit(queryLimits.) runs after the WHERE clause, and relies on Golbat's max_fort_results as the true traversal bound. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/models/Gym.js | 9 +++++++-- server/src/models/Pokestop.js | 8 +++++++- server/src/models/Station.js | 9 +++++++-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/server/src/models/Gym.js b/server/src/models/Gym.js index 7db90ab72..d375c6ce0 100644 --- a/server/src/models/Gym.js +++ b/server/src/models/Gym.js @@ -543,7 +543,10 @@ class Gym extends Model { JSON.stringify({ min: { latitude: args.minLat, longitude: args.minLon }, max: { latitude: args.maxLat, longitude: args.maxLon }, - limit: queryLimits.gyms, + // 0 = Golbat's server default (max_fort_results); the display cap + // (queryLimits.gyms) is applied after the local gates below, not as + // a pre-filter traversal cap (see Pokestop.getAll). + limit: 0, filters: dnf, }), 'POST', @@ -596,7 +599,9 @@ class Gym extends Model { final.length, ), ) - return final + // Display cap, applied after the local gates (mirrors SQL's + // `.limit(queryLimits.gyms)`); the scan request itself is uncapped. + return final.slice(0, queryLimits.gyms) } log.warn( TAGS.gyms, diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 9c165b89f..5e6e8e28d 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -883,7 +883,13 @@ class Pokestop extends Model { JSON.stringify({ min: { latitude: args.minLat, longitude: args.minLon }, max: { latitude: args.maxLat, longitude: args.maxLon }, - limit: queryLimits.pokestops, + // 0 = Golbat's server default (max_fort_results). The request must + // NOT cap traversal at the display limit: Golbat stops before + // ReactMap's filterRTree/freshness/secondaryFilter run, so rejected + // stops consume the cap and valid ones later in the scan are lost. + // queryLimits.pokestops is applied below as the display cap, after + // those local gates. + limit: 0, filters: dnf, with_incidents: true, }), diff --git a/server/src/models/Station.js b/server/src/models/Station.js index 064635cf3..ffc928727 100644 --- a/server/src/models/Station.js +++ b/server/src/models/Station.js @@ -715,7 +715,10 @@ class Station extends Model { JSON.stringify({ min: { latitude: args.minLat, longitude: args.minLon }, max: { latitude: args.maxLat, longitude: args.maxLon }, - limit: queryLimits.stations, + // 0 = Golbat's server default (max_fort_results); the display cap + // (queryLimits.stations) is applied after the local gates below, not + // as a pre-filter traversal cap (see Pokestop.getAll). + limit: 0, filters: dnf, }), 'POST', @@ -849,7 +852,9 @@ class Station extends Model { stations.length, ), ) - return stations + // Display cap, applied after the local gates; the scan request itself + // is uncapped (Golbat's max_fort_results is the traversal backstop). + return stations.slice(0, queryLimits.stations) } log.warn( TAGS.stations, From e0e2dabf55dcb7c654808bb885a4b4f09af906c5 Mon Sep 17 00:00:00 2001 From: James Berry Date: Mon, 27 Jul 2026 19:24:12 +0100 Subject: [PATCH 33/38] fix(fort): address review round (forced-refresh generation, station battles) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ReactMap-side fixes from the latest review; none touch Golbat or broaden its queries. - Forced availability refreshes no longer reuse superseded in-flight work. setAvailable's single-flight guard now applies only to non-forced calls; a forced refresh (hot reload / PUT /api/v1/available / scheduled interval) always starts a fresh refresh against the current Db, and a monotonic per-category generation token gates the commit so an older in-flight refresh can't commit or TTL-stamp its (old-manager) result after a reload. Non-forced session-init stampedes still coalesce on the shared pending promise. - Endpoint-backed station battles now carry an `updated` timestamp. Golbat's scan battle has no per-battle `updated`, so it is populated from the station's own `updated` (b.updated ?? apiStation.updated ?? null), mirroring the SQL getFallbackStationBattle path so StationBattleTimer has a last_seen to render. - Station availability no longer advertises a phantom boss. A battle whose boss isn't known yet arrives with a null (Golbat) or 0 pokemon_id; mapStationAvailable now guards the boss key with Number(pokemon_id) > 0 so it doesn't inject a null-null/0-0 Pokémon into the filter catalog that no marker can match. The tier key (j{level}) is still published while the boss is unknown. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/models/Station.js | 12 ++++- server/src/models/stationAvailableMapper.js | 8 ++- server/src/services/EventManager.js | 54 +++++++++++++++------ 3 files changed, 55 insertions(+), 19 deletions(-) diff --git a/server/src/models/Station.js b/server/src/models/Station.js index ffc928727..dcec93f5a 100644 --- a/server/src/models/Station.js +++ b/server/src/models/Station.js @@ -810,7 +810,17 @@ class Station extends Model { ...apiStation, battles: includeBattleData ? (apiStation.battles || []).map((b) => - enrichStationBattle(b, pokemonData), + enrichStationBattle( + // Golbat's scan battle has no per-battle `updated`; the + // SQL path (getFallbackStationBattle) uses the station's + // `updated` for last_seen, so mirror that here — else + // StationBattleTimer has no timestamp to render. + { + ...b, + updated: b.updated ?? apiStation.updated ?? null, + }, + pokemonData, + ), ) : [], } diff --git a/server/src/models/stationAvailableMapper.js b/server/src/models/stationAvailableMapper.js index 7da807e2e..e44febfa3 100644 --- a/server/src/models/stationAvailableMapper.js +++ b/server/src/models/stationAvailableMapper.js @@ -5,7 +5,7 @@ * key output of the SQL `Station.getAvailable`: `j{level}` battle-tier keys and * `-` battle-pokemon keys. Dependency-free (golden-testable * under plain node). - * @param {{ battles?: {battle_level:number, pokemon_id:number, form:number, count:number}[] }} api + * @param {{ battles?: {battle_level:number, pokemon_id:number|null, form:number|null, count:number}[] }} api * @returns {{ available: string[] }} */ function mapStationAvailable(api) { @@ -13,7 +13,11 @@ function mapStationAvailable(api) { const battles = api.battles || [] battles.forEach((b) => { if (!b.battle_level) return - available.add(`${b.pokemon_id}-${b.form}`) + // A battle whose boss isn't known yet arrives with a null (Golbat) or 0 + // pokemon_id; publishing `null-null`/`0-0` injects a bogus Pokémon into the + // masterfile/filter catalog that no station marker can match. Only advertise + // a boss key once it's known (Number() coerces null/undefined → NaN → false). + if (Number(b.pokemon_id) > 0) available.add(`${b.pokemon_id}-${b.form}`) available.add(`j${b.battle_level}`) }) return { available: [...available] } diff --git a/server/src/services/EventManager.js b/server/src/services/EventManager.js index fb1799a1a..93ea0dce5 100644 --- a/server/src/services/EventManager.js +++ b/server/src/services/EventManager.js @@ -45,6 +45,8 @@ class EventManager extends Logger { this.availablePending = {} /** @type {Record} last successful setAvailable per category */ this.availableUpdatedAt = {} + /** @type {Record} monotonic refresh token per category; a stale in-flight refresh whose token is superseded must not commit */ + this.availableGeneration = {} this.baseUrl = 'https://raw.githubusercontent.com/WatWowMap/wwm-uicons-webp/main' @@ -100,36 +102,52 @@ class EventManager extends Logger { async setAvailable(category, model, Db, force = false) { // Single-flight + TTL: session-init triggers (queryOnSessionInit) fire on // EVERY page load and can stampede — on endpoint-backed sources each - // refresh walks Golbat's whole fort cache. Concurrent calls share one - // in-flight promise; repeats within the TTL are served by the last result - // (map markers never depend on this — only the filter drawer options — - // so the staleness cost is bounded and cosmetic). Scheduled intervals and - // the explicit /api/v1/available route pass force=true. - if (this.availablePending[category]) return this.availablePending[category] + // refresh walks Golbat's whole fort cache. Non-forced concurrent calls + // share one in-flight promise; repeats within the TTL are served by the + // last result (map markers never depend on this — only the filter drawer + // options — so the staleness cost is bounded and cosmetic). + // + // A forced refresh (scheduled interval, /api/v1/available, hot reload) must + // NOT adopt an in-flight non-forced promise: that promise may hold a + // superseded Db/cache generation and would never query the newly installed + // manager. It supersedes instead — bumping the generation so the older + // refresh bails before committing stale data (see #refreshAvailable). const ttlMs = (config.getSafe('api.availableRefreshSeconds') || 60) * 1000 - if ( - !force && - this.availableUpdatedAt[category] && - Date.now() - this.availableUpdatedAt[category] < ttlMs - ) { - return undefined + if (!force) { + if (this.availablePending[category]) { + return this.availablePending[category] + } + if ( + this.availableUpdatedAt[category] && + Date.now() - this.availableUpdatedAt[category] < ttlMs + ) { + return undefined + } } - this.availablePending[category] = this.#refreshAvailable( + const generation = (this.availableGeneration[category] || 0) + 1 + this.availableGeneration[category] = generation + const pending = this.#refreshAvailable( category, model, Db, + generation, ).finally(() => { - delete this.availablePending[category] + // Only clear the slot if a newer refresh hasn't already replaced it. + if (this.availablePending[category] === pending) { + delete this.availablePending[category] + } }) - return this.availablePending[category] + this.availablePending[category] = pending + return pending } /** * @param {keyof EventManager['available']} category * @param {import('../models').ScannerModelKeys} model * @param {import('./DbManager').DbManager} Db + * @param {number} generation refresh token; only commits if still current */ - async #refreshAvailable(category, model, Db) { + async #refreshAvailable(category, model, Db, generation) { const available = await Db.getAvailable(model) // null = total source failure (getAvailable): keep the last-good drawer + // conditions and retry on the next call. A successful snapshot — even an @@ -137,6 +155,10 @@ class EventManager extends Logger { // through and commits, so the drawer clears instead of showing stale // filters forever. if (available == null) return + // A newer refresh (typically a forced reload) started while this one was + // awaiting Golbat/SQL; it owns the current Db, so drop this superseded + // result rather than committing/TTL-stamping data from an old generation. + if (this.availableGeneration[category] !== generation) return /** @param {string} key */ const parseKey = (key) => { From b2cee95824c9bb792e9a34b5b4a1a9d0231e345d Mon Sep 17 00:00:00 2001 From: James Berry Date: Mon, 27 Jul 2026 22:44:40 +0100 Subject: [PATCH 34/38] fix(fort): consume DB-null availability; per-battle updated; manager-aware refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to Golbat's availability null contract and per-battle `updated`, plus the availability-refresh lifecycle fixes from the review. A2 (null availability): the availability mappers already withhold a boss key via `> 0` guards, so a null pokemon_id flows through identically to the old 0. JSDoc/types updated to number|null (station battle, raid, showcase focus + type_id, invasion slots), and the incorrect station comment corrected — Golbat now sends null, and Number(null) is 0 (not NaN), so `> 0` still withholds the key while shipping the tier key. Quests unchanged. B1 (per-battle updated): Golbat now exposes the real per-battle `updated` on ApiStationBattleResult, so the station endpoint passes `b.updated` through unchanged instead of borrowing the station-wide timestamp (which fabricated last_seen for multi-battle cards). C1 (intervals): a database-only reload now restarts the availability intervals, rebinding their captured Db to the freshly installed manager (previously only an events reload restarted them, leaving timers querying the replaced manager). C2 (atomic gated refresh): DbManager.getAvailable is now pure, returning { available, conditions?, rarity? }; EventManager commits the drawer AND the manager metadata (questConditions/rarity, via applyAvailableMetadata) together under the generation gate, so a superseded refresh can overwrite neither. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/types/lib/server.d.ts | 22 ++- server/src/models/Station.js | 17 +- server/src/models/gymAvailableMapper.js | 4 +- server/src/models/pokestopAvailableMapper.js | 18 +- server/src/models/stationAvailableMapper.js | 10 +- server/src/routes/api/v1/available.js | 24 ++- server/src/services/DbManager.js | 191 +++++++++++-------- server/src/services/EventManager.js | 10 +- server/src/services/state.js | 6 +- 9 files changed, 171 insertions(+), 131 deletions(-) diff --git a/packages/types/lib/server.d.ts b/packages/types/lib/server.d.ts index 997d72af6..0af4084a9 100644 --- a/packages/types/lib/server.d.ts +++ b/packages/types/lib/server.d.ts @@ -85,12 +85,14 @@ export interface AvailablePokestopInvasion { character: number display_type: number confirmed: boolean - slot1_pokemon_id: number - slot1_form: number - slot2_pokemon_id: number - slot2_form: number - slot3_pokemon_id: number - slot3_form: number + // Confirmed slots are null when unknown/unconfirmed; a present pokemon id + // pairs with a real form (0 is valid). See Golbat ApiPokestopInvasionAvailable. + slot1_pokemon_id: number | null + slot1_form: number | null + slot2_pokemon_id: number | null + slot2_form: number | null + slot3_pokemon_id: number | null + slot3_form: number | null } export interface AvailablePokestopLure { @@ -98,9 +100,11 @@ export interface AvailablePokestopLure { } export interface AvailablePokestopShowcase { - pokemon_id: number - form: number - type_id: number + // Pokemon-based showcase: pokemon_id/form set, type_id null. Type-based: + // pokemon_id/form null, type_id set. See Golbat ApiPokestopShowcaseAvailable. + pokemon_id: number | null + form: number | null + type_id: number | null } export interface AvailablePokestops { diff --git a/server/src/models/Station.js b/server/src/models/Station.js index dcec93f5a..7a5db0e6b 100644 --- a/server/src/models/Station.js +++ b/server/src/models/Station.js @@ -809,18 +809,11 @@ class Station extends Model { const station = { ...apiStation, battles: includeBattleData - ? (apiStation.battles || []).map((b) => - enrichStationBattle( - // Golbat's scan battle has no per-battle `updated`; the - // SQL path (getFallbackStationBattle) uses the station's - // `updated` for last_seen, so mirror that here — else - // StationBattleTimer has no timestamp to render. - { - ...b, - updated: b.updated ?? apiStation.updated ?? null, - }, - pokemonData, - ), + ? // Golbat now exposes the real per-battle `updated` on each + // ApiStationBattleResult (matching the SQL station_battle.updated + // the multi-battle path relies on), so pass it through unchanged. + (apiStation.battles || []).map((b) => + enrichStationBattle(b, pokemonData), ) : [], } diff --git a/server/src/models/gymAvailableMapper.js b/server/src/models/gymAvailableMapper.js index c6812bd26..2425891a3 100644 --- a/server/src/models/gymAvailableMapper.js +++ b/server/src/models/gymAvailableMapper.js @@ -5,7 +5,7 @@ * of the SQL `Gym.getAvailable` (e/r + boss `-`); team/slot (t/g) * keys are generated statically by buildGyms, so Golbat no longer returns them. * Dependency-free so it can run under plain node for golden checks. - * @param {{ raids?: {raid_level:number,pokemon_id:number,form:number,count:number}[] }} api + * @param {{ raids?: {raid_level:number,pokemon_id:number|null,form:number|null}[] }} api * @returns {{ available: string[] }} */ function mapGymAvailable(api) { @@ -16,6 +16,8 @@ function mapGymAvailable(api) { raids.forEach((r) => { if (!r.raid_level) return raidLevels.add(r.raid_level) + // A null pokemon_id is an unhatched egg (`null > 0` is false → egg key); + // a known boss keeps `-` (form 0 is valid and stays 0). if (r.pokemon_id > 0) { available.add(`${r.pokemon_id}-${r.form}`) } else { diff --git a/server/src/models/pokestopAvailableMapper.js b/server/src/models/pokestopAvailableMapper.js index b3caf81cd..d4afbe626 100644 --- a/server/src/models/pokestopAvailableMapper.js +++ b/server/src/models/pokestopAvailableMapper.js @@ -26,20 +26,20 @@ * @property {number} character * @property {number} display_type * @property {boolean} confirmed - * @property {number} slot1_pokemon_id - * @property {number} slot1_form - * @property {number} slot2_pokemon_id - * @property {number} slot2_form - * @property {number} slot3_pokemon_id - * @property {number} slot3_form + * @property {number|null} slot1_pokemon_id + * @property {number|null} slot1_form + * @property {number|null} slot2_pokemon_id + * @property {number|null} slot2_form + * @property {number|null} slot3_pokemon_id + * @property {number|null} slot3_form * * @typedef {object} AvailablePokestopLure * @property {number} lure_id * * @typedef {object} AvailablePokestopShowcase - * @property {number} pokemon_id - * @property {number} form - * @property {number} type_id + * @property {number|null} pokemon_id + * @property {number|null} form + * @property {number|null} type_id * * @typedef {object} AvailablePokestops * @property {AvailablePokestopQuest[]} quests diff --git a/server/src/models/stationAvailableMapper.js b/server/src/models/stationAvailableMapper.js index e44febfa3..bfc9674c5 100644 --- a/server/src/models/stationAvailableMapper.js +++ b/server/src/models/stationAvailableMapper.js @@ -5,7 +5,7 @@ * key output of the SQL `Station.getAvailable`: `j{level}` battle-tier keys and * `-` battle-pokemon keys. Dependency-free (golden-testable * under plain node). - * @param {{ battles?: {battle_level:number, pokemon_id:number|null, form:number|null, count:number}[] }} api + * @param {{ battles?: {battle_level:number, pokemon_id:number|null, form:number|null}[] }} api * @returns {{ available: string[] }} */ function mapStationAvailable(api) { @@ -13,10 +13,10 @@ function mapStationAvailable(api) { const battles = api.battles || [] battles.forEach((b) => { if (!b.battle_level) return - // A battle whose boss isn't known yet arrives with a null (Golbat) or 0 - // pokemon_id; publishing `null-null`/`0-0` injects a bogus Pokémon into the - // masterfile/filter catalog that no station marker can match. Only advertise - // a boss key once it's known (Number() coerces null/undefined → NaN → false). + // Golbat sends a null pokemon_id when the boss isn't known yet; publishing + // `null-null` would inject a bogus Pokémon into the masterfile/filter catalog + // that no station marker can match. Number(null) is 0 (not NaN), so `> 0` + // withholds the boss key until it's known; the tier key still ships. if (Number(b.pokemon_id) > 0) available.add(`${b.pokemon_id}-${b.form}`) available.add(`j${b.battle_level}`) }) diff --git a/server/src/routes/api/v1/available.js b/server/src/routes/api/v1/available.js index 75b06338a..6d46ccab5 100644 --- a/server/src/routes/api/v1/available.js +++ b/server/src/routes/api/v1/available.js @@ -40,13 +40,17 @@ const resolveCategory = (category) => { /** @param {boolean} compare */ const getAll = async (compare) => { const available = compare - ? await Promise.all([ - state.db.getAvailable('Pokemon'), - state.db.getAvailable('Pokestop'), - state.db.getAvailable('Gym'), - state.db.getAvailable('Nest'), - state.db.getAvailable('Tappable'), - ]) + ? // getAvailable now returns { available, ... } (or null on total failure); + // this route only needs the string list. + ( + await Promise.all([ + state.db.getAvailable('Pokemon'), + state.db.getAvailable('Pokestop'), + state.db.getAvailable('Gym'), + state.db.getAvailable('Nest'), + state.db.getAvailable('Tappable'), + ]) + ).map((r) => r?.available || []) : [ state.event.available.pokemon, state.event.available.pokestops, @@ -66,9 +70,11 @@ router.get(['/', '/:category'], async (req, res) => { const { current, equal } = req.query if (model && category) { + // getAvailable returns { available, ... } (or null); the drawer snapshot + // in state.event.available is already a string list. const available = (current !== undefined - ? await state.db.getAvailable(model) + ? (await state.db.getAvailable(model))?.available : state.event.available[category]) || [] available.sort((a, b) => a.localeCompare(b)) @@ -76,7 +82,7 @@ router.get(['/', '/:category'], async (req, res) => { const compare = (current !== undefined ? state.event.available[category] - : await state.db.getAvailable(model)) || [] + : (await state.db.getAvailable(model))?.available) || [] compare.sort((a, b) => a.localeCompare(b)) res.status(200).json(available.every((item, i) => item === compare[i])) } else { diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index 93040f5c6..dc7d9d5a4 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -27,6 +27,43 @@ const STATION_BATTLE_REQUIRED_COLUMNS = [ 'updated', ] +// Pure: buckets summed spawn counts into rarity tiers and RETURNS the map (no +// mutation), so callers can commit it atomically under a gate — see +// DbManager.getAvailable / EventManager's generation check. +function computeRarityTiers(results, historical = false) { + const base = {} + const rarityPercents = config.getSafe('rarity.percents') + let total = 0 + results.forEach((result) => { + Object.entries(historical ? result : result.rarity).forEach( + ([key, count]) => { + if (key in base) { + base[key] += count + } else { + base[key] = count + } + total += count + }, + ) + }) + const tiers = {} + Object.entries(base).forEach(([id, count]) => { + const percent = (count / total) * 100 + if (percent === 0) { + tiers[id] = 'never' + } else if (percent < rarityPercents.ultraRare) { + tiers[id] = 'ultraRare' + } else if (percent < rarityPercents.rare) { + tiers[id] = 'rare' + } else if (percent < rarityPercents.uncommon) { + tiers[id] = 'uncommon' + } else { + tiers[id] = 'common' + } + }) + return tiers +} + /** * @type {import("@rm/types").DbManagerClass} */ @@ -322,37 +359,10 @@ class DbManager extends Logger { * @returns {void} */ setRarity(results, historical = false) { - const base = {} - const mapKey = historical ? 'historical' : 'rarity' - const rarityPercents = config.getSafe('rarity.percents') - let total = 0 - results.forEach((result) => { - Object.entries(historical ? result : result.rarity).forEach( - ([key, count]) => { - if (key in base) { - base[key] += count - } else { - base[key] = count - } - total += count - }, - ) - }) - this[mapKey] = {} - Object.entries(base).forEach(([id, count]) => { - const percent = (count / total) * 100 - if (percent === 0) { - this[mapKey][id] = 'never' - } else if (percent < rarityPercents.ultraRare) { - this[mapKey][id] = 'ultraRare' - } else if (percent < rarityPercents.rare) { - this[mapKey][id] = 'rare' - } else if (percent < rarityPercents.uncommon) { - this[mapKey][id] = 'uncommon' - } else { - this[mapKey][id] = 'common' - } - }) + this[historical ? 'historical' : 'rarity'] = computeRarityTiers( + results, + historical, + ) } async historicalRarity() { @@ -716,63 +726,78 @@ class DbManager extends Logger { * @param {import("../models").ScannerModelKeys} model * @returns {Promise} */ + // Returns { available, conditions?, rarity? }, or null on total source + // failure. PURE: it no longer commits questConditions/rarity onto `this` — + // the caller applies the metadata via applyAvailableMetadata AFTER the + // EventManager generation gate, so a superseded refresh (e.g. one holding a + // replaced Db after a reload) cannot overwrite current metadata. async getAvailable(model) { - if (this.models[model]) { - this.log.info(`Querying available for ${model}`) - const results = await this.runScannerSources( - model, - async ({ SubModel, ...source }) => SubModel.getAvailable(source), + if (!this.models[model]) return { available: [] } + this.log.info(`Querying available for ${model}`) + const results = await this.runScannerSources( + model, + async ({ SubModel, ...source }) => SubModel.getAvailable(source), + ) + // Total failure: sources exist but every one rejected. Return null so the + // caller retains its last-good data. A genuine empty snapshot (sources + // succeeded, no active options) falls through and returns { available: [] }, + // which legitimately clears the category. + if (this.models[model].length && results.length === 0) { + this.log.warn( + `Available for ${model}: all sources failed, retaining last-good`, ) - // Total failure: sources exist but every one rejected. Return null so the - // caller retains its last-good data. A genuine empty snapshot (sources - // succeeded, no active options) falls through and returns [], which - // legitimately clears the category. - if (this.models[model].length && results.length === 0) { - this.log.warn( - `Available for ${model}: all sources failed, retaining last-good`, - ) - return null - } - this.log.info(`Setting available for ${model}`) - // runScannerSources returns only fulfilled sources, so an empty `results` - // means every source failed. Don't overwrite manager-owned metadata - // (quest conditions / rarity) with that failure-derived emptiness — - // retain the last-good so a transient outage doesn't blank the drawer. - if (results.length && model === 'Pokestop') { - const newQuestConditions = {} - results.forEach((result) => { - if ('conditions' in result) { - config.util.extendDeep(newQuestConditions, result.conditions) - } - }) - this.questConditions = Object.fromEntries( - Object.entries(newQuestConditions).map(([key, titles]) => [ - key, - Object.values(titles), - ]), - ) - } - if (results.length && model === 'Pokemon') { - this.setRarity(results, false) - } - if (results.length === 1) return results[0].available - if (results.length > 1) { - const returnSet = new Set() - for (let i = 0; i < results.length; i += 1) { - for (let j = 0; j < results[i].available.length; j += 1) { - returnSet.add(results[i].available[j]) - } + return null + } + /** @type {{ available: string[], conditions?: object, rarity?: object }} */ + const out = { available: [] } + // runScannerSources returns only fulfilled sources, so an empty `results` + // means every source failed. Don't derive manager-owned metadata (quest + // conditions / rarity) from that failure-derived emptiness — leave it + // undefined so applyAvailableMetadata retains the last-good. + if (results.length && model === 'Pokestop') { + const newQuestConditions = {} + results.forEach((result) => { + if ('conditions' in result) { + config.util.extendDeep(newQuestConditions, result.conditions) + } + }) + out.conditions = Object.fromEntries( + Object.entries(newQuestConditions).map(([key, titles]) => [ + key, + Object.values(titles), + ]), + ) + } + if (results.length && model === 'Pokemon') { + out.rarity = computeRarityTiers(results, false) + } + if (results.length === 1) { + out.available = results[0].available + } else if (results.length > 1) { + const returnSet = new Set() + for (let i = 0; i < results.length; i += 1) { + for (let j = 0; j < results[i].available.length; j += 1) { + returnSet.add(results[i].available[j]) } - return [...returnSet] - } - if (results.length === 0 && model === 'Nest') { - this.log.warn( - 'This is likely due to "nest" being in a useFor array but not in the database', - ) } - return [] + out.available = [...returnSet] + } else if (model === 'Nest') { + this.log.warn( + 'This is likely due to "nest" being in a useFor array but not in the database', + ) } - return [] + return out + } + + // Commits the metadata half of a getAvailable() result onto this manager. + // EventManager calls it under the generation gate (so a superseded refresh + // never reaches here); a failure-derived result leaves conditions/rarity + // undefined, so a transient outage can't blank the drawer's metadata. + applyAvailableMetadata(result) { + if (!result) return + if (result.conditions !== undefined) + this.questConditions = result.conditions + if (result.rarity !== undefined) this.rarity = result.rarity } /** diff --git a/server/src/services/EventManager.js b/server/src/services/EventManager.js index 93ea0dce5..aa2ed105f 100644 --- a/server/src/services/EventManager.js +++ b/server/src/services/EventManager.js @@ -148,17 +148,20 @@ class EventManager extends Logger { * @param {number} generation refresh token; only commits if still current */ async #refreshAvailable(category, model, Db, generation) { - const available = await Db.getAvailable(model) + const result = await Db.getAvailable(model) // null = total source failure (getAvailable): keep the last-good drawer + // conditions and retry on the next call. A successful snapshot — even an // empty one, when a category's last active option disappears — falls // through and commits, so the drawer clears instead of showing stale // filters forever. - if (available == null) return + if (result == null) return // A newer refresh (typically a forced reload) started while this one was // awaiting Golbat/SQL; it owns the current Db, so drop this superseded // result rather than committing/TTL-stamping data from an old generation. + // This must precede applyAvailableMetadata below so the drawer AND the + // manager metadata (questConditions/rarity) commit as one gated snapshot. if (this.availableGeneration[category] !== generation) return + const { available } = result /** @param {string} key */ const parseKey = (key) => { @@ -196,6 +199,9 @@ class EventManager extends Logger { return 0 }) this.available[category] = available + // Commit the manager metadata (questConditions/rarity) in the same gated + // step as the drawer, so a superseded refresh can overwrite neither. + Db.applyAvailableMetadata(result) this.addAvailable(category) this.availableUpdatedAt[category] = Date.now() } diff --git a/server/src/services/state.js b/server/src/services/state.js index 06c098e27..b3ff5d0d5 100644 --- a/server/src/services/state.js +++ b/server/src/services/state.js @@ -162,7 +162,11 @@ const state = { if (reloadReport.strategies) { this.setAuthClients() } - if (reloadReport.events) { + if (reloadReport.events || reloadReport.database) { + // startIntervals captures `this.db` in each timer's closure. A database + // reload swaps in a fresh DbManager, so the intervals must be rebuilt + // around it — otherwise a later forced refresh would query (and commit) + // the replaced manager's data. See EventManager.#refreshAvailable. this.event.startIntervals(this.db, this.pvp) } return this From 115fa5c7fdf482bab6516a4919626dfdb5fe70b9 Mon Sep 17 00:00:00 2001 From: James Berry Date: Mon, 27 Jul 2026 22:46:35 +0100 Subject: [PATCH 35/38] docs(fort): fix getAvailable JSDoc for the new object return type getAvailable now resolves { available, conditions?, rarity? } | null, not the old Promise; correct the @ts-check annotation (drop the stale @template T) and add a JSDoc for applyAvailableMetadata so callers' `.available` access type-checks. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/services/DbManager.js | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index dc7d9d5a4..6294ca9b8 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -722,15 +722,14 @@ class DbManager extends Logger { } /** - * @template T + * Returns { available, conditions?, rarity? }, or null on total source + * failure. PURE: it no longer commits questConditions/rarity onto `this` — + * the caller applies the metadata via applyAvailableMetadata AFTER the + * EventManager generation gate, so a superseded refresh (e.g. one holding a + * replaced Db after a reload) cannot overwrite current metadata. * @param {import("../models").ScannerModelKeys} model - * @returns {Promise} + * @returns {Promise<{ available: string[], conditions?: object, rarity?: object } | null>} */ - // Returns { available, conditions?, rarity? }, or null on total source - // failure. PURE: it no longer commits questConditions/rarity onto `this` — - // the caller applies the metadata via applyAvailableMetadata AFTER the - // EventManager generation gate, so a superseded refresh (e.g. one holding a - // replaced Db after a reload) cannot overwrite current metadata. async getAvailable(model) { if (!this.models[model]) return { available: [] } this.log.info(`Querying available for ${model}`) @@ -789,10 +788,13 @@ class DbManager extends Logger { return out } - // Commits the metadata half of a getAvailable() result onto this manager. - // EventManager calls it under the generation gate (so a superseded refresh - // never reaches here); a failure-derived result leaves conditions/rarity - // undefined, so a transient outage can't blank the drawer's metadata. + /** + * Commits the metadata half of a getAvailable() result onto this manager. + * EventManager calls it under the generation gate (so a superseded refresh + * never reaches here); a failure-derived result leaves conditions/rarity + * undefined, so a transient outage can't blank the drawer's metadata. + * @param {{ conditions?: object, rarity?: object } | null} result + */ applyAvailableMetadata(result) { if (!result) return if (result.conditions !== undefined) From b197123a0eb02d94a18ab502c67c6066aacd56d5 Mon Sep 17 00:00:00 2001 From: James Berry Date: Tue, 28 Jul 2026 16:36:13 +0100 Subject: [PATCH 36/38] fix(fort): endpoint search/fallback robustness (3 review findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stop the radius-widening search when every source rejects. runScannerSources returns [] only when all sources rejected (a genuine-empty source is fulfilled with [], giving [[]]); an all-pure-endpoint fort search (no endpoint branch on search) or a DB outage otherwise spun ~557 failing queries + warnings until the 2s cap. Break on data.length === 0. - Treat fort-by-id 404s as normal misses. fetchJson's miss exemption only covered /api/pokemon/id, so a getOne / manual / deep-link miss on gym/pokestop/station logged an error and dumped the request under logs/. Broaden to the by-id endpoints via /\/api\/(pokemon|gym|pokestop|station)\/id\//. - Keep endpoint context when the DB probe fails. getDbContext ran schemaCheck inside the try and overlaid mem/secret/httpAuth after it, so a schemaCheck rejection (transient scanner-DB outage) skipped the overlay and left a dual source without mem — its fort queries then bypassed the healthy endpoint and hit the down DB. schemaCheck now has its own try/catch (degrading SQL flags to {}), and the endpoint overlay + assignment always run. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/services/DbManager.js | 59 ++++++++++++++++++++++++-------- server/src/utils/fetchJson.js | 7 +++- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index 6294ca9b8..059e80c56 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -307,25 +307,48 @@ class DbManager extends Logger { await Promise.all( this.connections.map(async (schema, i) => { try { - const schemaContext = schema - ? await DbManager.schemaCheck(schema) - : { - mem: this.endpoints[i].endpoint, - secret: this.endpoints[i].secret, - httpAuth: this.endpoints[i].httpAuth, - pvpV2: true, - // No DB schema check runs for a pure-endpoint source, but the - // Golbat scan always returns confirmed incident data (confirmed - // flag + lineup slots), so it IS confirmed-capable. Without this, - // onlyConfirmed is ineffective and confirmed `a` reward filters - // fall back to the grunt's possible-encounter pool. - hasConfirmed: true, - } + // Endpoint context is independent of the DB capability probe. A dual + // (endpoint + DB) source must keep mem/secret/httpAuth even if + // schemaCheck rejects (e.g. a transient scanner-DB outage), otherwise + // its endpoint-capable fort queries would bypass the healthy endpoint + // and run against the down DB. So resolve the schema flags in their + // own try/catch and always overlay + assign the endpoint context. + let schemaContext + if (schema) { + try { + schemaContext = await DbManager.schemaCheck(schema) + } catch (e) { + this.log.error( + `schemaCheck failed for connection ${i}${ + this.endpoints[i] + ? ' — retaining endpoint context; SQL fallback degraded until reload' + : '' + }`, + e, + ) + schemaContext = {} + } + } else { + schemaContext = { + mem: this.endpoints[i].endpoint, + secret: this.endpoints[i].secret, + httpAuth: this.endpoints[i].httpAuth, + pvpV2: true, + // No DB schema check runs for a pure-endpoint source, but the + // Golbat scan always returns confirmed incident data (confirmed + // flag + lineup slots), so it IS confirmed-capable. Without this, + // onlyConfirmed is ineffective and confirmed `a` reward filters + // fall back to the grunt's possible-encounter pool. + hasConfirmed: 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. + // un-migrated ones fall back to this.query() on the bound DB. Runs + // even when schemaCheck failed above (schemaContext={}), so a DB + // outage cannot disable the healthy endpoint. if (schema && this.endpoints[i]) { schemaContext.mem = this.endpoints[i].endpoint schemaContext.secret = this.endpoints[i].secret @@ -623,6 +646,12 @@ class DbManager extends Logger { bbox, ), ) + // runScannerSources returns [] ONLY when every source rejected — a + // genuine-empty source is fulfilled with [] (so data would be [[]]). + // That happens for an all-pure-endpoint fort search (the search methods + // have no endpoint branch) or a DB outage; widening just reissues the + // same failing queries, so stop rather than spin the radius to `max`. + if (data.length === 0) break const results = DbManager.deDupeResults(data) if (results.length > deDuped.length) { deDuped = results diff --git a/server/src/utils/fetchJson.js b/server/src/utils/fetchJson.js index 884ee6993..2122598f0 100644 --- a/server/src/utils/fetchJson.js +++ b/server/src/utils/fetchJson.js @@ -54,10 +54,15 @@ async function fetchJson(url, options = undefined) { return response.json() } catch (e) { if (e instanceof Error) { + // A 404 on a by-id lookup is an expected miss (the id isn't in the + // endpoint's cache), mirroring an empty SQL lookup — return it quietly + // instead of logging an error + dumping the request to logs/. Covers + // pokemon and the fort by-id endpoints (gym/pokestop/station getOne + + // manual/deep-link ids). if ( e.cause instanceof Response && e.cause.status === 404 && - url.includes('/api/pokemon/id') + /\/api\/(pokemon|gym|pokestop|station)\/id\//.test(url) ) return e.cause log.error(TAGS.fetch, `Unable to fetch ${url}`, '\n', e) From 400bce0eb76894983ed4f35f57d8a9147acf1868 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 28 Jul 2026 15:44:47 -0400 Subject: [PATCH 37/38] fix: force pvp v2 --- config/custom-environment-variables.json | 22 ++++------------------ config/default.json | 7 +------ packages/config/.configref | 2 +- packages/types/lib/scanner.d.ts | 2 -- packages/types/lib/server.d.ts | 1 - server/src/filters/pokemon/Backend.js | 12 +----------- server/src/filters/pokemon/functions.js | 15 ++------------- server/src/models/Pokemon.js | 17 ++--------------- server/src/services/DbManager.js | 22 ++++++++++------------ 9 files changed, 21 insertions(+), 79 deletions(-) diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index fbb9a62fe..2dc223864 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -60,10 +60,6 @@ "__name": "API_COOKIE_AGE_DAYS", "__format": "number" }, - "availableRefreshSeconds": { - "__name": "API_AVAILABLE_REFRESH_SECONDS", - "__format": "number" - }, "rateLimit": { "time": { "__name": "API_RATE_LIMIT_TIME", @@ -192,6 +188,10 @@ "__format": "number" } }, + "availableRefreshSeconds": { + "__name": "API_AVAILABLE_REFRESH_SECONDS", + "__format": "number" + }, "queryOnSessionInit": { "pokemon": { "__name": "API_QUERY_ON_SESSION_INIT_POKEMON", @@ -320,20 +320,6 @@ "reactMapHandlesPvp": { "__name": "API_PVP_REACT_MAP_HANDLES_PVP", "__format": "boolean" - }, - "minCp": { - "little": { - "__name": "API_PVP_MIN_CP_LITTLE", - "__format": "number" - }, - "great": { - "__name": "API_PVP_MIN_CP_GREAT", - "__format": "number" - }, - "ultra": { - "__name": "API_PVP_MIN_CP_ULTRA", - "__format": "number" - } } }, "portalUpdateLimit": { diff --git a/config/default.json b/config/default.json index 730a58f96..ed36c09e9 100644 --- a/config/default.json +++ b/config/default.json @@ -111,12 +111,7 @@ } ], "levels": [50, 51], - "reactMapHandlesPvp": false, - "minCp": { - "little": 400, - "great": 1400, - "ultra": 2400 - } + "reactMapHandlesPvp": false }, "portalUpdateLimit": 30, "weatherCellLimit": 3, diff --git a/packages/config/.configref b/packages/config/.configref index 9d4246a9f..da3751b04 100644 --- a/packages/config/.configref +++ b/packages/config/.configref @@ -1 +1 @@ -26851 \ No newline at end of file +26792 \ No newline at end of file diff --git a/packages/types/lib/scanner.d.ts b/packages/types/lib/scanner.d.ts index 7c87434de..1a11980ae 100644 --- a/packages/types/lib/scanner.d.ts +++ b/packages/types/lib/scanner.d.ts @@ -260,8 +260,6 @@ export interface Pokemon { expire_timestamp_verified: boolean updated: number pvp: CleanPvp - pvp_rankings_great_league?: import('ohbem').PvPRankEntry[] - pvp_rankings_ultra_league?: import('ohbem').PvPRankEntry[] distance?: number shiny?: boolean } diff --git a/packages/types/lib/server.d.ts b/packages/types/lib/server.d.ts index 0af4084a9..32614260c 100644 --- a/packages/types/lib/server.d.ts +++ b/packages/types/lib/server.d.ts @@ -28,7 +28,6 @@ import { OperationTypeNode } from 'graphql' export interface DbContext { isMad: boolean - pvpV2: boolean mem: string secret: string hasSize: boolean diff --git a/server/src/filters/pokemon/Backend.js b/server/src/filters/pokemon/Backend.js index 77c21d12e..1ce7bb513 100644 --- a/server/src/filters/pokemon/Backend.js +++ b/server/src/filters/pokemon/Backend.js @@ -29,7 +29,6 @@ class PkmnBackend { * @param {string[]} perms.areaRestrictions * @param {object} mods * @param {boolean} mods.onlyLinkGlobal - * @param {boolean} mods.pvpV2 * @param {boolean} mods.hasSize * @param {boolean} mods.isMad * @param {boolean} mods.mem @@ -207,19 +206,10 @@ class PkmnBackend { between(entry.rank, ...this.global[league]) if (!rankCheck) return false - const cpCheck = - this.mods.pvpV2 || - this.pvpConfig.reactMapHandlesPvp || - entry.cp >= this.pvpConfig.minCp[league] - if (!cpCheck) return false - const megaCheck = !entry.evolution || this.mods.onlyPvpMega if (!megaCheck) return false - const capCheck = - this.mods.pvpV2 || this.pvpConfig.reactMapHandlesPvp - ? entry.capped || this.mods[`onlyPvp${entry.cap}`] - : true + const capCheck = entry.capped || this.mods[`onlyPvp${entry.cap}`] if (!capCheck) return false return true diff --git a/server/src/filters/pokemon/functions.js b/server/src/filters/pokemon/functions.js index 1b56d44c0..d27db2be3 100644 --- a/server/src/filters/pokemon/functions.js +++ b/server/src/filters/pokemon/functions.js @@ -14,19 +14,8 @@ const { log, TAGS } = require('@rm/logger') * @returns {Record} */ function getParsedPvp(pokemon) { - if (pokemon.pvp) - return typeof pokemon.pvp === 'string' - ? JSON.parse(pokemon.pvp) - : pokemon.pvp - - const parsed = { great: [], ultra: [], little: [] } - const pvpKeys = ['great', 'ultra'] - pvpKeys.forEach((league) => { - if (pokemon[`pvp_rankings_${league}_league`]) { - parsed[league] = JSON.parse(pokemon[`pvp_rankings_${league}_league`]) - } - }) - return parsed + if (!pokemon.pvp) return {} + return typeof pokemon.pvp === 'string' ? JSON.parse(pokemon.pvp) : pokemon.pvp } /** diff --git a/server/src/models/Pokemon.js b/server/src/models/Pokemon.js index f9dfbc46d..c85081e0f 100644 --- a/server/src/models/Pokemon.js +++ b/server/src/models/Pokemon.js @@ -139,7 +139,6 @@ class Pokemon extends Model { mem, secret, httpAuth, - pvpV2, } = ctx const { filterMap, globalFilter } = this.getFilters(perms, args, ctx) @@ -371,13 +370,7 @@ class Pokemon extends Model { const filter = filterMap[id] || globalFilter let noPvp = true - if ( - pvp && - (pkmn.pvp || - pkmn.pvp_rankings_great_league || - pkmn.pvp_rankings_ultra_league || - (isMad && reactMapHandlesPvp && pkmn.cp)) - ) { + if (pvp && (pkmn.pvp || (isMad && reactMapHandlesPvp && pkmn.cp))) { noPvp = false listOfIds.push(pkmn.id) pvpResults.push(pkmn) @@ -419,14 +412,8 @@ class Pokemon extends Model { } if (reactMapHandlesPvp) { pvpQuery.whereNotNull('cp') - } else if (pvpV2) { - pvpQuery.whereNotNull('pvp') } else { - pvpQuery.andWhere((pvpBuilder) => { - pvpBuilder - .whereNotNull('pvp_rankings_great_league') - .orWhereNotNull('pvp_rankings_ultra_league') - }) + pvpQuery.whereNotNull('pvp') } if ( !getAreaSql(pvpQuery, areaRestrictions, onlyAreas, isMad, 'pokemon') diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index 059e80c56..b19bd7106 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -192,16 +192,16 @@ class DbManager extends Logger { * @returns {Promise} */ static async schemaCheck(schema) { - const [isMad, pvpV2, hasSize, hasHeight, hasPokemonBackground] = - await schema('pokemon') - .columnInfo() - .then((columns) => [ - 'cp_multiplier' in columns, - 'pvp' in columns, - 'size' in columns, - 'height' in columns, - 'background' in columns, - ]) + const [isMad, hasSize, hasHeight, hasPokemonBackground] = await schema( + 'pokemon', + ) + .columnInfo() + .then((columns) => [ + 'cp_multiplier' in columns, + 'size' in columns, + 'height' in columns, + 'background' in columns, + ]) const [ hasRewardAmount, hasPowerUp, @@ -271,7 +271,6 @@ class DbManager extends Logger { return { isMad, - pvpV2, mem: '', secret: '', hasSize, @@ -333,7 +332,6 @@ class DbManager extends Logger { mem: this.endpoints[i].endpoint, secret: this.endpoints[i].secret, httpAuth: this.endpoints[i].httpAuth, - pvpV2: true, // No DB schema check runs for a pure-endpoint source, but the // Golbat scan always returns confirmed incident data (confirmed // flag + lineup slots), so it IS confirmed-capable. Without this, From e7ea3d8ead546cb19573bd58447a1d29e69aea17 Mon Sep 17 00:00:00 2001 From: James Berry Date: Tue, 28 Jul 2026 22:15:54 +0100 Subject: [PATCH 38/38] fix(fort): exclude badged gyms from the endpoint's badge=none view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint candidate set isn't pre-filtered by badge (per-user ReactMap data Golbat can't know), so the shared secondaryFilter's badge clause — `(actualBadge === 'none' && onlyGymBadges)` — matched every candidate, letting previously-badged gyms leak into the `none` view that the SQL path removes via `whereNotIn(userBadges)`. Replace the always-true clause with one that mirrors the SQL whereIn/whereNotIn: `onlyGymBadges && (actualBadge === 'none' ? !newGym.badge : !!newGym.badge)` — the `none` view keeps only gyms with no user badge, a specific/all badge view keeps only those with one. It's a no-op for the SQL path (results are already badge-filtered) and closes the endpoint leak. Verified equivalent across all badge views (none/tier/all/off) for both paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/models/Gym.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/server/src/models/Gym.js b/server/src/models/Gym.js index d375c6ce0..8b915a07f 100644 --- a/server/src/models/Gym.js +++ b/server/src/models/Gym.js @@ -518,9 +518,17 @@ class Gym extends Model { } if ( newGym.hasRaid || - newGym.badge || - (actualBadge === 'none' && onlyGymBadges) || - newGym.hasGym + newGym.hasGym || + // Badge layer: mirror the SQL query's whereIn/whereNotIn(userBadges). + // The endpoint candidate set is NOT pre-filtered by badge (it's + // per-user ReactMap data Golbat can't know), so verify it here: the + // `none` view keeps only gyms the user has NO badge for, and a + // specific/`all` badge view keeps only gyms they DO. For the SQL path + // this is a no-op — its results are already whereIn/whereNotIn'd — but + // it stops the endpoint `none` view from leaking previously-badged + // gyms. (newGym.badge is only ever set when onlyGymBadges.) + (onlyGymBadges && + (actualBadge === 'none' ? !newGym.badge : !!newGym.badge)) ) { filteredResults.push(newGym) }