From 9ff4bb800a9c46e82bb9297a1ecb8cd7bfb1b393 Mon Sep 17 00:00:00 2001 From: James Berry Date: Mon, 3 Aug 2026 13:38:53 +0100 Subject: [PATCH 01/17] docs: implementation plan for golbat fort api migration Plan for serving gym/pokestop/station map data from Golbat's fort API (UnownHash/Golbat#385) with automatic detection and SQL fallback, plus filter pick lists from fort availability. Agreed design: ccev/diadem#174. Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-03-golbat-fort-api.md | 1217 +++++++++++++++++ 1 file changed, 1217 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-golbat-fort-api.md diff --git a/docs/superpowers/plans/2026-08-03-golbat-fort-api.md b/docs/superpowers/plans/2026-08-03-golbat-fort-api.md new file mode 100644 index 00000000..98feaccf --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-golbat-fort-api.md @@ -0,0 +1,1217 @@ +# Golbat Fort API Migration 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:** Serve gym/pokestop/station map data from Golbat's fort HTTP API (UnownHash/Golbat#385) instead of raw SQL, and source filter pick lists from `GET api/fort/available`, with automatic detection and SQL fallback. + +**Architecture:** Three new API-backed query classes subclass the existing SQL query classes and override only `query()`/`querySingle()`, reusing all `filter()`/`prepare()` logic (the same pattern `PokemonQuery` established for `api/pokemon/v3/scan`). A detection module polls `GET api/fort/available` every 60s: success enables the API registry entries and caches the availability payload; failure (503/404) falls back to SQL. Filter pick lists merge availability data into the existing `MasterStats` shape at `/api/stats` request time, so no component changes. + +**Tech Stack:** SvelteKit (SSR disabled), TypeScript strict, vitest, mysql2 (fallback path), Golbat HTTP API. + +**Authoritative context:** https://github.com/ccev/diadem/issues/174 (the agreed proposal — read it first). Golbat PRs: #385 (merged fort API), #392 (pokemon `limit_reached`, will land), plus the Phase 0 PRs in Appendix A (assume they land; code defensively until then). + +## Global Constraints + +- Node 22+, pnpm. Run `pnpm run check` (svelte-check, must pass) and `pnpm run lint` (prettier) before every commit. Tests run with `pnpm test` (vitest). +- CLAUDE.md rules apply: do not touch unrelated code; Svelte 5 runes only; no new i18n strings are expected for this plan (if you add any: append English-only to `messages/en.json`). +- Path alias `@` and `$lib` both map into `./src`. +- **Pushed DNF filters must always be a SUPERSET of what the local filter logic accepts.** Local `filter()`/`shouldDisplay*` re-checks everything; a too-loose pushed filter costs bandwidth, a too-tight one silently hides map objects. When in doubt, loosen. +- The API response field for open gym slots is `available_slots`; the diadem field is the DB typo `availble_slots` (`gym.d.ts:19`). Do not "fix" the typo — it's the DB column name and is used throughout. +- Do not add a config option for any of this. Detection is automatic (decision recorded in issue #174). +- Commit style: conventional prefixes (`feat:`, `fix:`, `chore:`, `docs:`) matching `git log`. + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/lib/server/queryMapObjects/queries.d.ts` | modify | add fort DNF + fort scan/availability wire types | +| `src/lib/server/api/golbatApi.ts` | modify | add fort scan / by-id / availability HTTP functions | +| `src/lib/server/api/golbatFortApi.ts` | create | detection state + 60s availability poll + cached payload | +| `src/lib/server/init.ts` | modify | start detection during init | +| `src/lib/server/queryMapObjects/fortDnf.ts` | create | pure DNF translation functions (filter → clauses) | +| `src/lib/server/queryMapObjects/fortDnf.test.ts` | create | vitest for the translators | +| `src/lib/server/queryMapObjects/queryGymApi.ts` | create | `ApiGymQuery extends GymQuery` | +| `src/lib/server/queryMapObjects/queryPokestopApi.ts` | create | `ApiPokestopQuery extends PokestopQuery` | +| `src/lib/server/queryMapObjects/queryStationApi.ts` | create | `ApiStationQuery extends StationQuery` | +| `src/lib/server/queryMapObjects/queryMapObjects.ts` | modify | API registry consulted when detection is on | +| `src/lib/server/api/queryStats.ts` | modify | skip live-table SQL when fort API on; merge availability | +| `src/routes/api/stats/+server.ts` | modify | merge fresh availability at request time | +| `docs/src/content/docs/reference/configuration.md` | modify | document Golbat-side requirements | + +--- + +### Task 1: Wire types + Golbat API client functions + +**Files:** +- Modify: `src/lib/server/queryMapObjects/queries.d.ts` +- Modify: `src/lib/server/api/golbatApi.ts` + +**Interfaces:** +- Produces: types `GolbatDnfId`, `GolbatFortDnfFilter`, `FortScanBody`, `GolbatGymResult`, `GolbatPokestopResult`, `GolbatStationResult`, `GymScanResponse`, `PokestopScanResponse`, `StationScanResponse`, `FortAvailability`; functions `scanGyms(body)`, `scanPokestops(body)`, `scanStations(body)`, `getGolbatGym(id, thisFetch?)`, `getGolbatPokestop(id, thisFetch?)`, `getGolbatStation(id, thisFetch?)`, `fetchFortAvailability()` — all return `T | undefined` (undefined on any non-2xx, matching `callGolbat` semantics). + +- [ ] **Step 1: Add the wire types to `queries.d.ts`** (append after `GolbatPokemonQuery`): + +```ts +export type GolbatDnfId = { pokemon_id: number; form?: number }; + +/** One DNF clause: conditions AND within, clauses OR across. Omitted field = no constraint. */ +export type GolbatFortDnfFilter = { + is_ar_scan_eligible?: boolean; + // gym + available_slots?: { min: number; max: number }; + team_id?: number[]; + raid_level?: number[]; + raid_pokemon_id?: GolbatDnfId[]; + // pokestop + lure_id?: number[]; + quest_reward_type?: number[]; + quest_reward_amount?: { min: number; max: number }; + quest_reward_item_id?: number[]; + quest_reward_pokemon?: GolbatDnfId[]; + incident_display_type?: number[]; + incident_character?: number[]; + contest_pokemon?: GolbatDnfId[]; + contest_pokemon_type?: number[]; + // station + battle_level?: number[]; + battle_pokemon?: GolbatDnfId[]; + stationed_gmax?: boolean; + station_active?: boolean; +}; + +export type FortScanBody = { + min: { latitude: number; longitude: number }; + max: { latitude: number; longitude: number }; + limit: number; + filters?: GolbatFortDnfFilter[]; + with_incidents?: boolean; +}; + +export type FortAvailability = { + gyms: { raids: { raid_level: number; pokemon_id: number | null; form: number | null }[] }; + pokestops: { + quests: { + with_ar: boolean; + reward_type: number; + item_id: number; + amount: number; + pokemon_id: number; + form_id: number; + title: string; + target: number; + count: number; + }[]; + invasions: { character: number; display_type: number; confirmed: boolean }[]; + lures: { lure_id: number }[]; + showcases: { pokemon_id: number | null; form: number | null; type_id: number | null }[]; + }; + stations: { + battles: { battle_level: number; pokemon_id: number | null; form: number | null }[]; + }; +}; +``` + +- [ ] **Step 2: Add response/result types and functions to `golbatApi.ts`.** The API returns whole DB records; type only what diadem consumes, via the existing `MinMapObject` shapes plus the fields that need renaming. Add: + +```ts +import type { MinMapObject } from "@/lib/mapObjects/mapObjectTypes"; +import type { GymData, GymDefender, Rsvp } from "@/lib/types/mapObjectData/gym"; +import type { Incident, PokestopData } from "@/lib/types/mapObjectData/pokestop"; +import type { StationData } from "@/lib/types/mapObjectData/station"; +import type { FortAvailability, FortScanBody } from "@/lib/server/queryMapObjects/queries"; + +// Raw API records: like diadem's rows except the fields the mappers rename/reshape. +export type GolbatGymResult = Omit< + MinMapObject, + "availble_slots" | "defenders_raw" | "defenders" | "raw_rsvps" | "rsvps" +> & { + available_slots?: number | null; + deleted: boolean; + defenders?: GymDefender[] | null; // native JSON, not a string + rsvps?: Rsvp[] | null; // native JSON, not a string +}; + +export type GolbatIncidentResult = Omit & { confirmed: boolean }; + +export type GolbatPokestopResult = Omit, "incident" | "deleted"> & { + deleted: boolean; + invasions?: GolbatIncidentResult[]; +}; + +export type GolbatStationResult = Omit< + MinMapObject, + "is_inactive" | "is_battle_available" | "stationed_pokemon" | "raw_stationed_pokemon" +> & { + is_inactive: boolean; + is_battle_available: boolean; + stationed_pokemon?: string | null; // still a serialized string on the wire +}; + +export type GymScanResponse = { + gyms: GolbatGymResult[]; + examined: number; + skipped: number; + total: number; + limit_reached?: boolean; // present once the fort mirror of Golbat #392 lands +}; +export type PokestopScanResponse = { + pokestops: GolbatPokestopResult[]; + examined: number; + skipped: number; + total: number; + limit_reached?: boolean; +}; +export type StationScanResponse = { + stations: GolbatStationResult[]; + examined: number; + skipped: number; + total: number; + limit_reached?: boolean; +}; +``` + +Add a `quiet` parameter to `callGolbat` so the detection poll doesn't spam the error log while Golbat runs without `fort_in_memory` (change the signature, leave all existing call sites alone — the parameter defaults to false): + +```ts +async function callGolbat( + path: string, + method: "GET" | "POST", + body: BodyInit | undefined = undefined, + thisFetch: typeof fetch = fetch, + quiet = false +): Promise { +``` + +and inside the `!response.ok` branch: `if (!quiet) log.error(...)` (keep a `log.debug` for the quiet case). + +Then the new exported functions (note: paths without a leading slash — `new URL` would drop Golbat base paths otherwise): + +```ts +export async function scanGyms(body: FortScanBody) { + return await callGolbat("api/gym/scan", "POST", JSON.stringify(body)); +} + +export async function scanPokestops(body: FortScanBody) { + return await callGolbat("api/pokestop/scan", "POST", JSON.stringify(body)); +} + +export async function scanStations(body: FortScanBody) { + return await callGolbat("api/station/scan", "POST", JSON.stringify(body)); +} + +export async function getGolbatGym(id: string, thisFetch: typeof fetch = fetch) { + return await callGolbat("api/gym/id/" + id, "GET", undefined, thisFetch); +} + +export async function getGolbatPokestop(id: string, thisFetch: typeof fetch = fetch) { + return await callGolbat("api/pokestop/id/" + id, "GET", undefined, thisFetch); +} + +export async function getGolbatStation(id: string, thisFetch: typeof fetch = fetch) { + return await callGolbat("api/station/id/" + id, "GET", undefined, thisFetch); +} + +export async function fetchFortAvailability() { + return await callGolbat("api/fort/available", "GET", undefined, fetch, true); +} +``` + +- [ ] **Step 3: Verify** — Run: `pnpm run check` — expected: 0 errors. (If `Rsvp` isn't exported from `gym.d.ts`, export it there; same for `Incident` in `pokestop.d.ts` — both already exist as types.) + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/server/queryMapObjects/queries.d.ts src/lib/server/api/golbatApi.ts +git commit -m "feat: add golbat fort api client functions and wire types" +``` + +--- + +### Task 2: Detection module + init wiring + +**Files:** +- Create: `src/lib/server/api/golbatFortApi.ts` +- Modify: `src/lib/server/init.ts` + +**Interfaces:** +- Consumes: `fetchFortAvailability()` from Task 1. +- Produces: `isFortApiEnabled(): boolean`, `getCachedFortAvailability(): FortAvailability | undefined`, `startFortApiDetection(): Promise`, `refreshFortAvailability(): Promise`. + +- [ ] **Step 1: Create `src/lib/server/api/golbatFortApi.ts`:** + +```ts +import { fetchFortAvailability } from "@/lib/server/api/golbatApi"; +import type { FortAvailability } from "@/lib/server/queryMapObjects/queries"; +import { getLogger } from "@/lib/utils/logger"; + +const log = getLogger("golbat:fort"); +const REFRESH_SECONDS = 60; + +let fortApiEnabled = false; +let cachedAvailability: FortAvailability | undefined; + +export function isFortApiEnabled() { + return fortApiEnabled; +} + +export function getCachedFortAvailability() { + return cachedAvailability; +} + +// Golbat gates every fort endpoint on fort_in_memory (503 when off, 404 on +// older versions), so a successful availability fetch doubles as detection. +export async function refreshFortAvailability() { + const result = await fetchFortAvailability(); + const nowEnabled = result !== undefined; + + if (nowEnabled !== fortApiEnabled) { + log.info( + nowEnabled + ? "Golbat fort API detected, serving gyms/pokestops/stations from it" + : "Golbat fort API unavailable, serving gyms/pokestops/stations from SQL" + ); + } + + fortApiEnabled = nowEnabled; + if (result) cachedAvailability = result; +} + +export async function startFortApiDetection() { + setInterval(() => { + refreshFortAvailability().catch((err) => log.error("Fort availability refresh failed: %s", err)); + }, REFRESH_SECONDS * 1000)?.unref?.(); + + await refreshFortAvailability(); +} +``` + +- [ ] **Step 2: Wire into `init.ts`** — add to the `Promise.all` in `initDiadem()`: + +```ts +import { startFortApiDetection } from "@/lib/server/api/golbatFortApi"; +// ... + await Promise.all([ + masterfileProvider.refresh(), + uiconsIndexProvider.refresh(), + remoteLocaleProvider.refresh(), + masterstatsProvider.refresh(), + startFortApiDetection() + ]); +``` + +- [ ] **Step 3: Verify** — `pnpm run check` passes, then `pnpm run dev` and confirm one of the two detection log lines appears at startup (which one depends on your Golbat's `fort_in_memory`). + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/server/api/golbatFortApi.ts src/lib/server/init.ts +git commit -m "feat: detect golbat fort api support at startup" +``` + +--- + +### Task 3: DNF translation functions (TDD) + +**Files:** +- Create: `src/lib/server/queryMapObjects/fortDnf.ts` +- Test: `src/lib/server/queryMapObjects/fortDnf.test.ts` + +**Interfaces:** +- Consumes: `FilterGym`, `FilterPokestop`, `FilterStation` from `@/lib/features/filters/filters`; `GolbatFortDnfFilter` from Task 1; `RewardType`, `INCIDENT_DISPLAYS_INVASION`, `INCIDENT_DISPLAY_GOLD`, `INCIDENT_DISPLAY_KECLEON`, `INCIDENT_DISPLAY_CONTEST` from `@/lib/utils/pokestopUtils`. +- Produces: `buildGymDnfFilters(filter)`, `buildPokestopDnfFilters(filter)`, `buildStationDnfFilters(filter)` — each returns `GolbatFortDnfFilter[] | null`. **Contract: `[]` = match everything (send no filters), non-empty = clauses to send, `null` = match nothing (caller returns an empty result without calling Golbat).** + +The source of truth for the semantics is the SQL in `queryGym.ts:53-87`, `queryPokestop.ts:114-326`, `queryStation.ts:44-89` — each SQL OR-branch becomes one DNF clause. Every clause must be a superset of its SQL counterpart (see Global Constraints). + +- [ ] **Step 1: Write failing tests** in `fortDnf.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { + buildGymDnfFilters, + buildPokestopDnfFilters, + buildStationDnfFilters +} from "./fortDnf"; + +const disabled = { enabled: false }; +const enabledEmpty = { enabled: true, filters: [] }; + +describe("buildGymDnfFilters", () => { + it("matches all when plain gyms are shown", () => { + expect( + buildGymDnfFilters({ gymPlain: { enabled: true }, raid: { enabled: true, filters: [] } } as any) + ).toEqual([]); + }); + + it("translates levels, eggs and bosses into separate OR clauses", () => { + const result = buildGymDnfFilters({ + gymPlain: { enabled: false }, + raid: { + enabled: true, + filters: [ + { + enabled: true, + show: ["egg"], + levels: [5], + bosses: [{ pokemon_id: 150, temp_evolution_id: 2 }] + } + ] + } + } as any); + expect(result).toContainEqual({ raid_pokemon_id: [{ pokemon_id: 0 }] }); + expect(result).toContainEqual({ raid_level: [5] }); + // temp_evolution_id is not expressible — pokemon id alone (superset) + expect(result).toContainEqual({ raid_pokemon_id: [{ pokemon_id: 150 }] }); + }); + + it("falls back to an any-active-raid clause when raid filter has no conditions", () => { + const result = buildGymDnfFilters({ + gymPlain: { enabled: false }, + raid: { enabled: true, filters: [{ enabled: true }] } + } as any); + expect(result).toHaveLength(1); + expect(result![0].raid_level!.length).toBeGreaterThanOrEqual(9); + }); +}); + +describe("buildPokestopDnfFilters", () => { + it("matches all when plain pokestops are shown", () => { + expect( + buildPokestopDnfFilters({ enabled: true, pokestopPlain: { enabled: true } } as any) + ).toEqual([]); + }); + + it("matches nothing when enabled but no sub-filter is on", () => { + expect( + buildPokestopDnfFilters({ + enabled: true, + pokestopPlain: disabled, + lure: enabledEmpty && disabled, + quest: disabled, + invasion: disabled, + goldPokestop: disabled, + kecleon: disabled, + contest: disabled + } as any) + ).toBeNull(); + }); + + it("translates invasions with characters exactly like the SQL", () => { + const result = buildPokestopDnfFilters({ + enabled: true, + pokestopPlain: disabled, + lure: disabled, + quest: disabled, + invasion: { enabled: true, filters: [{ enabled: true, characters: [41, 42] }] }, + goldPokestop: disabled, + kecleon: disabled, + contest: disabled + } as any); + expect(result).toEqual([ + { incident_display_type: [1, 2, 3], incident_character: [41, 42] } + ]); + }); + + it("pushes a stardust range as reward_type + amount in one AND clause", () => { + const result = buildPokestopDnfFilters({ + enabled: true, + pokestopPlain: disabled, + lure: disabled, + quest: { + enabled: true, + filters: [{ enabled: true, stardust: { min: 500, max: Infinity } }] + }, + invasion: disabled, + goldPokestop: disabled, + kecleon: disabled, + contest: disabled + } as any); + expect(result).toEqual([ + { quest_reward_type: [3], quest_reward_amount: { min: 500, max: 10000 } } + ]); + }); +}); + +describe("buildStationDnfFilters", () => { + it("adds station_active to every battle clause", () => { + const result = buildStationDnfFilters({ + enabled: true, + stationPlain: { enabled: false }, + maxBattle: { + enabled: true, + filters: [{ enabled: true, bosses: [{ pokemon_id: 809, bread_mode: 2 }] }] + } + } as any); + expect(result).toEqual([ + { station_active: true, battle_pokemon: [{ pokemon_id: 809 }] } + ]); + }); + + it("translates hasGmax", () => { + const result = buildStationDnfFilters({ + enabled: true, + stationPlain: { enabled: false }, + maxBattle: { enabled: true, filters: [{ enabled: true, hasGmax: true }] } + } as any); + expect(result).toEqual([{ stationed_gmax: true }]); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — Run: `pnpm test -- fortDnf` — expected: FAIL (module not found). + +- [ ] **Step 3: Implement `fortDnf.ts`:** + +```ts +import type { FilterGym, FilterPokestop, FilterStation } from "@/lib/features/filters/filters"; +import type { GolbatFortDnfFilter } from "@/lib/server/queryMapObjects/queries"; +import { + INCIDENT_DISPLAY_CONTEST, + INCIDENT_DISPLAY_GOLD, + INCIDENT_DISPLAY_KECLEON, + INCIDENT_DISPLAYS_INVASION, + RewardType +} from "@/lib/utils/pokestopUtils"; + +// Superset guards: broad enough that a matching object can never be missed; +// the local filter()/shouldDisplay* pass trims the excess. +const ALL_RAID_LEVELS = Array.from({ length: 20 }, (_, i) => i + 1); +const ALL_LURE_IDS = [501, 502, 503, 504, 505, 506]; +const ALL_QUEST_REWARD_TYPES = Object.values(RewardType).filter( + (v): v is number => typeof v === "number" && v > 0 +); +const AMOUNT_MAX = 10000; + +function minMax(range: { min: number; max: number }) { + return { + min: Number.isFinite(range.min) ? range.min : 0, + max: Number.isFinite(range.max) ? range.max : AMOUNT_MAX + }; +} + +/** Returns [] = match all, clauses = send as filters, null = match nothing. */ +export function buildGymDnfFilters(filter: FilterGym | undefined): GolbatFortDnfFilter[] | null { + if (!filter || filter.gymPlain.enabled || !filter.raid.enabled) return []; + + const clauses: GolbatFortDnfFilter[] = []; + for (const filterset of filter.raid.filters.filter((f) => f.enabled)) { + // Mirrors queryGym.getFilterWhere: each SQL OR-branch is one clause. + // A clause with any raid_* field only matches gyms with an active raid. + if (filterset.show?.includes("egg")) clauses.push({ raid_pokemon_id: [{ pokemon_id: 0 }] }); + if (filterset.show?.includes("boss")) + clauses.push({ raid_level: filterset.levels?.length ? filterset.levels : ALL_RAID_LEVELS }); + if (filterset.levels?.length) clauses.push({ raid_level: filterset.levels }); + for (const boss of filterset.bosses ?? []) { + // temp_evolution_id is not a DNF field: match by id only, re-filter locally + clauses.push({ raid_pokemon_id: [{ pokemon_id: boss.pokemon_id }] }); + } + } + + // SQL equivalent had a bare "raid_end_timestamp > now" when no clauses exist + return clauses.length ? clauses : [{ raid_level: ALL_RAID_LEVELS }]; +} + +export function buildPokestopDnfFilters( + filter: FilterPokestop | undefined +): GolbatFortDnfFilter[] | null { + if (!filter?.enabled || filter.pokestopPlain.enabled) return []; + + const clauses: GolbatFortDnfFilter[] = []; + + if (filter.lure.enabled) { + const items = filter.lure.filters.filter((f) => f.enabled).flatMap((f) => f.items); + clauses.push({ lure_id: items.length ? items : ALL_LURE_IDS }); + } + + if (filter.quest.enabled) { + const questFilters = filter.quest.filters.filter((f) => f.enabled); + if (!questFilters.length) { + // SQL fell back to "has any active quest" + clauses.push({ quest_reward_type: ALL_QUEST_REWARD_TYPES }); + } + for (const filterset of questFilters) { + const rewardClauses: GolbatFortDnfFilter[] = []; + + if (filterset.stardust) + rewardClauses.push({ + quest_reward_type: [RewardType.STARDUST], + quest_reward_amount: minMax(filterset.stardust) + }); + if (filterset.pokecoins) + rewardClauses.push({ + quest_reward_type: [RewardType.POKECOINS], + quest_reward_amount: minMax(filterset.pokecoins) + }); + if (filterset.xp) + rewardClauses.push({ + quest_reward_type: [RewardType.XP], + quest_reward_amount: minMax(filterset.xp) + }); + if (filterset.pokemon?.length) + rewardClauses.push({ + quest_reward_type: [RewardType.POKEMON], + quest_reward_pokemon: filterset.pokemon.map((p) => ({ pokemon_id: p.pokemon_id })) + }); + for (const item of filterset.item ?? []) + rewardClauses.push({ + quest_reward_type: [RewardType.ITEM], + quest_reward_item_id: [item.id] + // exact amount match is not expressible as a range safely — local re-filter + }); + for (const reward of filterset.megaResource ?? []) + rewardClauses.push({ + quest_reward_type: [RewardType.MEGA_ENERGY, RewardType.TEMP_EVO_BRANCH_RESOURCE], + quest_reward_pokemon: [{ pokemon_id: reward.id }] + }); + for (const reward of [...(filterset.candy ?? []), ...(filterset.xlCandy ?? [])]) + rewardClauses.push({ + quest_reward_type: [RewardType.CANDY, RewardType.XL_CANDY], + quest_reward_pokemon: [{ pokemon_id: reward.id }] + }); + + if (rewardClauses.length) { + clauses.push(...rewardClauses); + } else { + // tasks-only filterset (title/target isn't a DNF field): any-quest superset + clauses.push({ quest_reward_type: ALL_QUEST_REWARD_TYPES }); + } + } + } + + if (filter.invasion.enabled) { + const invasionFilters = filter.invasion.filters.filter((f) => f.enabled); + const characterIds = invasionFilters.flatMap((f) => f.characters ?? []); + const hasUnsafeInvasionFilter = invasionFilters.some((f) => f.rewards?.length); + const clause: GolbatFortDnfFilter = { incident_display_type: [...INCIDENT_DISPLAYS_INVASION] }; + if (invasionFilters.length > 0 && characterIds.length > 0 && !hasUnsafeInvasionFilter) { + clause.incident_character = characterIds; + } + clauses.push(clause); + } + + if (filter.goldPokestop.enabled) clauses.push({ incident_display_type: [INCIDENT_DISPLAY_GOLD] }); + if (filter.kecleon.enabled) clauses.push({ incident_display_type: [INCIDENT_DISPLAY_KECLEON] }); + + if (filter.contest.enabled) { + const contestFilters = filter.contest.filters.filter((f) => f.enabled); + if (!contestFilters.length) { + clauses.push({ incident_display_type: [INCIDENT_DISPLAY_CONTEST] }); + } + for (const filterset of contestFilters) { + const clause: GolbatFortDnfFilter = { incident_display_type: [INCIDENT_DISPLAY_CONTEST] }; + // ranking_standard is not a DNF field — local re-filter + if (filterset.focus.pokemon_id) + clause.contest_pokemon = [{ pokemon_id: filterset.focus.pokemon_id }]; + if (filterset.focus.type_id) clause.contest_pokemon_type = [filterset.focus.type_id]; + clauses.push(clause); + } + } + + // SQL equivalent: "1 = 0" + return clauses.length ? clauses : null; +} + +export function buildStationDnfFilters( + filter: FilterStation | undefined +): GolbatFortDnfFilter[] | null { + if (!filter || filter.stationPlain.enabled || !filter.maxBattle.enabled) return []; + + const clauses: GolbatFortDnfFilter[] = []; + for (const filterset of filter.maxBattle.filters.filter((f) => f.enabled)) { + if (filterset.isActive) { + clauses.push({ station_active: true }); + continue; + } + if (filterset.hasGmax) { + clauses.push({ stationed_gmax: true }); + continue; + } + for (const boss of filterset.bosses ?? []) { + // bread_mode is covered by battle_level in practice (gmax = level 6), + // and re-checked locally either way — push id only + clauses.push({ station_active: true, battle_pokemon: [{ pokemon_id: boss.pokemon_id }] }); + } + } + + // SQL fallback: active battle with no boss constraint + return clauses.length ? clauses : [{ station_active: true }]; +} +``` + +Note: if `RewardType.TEMP_EVO_BRANCH_RESOURCE` doesn't exist under that exact name, use whatever member `queryPokestop.ts:195` references — copy it, don't invent. + +- [ ] **Step 4: Run tests** — `pnpm test -- fortDnf` — expected: PASS. Adjust the test fixtures if the real `FilterX` types force different shapes (`as any` keeps fixtures small; keep them minimal but honest). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/queryMapObjects/fortDnf.ts src/lib/server/queryMapObjects/fortDnf.test.ts +git commit -m "feat: translate diadem filters to golbat fort dnf clauses" +``` + +--- + +### Task 4: ApiGymQuery + +**Files:** +- Create: `src/lib/server/queryMapObjects/queryGymApi.ts` + +**Interfaces:** +- Consumes: `scanGyms`, `getGolbatGym`, `GolbatGymResult` (Task 1); `buildGymDnfFilters` (Task 3); `GymQuery` (existing). +- Produces: `class ApiGymQuery extends GymQuery` overriding `query` and `querySingle`. Inherits `filter()`/`prepare()` from `GymQuery` — the mapper must produce exactly the `MinMapObject` shape the SQL rows had, except native-JSON fields are set directly (so the inherited `defenders_raw`/`raw_rsvps` parsing no-ops). + +- [ ] **Step 1: Create `queryGymApi.ts`:** + +```ts +import type { FilterGym } from "@/lib/features/filters/filters"; +import type { Bounds } from "@/lib/mapObjects/mapBounds"; +import type { MinMapObject } from "@/lib/mapObjects/mapObjectTypes"; +import { getGolbatGym, scanGyms, type GolbatGymResult } from "@/lib/server/api/golbatApi"; +import { buildGymDnfFilters } from "@/lib/server/queryMapObjects/fortDnf"; +import type { MapObjectResponse } from "@/lib/server/queryMapObjects/MapObjectQuery"; +import { GymQuery } from "@/lib/server/queryMapObjects/queryGym"; +import type { PermittedPolygon } from "@/lib/services/user/checkPerm"; +import type { GymData } from "@/lib/types/mapObjectData/gym"; +import { getNormalizedForm } from "@/lib/utils/pokemonUtils"; +import { error } from "@sveltejs/kit"; +import { booleanPointInPolygon, point } from "@turf/turf"; + +function mapGym(g: GolbatGymResult): MinMapObject { + const { available_slots, deleted, defenders, rsvps, ...rest } = g; + const gym = { + ...rest, + availble_slots: available_slots ?? undefined, + deleted: deleted ? 1 : 0 + } as MinMapObject; + + // Native JSON on the wire — inherited prepare() only parses the *_raw string + // variants, so normalize forms here and assign directly. + if (defenders) { + gym.defenders = defenders; + for (const defender of gym.defenders) { + defender.form = getNormalizedForm(defender.pokemon_id, defender.form); + } + } + if (rsvps) gym.rsvps = rsvps; + + return gym; +} + +export class ApiGymQuery extends GymQuery { + async query( + bounds: Bounds, + filter: FilterGym | undefined, + polygon: PermittedPolygon, + since?: number, + limit?: number + ): Promise>> { + const dnf = buildGymDnfFilters(filter); + if (dnf === null) return { data: [], examined: 0 }; + + const actualLimit = Math.min(limit ?? this.limit, this.limit); + const result = await scanGyms({ + min: { latitude: bounds.minLat, longitude: bounds.minLon }, + max: { latitude: bounds.maxLat, longitude: bounds.maxLon }, + limit: actualLimit + 1, + filters: dnf.length ? dnf : undefined + }); + if (!result) error(500); + + if (result.limit_reached || result.gyms.length > actualLimit) { + return { data: [], examined: actualLimit, limitReached: true }; + } + + let examined = result.examined; + const data: MinMapObject[] = []; + for (const g of result.gyms) { + if (g.deleted) continue; + if (since !== undefined && (g.updated ?? 0) <= since) continue; + if (polygon && !booleanPointInPolygon(point([g.lon, g.lat]), polygon)) { + examined -= 1; + continue; + } + data.push(mapGym(g)); + } + return { data, examined }; + } + + async querySingle(id: string, thisFetch?: typeof fetch): Promise[]> { + const gym = await getGolbatGym(id, thisFetch); + return gym && !gym.deleted ? [mapGym(gym)] : []; + } +} +``` + +- [ ] **Step 2: Verify** — `pnpm run check` passes. If `GymQuery`'s `query` signature clash produces a variance error, match the parent signature exactly (add the unused `context?: FeaturePermissionContext` parameter). + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/server/queryMapObjects/queryGymApi.ts +git commit -m "feat: gym map queries via golbat fort api" +``` + +--- + +### Task 5: ApiPokestopQuery + +**Files:** +- Create: `src/lib/server/queryMapObjects/queryPokestopApi.ts` + +**Interfaces:** +- Consumes: `scanPokestops`, `getGolbatPokestop`, `GolbatPokestopResult` (Task 1); `buildPokestopDnfFilters` (Task 3); `PokestopQuery` (existing). +- Produces: `class ApiPokestopQuery extends PokestopQuery` overriding `query`/`querySingle`. The mapper turns the API's nested `invasions[]` into diadem's `incident[]`; the inherited `prepare()` then builds quests, showcase data and confirmed rewards from the same field names the SQL provided. + +- [ ] **Step 1: Create `queryPokestopApi.ts`:** + +```ts +import type { FilterPokestop } from "@/lib/features/filters/filters"; +import type { Bounds } from "@/lib/mapObjects/mapBounds"; +import type { MinMapObject } from "@/lib/mapObjects/mapObjectTypes"; +import { + getGolbatPokestop, + scanPokestops, + type GolbatPokestopResult +} from "@/lib/server/api/golbatApi"; +import { buildPokestopDnfFilters } from "@/lib/server/queryMapObjects/fortDnf"; +import type { MapObjectResponse } from "@/lib/server/queryMapObjects/MapObjectQuery"; +import { PokestopQuery } from "@/lib/server/queryMapObjects/queryPokestop"; +import type { PermittedPolygon } from "@/lib/services/user/checkPerm"; +import type { Incident, PokestopData } from "@/lib/types/mapObjectData/pokestop"; +import { error } from "@sveltejs/kit"; +import { booleanPointInPolygon, point } from "@turf/turf"; + +function mapPokestop(p: GolbatPokestopResult): MinMapObject { + const { deleted, invasions, ...rest } = p; + const pokestop = { + ...rest, + deleted: deleted ? 1 : 0, + incident: (invasions ?? []).map( + (i) => ({ ...i, confirmed: i.confirmed ? 1 : 0 }) as unknown as Incident + ) + } as MinMapObject; + return pokestop; +} + +export class ApiPokestopQuery extends PokestopQuery { + async query( + bounds: Bounds, + filter: FilterPokestop | undefined, + polygon: PermittedPolygon, + since?: number, + limit?: number + ): Promise>> { + const dnf = buildPokestopDnfFilters(filter); + if (dnf === null) return { data: [], examined: 0 }; + + const actualLimit = Math.min(limit ?? this.limit, this.limit); + const result = await scanPokestops({ + min: { latitude: bounds.minLat, longitude: bounds.minLon }, + max: { latitude: bounds.maxLat, longitude: bounds.maxLon }, + limit: actualLimit + 1, + filters: dnf.length ? dnf : undefined, + with_incidents: true + }); + if (!result) error(500); + + if (result.limit_reached || result.pokestops.length > actualLimit) { + return { data: [], examined: actualLimit, limitReached: true }; + } + + let examined = result.examined; + const data: MinMapObject[] = []; + for (const p of result.pokestops) { + if (p.deleted) continue; + if (since !== undefined && (p.updated ?? 0) <= since) continue; + if (polygon && !booleanPointInPolygon(point([p.lon, p.lat]), polygon)) { + examined -= 1; + continue; + } + data.push(mapPokestop(p)); + } + return { data, examined }; + } + + async querySingle(id: string, thisFetch?: typeof fetch): Promise[]> { + const stop = await getGolbatPokestop(id, thisFetch); + return stop && !stop.deleted ? [mapPokestop(stop)] : []; + } +} +``` + +- [ ] **Step 2: Check the incident field names line up.** The API incident uses `start`/`expiration` JSON keys (`ApiPokestopIncident`), diadem's `Incident` type uses `start`/`expiration` too (`pokestop.d.ts:61-70`) — verify with the type checker; if a name differs, rename it inside `mapPokestop`'s incident map instead of casting. + +- [ ] **Step 3: Verify** — `pnpm run check` passes. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/server/queryMapObjects/queryPokestopApi.ts +git commit -m "feat: pokestop map queries via golbat fort api" +``` + +--- + +### Task 6: ApiStationQuery + +**Files:** +- Create: `src/lib/server/queryMapObjects/queryStationApi.ts` + +**Interfaces:** +- Consumes: `scanStations`, `getGolbatStation`, `GolbatStationResult` (Task 1); `buildStationDnfFilters` (Task 3); `StationQuery` (existing). +- Produces: `class ApiStationQuery extends StationQuery`. Booleans map to 0/1 (the `StationData` type declares numbers; `isMaxBattleActive` in `stationUtils.ts:40` and `matchMaxBattleFilterset` only use truthiness and `===` on ids, so 0/1 preserves behavior). The wire's `stationed_pokemon` string maps to `raw_stationed_pokemon` so the inherited `prepare()` parses it. + +- [ ] **Step 1: Create `queryStationApi.ts`:** + +```ts +import type { FilterStation } from "@/lib/features/filters/filters"; +import type { Bounds } from "@/lib/mapObjects/mapBounds"; +import type { MinMapObject } from "@/lib/mapObjects/mapObjectTypes"; +import { + getGolbatStation, + scanStations, + type GolbatStationResult +} from "@/lib/server/api/golbatApi"; +import { buildStationDnfFilters } from "@/lib/server/queryMapObjects/fortDnf"; +import type { MapObjectResponse } from "@/lib/server/queryMapObjects/MapObjectQuery"; +import { StationQuery } from "@/lib/server/queryMapObjects/queryStation"; +import type { PermittedPolygon } from "@/lib/services/user/checkPerm"; +import type { StationData } from "@/lib/types/mapObjectData/station"; +import { error } from "@sveltejs/kit"; +import { booleanPointInPolygon, point } from "@turf/turf"; + +function mapStation(s: GolbatStationResult): MinMapObject { + const { is_inactive, is_battle_available, stationed_pokemon, ...rest } = s; + return { + ...rest, + is_inactive: is_inactive ? 1 : 0, + is_battle_available: is_battle_available ? 1 : 0, + raw_stationed_pokemon: stationed_pokemon ?? undefined + } as MinMapObject; +} + +export class ApiStationQuery extends StationQuery { + async query( + bounds: Bounds, + filter: FilterStation | undefined, + polygon: PermittedPolygon, + since?: number, + limit?: number + ): Promise>> { + const dnf = buildStationDnfFilters(filter); + if (dnf === null) return { data: [], examined: 0 }; + + const actualLimit = Math.min(limit ?? this.limit, this.limit); + const result = await scanStations({ + min: { latitude: bounds.minLat, longitude: bounds.minLon }, + max: { latitude: bounds.maxLat, longitude: bounds.maxLon }, + limit: actualLimit + 1, + filters: dnf.length ? dnf : undefined + }); + if (!result) error(500); + + if (result.limit_reached || result.stations.length > actualLimit) { + return { data: [], examined: actualLimit, limitReached: true }; + } + + let examined = result.examined; + const data: MinMapObject[] = []; + for (const s of result.stations) { + if (since !== undefined && (s.updated ?? 0) <= since) continue; + if (polygon && !booleanPointInPolygon(point([s.lon, s.lat]), polygon)) { + examined -= 1; + continue; + } + data.push(mapStation(s)); + } + return { data, examined }; + } + + async querySingle(id: string, thisFetch?: typeof fetch): Promise[]> { + const station = await getGolbatStation(id, thisFetch); + return station ? [mapStation(station)] : []; + } +} +``` + +Note: unlike the SQL path, expired stations exist in the index (stations are the ephemeral fort type). `buildStationDnfFilters` pushes `station_active: true` on battle clauses; when plain stations are shown, expired ones may appear that SQL also returned (the `station` table keeps them too) — behavior matches. If parity testing shows extra expired stations vs SQL, add `{ station_active: true }` to the plain-station case and note it in the PR. + +- [ ] **Step 2: Verify** — `pnpm run check` passes. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/server/queryMapObjects/queryStationApi.ts +git commit -m "feat: station map queries via golbat fort api" +``` + +--- + +### Task 7: Registry switch + live parity verification + +**Files:** +- Modify: `src/lib/server/queryMapObjects/queryMapObjects.ts` + +**Interfaces:** +- Consumes: `isFortApiEnabled` (Task 2), the three Api*Query classes (Tasks 4-6). +- Produces: `getQuery()` returns the API-backed instance for GYM/POKESTOP/STATION whenever detection is on; everything else (and the fallback) unchanged. + +- [ ] **Step 1: Modify `queryMapObjects.ts`** — add below the existing `registry`: + +```ts +import { isFortApiEnabled } from "@/lib/server/api/golbatFortApi"; +import { ApiGymQuery } from "@/lib/server/queryMapObjects/queryGymApi"; +import { ApiPokestopQuery } from "@/lib/server/queryMapObjects/queryPokestopApi"; +import { ApiStationQuery } from "@/lib/server/queryMapObjects/queryStationApi"; + +// Used instead of the SQL classes while the Golbat fort API is detected (golbatFortApi.ts) +const fortApiRegistry: Partial>> = { + [MapObjectType.GYM]: new ApiGymQuery(), + [MapObjectType.POKESTOP]: new ApiPokestopQuery(), + [MapObjectType.STATION]: new ApiStationQuery() +}; + +export function getQuery(type: MapObjectType): MapObjectQuery { + if (isFortApiEnabled()) { + const apiQuery = fortApiRegistry[type]; + if (apiQuery) return apiQuery; + } + const query = registry[type]; + if (!query) error(404); + return query; +} +``` + +(Replace the existing `getQuery` — keep a single export.) + +- [ ] **Step 2: `pnpm run check` + `pnpm test`** — both pass. + +- [ ] **Step 3: Live parity verification** (requires a Golbat with #385 and `fort_in_memory = true`; toggle Golbat's flag or stop Golbat to compare against the SQL path): + +1. `pnpm run dev`, confirm the "fort API detected" log line. +2. In the browser: pan around with (a) plain gyms+pokestops+stations on, (b) raid-only filter with a boss list, (c) quest filter with a stardust range and an item, (d) invasion filter with characters, (e) max-battle filter with a boss and with hasGmax. Compare rendered objects against the same filters with Golbat's `fort_in_memory` off (SQL path) — same forts should render; popups must show defenders, RSVPs, quests, incidents (with confirmed lineups), showcases, stationed Pokémon. +3. Click individual gym/pokestop/station markers (exercises `querySingle` by-id). +4. Zoom out until the limit notice appears — confirm the "query limit reached" toast still fires (issue #151 behavior). +5. Stop Golbat, wait ≤60s: log line flips, map queries keep working via SQL. Start Golbat again: flips back. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/server/queryMapObjects/queryMapObjects.ts +git commit -m "feat: serve fort map objects from golbat api when detected" +``` + +--- + +### Task 8: Pick lists from availability (merged into MasterStats) + +**Files:** +- Modify: `src/lib/server/api/queryStats.ts` +- Modify: `src/routes/api/stats/+server.ts` + +**Interfaces:** +- Consumes: `isFortApiEnabled`, `getCachedFortAvailability` (Task 2); existing `MasterStats` types. +- Produces: `mergeFortAvailability(stats: MasterStats): MasterStats` exported from `queryStats.ts`; `count` becomes optional on `ActiveRaidStats`, `MaxBattleStatsEntry`, `ContestStatsEntry` (decision in issue #174 — the availability index cannot provide counts and nothing renders them). + +Provisional decision (issue #174 open question): availability is merged into the existing `MasterStats` shape so no component changes. If ccev answers the open question differently, this task is the only one that moves. + +- [ ] **Step 1: Make `count` optional** on `ActiveRaidStats`, `MaxBattleStatsEntry`, `ContestStatsEntry` in `queryStats.ts` (`count?: number`), run `pnpm run check`, and fix any strictness fallout (there should be none — producers still set it). + +- [ ] **Step 2: Skip the three live-table SQL queries when the fort API is on.** In `queryMasterStats()`, the `Promise.all` currently always runs `allQuestStats` (UNION ALL over `pokestop` — the expensive one), `allContestStats` (live `pokestop` scan) and `allMaxBattlesStats` (live `station` scan). Wrap each: + +```ts +import { isFortApiEnabled } from "@/lib/server/api/golbatFortApi"; +// ... inside the Promise.all, replace the three queries: + isFortApiEnabled() ? Promise.resolve([] as QuestStatsRow[]) : query(/* unchanged SQL */), +// same pattern for ContestStatsRow[] and MaxBattleStatsRow[] +``` + +(`allRaidStats` from `raid_stats` stays — it's an aggregate table, cheap, and stats keep their hourly cadence per issue #174.) + +- [ ] **Step 3: Add `mergeFortAvailability` to `queryStats.ts`:** + +```ts +import { getCachedFortAvailability, isFortApiEnabled } from "@/lib/server/api/golbatFortApi"; +import type { FortAvailability } from "@/lib/server/queryMapObjects/queries"; + +// gmax battles are the level-6 tier; verify the numeric against ingameLocale.ts:135 +// (1 = dynamax, 2 = gigantamax) and a live spot check before merging: +// SELECT DISTINCT battle_level, battle_pokemon_bread_mode FROM station +// WHERE battle_pokemon_bread_mode IS NOT NULL; +const BREAD_MODE_DYNAMAX = 1; +const BREAD_MODE_GIGANTAMAX = 2; + +export function mergeFortAvailability(stats: MasterStats): MasterStats { + const availability = getCachedFortAvailability(); + if (!isFortApiEnabled() || !availability) return stats; + + const activeRaids: ActiveRaidStats[] = availability.gyms.raids + .filter((r) => r.pokemon_id) + .map((r) => ({ + level: r.raid_level, + pokemon_id: r.pokemon_id!, + form: getNormalizedForm(r.pokemon_id!, r.form ?? 0), + // not in availability yet (Golbat enrichment PR pending) — 0 until it lands + temp_evolution_id: 0 + })); + + const activeMaxBattles: MaxBattleStatsEntry[] = availability.stations.battles + .filter((b) => b.pokemon_id) + .map((b) => ({ + level: b.battle_level, + pokemon_id: b.pokemon_id!, + form: getNormalizedForm(b.pokemon_id!, b.form ?? 0), + bread_mode: b.battle_level >= 6 ? BREAD_MODE_GIGANTAMAX : BREAD_MODE_DYNAMAX + })); + + const activeContests: ContestStatsEntry[] = availability.pokestops.showcases.map((s) => ({ + // ranking_standard not in availability yet (Golbat enrichment PR pending) + ranking_standard: 0, + focus: s.pokemon_id + ? { + type: "pokemon", + pokemon_id: s.pokemon_id, + pokemon_form: getNormalizedForm(s.pokemon_id, s.form ?? 0) + } + : { type: "pokemon_type", pokemon_type: s.type_id ?? 0 } + })) as ContestStatsEntry[]; + + const quests: QuestStats = {}; + let questsTotal = 0; + for (const q of availability.pokestops.quests) { + const reward = questRewardFromAvailability(q); + if (!reward) continue; + const key = `${q.reward_type}|${q.item_id}|${q.pokemon_id}|${q.form_id}|${q.amount}|${q.title}|${q.target}`; + const existing = quests[key]; + if (existing) { + existing.count += q.count; // AR + no-AR variants of the same quest merge + } else { + quests[key] = { reward, title: q.title, target: q.target, count: q.count }; + questsTotal += q.count; + } + } + + return { + ...stats, + activeRaids, + activeMaxBattles, + activeContests, + quests, + totalQuests: { count: questsTotal } + }; +} + +function questRewardFromAvailability( + q: FortAvailability["pokestops"]["quests"][number] +): QuestReward | undefined { + switch (q.reward_type) { + case RewardType.ITEM: + return { type: RewardType.ITEM, info: { item_id: q.item_id, amount: q.amount } }; + case RewardType.POKEMON: + return { + type: RewardType.POKEMON, + info: { pokemon_id: q.pokemon_id, form: getNormalizedForm(q.pokemon_id, q.form_id) } + } as QuestReward; + case RewardType.CANDY: + case RewardType.XL_CANDY: + case RewardType.MEGA_ENERGY: + return { + type: q.reward_type, + info: { pokemon_id: q.pokemon_id, amount: q.amount } + } as QuestReward; + case RewardType.STARDUST: + case RewardType.XP: + case RewardType.POKECOINS: + return { type: q.reward_type, info: { amount: q.amount } } as QuestReward; + default: + return { type: q.reward_type, info: {} } as QuestReward; + } +} +``` + +Check the exact member shapes of the `QuestReward` union and `ContestFocus` union in `pokestop.d.ts` (e.g. the type-based focus member's property names, `ContestFocusType`) and adjust the literals so `pnpm run check` passes without `as` erasing real mismatches — the casts above are only for narrowing the union, not hiding wrong field names. + +- [ ] **Step 4: Merge at request time** in `src/routes/api/stats/+server.ts` — wrap the provider result: + +```ts +import { mergeFortAvailability } from "@/lib/server/api/queryStats"; +// ... + const stats = await masterstatsProvider.get(); + return json(mergeFortAvailability(stats), { headers: ... /* keep existing headers */ }); +``` + +This keeps the hourly SQL cadence for everything else while pick lists reflect the ≤60s-fresh availability cache on every `/api/stats` fetch. + +- [ ] **Step 5: Verify live** — with fort API on: open the raid filterset → boss pick list populated (mega bosses appear as their base form until the Golbat enrichment lands — known, accepted); quest reward select shows rewards with counts; max battle boss list shows gmax badges only on level-6 bosses; showcase filter lists focus options; GymPopup on an egg shows possible bosses. With fort API off: everything falls back to the SQL-built stats exactly as today. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/server/api/queryStats.ts src/routes/api/stats/+server.ts +git commit -m "feat: source filter pick lists from golbat fort availability" +``` + +--- + +### Task 9: Documentation + +**Files:** +- Modify: `docs/src/content/docs/reference/configuration.md` (the `server.golbat` section) + +- [ ] **Step 1:** Add to the `server.golbat` section: + +```md +### Golbat fort API (optional, recommended) + +When your Golbat exposes the fort map-data API (Golbat with [#385](https://github.com/UnownHash/Golbat/pull/385), `fort_in_memory = true` in Golbat's config — `preload = true` recommended), Diadem detects it automatically at startup and serves gyms, pokéstops and stations from it instead of SQL, and sources filter pick lists from Golbat's availability index. No Diadem configuration is needed — detection re-checks every minute, so Golbat can be upgraded or toggled without restarting Diadem. Without it, Diadem falls back to direct database queries as before. +``` + +- [ ] **Step 2:** `pnpm run lint` passes (prettier covers markdown). Commit: + +```bash +git add docs/src/content/docs/reference/configuration.md +git commit -m "docs: document golbat fort api auto-detection" +``` + +--- + +### Task 10 (blocked on Golbat status endpoint — Appendix A.1): prefer the status call for detection + +Keep the availability-probe from Task 2 as the fallback for older Golbat versions. Once A.1 lands: + +- [ ] **Step 1:** Add to `golbatApi.ts`: + +```ts +export type GolbatStatus = { + features: { fort_in_memory: boolean }; + limits?: { max_pokemon_results?: number; max_fort_results?: number }; +}; + +export async function fetchGolbatStatus() { + return await callGolbat("api/status", "GET", undefined, fetch, true); +} +``` + +(Adjust path/field names to whatever the merged Golbat PR defines — check the PR, not this plan.) + +- [ ] **Step 2:** In `golbatFortApi.ts`, call `fetchGolbatStatus()` first in `refreshFortAvailability()`: if it returns a body, `fortApiEnabled = status.features.fort_in_memory` and only fetch availability when enabled; if it returns `undefined` (older Golbat), keep the existing probe behavior unchanged. If the status call reports limits, log them once — clamping `requestLimits` to server caps is a follow-up, not this plan. + +- [ ] **Step 3:** `pnpm run check`, live-verify both detection paths, commit `feat: detect fort api via golbat status endpoint`. + +--- + +## Appendix A — Golbat-side prerequisites (separate repo: UnownHash/Golbat) + +These are **not** tasks in this plan (different repo/language); they're the contracts diadem codes against. James (jfberry) is a Golbat committer; treat them as accepted-on-filing. The diadem tasks above all work against currently-merged Golbat (#385) — nothing here blocks Tasks 1-9. + +1. **Status/feature-flags endpoint** (blocks Task 10 only): report enabled optional features (`fort_in_memory`) and useful server facts (max scan limits). Diadem's Task 2 probe remains the fallback. +2. **`limit_reached` on fort scan responses**: straight mirror of Golbat #392 (pre-expiry key count vs effective limit) on `ApiGymScanResult`, `ApiPokestopScanResult`, `ApiStationScanResult`, `ApiFortCombinedScanResult`. Diadem already reads the optional field (Tasks 4-6) and the `limit + 1` overflow check covers the interim. +3. **Availability enrichment**: `temp_evolution_id` on `ApiGymRaidAvailable`, `ranking_standard` on `ApiPokestopShowcaseAvailable` (both exist on the underlying records; update `decoder/fort_availability.go` index keys + the golden-snapshot tests). When landed: map them in Task 8's merge (replace the two `0` placeholders). +4. **Bug fix**: `ApiPokestopResult.FirstSeenTimestamp` is `int16` (`decoder/api_pokestop.go`) — unix timestamps truncate. Change to `int64`; the reflection completeness test should have caught the width, extend it if practical. + +## Appendix B — Known behavioral deltas (accepted in issue #174) + +- Raid/battle/showcase pick lists lose `count` (never rendered; availability index can't provide them by design). +- Mega raid bosses appear without their mega form in pick lists until A.3 lands (`temp_evolution_id: 0`). +- Showcase `ranking_standard` is 0 in pick lists until A.3 lands. +- Pick lists refresh within ~60s of game-state changes instead of hourly (improvement). +- `enabled` is now populated on gyms/pokestops from the API (was declared but never selected). From 4d7bdc067034f83d651b931e6e2683baeab68b6c Mon Sep 17 00:00:00 2001 From: james Date: Mon, 3 Aug 2026 13:52:16 +0100 Subject: [PATCH 02/17] feat: add golbat fort api client functions and wire types --- src/lib/server/api/golbatApi.ts | 105 ++++++++++++++++++-- src/lib/server/queryMapObjects/queries.d.ts | 58 +++++++++++ 2 files changed, 155 insertions(+), 8 deletions(-) diff --git a/src/lib/server/api/golbatApi.ts b/src/lib/server/api/golbatApi.ts index d83729e4..eb07d0f8 100644 --- a/src/lib/server/api/golbatApi.ts +++ b/src/lib/server/api/golbatApi.ts @@ -1,9 +1,12 @@ import type { MinMapObject } from "@/lib/mapObjects/mapObjectTypes"; import { getServerConfig } from "@/lib/services/config/config.server"; -import type { GymData } from "@/lib/types/mapObjectData/gym"; +import type { GymData, GymDefender, Rsvp } from "@/lib/types/mapObjectData/gym"; +import type { Incident, PokestopData } from "@/lib/types/mapObjectData/pokestop"; +import type { StationData } from "@/lib/types/mapObjectData/station"; import type { PokemonData } from "@/lib/types/mapObjectData/pokemon"; import type { Coords } from "@/lib/utils/coordinates"; import { getLogger } from "@/lib/utils/logger"; +import type { FortAvailability, FortScanBody } from "@/lib/server/queryMapObjects/queries"; export type PokemonResponse = { pokemon: MinMapObject[]; @@ -13,6 +16,55 @@ export type PokemonResponse = { limit_reached?: boolean; }; +// Raw API records: like diadem's rows except the fields the mappers rename/reshape. +export type GolbatGymResult = Omit< + MinMapObject, + "availble_slots" | "defenders_raw" | "defenders" | "raw_rsvps" | "rsvps" +> & { + available_slots?: number | null; + deleted: boolean; + defenders?: GymDefender[] | null; // native JSON, not a string + rsvps?: Rsvp[] | null; // native JSON, not a string +}; + +export type GolbatIncidentResult = Omit & { confirmed: boolean }; + +export type GolbatPokestopResult = Omit, "incident" | "deleted"> & { + deleted: boolean; + invasions?: GolbatIncidentResult[]; +}; + +export type GolbatStationResult = Omit< + MinMapObject, + "is_inactive" | "is_battle_available" | "stationed_pokemon" | "raw_stationed_pokemon" +> & { + is_inactive: boolean; + is_battle_available: boolean; + stationed_pokemon?: string | null; // still a serialized string on the wire +}; + +export type GymScanResponse = { + gyms: GolbatGymResult[]; + examined: number; + skipped: number; + total: number; + limit_reached?: boolean; // present once the fort mirror of Golbat #392 lands +}; +export type PokestopScanResponse = { + pokestops: GolbatPokestopResult[]; + examined: number; + skipped: number; + total: number; + limit_reached?: boolean; +}; +export type StationScanResponse = { + stations: GolbatStationResult[]; + examined: number; + skipped: number; + total: number; + limit_reached?: boolean; +}; + const log = getLogger("golbat"); const config = getServerConfig().golbat; @@ -20,7 +72,8 @@ async function callGolbat( path: string, method: "GET" | "POST", body: BodyInit | undefined = undefined, - thisFetch: typeof fetch = fetch + thisFetch: typeof fetch = fetch, + quiet = false ): Promise { const start = performance.now(); const url = new URL(path, config.url); @@ -39,12 +92,20 @@ async function callGolbat( const response = await thisFetch(url, { method, body, headers }); if (!response.ok) { - log.error( - "[%s] Golbat returned a bad status | %d (%s)", - url.toString(), - response.status, - await response.text() - ); + if (!quiet) { + log.error( + "[%s] Golbat returned a bad status | %d (%s)", + url.toString(), + response.status, + await response.text() + ); + } else { + log.debug( + "[%s] Golbat returned a bad status | %d", + url.toString(), + response.status + ); + } return undefined; } @@ -78,3 +139,31 @@ export async function searchGyms(query: string, coords: Coords, range: number) { }; return await callGolbat("api/gym/search", "POST", JSON.stringify(body)); } + +export async function scanGyms(body: FortScanBody) { + return await callGolbat("api/gym/scan", "POST", JSON.stringify(body)); +} + +export async function scanPokestops(body: FortScanBody) { + return await callGolbat("api/pokestop/scan", "POST", JSON.stringify(body)); +} + +export async function scanStations(body: FortScanBody) { + return await callGolbat("api/station/scan", "POST", JSON.stringify(body)); +} + +export async function getGolbatGym(id: string, thisFetch: typeof fetch = fetch) { + return await callGolbat("api/gym/id/" + id, "GET", undefined, thisFetch); +} + +export async function getGolbatPokestop(id: string, thisFetch: typeof fetch = fetch) { + return await callGolbat("api/pokestop/id/" + id, "GET", undefined, thisFetch); +} + +export async function getGolbatStation(id: string, thisFetch: typeof fetch = fetch) { + return await callGolbat("api/station/id/" + id, "GET", undefined, thisFetch); +} + +export async function fetchFortAvailability() { + return await callGolbat("api/fort/available", "GET", undefined, fetch, true); +} diff --git a/src/lib/server/queryMapObjects/queries.d.ts b/src/lib/server/queryMapObjects/queries.d.ts index 5284c0e3..729a3f1b 100644 --- a/src/lib/server/queryMapObjects/queries.d.ts +++ b/src/lib/server/queryMapObjects/queries.d.ts @@ -16,3 +16,61 @@ export type GolbatPokemonQuery = { pvp_great?: MinMax; pvp_ultra?: MinMax; }; + +export type GolbatDnfId = { pokemon_id: number; form?: number }; + +/** One DNF clause: conditions AND within, clauses OR across. Omitted field = no constraint. */ +export type GolbatFortDnfFilter = { + is_ar_scan_eligible?: boolean; + // gym + available_slots?: { min: number; max: number }; + team_id?: number[]; + raid_level?: number[]; + raid_pokemon_id?: GolbatDnfId[]; + // pokestop + lure_id?: number[]; + quest_reward_type?: number[]; + quest_reward_amount?: { min: number; max: number }; + quest_reward_item_id?: number[]; + quest_reward_pokemon?: GolbatDnfId[]; + incident_display_type?: number[]; + incident_character?: number[]; + contest_pokemon?: GolbatDnfId[]; + contest_pokemon_type?: number[]; + // station + battle_level?: number[]; + battle_pokemon?: GolbatDnfId[]; + stationed_gmax?: boolean; + station_active?: boolean; +}; + +export type FortScanBody = { + min: { latitude: number; longitude: number }; + max: { latitude: number; longitude: number }; + limit: number; + filters?: GolbatFortDnfFilter[]; + with_incidents?: boolean; +}; + +export type FortAvailability = { + gyms: { raids: { raid_level: number; pokemon_id: number | null; form: number | null }[] }; + pokestops: { + quests: { + with_ar: boolean; + reward_type: number; + item_id: number; + amount: number; + pokemon_id: number; + form_id: number; + title: string; + target: number; + count: number; + }[]; + invasions: { character: number; display_type: number; confirmed: boolean }[]; + lures: { lure_id: number }[]; + showcases: { pokemon_id: number | null; form: number | null; type_id: number | null }[]; + }; + stations: { + battles: { battle_level: number; pokemon_id: number | null; form: number | null }[]; + }; +}; From 85bf3e0fa0abbd069491db062f1a646878922a62 Mon Sep 17 00:00:00 2001 From: james Date: Mon, 3 Aug 2026 13:57:40 +0100 Subject: [PATCH 03/17] fix: add deleted to GolbatGymResult Omit and format with prettier --- src/lib/server/api/golbatApi.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/lib/server/api/golbatApi.ts b/src/lib/server/api/golbatApi.ts index eb07d0f8..4d9fe42f 100644 --- a/src/lib/server/api/golbatApi.ts +++ b/src/lib/server/api/golbatApi.ts @@ -19,7 +19,7 @@ export type PokemonResponse = { // Raw API records: like diadem's rows except the fields the mappers rename/reshape. export type GolbatGymResult = Omit< MinMapObject, - "availble_slots" | "defenders_raw" | "defenders" | "raw_rsvps" | "rsvps" + "availble_slots" | "defenders_raw" | "defenders" | "raw_rsvps" | "rsvps" | "deleted" > & { available_slots?: number | null; deleted: boolean; @@ -100,11 +100,7 @@ async function callGolbat( await response.text() ); } else { - log.debug( - "[%s] Golbat returned a bad status | %d", - url.toString(), - response.status - ); + log.debug("[%s] Golbat returned a bad status | %d", url.toString(), response.status); } return undefined; } @@ -157,7 +153,12 @@ export async function getGolbatGym(id: string, thisFetch: typeof fetch = fetch) } export async function getGolbatPokestop(id: string, thisFetch: typeof fetch = fetch) { - return await callGolbat("api/pokestop/id/" + id, "GET", undefined, thisFetch); + return await callGolbat( + "api/pokestop/id/" + id, + "GET", + undefined, + thisFetch + ); } export async function getGolbatStation(id: string, thisFetch: typeof fetch = fetch) { From d9771d661f46dae874493749bfca6beed6510c2b Mon Sep 17 00:00:00 2001 From: james Date: Mon, 3 Aug 2026 14:00:31 +0100 Subject: [PATCH 04/17] feat: detect golbat fort api support at startup --- src/lib/server/api/golbatFortApi.ts | 45 +++++++++++++++++++++++++++++ src/lib/server/init.ts | 4 ++- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/lib/server/api/golbatFortApi.ts diff --git a/src/lib/server/api/golbatFortApi.ts b/src/lib/server/api/golbatFortApi.ts new file mode 100644 index 00000000..d88eda1c --- /dev/null +++ b/src/lib/server/api/golbatFortApi.ts @@ -0,0 +1,45 @@ +import { fetchFortAvailability } from "@/lib/server/api/golbatApi"; +import type { FortAvailability } from "@/lib/server/queryMapObjects/queries"; +import { getLogger } from "@/lib/utils/logger"; + +const log = getLogger("golbat:fort"); +const REFRESH_SECONDS = 60; + +let fortApiEnabled = false; +let cachedAvailability: FortAvailability | undefined; + +export function isFortApiEnabled() { + return fortApiEnabled; +} + +export function getCachedFortAvailability() { + return cachedAvailability; +} + +// Golbat gates every fort endpoint on fort_in_memory (503 when off, 404 on +// older versions), so a successful availability fetch doubles as detection. +export async function refreshFortAvailability() { + const result = await fetchFortAvailability(); + const nowEnabled = result !== undefined; + + if (nowEnabled !== fortApiEnabled) { + log.info( + nowEnabled + ? "Golbat fort API detected, serving gyms/pokestops/stations from it" + : "Golbat fort API unavailable, serving gyms/pokestops/stations from SQL" + ); + } + + fortApiEnabled = nowEnabled; + if (result) cachedAvailability = result; +} + +export async function startFortApiDetection() { + setInterval(() => { + refreshFortAvailability().catch((err) => + log.error("Fort availability refresh failed: %s", err) + ); + }, REFRESH_SECONDS * 1000)?.unref?.(); + + await refreshFortAvailability(); +} diff --git a/src/lib/server/init.ts b/src/lib/server/init.ts index c9678613..cd5c12e2 100644 --- a/src/lib/server/init.ts +++ b/src/lib/server/init.ts @@ -2,6 +2,7 @@ import { masterfileProvider } from "@/lib/server/provider/masterfileProvider"; import { masterstatsProvider } from "@/lib/server/provider/masterStatsProvider"; import { remoteLocaleProvider } from "@/lib/server/provider/remoteLocaleProvider"; import { uiconsIndexProvider } from "@/lib/server/provider/uiconsIndexProvider"; +import { startFortApiDetection } from "@/lib/server/api/golbatFortApi"; import { getLogger } from "@/lib/utils/logger"; export async function initDiadem() { @@ -12,7 +13,8 @@ export async function initDiadem() { masterfileProvider.refresh(), uiconsIndexProvider.refresh(), remoteLocaleProvider.refresh(), - masterstatsProvider.refresh() + masterstatsProvider.refresh(), + startFortApiDetection() ]); log.info("Finished initializing"); } From 2a892a69f468b156cdc964b99764c8804f2155f2 Mon Sep 17 00:00:00 2001 From: james Date: Mon, 3 Aug 2026 14:04:33 +0100 Subject: [PATCH 05/17] fix: tolerate unreachable golbat in fort api detection --- src/lib/server/api/golbatFortApi.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/server/api/golbatFortApi.ts b/src/lib/server/api/golbatFortApi.ts index d88eda1c..27ab8f0f 100644 --- a/src/lib/server/api/golbatFortApi.ts +++ b/src/lib/server/api/golbatFortApi.ts @@ -19,7 +19,14 @@ export function getCachedFortAvailability() { // Golbat gates every fort endpoint on fort_in_memory (503 when off, 404 on // older versions), so a successful availability fetch doubles as detection. export async function refreshFortAvailability() { - const result = await fetchFortAvailability(); + let result: FortAvailability | undefined; + try { + result = await fetchFortAvailability(); + } catch (err) { + log.debug("Fort availability fetch failed: %s", err); + result = undefined; + } + const nowEnabled = result !== undefined; if (nowEnabled !== fortApiEnabled) { From 317a0400e76bb876240854ff415907c67c2279e8 Mon Sep 17 00:00:00 2001 From: james Date: Mon, 3 Aug 2026 14:18:31 +0100 Subject: [PATCH 06/17] feat: translate diadem filters to golbat fort dnf clauses --- .../server/queryMapObjects/fortDnf.test.ts | 153 ++++++++++++++++ src/lib/server/queryMapObjects/fortDnf.ts | 173 ++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 src/lib/server/queryMapObjects/fortDnf.test.ts create mode 100644 src/lib/server/queryMapObjects/fortDnf.ts diff --git a/src/lib/server/queryMapObjects/fortDnf.test.ts b/src/lib/server/queryMapObjects/fortDnf.test.ts new file mode 100644 index 00000000..d50f53d8 --- /dev/null +++ b/src/lib/server/queryMapObjects/fortDnf.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildGymDnfFilters, buildPokestopDnfFilters, buildStationDnfFilters } from "./fortDnf"; + +// pokestopUtils.ts (a pure-constants module we need RewardType/INCIDENT_DISPLAY_* from) also +// imports several client-only Svelte state modules (for its display/label helper functions, +// which fortDnf.ts never calls) that pull in SvelteKit's `$app/*` virtual modules and Svelte 5 +// runes — neither resolvable outside the full SvelteKit vite plugin, which isn't configured for +// plain vitest. Stub pokestopUtils.ts's own non-pure direct imports so its module graph loads. +vi.mock("@/lib/features/activeSearch.svelte", () => ({ + getActiveSearch: () => undefined +})); +vi.mock("@/lib/services/userSettings.svelte", () => ({ + getUserSettings: () => ({}), + defaultFilter: () => ({ enabled: true, filters: [] }) +})); +vi.mock("@/lib/services/ingameLocale", () => ({ + mAlignment: () => "", + mGeneration: () => "", + mItem: () => "", + mPokemon: () => "", + mType: () => "" +})); +vi.mock("@/lib/services/uicons.svelte", () => ({ + getIconContest: () => "", + getIconPokemon: () => "", + getIconType: () => "" +})); +vi.mock("@/lib/utils/pokemonUtils", () => ({ + getNormalizedForm: (_pokemonId: number, form: number) => form +})); + +const disabled = { enabled: false }; +const enabledEmpty = { enabled: true, filters: [] }; + +describe("buildGymDnfFilters", () => { + it("matches all when plain gyms are shown", () => { + expect( + buildGymDnfFilters({ + gymPlain: { enabled: true }, + raid: { enabled: true, filters: [] } + } as any) + ).toEqual([]); + }); + + it("translates levels, eggs and bosses into separate OR clauses", () => { + const result = buildGymDnfFilters({ + gymPlain: { enabled: false }, + raid: { + enabled: true, + filters: [ + { + enabled: true, + show: ["egg"], + levels: [5], + bosses: [{ pokemon_id: 150, temp_evolution_id: 2 }] + } + ] + } + } as any); + expect(result).toContainEqual({ raid_pokemon_id: [{ pokemon_id: 0 }] }); + expect(result).toContainEqual({ raid_level: [5] }); + // temp_evolution_id is not expressible — pokemon id alone (superset) + expect(result).toContainEqual({ raid_pokemon_id: [{ pokemon_id: 150 }] }); + }); + + it("falls back to an any-active-raid clause when raid filter has no conditions", () => { + const result = buildGymDnfFilters({ + gymPlain: { enabled: false }, + raid: { enabled: true, filters: [{ enabled: true }] } + } as any); + expect(result).toHaveLength(1); + expect(result![0].raid_level!.length).toBeGreaterThanOrEqual(9); + }); +}); + +describe("buildPokestopDnfFilters", () => { + it("matches all when plain pokestops are shown", () => { + expect( + buildPokestopDnfFilters({ enabled: true, pokestopPlain: { enabled: true } } as any) + ).toEqual([]); + }); + + it("matches nothing when enabled but no sub-filter is on", () => { + expect( + buildPokestopDnfFilters({ + enabled: true, + pokestopPlain: disabled, + lure: enabledEmpty && disabled, + quest: disabled, + invasion: disabled, + goldPokestop: disabled, + kecleon: disabled, + contest: disabled + } as any) + ).toBeNull(); + }); + + it("translates invasions with characters exactly like the SQL", () => { + const result = buildPokestopDnfFilters({ + enabled: true, + pokestopPlain: disabled, + lure: disabled, + quest: disabled, + invasion: { enabled: true, filters: [{ enabled: true, characters: [41, 42] }] }, + goldPokestop: disabled, + kecleon: disabled, + contest: disabled + } as any); + expect(result).toEqual([{ incident_display_type: [1, 2, 3], incident_character: [41, 42] }]); + }); + + it("pushes a stardust range as reward_type + amount in one AND clause", () => { + const result = buildPokestopDnfFilters({ + enabled: true, + pokestopPlain: disabled, + lure: disabled, + quest: { + enabled: true, + filters: [{ enabled: true, stardust: { min: 500, max: Infinity } }] + }, + invasion: disabled, + goldPokestop: disabled, + kecleon: disabled, + contest: disabled + } as any); + expect(result).toEqual([ + { quest_reward_type: [3], quest_reward_amount: { min: 500, max: 10000 } } + ]); + }); +}); + +describe("buildStationDnfFilters", () => { + it("adds station_active to every battle clause", () => { + const result = buildStationDnfFilters({ + enabled: true, + stationPlain: { enabled: false }, + maxBattle: { + enabled: true, + filters: [{ enabled: true, bosses: [{ pokemon_id: 809, bread_mode: 2 }] }] + } + } as any); + expect(result).toEqual([{ station_active: true, battle_pokemon: [{ pokemon_id: 809 }] }]); + }); + + it("translates hasGmax", () => { + const result = buildStationDnfFilters({ + enabled: true, + stationPlain: { enabled: false }, + maxBattle: { enabled: true, filters: [{ enabled: true, hasGmax: true }] } + } as any); + expect(result).toEqual([{ stationed_gmax: true }]); + }); +}); diff --git a/src/lib/server/queryMapObjects/fortDnf.ts b/src/lib/server/queryMapObjects/fortDnf.ts new file mode 100644 index 00000000..ab9ddfe2 --- /dev/null +++ b/src/lib/server/queryMapObjects/fortDnf.ts @@ -0,0 +1,173 @@ +import type { FilterGym, FilterPokestop, FilterStation } from "@/lib/features/filters/filters"; +import type { GolbatFortDnfFilter } from "@/lib/server/queryMapObjects/queries"; +import { + INCIDENT_DISPLAY_CONTEST, + INCIDENT_DISPLAY_GOLD, + INCIDENT_DISPLAY_KECLEON, + INCIDENT_DISPLAYS_INVASION, + RewardType +} from "@/lib/utils/pokestopUtils"; + +// Superset guards: broad enough that a matching object can never be missed; +// the local filter()/shouldDisplay* pass trims the excess. +const ALL_RAID_LEVELS = Array.from({ length: 20 }, (_, i) => i + 1); +const ALL_LURE_IDS = [501, 502, 503, 504, 505, 506]; +const ALL_QUEST_REWARD_TYPES = Object.values(RewardType).filter( + (v): v is number => typeof v === "number" && v > 0 +); +const AMOUNT_MAX = 10000; + +function minMax(range: { min: number; max: number }) { + return { + min: Number.isFinite(range.min) ? range.min : 0, + max: Number.isFinite(range.max) ? range.max : AMOUNT_MAX + }; +} + +/** Returns [] = match all, clauses = send as filters, null = match nothing. */ +export function buildGymDnfFilters(filter: FilterGym | undefined): GolbatFortDnfFilter[] | null { + if (!filter || filter.gymPlain.enabled || !filter.raid.enabled) return []; + + const clauses: GolbatFortDnfFilter[] = []; + for (const filterset of filter.raid.filters.filter((f) => f.enabled)) { + // Mirrors queryGym.getFilterWhere: each SQL OR-branch is one clause. + // A clause with any raid_* field only matches gyms with an active raid. + if (filterset.show?.includes("egg")) clauses.push({ raid_pokemon_id: [{ pokemon_id: 0 }] }); + if (filterset.show?.includes("boss")) + clauses.push({ raid_level: filterset.levels?.length ? filterset.levels : ALL_RAID_LEVELS }); + if (filterset.levels?.length) clauses.push({ raid_level: filterset.levels }); + for (const boss of filterset.bosses ?? []) { + // temp_evolution_id is not a DNF field: match by id only, re-filter locally + clauses.push({ raid_pokemon_id: [{ pokemon_id: boss.pokemon_id }] }); + } + } + + // SQL equivalent had a bare "raid_end_timestamp > now" when no clauses exist + return clauses.length ? clauses : [{ raid_level: ALL_RAID_LEVELS }]; +} + +export function buildPokestopDnfFilters( + filter: FilterPokestop | undefined +): GolbatFortDnfFilter[] | null { + if (!filter?.enabled || filter.pokestopPlain.enabled) return []; + + const clauses: GolbatFortDnfFilter[] = []; + + if (filter.lure.enabled) { + const items = filter.lure.filters.filter((f) => f.enabled).flatMap((f) => f.items); + clauses.push({ lure_id: items.length ? items : ALL_LURE_IDS }); + } + + if (filter.quest.enabled) { + const questFilters = filter.quest.filters.filter((f) => f.enabled); + if (!questFilters.length) { + // SQL fell back to "has any active quest" + clauses.push({ quest_reward_type: ALL_QUEST_REWARD_TYPES }); + } + for (const filterset of questFilters) { + const rewardClauses: GolbatFortDnfFilter[] = []; + + if (filterset.stardust) + rewardClauses.push({ + quest_reward_type: [RewardType.STARDUST], + quest_reward_amount: minMax(filterset.stardust) + }); + if (filterset.pokecoins) + rewardClauses.push({ + quest_reward_type: [RewardType.POKECOINS], + quest_reward_amount: minMax(filterset.pokecoins) + }); + if (filterset.xp) + rewardClauses.push({ + quest_reward_type: [RewardType.XP], + quest_reward_amount: minMax(filterset.xp) + }); + if (filterset.pokemon?.length) + rewardClauses.push({ + quest_reward_type: [RewardType.POKEMON], + quest_reward_pokemon: filterset.pokemon.map((p) => ({ pokemon_id: p.pokemon_id })) + }); + for (const item of filterset.item ?? []) + rewardClauses.push({ + quest_reward_type: [RewardType.ITEM], + quest_reward_item_id: [Number(item.id)] + // exact amount match is not expressible as a range safely — local re-filter + }); + for (const reward of filterset.megaResource ?? []) + rewardClauses.push({ + quest_reward_type: [RewardType.MEGA_ENERGY, RewardType.TEMP_EVO_BRANCH_RESOURCE], + quest_reward_pokemon: [{ pokemon_id: Number(reward.id) }] + }); + for (const reward of [...(filterset.candy ?? []), ...(filterset.xlCandy ?? [])]) + rewardClauses.push({ + quest_reward_type: [RewardType.CANDY, RewardType.XL_CANDY], + quest_reward_pokemon: [{ pokemon_id: Number(reward.id) }] + }); + + if (rewardClauses.length) { + clauses.push(...rewardClauses); + } else { + // tasks-only filterset (title/target isn't a DNF field): any-quest superset + clauses.push({ quest_reward_type: ALL_QUEST_REWARD_TYPES }); + } + } + } + + if (filter.invasion.enabled) { + const invasionFilters = filter.invasion.filters.filter((f) => f.enabled); + const characterIds = invasionFilters.flatMap((f) => f.characters ?? []); + const hasUnsafeInvasionFilter = invasionFilters.some((f) => f.rewards?.length); + const clause: GolbatFortDnfFilter = { incident_display_type: [...INCIDENT_DISPLAYS_INVASION] }; + if (invasionFilters.length > 0 && characterIds.length > 0 && !hasUnsafeInvasionFilter) { + clause.incident_character = characterIds; + } + clauses.push(clause); + } + + if (filter.goldPokestop.enabled) clauses.push({ incident_display_type: [INCIDENT_DISPLAY_GOLD] }); + if (filter.kecleon.enabled) clauses.push({ incident_display_type: [INCIDENT_DISPLAY_KECLEON] }); + + if (filter.contest.enabled) { + const contestFilters = filter.contest.filters.filter((f) => f.enabled); + if (!contestFilters.length) { + clauses.push({ incident_display_type: [INCIDENT_DISPLAY_CONTEST] }); + } + for (const filterset of contestFilters) { + const clause: GolbatFortDnfFilter = { incident_display_type: [INCIDENT_DISPLAY_CONTEST] }; + // ranking_standard is not a DNF field — local re-filter + if (filterset.focus.pokemon_id) + clause.contest_pokemon = [{ pokemon_id: filterset.focus.pokemon_id }]; + if (filterset.focus.type_id) clause.contest_pokemon_type = [filterset.focus.type_id]; + clauses.push(clause); + } + } + + // SQL equivalent: "1 = 0" + return clauses.length ? clauses : null; +} + +export function buildStationDnfFilters( + filter: FilterStation | undefined +): GolbatFortDnfFilter[] | null { + if (!filter || filter.stationPlain.enabled || !filter.maxBattle.enabled) return []; + + const clauses: GolbatFortDnfFilter[] = []; + for (const filterset of filter.maxBattle.filters.filter((f) => f.enabled)) { + if (filterset.isActive) { + clauses.push({ station_active: true }); + continue; + } + if (filterset.hasGmax) { + clauses.push({ stationed_gmax: true }); + continue; + } + for (const boss of filterset.bosses ?? []) { + // bread_mode is covered by battle_level in practice (gmax = level 6), + // and re-checked locally either way — push id only + clauses.push({ station_active: true, battle_pokemon: [{ pokemon_id: boss.pokemon_id }] }); + } + } + + // SQL fallback: active battle with no boss constraint + return clauses.length ? clauses : [{ station_active: true }]; +} From c50533468c6ed1344668605600ec4bfe02460edb Mon Sep 17 00:00:00 2001 From: james Date: Mon, 3 Aug 2026 14:27:37 +0100 Subject: [PATCH 07/17] fix: keep gym boss dnf clause a superset of the sql branch The SQL "boss" OR-branch (COALESCE(raid_pokemon_id, 0) != 0) is unconditional and doesn't fold in `levels`. buildGymDnfFilters was narrowing it to filterset.levels when present, which could exclude a hatched raid at a level outside that filterset's `levels` list - silently hiding it from the map. Push an any-active-raid clause instead, matching the egg branch's unconditional style; the local filter pass re-checks the hatched/level constraint. --- .../server/queryMapObjects/fortDnf.test.ts | 19 +++++++++++++++++-- src/lib/server/queryMapObjects/fortDnf.ts | 10 +++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/lib/server/queryMapObjects/fortDnf.test.ts b/src/lib/server/queryMapObjects/fortDnf.test.ts index d50f53d8..0975faef 100644 --- a/src/lib/server/queryMapObjects/fortDnf.test.ts +++ b/src/lib/server/queryMapObjects/fortDnf.test.ts @@ -30,7 +30,6 @@ vi.mock("@/lib/utils/pokemonUtils", () => ({ })); const disabled = { enabled: false }; -const enabledEmpty = { enabled: true, filters: [] }; describe("buildGymDnfFilters", () => { it("matches all when plain gyms are shown", () => { @@ -63,6 +62,22 @@ describe("buildGymDnfFilters", () => { expect(result).toContainEqual({ raid_pokemon_id: [{ pokemon_id: 150 }] }); }); + it("keeps the boss clause a superset of the SQL branch even when levels are set", () => { + // SQL's "boss" OR-branch is unconditional (COALESCE(raid_pokemon_id, 0) != 0) — + // it does NOT fold in `levels`. A hatched level-3 boss must still match even + // when this filterset also restricts `levels` to [5]. + const result = buildGymDnfFilters({ + gymPlain: { enabled: false }, + raid: { + enabled: true, + filters: [{ enabled: true, show: ["boss"], levels: [5] }] + } + } as any); + expect(result).toContainEqual({ raid_level: [5] }); + const anyLevelClause = result!.find((c) => c.raid_level && c.raid_level.length >= 9); + expect(anyLevelClause).toBeDefined(); + }); + it("falls back to an any-active-raid clause when raid filter has no conditions", () => { const result = buildGymDnfFilters({ gymPlain: { enabled: false }, @@ -85,7 +100,7 @@ describe("buildPokestopDnfFilters", () => { buildPokestopDnfFilters({ enabled: true, pokestopPlain: disabled, - lure: enabledEmpty && disabled, + lure: disabled, quest: disabled, invasion: disabled, goldPokestop: disabled, diff --git a/src/lib/server/queryMapObjects/fortDnf.ts b/src/lib/server/queryMapObjects/fortDnf.ts index ab9ddfe2..52be8bb8 100644 --- a/src/lib/server/queryMapObjects/fortDnf.ts +++ b/src/lib/server/queryMapObjects/fortDnf.ts @@ -30,11 +30,15 @@ export function buildGymDnfFilters(filter: FilterGym | undefined): GolbatFortDnf const clauses: GolbatFortDnfFilter[] = []; for (const filterset of filter.raid.filters.filter((f) => f.enabled)) { - // Mirrors queryGym.getFilterWhere: each SQL OR-branch is one clause. + // Mirrors queryGym.getFilterWhere: each SQL OR-branch is one clause, pushed + // unconditionally — the SQL "boss" branch doesn't fold in `levels` either. // A clause with any raid_* field only matches gyms with an active raid. if (filterset.show?.includes("egg")) clauses.push({ raid_pokemon_id: [{ pokemon_id: 0 }] }); - if (filterset.show?.includes("boss")) - clauses.push({ raid_level: filterset.levels?.length ? filterset.levels : ALL_RAID_LEVELS }); + if (filterset.show?.includes("boss")) { + // DNF can't express "raid_pokemon_id != 0" (hatched); any-active-raid is a + // proper superset of any-hatched-raid — local re-filter trims eggs back out. + clauses.push({ raid_level: ALL_RAID_LEVELS }); + } if (filterset.levels?.length) clauses.push({ raid_level: filterset.levels }); for (const boss of filterset.bosses ?? []) { // temp_evolution_id is not a DNF field: match by id only, re-filter locally From 9f3ed03cacd84d622f6264d98358a1c8cde10664 Mon Sep 17 00:00:00 2001 From: james Date: Mon, 3 Aug 2026 14:30:41 +0100 Subject: [PATCH 08/17] feat: gym map queries via golbat fort api --- src/lib/server/queryMapObjects/queryGymApi.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/lib/server/queryMapObjects/queryGymApi.ts diff --git a/src/lib/server/queryMapObjects/queryGymApi.ts b/src/lib/server/queryMapObjects/queryGymApi.ts new file mode 100644 index 00000000..405dca4f --- /dev/null +++ b/src/lib/server/queryMapObjects/queryGymApi.ts @@ -0,0 +1,78 @@ +import type { FilterGym } from "@/lib/features/filters/filters"; +import type { Bounds } from "@/lib/mapObjects/mapBounds"; +import type { MinMapObject } from "@/lib/mapObjects/mapObjectTypes"; +import { getGolbatGym, scanGyms, type GolbatGymResult } from "@/lib/server/api/golbatApi"; +import { buildGymDnfFilters } from "@/lib/server/queryMapObjects/fortDnf"; +import type { MapObjectResponse } from "@/lib/server/queryMapObjects/MapObjectQuery"; +import { GymQuery } from "@/lib/server/queryMapObjects/queryGym"; +import type { FeaturePermissionContext, PermittedPolygon } from "@/lib/services/user/checkPerm"; +import type { GymData } from "@/lib/types/mapObjectData/gym"; +import { getNormalizedForm } from "@/lib/utils/pokemonUtils"; +import { error } from "@sveltejs/kit"; +import { booleanPointInPolygon, point } from "@turf/turf"; + +function mapGym(g: GolbatGymResult): MinMapObject { + const { available_slots, deleted, defenders, rsvps, ...rest } = g; + const gym = { + ...rest, + availble_slots: available_slots ?? undefined, + deleted: deleted ? 1 : 0 + } as MinMapObject; + + // Native JSON on the wire — inherited prepare() only parses the *_raw string + // variants, so normalize forms here and assign directly. + if (defenders) { + gym.defenders = defenders; + for (const defender of gym.defenders) { + defender.form = getNormalizedForm(defender.pokemon_id, defender.form); + } + } + if (rsvps) gym.rsvps = rsvps; + + return gym; +} + +export class ApiGymQuery extends GymQuery { + async query( + bounds: Bounds, + filter: FilterGym | undefined, + polygon: PermittedPolygon, + since?: number, + limit?: number, + context?: FeaturePermissionContext + ): Promise>> { + const dnf = buildGymDnfFilters(filter); + if (dnf === null) return { data: [], examined: 0 }; + + const actualLimit = Math.min(limit ?? this.limit, this.limit); + const result = await scanGyms({ + min: { latitude: bounds.minLat, longitude: bounds.minLon }, + max: { latitude: bounds.maxLat, longitude: bounds.maxLon }, + limit: actualLimit + 1, + filters: dnf.length ? dnf : undefined + }); + if (!result) error(500); + + if (result.limit_reached || result.gyms.length > actualLimit) { + return { data: [], examined: actualLimit, limitReached: true }; + } + + let examined = result.examined; + const data: MinMapObject[] = []; + for (const g of result.gyms) { + if (g.deleted) continue; + if (since !== undefined && (g.updated ?? 0) <= since) continue; + if (polygon && !booleanPointInPolygon(point([g.lon, g.lat]), polygon)) { + examined -= 1; + continue; + } + data.push(mapGym(g)); + } + return { data, examined }; + } + + async querySingle(id: string, thisFetch?: typeof fetch): Promise[]> { + const gym = await getGolbatGym(id, thisFetch); + return gym && !gym.deleted ? [mapGym(gym)] : []; + } +} From a4c40c01b5d928816724d54d92806628738bb7b3 Mon Sep 17 00:00:00 2001 From: james Date: Mon, 3 Aug 2026 14:36:26 +0100 Subject: [PATCH 09/17] feat: pokestop map queries via golbat fort api --- .../queryMapObjects/queryPokestopApi.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/lib/server/queryMapObjects/queryPokestopApi.ts diff --git a/src/lib/server/queryMapObjects/queryPokestopApi.ts b/src/lib/server/queryMapObjects/queryPokestopApi.ts new file mode 100644 index 00000000..70126911 --- /dev/null +++ b/src/lib/server/queryMapObjects/queryPokestopApi.ts @@ -0,0 +1,72 @@ +import type { FilterPokestop } from "@/lib/features/filters/filters"; +import type { Bounds } from "@/lib/mapObjects/mapBounds"; +import type { MinMapObject } from "@/lib/mapObjects/mapObjectTypes"; +import { + getGolbatPokestop, + scanPokestops, + type GolbatPokestopResult +} from "@/lib/server/api/golbatApi"; +import { buildPokestopDnfFilters } from "@/lib/server/queryMapObjects/fortDnf"; +import type { MapObjectResponse } from "@/lib/server/queryMapObjects/MapObjectQuery"; +import { PokestopQuery } from "@/lib/server/queryMapObjects/queryPokestop"; +import type { PermittedPolygon } from "@/lib/services/user/checkPerm"; +import type { Incident, PokestopData } from "@/lib/types/mapObjectData/pokestop"; +import { error } from "@sveltejs/kit"; +import { booleanPointInPolygon, point } from "@turf/turf"; + +function mapPokestop(p: GolbatPokestopResult): MinMapObject { + const { deleted, invasions, ...rest } = p; + const pokestop = { + ...rest, + deleted: deleted ? 1 : 0, + incident: (invasions ?? []).map( + (i) => ({ ...i, confirmed: i.confirmed }) as unknown as Incident + ) + } as MinMapObject; + return pokestop; +} + +export class ApiPokestopQuery extends PokestopQuery { + async query( + bounds: Bounds, + filter: FilterPokestop | undefined, + polygon: PermittedPolygon, + since?: number, + limit?: number + ): Promise>> { + const dnf = buildPokestopDnfFilters(filter); + if (dnf === null) return { data: [], examined: 0 }; + + const actualLimit = Math.min(limit ?? this.limit, this.limit); + const result = await scanPokestops({ + min: { latitude: bounds.minLat, longitude: bounds.minLon }, + max: { latitude: bounds.maxLat, longitude: bounds.maxLon }, + limit: actualLimit + 1, + filters: dnf.length ? dnf : undefined, + with_incidents: true + }); + if (!result) error(500); + + if (result.limit_reached || result.pokestops.length > actualLimit) { + return { data: [], examined: actualLimit, limitReached: true }; + } + + let examined = result.examined; + const data: MinMapObject[] = []; + for (const p of result.pokestops) { + if (p.deleted) continue; + if (since !== undefined && (p.updated ?? 0) <= since) continue; + if (polygon && !booleanPointInPolygon(point([p.lon, p.lat]), polygon)) { + examined -= 1; + continue; + } + data.push(mapPokestop(p)); + } + return { data, examined }; + } + + async querySingle(id: string, thisFetch?: typeof fetch): Promise[]> { + const stop = await getGolbatPokestop(id, thisFetch); + return stop && !stop.deleted ? [mapPokestop(stop)] : []; + } +} From ddab610d352c048b08c6bba9844a3c26c8b6e41e Mon Sep 17 00:00:00 2001 From: james Date: Mon, 3 Aug 2026 14:42:42 +0100 Subject: [PATCH 10/17] feat: station map queries via golbat fort api --- .../server/queryMapObjects/queryStationApi.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/lib/server/queryMapObjects/queryStationApi.ts diff --git a/src/lib/server/queryMapObjects/queryStationApi.ts b/src/lib/server/queryMapObjects/queryStationApi.ts new file mode 100644 index 00000000..c391e010 --- /dev/null +++ b/src/lib/server/queryMapObjects/queryStationApi.ts @@ -0,0 +1,68 @@ +import type { FilterStation } from "@/lib/features/filters/filters"; +import type { Bounds } from "@/lib/mapObjects/mapBounds"; +import type { MinMapObject } from "@/lib/mapObjects/mapObjectTypes"; +import { + getGolbatStation, + scanStations, + type GolbatStationResult +} from "@/lib/server/api/golbatApi"; +import { buildStationDnfFilters } from "@/lib/server/queryMapObjects/fortDnf"; +import type { MapObjectResponse } from "@/lib/server/queryMapObjects/MapObjectQuery"; +import { StationQuery } from "@/lib/server/queryMapObjects/queryStation"; +import type { PermittedPolygon } from "@/lib/services/user/checkPerm"; +import type { StationData } from "@/lib/types/mapObjectData/station"; +import { error } from "@sveltejs/kit"; +import { booleanPointInPolygon, point } from "@turf/turf"; + +function mapStation(s: GolbatStationResult): MinMapObject { + const { is_inactive, is_battle_available, stationed_pokemon, ...rest } = s; + return { + ...rest, + is_inactive: is_inactive ? 1 : 0, + is_battle_available: is_battle_available ? 1 : 0, + raw_stationed_pokemon: stationed_pokemon ?? undefined + } as MinMapObject; +} + +export class ApiStationQuery extends StationQuery { + async query( + bounds: Bounds, + filter: FilterStation | undefined, + polygon: PermittedPolygon, + since?: number, + limit?: number + ): Promise>> { + const dnf = buildStationDnfFilters(filter); + if (dnf === null) return { data: [], examined: 0 }; + + const actualLimit = Math.min(limit ?? this.limit, this.limit); + const result = await scanStations({ + min: { latitude: bounds.minLat, longitude: bounds.minLon }, + max: { latitude: bounds.maxLat, longitude: bounds.maxLon }, + limit: actualLimit + 1, + filters: dnf.length ? dnf : undefined + }); + if (!result) error(500); + + if (result.limit_reached || result.stations.length > actualLimit) { + return { data: [], examined: actualLimit, limitReached: true }; + } + + let examined = result.examined; + const data: MinMapObject[] = []; + for (const s of result.stations) { + if (since !== undefined && (s.updated ?? 0) <= since) continue; + if (polygon && !booleanPointInPolygon(point([s.lon, s.lat]), polygon)) { + examined -= 1; + continue; + } + data.push(mapStation(s)); + } + return { data, examined }; + } + + async querySingle(id: string, thisFetch?: typeof fetch): Promise[]> { + const station = await getGolbatStation(id, thisFetch); + return station ? [mapStation(station)] : []; + } +} From 9e05caea8e9d88d2a2bd6875312569fe2a7174f5 Mon Sep 17 00:00:00 2001 From: james Date: Mon, 3 Aug 2026 14:47:33 +0100 Subject: [PATCH 11/17] feat: serve fort map objects from golbat api when detected Co-Authored-By: Claude Fable 5 --- src/lib/server/queryMapObjects/queryMapObjects.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/lib/server/queryMapObjects/queryMapObjects.ts b/src/lib/server/queryMapObjects/queryMapObjects.ts index d52b7937..2535e27a 100644 --- a/src/lib/server/queryMapObjects/queryMapObjects.ts +++ b/src/lib/server/queryMapObjects/queryMapObjects.ts @@ -6,6 +6,10 @@ import { type MapObjectQuery, type MapObjectResponse } from "@/lib/server/queryMapObjects/MapObjectQuery"; +import { isFortApiEnabled } from "@/lib/server/api/golbatFortApi"; +import { ApiGymQuery } from "@/lib/server/queryMapObjects/queryGymApi"; +import { ApiPokestopQuery } from "@/lib/server/queryMapObjects/queryPokestopApi"; +import { ApiStationQuery } from "@/lib/server/queryMapObjects/queryStationApi"; import { GymQuery } from "@/lib/server/queryMapObjects/queryGym"; import { NestQuery } from "@/lib/server/queryMapObjects/queryNest"; import { PokemonQuery } from "@/lib/server/queryMapObjects/queryPokemon"; @@ -28,7 +32,18 @@ const registry: Partial>> = { [MapObjectType.TAPPABLE]: new TappableQuery() }; +// Used instead of the SQL classes while the Golbat fort API is detected (golbatFortApi.ts) +const fortApiRegistry: Partial>> = { + [MapObjectType.GYM]: new ApiGymQuery(), + [MapObjectType.POKESTOP]: new ApiPokestopQuery(), + [MapObjectType.STATION]: new ApiStationQuery() +}; + export function getQuery(type: MapObjectType): MapObjectQuery { + if (isFortApiEnabled()) { + const apiQuery = fortApiRegistry[type]; + if (apiQuery) return apiQuery; + } const query = registry[type]; if (!query) error(404); return query; From 5c9bd002607f94acb60070bd76c30ac1dd458f26 Mon Sep 17 00:00:00 2001 From: james Date: Mon, 3 Aug 2026 15:00:10 +0100 Subject: [PATCH 12/17] feat: source filter pick lists from golbat fort availability --- src/lib/server/api/queryStats.ts | 207 +++++++++++++++++++++++++------ src/routes/api/stats/+server.ts | 3 +- 2 files changed, 174 insertions(+), 36 deletions(-) diff --git a/src/lib/server/api/queryStats.ts b/src/lib/server/api/queryStats.ts index 31314ffc..e96ad352 100644 --- a/src/lib/server/api/queryStats.ts +++ b/src/lib/server/api/queryStats.ts @@ -1,7 +1,14 @@ +import { isFortApiEnabled, getCachedFortAvailability } from "@/lib/server/api/golbatFortApi"; import { query } from "@/lib/server/db/external/internalQuery"; import { masterfileProvider } from "@/lib/server/provider/masterfileProvider"; +import type { FortAvailability } from "@/lib/server/queryMapObjects/queries"; import { getMasterPokemon } from "@/lib/services/masterfile"; -import type { ContestFocus, QuestReward } from "@/lib/types/mapObjectData/pokestop"; +import type { + ContestFocus, + ContestFocusPokemon, + ContestFocusType, + QuestReward +} from "@/lib/types/mapObjectData/pokestop"; import { getLogger } from "@/lib/utils/logger"; import { getNormalizedForm } from "@/lib/utils/pokemonUtils"; import { getQuestKey, parseQuestReward, RewardType } from "@/lib/utils/pokestopUtils"; @@ -74,7 +81,7 @@ export type ActiveRaidStats = { pokemon_id: number; form: number; temp_evolution_id: number; - count: number; + count?: number; }; type InvasionStatsRow = { @@ -123,7 +130,7 @@ export type TotalQuestStats = { export type ContestStatsEntry = { ranking_standard: number; focus: ContestFocus; - count: number; + count?: number; }; export type MaxBattleStatsEntry = { @@ -131,7 +138,7 @@ export type MaxBattleStatsEntry = { pokemon_id: number; form: number; bread_mode: number; - count: number; + count?: number; }; export type NestStatsEntry = { @@ -298,19 +305,21 @@ export async function queryMasterStats(): Promise { "GROUP BY pokemon_id, form " + "HAVING count > 0" ), - query( - "SELECT q.quest_rewards, q.quest_title, q.quest_target, COUNT(*) AS count " + - "FROM ( " + - "SELECT quest_rewards, quest_title, quest_target " + - "FROM pokestop " + - "WHERE quest_title IS NOT NULL " + - "UNION ALL " + - "SELECT alternative_quest_rewards as quest_rewards, alternative_quest_title as quest_title, alternative_quest_target as quest_target " + - "FROM pokestop " + - "WHERE alternative_quest_title IS NOT NULL " + - ") q " + - "GROUP BY q.quest_title, q.quest_rewards, q.quest_target" - ), + isFortApiEnabled() + ? Promise.resolve([] as QuestStatsRow[]) + : query( + "SELECT q.quest_rewards, q.quest_title, q.quest_target, COUNT(*) AS count " + + "FROM ( " + + "SELECT quest_rewards, quest_title, quest_target " + + "FROM pokestop " + + "WHERE quest_title IS NOT NULL " + + "UNION ALL " + + "SELECT alternative_quest_rewards as quest_rewards, alternative_quest_title as quest_title, alternative_quest_target as quest_target " + + "FROM pokestop " + + "WHERE alternative_quest_title IS NOT NULL " + + ") q " + + "GROUP BY q.quest_title, q.quest_rewards, q.quest_target" + ), query( "SELECT level, pokemon_id, form_id AS form, temp_evo_id AS temp_evolution_id, SUM(count) AS count " + "FROM raid_stats " + @@ -325,21 +334,25 @@ export async function queryMasterStats(): Promise { "GROUP BY 1 " + "ORDER BY `character` ASC" ), - query( - "SELECT showcase_ranking_standard AS ranking_standard, showcase_focus AS focus, COUNT(*) as count " + - "FROM pokestop " + - "WHERE showcase_ranking_standard IS NOT NULL " + - "AND showcase_focus IS NOT NULL " + - "AND showcase_expiry > UNIX_TIMESTAMP() " + - "GROUP BY 1, 2" - ), - query( - "SELECT battle_level AS level, battle_pokemon_id AS pokemon_id, battle_pokemon_form AS form, battle_pokemon_bread_mode AS bread_mode, COUNT(*) as count " + - "FROM station " + - "WHERE battle_pokemon_id IS NOT NULL " + - "AND battle_start > UNIX_TIMESTAMP() - 86400 " + - "GROUP BY 1, 2, 3, 4" - ), + isFortApiEnabled() + ? Promise.resolve([] as ContestStatsRow[]) + : query( + "SELECT showcase_ranking_standard AS ranking_standard, showcase_focus AS focus, COUNT(*) as count " + + "FROM pokestop " + + "WHERE showcase_ranking_standard IS NOT NULL " + + "AND showcase_focus IS NOT NULL " + + "AND showcase_expiry > UNIX_TIMESTAMP() " + + "GROUP BY 1, 2" + ), + isFortApiEnabled() + ? Promise.resolve([] as MaxBattleStatsRow[]) + : query( + "SELECT battle_level AS level, battle_pokemon_id AS pokemon_id, battle_pokemon_form AS form, battle_pokemon_bread_mode AS bread_mode, COUNT(*) as count " + + "FROM station " + + "WHERE battle_pokemon_id IS NOT NULL " + + "AND battle_start > UNIX_TIMESTAMP() - 86400 " + + "GROUP BY 1, 2, 3, 4" + ), query( "SELECT pokemon_id, pokemon_form AS form, COUNT(*) AS count " + "FROM nests " + @@ -447,7 +460,7 @@ export async function queryMasterStats(): Promise { ); if (existingRaid) { - existingRaid.count += count; + existingRaid.count = (existingRaid.count ?? 0) + count; } else { activeRaids.push({ level: row.level, @@ -475,7 +488,7 @@ export async function queryMasterStats(): Promise { ); if (existingContest) { - existingContest.count += count; + existingContest.count = (existingContest.count ?? 0) + count; } else { activeContests.push({ ranking_standard: row.ranking_standard, @@ -498,7 +511,7 @@ export async function queryMasterStats(): Promise { ); if (existingMaxBattle) { - existingMaxBattle.count += count; + existingMaxBattle.count = (existingMaxBattle.count ?? 0) + count; } else { activeMaxBattles.push({ level: row.level, @@ -616,3 +629,127 @@ export async function queryMasterStats(): Promise { generatedAt: Date.now() }; } + +// gmax battles are the level-6 tier; verified against ingameLocale.ts:135 +// (1 = dynamax, 2 = gigantamax). Live spot check against `station` (SELECT DISTINCT +// battle_level, battle_pokemon_bread_mode FROM station WHERE battle_pokemon_bread_mode +// IS NOT NULL) was not possible in this environment (no DB access) — pending. +const BREAD_MODE_DYNAMAX = 1; +const BREAD_MODE_GIGANTAMAX = 2; + +/** + * Merges the ≤60s-fresh Golbat fort availability cache into the hourly SQL-built + * MasterStats, replacing the pick-list-relevant fields (active raids, max battles, + * contests, quests) when the fort API is on. No-op otherwise. + */ +export function mergeFortAvailability(stats: MasterStats): MasterStats { + const availability = getCachedFortAvailability(); + if (!isFortApiEnabled() || !availability) return stats; + + const activeRaids: ActiveRaidStats[] = availability.gyms.raids + .filter((r) => r.pokemon_id) + .map((r) => ({ + level: r.raid_level, + pokemon_id: r.pokemon_id!, + form: getNormalizedForm(r.pokemon_id!, r.form ?? 0), + // not in availability yet (Golbat enrichment PR pending) — 0 until it lands + temp_evolution_id: 0 + })); + + const activeMaxBattles: MaxBattleStatsEntry[] = availability.stations.battles + .filter((b) => b.pokemon_id) + .map((b) => ({ + level: b.battle_level, + pokemon_id: b.pokemon_id!, + form: getNormalizedForm(b.pokemon_id!, b.form ?? 0), + bread_mode: b.battle_level >= 6 ? BREAD_MODE_GIGANTAMAX : BREAD_MODE_DYNAMAX + })); + + const activeContests: ContestStatsEntry[] = availability.pokestops.showcases.map((s) => ({ + // ranking_standard not in availability yet (Golbat enrichment PR pending) + ranking_standard: 0, + focus: s.pokemon_id + ? ({ + type: "pokemon", + pokemon_id: s.pokemon_id, + pokemon_form: getNormalizedForm(s.pokemon_id, s.form ?? 0) + } satisfies ContestFocusPokemon) + : ({ type: "type", pokemon_type_1: s.type_id ?? 0 } satisfies ContestFocusType) + })); + + const quests: QuestStats = {}; + let questsTotal = 0; + for (const q of availability.pokestops.quests) { + const reward = questRewardFromAvailability(q); + if (!reward) continue; + + // with_ar intentionally excluded — AR and no-AR variants of the same quest merge + const key = `${q.reward_type}|${q.item_id}|${q.pokemon_id}|${q.form_id}|${q.amount}|${q.title}|${q.target}`; + const existing = quests[key]; + if (existing) { + existing.count += q.count; + } else { + quests[key] = { reward, title: q.title, target: q.target, count: q.count }; + questsTotal += q.count; + } + } + + return { + ...stats, + activeRaids, + activeMaxBattles, + activeContests, + quests, + totalQuests: { count: questsTotal } + }; +} + +function questRewardFromAvailability( + q: FortAvailability["pokestops"]["quests"][number] +): QuestReward | undefined { + switch (q.reward_type) { + case RewardType.ITEM: + return { type: RewardType.ITEM, info: { item_id: q.item_id, amount: q.amount } }; + case RewardType.POKEMON: + return { + type: RewardType.POKEMON, + info: { pokemon_id: q.pokemon_id, form: getNormalizedForm(q.pokemon_id, q.form_id) } + }; + case RewardType.CANDY: + return { type: RewardType.CANDY, info: { pokemon_id: q.pokemon_id, amount: q.amount } }; + case RewardType.XL_CANDY: + return { type: RewardType.XL_CANDY, info: { pokemon_id: q.pokemon_id, amount: q.amount } }; + case RewardType.MEGA_ENERGY: + return { + type: RewardType.MEGA_ENERGY, + info: { pokemon_id: q.pokemon_id, amount: q.amount } + }; + case RewardType.STARDUST: + return { type: RewardType.STARDUST, info: { amount: q.amount } }; + case RewardType.XP: + return { type: RewardType.XP, info: { amount: q.amount } }; + case RewardType.POKECOINS: + return { type: RewardType.POKECOINS, info: { amount: q.amount } }; + case RewardType.AVATAR_CLOTHING: + return { type: RewardType.AVATAR_CLOTHING, info: {} }; + case RewardType.QUEST: + return { type: RewardType.QUEST, info: {} }; + case RewardType.LEVEL_CAP: + return { type: RewardType.LEVEL_CAP, info: {} }; + case RewardType.STICKER: + return { type: RewardType.STICKER, info: {} }; + case RewardType.INCIDENT: + return { type: RewardType.INCIDENT, info: {} }; + case RewardType.PLAYER_ATTRIBUTE: + return { type: RewardType.PLAYER_ATTRIBUTE, info: {} }; + case RewardType.EVENT_BADGE: + return { type: RewardType.EVENT_BADGE, info: {} }; + default: + // POKEMON_EGG, POKEMON_INDIVIDUAL_STAT, LOOT_TABLE, FRIENDSHIP_POINTS, + // TEMP_EVO_BRANCH_RESOURCE: Golbat's fort availability quest rewards don't + // surface these today, and pokestop.d.ts doesn't cleanly define members for + // them in the QuestReward union (pre-existing; out of scope here) — skip + // rather than fabricate a shape. + return undefined; + } +} diff --git a/src/routes/api/stats/+server.ts b/src/routes/api/stats/+server.ts index 04b015de..4e0c30a8 100644 --- a/src/routes/api/stats/+server.ts +++ b/src/routes/api/stats/+server.ts @@ -1,3 +1,4 @@ +import { mergeFortAvailability } from "@/lib/server/api/queryStats"; import { masterstatsProvider } from "@/lib/server/provider/masterStatsProvider"; import { cacheHttpHeaders } from "@/lib/utils/apiUtils.server"; import { json } from "@sveltejs/kit"; @@ -5,7 +6,7 @@ import { json } from "@sveltejs/kit"; export async function GET() { try { const stats = await masterstatsProvider.get(); - return json(stats, { headers: cacheHttpHeaders(300, 3600, 3600) }); + return json(mergeFortAvailability(stats), { headers: cacheHttpHeaders(300, 3600, 3600) }); } catch (e) { return json( { From e7d30c5c29c69961f4e942b9fadd26a61a60dfc4 Mon Sep 17 00:00:00 2001 From: james Date: Mon, 3 Aug 2026 15:17:09 +0100 Subject: [PATCH 13/17] fix: keep temp evo branch resource quests in availability pick lists --- src/lib/server/api/queryStats.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/lib/server/api/queryStats.ts b/src/lib/server/api/queryStats.ts index e96ad352..e0f2833c 100644 --- a/src/lib/server/api/queryStats.ts +++ b/src/lib/server/api/queryStats.ts @@ -7,7 +7,8 @@ import type { ContestFocus, ContestFocusPokemon, ContestFocusType, - QuestReward + QuestReward, + QuestRewardTempEvoBranch } from "@/lib/types/mapObjectData/pokestop"; import { getLogger } from "@/lib/utils/logger"; import { getNormalizedForm } from "@/lib/utils/pokemonUtils"; @@ -744,12 +745,26 @@ function questRewardFromAvailability( return { type: RewardType.PLAYER_ATTRIBUTE, info: {} }; case RewardType.EVENT_BADGE: return { type: RewardType.EVENT_BADGE, info: {} }; + case RewardType.TEMP_EVO_BRANCH_RESOURCE: { + // QuestRewardTempEvoBranch is defined in pokestop.d.ts (~line 287) but is + // misplaced in the ContestFocus union rather than QuestReward (pre-existing + // bug found in Task 8; not fixed here — pokestop.d.ts is out of this task's + // file scope). It's live: QuestFilterset.svelte and pokestopUtils.ts both + // handle RewardType.TEMP_EVO_BRANCH_RESOURCE, so it must not be dropped like + // the genuinely-unreachable types below. The cast only compensates for the + // union placement bug — the object shape itself is exactly + // QuestRewardTempEvoBranch's `info: { amount, pokemon_id }`, not a mismatch. + const reward: QuestRewardTempEvoBranch = { + type: RewardType.TEMP_EVO_BRANCH_RESOURCE, + info: { amount: q.amount, pokemon_id: q.pokemon_id } + }; + return reward as unknown as QuestReward; + } default: - // POKEMON_EGG, POKEMON_INDIVIDUAL_STAT, LOOT_TABLE, FRIENDSHIP_POINTS, - // TEMP_EVO_BRANCH_RESOURCE: Golbat's fort availability quest rewards don't - // surface these today, and pokestop.d.ts doesn't cleanly define members for - // them in the QuestReward union (pre-existing; out of scope here) — skip - // rather than fabricate a shape. + // POKEMON_EGG, POKEMON_INDIVIDUAL_STAT, LOOT_TABLE, FRIENDSHIP_POINTS: Golbat's + // fort availability quest rewards don't surface these today, and pokestop.d.ts + // doesn't define usable QuestReward members for them (pre-existing; out of + // scope here) — skip rather than fabricate a shape. return undefined; } } From 361bbe262d4dcfe6a2a6fc671c719f9c87709acc Mon Sep 17 00:00:00 2001 From: james Date: Mon, 3 Aug 2026 15:19:20 +0100 Subject: [PATCH 14/17] docs: document golbat fort api auto-detection --- docs/src/content/docs/reference/configuration.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index a2e83343..598f42e4 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -36,6 +36,10 @@ defaultNestName = "Unknown Nest" - `secret`: Must match your configured Golbat secret - `defaultNestName`: The default nest name, as configured in Fletchling +### Golbat fort API (optional, recommended) + +When your Golbat exposes the fort map-data API (Golbat with [#385](https://github.com/UnownHash/Golbat/pull/385), `fort_in_memory = true` in Golbat's config — `preload = true` recommended), Diadem detects it automatically at startup and serves gyms, pokéstops and stations from it instead of SQL, and sources filter pick lists from Golbat's availability index. No Diadem configuration is needed — detection re-checks every minute, so Golbat can be upgraded or toggled without restarting Diadem. Without it, Diadem falls back to direct database queries as before. + ## `server.dragonite` ```toml From 528b8c57c2f917c5ffd5a0d63ce62a4fd771aaa9 Mon Sep 17 00:00:00 2001 From: james Date: Mon, 3 Aug 2026 15:38:11 +0100 Subject: [PATCH 15/17] fix: widen fort DNF amount superset and add SQL fallback on fetch failure AMOUNT_MAX capped quest reward amount ranges at 10000, silently dropping quests above that threshold since the SQL fallback omitted the max clause entirely. Raise it to int32 max so the DNF range can never exclude a real amount. Also add per-request SQL fallback to ApiGymQuery/ApiPokestopQuery/ ApiStationQuery: a thrown fetch error (e.g. Golbat dying mid-request) or an undefined scan/by-id result now falls back to the parent SQL query instead of always 500ing or silently missing data. Found-but-deleted objects still short-circuit to [] as before. --- src/lib/server/api/golbatApi.ts | 15 ++++-- src/lib/server/api/golbatFortApi.ts | 19 +++++++- src/lib/server/api/queryStats.ts | 31 +++++++----- .../server/queryMapObjects/fortDnf.test.ts | 2 +- src/lib/server/queryMapObjects/fortDnf.ts | 10 +++- src/lib/server/queryMapObjects/queryGymApi.ts | 40 +++++++++++----- .../queryMapObjects/queryPokestopApi.ts | 47 ++++++++++++------- .../server/queryMapObjects/queryStationApi.ts | 40 +++++++++++----- 8 files changed, 144 insertions(+), 60 deletions(-) diff --git a/src/lib/server/api/golbatApi.ts b/src/lib/server/api/golbatApi.ts index 4d9fe42f..88f463b7 100644 --- a/src/lib/server/api/golbatApi.ts +++ b/src/lib/server/api/golbatApi.ts @@ -73,7 +73,8 @@ async function callGolbat( method: "GET" | "POST", body: BodyInit | undefined = undefined, thisFetch: typeof fetch = fetch, - quiet = false + quiet = false, + signal?: AbortSignal ): Promise { const start = performance.now(); const url = new URL(path, config.url); @@ -89,7 +90,7 @@ async function callGolbat( headers["X-Golbat-Secret"] = config.secret; } - const response = await thisFetch(url, { method, body, headers }); + const response = await thisFetch(url, { method, body, headers, signal }); if (!response.ok) { if (!quiet) { @@ -166,5 +167,13 @@ export async function getGolbatStation(id: string, thisFetch: typeof fetch = fet } export async function fetchFortAvailability() { - return await callGolbat("api/fort/available", "GET", undefined, fetch, true); + // Bounded so a hung Golbat connection during detection can't block initDiadem(). + return await callGolbat( + "api/fort/available", + "GET", + undefined, + fetch, + true, + AbortSignal.timeout(10_000) + ); } diff --git a/src/lib/server/api/golbatFortApi.ts b/src/lib/server/api/golbatFortApi.ts index 27ab8f0f..e9d37c34 100644 --- a/src/lib/server/api/golbatFortApi.ts +++ b/src/lib/server/api/golbatFortApi.ts @@ -28,8 +28,9 @@ export async function refreshFortAvailability() { } const nowEnabled = result !== undefined; + const wasEnabled = fortApiEnabled; - if (nowEnabled !== fortApiEnabled) { + if (nowEnabled !== wasEnabled) { log.info( nowEnabled ? "Golbat fort API detected, serving gyms/pokestops/stations from it" @@ -39,6 +40,22 @@ export async function refreshFortAvailability() { fortApiEnabled = nowEnabled; if (result) cachedAvailability = result; + + // enabled -> disabled: the hourly MasterStats snapshot was built with fort + // availability merged in, so quests/contests/max-battles are empty SQL-side + // placeholders until the next hourly refresh. Force one now so pick lists + // don't sit empty for up to an hour. (disabled -> enabled needs no such kick: + // mergeFortAvailability already overrides the stale SQL fields at request time.) + // + // Lazy import to break a load-time cycle: masterStatsProvider imports + // queryStats, which imports this module for isFortApiEnabled/getCachedFortAvailability. + if (wasEnabled && !nowEnabled) { + import("@/lib/server/provider/masterStatsProvider") + .then(({ masterstatsProvider }) => masterstatsProvider.refresh()) + .catch((err) => + log.error("Failed to refresh master stats after fort API went down: %s", err) + ); + } } export async function startFortApiDetection() { diff --git a/src/lib/server/api/queryStats.ts b/src/lib/server/api/queryStats.ts index e0f2833c..9fabb264 100644 --- a/src/lib/server/api/queryStats.ts +++ b/src/lib/server/api/queryStats.ts @@ -666,17 +666,22 @@ export function mergeFortAvailability(stats: MasterStats): MasterStats { bread_mode: b.battle_level >= 6 ? BREAD_MODE_GIGANTAMAX : BREAD_MODE_DYNAMAX })); - const activeContests: ContestStatsEntry[] = availability.pokestops.showcases.map((s) => ({ - // ranking_standard not in availability yet (Golbat enrichment PR pending) - ranking_standard: 0, - focus: s.pokemon_id - ? ({ - type: "pokemon", - pokemon_id: s.pokemon_id, - pokemon_form: getNormalizedForm(s.pokemon_id, s.form ?? 0) - } satisfies ContestFocusPokemon) - : ({ type: "type", pokemon_type_1: s.type_id ?? 0 } satisfies ContestFocusType) - })); + const activeContests: ContestStatsEntry[] = availability.pokestops.showcases + // A row with no pokemon_id and no type_id is a junk/incomplete showcase entry + // (Golbat has no confirmed focus for it yet) — skip rather than fabricate a + // bogus "type" focus with pokemon_type_1: 0. + .filter((s) => s.pokemon_id !== null || s.type_id !== null) + .map((s) => ({ + // ranking_standard not in availability yet (Golbat enrichment PR pending) + ranking_standard: 0, + focus: s.pokemon_id + ? ({ + type: "pokemon", + pokemon_id: s.pokemon_id, + pokemon_form: getNormalizedForm(s.pokemon_id, s.form ?? 0) + } satisfies ContestFocusPokemon) + : ({ type: "type", pokemon_type_1: s.type_id ?? 0 } satisfies ContestFocusType) + })); const quests: QuestStats = {}; let questsTotal = 0; @@ -691,8 +696,10 @@ export function mergeFortAvailability(stats: MasterStats): MasterStats { existing.count += q.count; } else { quests[key] = { reward, title: q.title, target: q.target, count: q.count }; - questsTotal += q.count; } + // Counted once per availability row regardless of key merge, matching the SQL + // path which sums every row (merged AR/no-AR variants must still be counted). + questsTotal += q.count; } return { diff --git a/src/lib/server/queryMapObjects/fortDnf.test.ts b/src/lib/server/queryMapObjects/fortDnf.test.ts index 0975faef..79093f3c 100644 --- a/src/lib/server/queryMapObjects/fortDnf.test.ts +++ b/src/lib/server/queryMapObjects/fortDnf.test.ts @@ -139,7 +139,7 @@ describe("buildPokestopDnfFilters", () => { contest: disabled } as any); expect(result).toEqual([ - { quest_reward_type: [3], quest_reward_amount: { min: 500, max: 10000 } } + { quest_reward_type: [3], quest_reward_amount: { min: 500, max: 2 ** 31 - 1 } } ]); }); }); diff --git a/src/lib/server/queryMapObjects/fortDnf.ts b/src/lib/server/queryMapObjects/fortDnf.ts index 52be8bb8..34d05716 100644 --- a/src/lib/server/queryMapObjects/fortDnf.ts +++ b/src/lib/server/queryMapObjects/fortDnf.ts @@ -9,13 +9,19 @@ import { } from "@/lib/utils/pokestopUtils"; // Superset guards: broad enough that a matching object can never be missed; -// the local filter()/shouldDisplay* pass trims the excess. +// the local filter()/shouldDisplay* pass trims the excess. These are hardcoded +// enumerations, not derived from the DNF schema — if Niantic ships new lure IDs +// or quest reward types, extend the lists here. SQL expressed the equivalent +// checks as EXISTS()-style predicates; upstream DNF has no exists-style field +// yet, so these lists stand in for that. const ALL_RAID_LEVELS = Array.from({ length: 20 }, (_, i) => i + 1); const ALL_LURE_IDS = [501, 502, 503, 504, 505, 506]; const ALL_QUEST_REWARD_TYPES = Object.values(RewardType).filter( (v): v is number => typeof v === "number" && v > 0 ); -const AMOUNT_MAX = 10000; +// DNF ranges require a finite max — this must never be low enough to exclude a +// real quest reward amount, so it's effectively "no upper bound" (int32 max). +const AMOUNT_MAX = 2 ** 31 - 1; function minMax(range: { min: number; max: number }) { return { diff --git a/src/lib/server/queryMapObjects/queryGymApi.ts b/src/lib/server/queryMapObjects/queryGymApi.ts index 405dca4f..f7a54509 100644 --- a/src/lib/server/queryMapObjects/queryGymApi.ts +++ b/src/lib/server/queryMapObjects/queryGymApi.ts @@ -1,16 +1,23 @@ import type { FilterGym } from "@/lib/features/filters/filters"; import type { Bounds } from "@/lib/mapObjects/mapBounds"; import type { MinMapObject } from "@/lib/mapObjects/mapObjectTypes"; -import { getGolbatGym, scanGyms, type GolbatGymResult } from "@/lib/server/api/golbatApi"; +import { + getGolbatGym, + scanGyms, + type GolbatGymResult, + type GymScanResponse +} from "@/lib/server/api/golbatApi"; import { buildGymDnfFilters } from "@/lib/server/queryMapObjects/fortDnf"; import type { MapObjectResponse } from "@/lib/server/queryMapObjects/MapObjectQuery"; import { GymQuery } from "@/lib/server/queryMapObjects/queryGym"; import type { FeaturePermissionContext, PermittedPolygon } from "@/lib/services/user/checkPerm"; import type { GymData } from "@/lib/types/mapObjectData/gym"; +import { getLogger } from "@/lib/utils/logger"; import { getNormalizedForm } from "@/lib/utils/pokemonUtils"; -import { error } from "@sveltejs/kit"; import { booleanPointInPolygon, point } from "@turf/turf"; +const log = getLogger("query:gym-api"); + function mapGym(g: GolbatGymResult): MinMapObject { const { available_slots, deleted, defenders, rsvps, ...rest } = g; const gym = { @@ -45,13 +52,18 @@ export class ApiGymQuery extends GymQuery { if (dnf === null) return { data: [], examined: 0 }; const actualLimit = Math.min(limit ?? this.limit, this.limit); - const result = await scanGyms({ - min: { latitude: bounds.minLat, longitude: bounds.minLon }, - max: { latitude: bounds.maxLat, longitude: bounds.maxLon }, - limit: actualLimit + 1, - filters: dnf.length ? dnf : undefined - }); - if (!result) error(500); + let result: GymScanResponse | undefined; + try { + result = await scanGyms({ + min: { latitude: bounds.minLat, longitude: bounds.minLon }, + max: { latitude: bounds.maxLat, longitude: bounds.maxLon }, + limit: actualLimit + 1, + filters: dnf.length ? dnf : undefined + }); + } catch (err) { + log.debug("Fort gym scan failed, falling back to SQL: %s", err); + } + if (!result) return super.query(bounds, filter, polygon, since, limit); if (result.limit_reached || result.gyms.length > actualLimit) { return { data: [], examined: actualLimit, limitReached: true }; @@ -72,7 +84,13 @@ export class ApiGymQuery extends GymQuery { } async querySingle(id: string, thisFetch?: typeof fetch): Promise[]> { - const gym = await getGolbatGym(id, thisFetch); - return gym && !gym.deleted ? [mapGym(gym)] : []; + let gym: GolbatGymResult | undefined; + try { + gym = await getGolbatGym(id, thisFetch); + } catch (err) { + log.debug("Fort gym fetch failed, falling back to SQL: %s", err); + } + if (!gym) return super.querySingle(id); + return gym.deleted ? [] : [mapGym(gym)]; } } diff --git a/src/lib/server/queryMapObjects/queryPokestopApi.ts b/src/lib/server/queryMapObjects/queryPokestopApi.ts index 70126911..a8102e93 100644 --- a/src/lib/server/queryMapObjects/queryPokestopApi.ts +++ b/src/lib/server/queryMapObjects/queryPokestopApi.ts @@ -4,24 +4,25 @@ import type { MinMapObject } from "@/lib/mapObjects/mapObjectTypes"; import { getGolbatPokestop, scanPokestops, - type GolbatPokestopResult + type GolbatPokestopResult, + type PokestopScanResponse } from "@/lib/server/api/golbatApi"; import { buildPokestopDnfFilters } from "@/lib/server/queryMapObjects/fortDnf"; import type { MapObjectResponse } from "@/lib/server/queryMapObjects/MapObjectQuery"; import { PokestopQuery } from "@/lib/server/queryMapObjects/queryPokestop"; -import type { PermittedPolygon } from "@/lib/services/user/checkPerm"; +import type { FeaturePermissionContext, PermittedPolygon } from "@/lib/services/user/checkPerm"; import type { Incident, PokestopData } from "@/lib/types/mapObjectData/pokestop"; -import { error } from "@sveltejs/kit"; +import { getLogger } from "@/lib/utils/logger"; import { booleanPointInPolygon, point } from "@turf/turf"; +const log = getLogger("query:pokestop-api"); + function mapPokestop(p: GolbatPokestopResult): MinMapObject { const { deleted, invasions, ...rest } = p; const pokestop = { ...rest, deleted: deleted ? 1 : 0, - incident: (invasions ?? []).map( - (i) => ({ ...i, confirmed: i.confirmed }) as unknown as Incident - ) + incident: (invasions ?? []).map((i): Incident => ({ ...i })) } as MinMapObject; return pokestop; } @@ -32,20 +33,26 @@ export class ApiPokestopQuery extends PokestopQuery { filter: FilterPokestop | undefined, polygon: PermittedPolygon, since?: number, - limit?: number + limit?: number, + context?: FeaturePermissionContext ): Promise>> { const dnf = buildPokestopDnfFilters(filter); if (dnf === null) return { data: [], examined: 0 }; const actualLimit = Math.min(limit ?? this.limit, this.limit); - const result = await scanPokestops({ - min: { latitude: bounds.minLat, longitude: bounds.minLon }, - max: { latitude: bounds.maxLat, longitude: bounds.maxLon }, - limit: actualLimit + 1, - filters: dnf.length ? dnf : undefined, - with_incidents: true - }); - if (!result) error(500); + let result: PokestopScanResponse | undefined; + try { + result = await scanPokestops({ + min: { latitude: bounds.minLat, longitude: bounds.minLon }, + max: { latitude: bounds.maxLat, longitude: bounds.maxLon }, + limit: actualLimit + 1, + filters: dnf.length ? dnf : undefined, + with_incidents: true + }); + } catch (err) { + log.debug("Fort pokestop scan failed, falling back to SQL: %s", err); + } + if (!result) return super.query(bounds, filter, polygon, since, limit); if (result.limit_reached || result.pokestops.length > actualLimit) { return { data: [], examined: actualLimit, limitReached: true }; @@ -66,7 +73,13 @@ export class ApiPokestopQuery extends PokestopQuery { } async querySingle(id: string, thisFetch?: typeof fetch): Promise[]> { - const stop = await getGolbatPokestop(id, thisFetch); - return stop && !stop.deleted ? [mapPokestop(stop)] : []; + let stop: GolbatPokestopResult | undefined; + try { + stop = await getGolbatPokestop(id, thisFetch); + } catch (err) { + log.debug("Fort pokestop fetch failed, falling back to SQL: %s", err); + } + if (!stop) return super.querySingle(id); + return stop.deleted ? [] : [mapPokestop(stop)]; } } diff --git a/src/lib/server/queryMapObjects/queryStationApi.ts b/src/lib/server/queryMapObjects/queryStationApi.ts index c391e010..8f2a2d78 100644 --- a/src/lib/server/queryMapObjects/queryStationApi.ts +++ b/src/lib/server/queryMapObjects/queryStationApi.ts @@ -4,16 +4,19 @@ import type { MinMapObject } from "@/lib/mapObjects/mapObjectTypes"; import { getGolbatStation, scanStations, - type GolbatStationResult + type GolbatStationResult, + type StationScanResponse } from "@/lib/server/api/golbatApi"; import { buildStationDnfFilters } from "@/lib/server/queryMapObjects/fortDnf"; import type { MapObjectResponse } from "@/lib/server/queryMapObjects/MapObjectQuery"; import { StationQuery } from "@/lib/server/queryMapObjects/queryStation"; -import type { PermittedPolygon } from "@/lib/services/user/checkPerm"; +import type { FeaturePermissionContext, PermittedPolygon } from "@/lib/services/user/checkPerm"; import type { StationData } from "@/lib/types/mapObjectData/station"; -import { error } from "@sveltejs/kit"; +import { getLogger } from "@/lib/utils/logger"; import { booleanPointInPolygon, point } from "@turf/turf"; +const log = getLogger("query:station-api"); + function mapStation(s: GolbatStationResult): MinMapObject { const { is_inactive, is_battle_available, stationed_pokemon, ...rest } = s; return { @@ -30,19 +33,25 @@ export class ApiStationQuery extends StationQuery { filter: FilterStation | undefined, polygon: PermittedPolygon, since?: number, - limit?: number + limit?: number, + context?: FeaturePermissionContext ): Promise>> { const dnf = buildStationDnfFilters(filter); if (dnf === null) return { data: [], examined: 0 }; const actualLimit = Math.min(limit ?? this.limit, this.limit); - const result = await scanStations({ - min: { latitude: bounds.minLat, longitude: bounds.minLon }, - max: { latitude: bounds.maxLat, longitude: bounds.maxLon }, - limit: actualLimit + 1, - filters: dnf.length ? dnf : undefined - }); - if (!result) error(500); + let result: StationScanResponse | undefined; + try { + result = await scanStations({ + min: { latitude: bounds.minLat, longitude: bounds.minLon }, + max: { latitude: bounds.maxLat, longitude: bounds.maxLon }, + limit: actualLimit + 1, + filters: dnf.length ? dnf : undefined + }); + } catch (err) { + log.debug("Fort station scan failed, falling back to SQL: %s", err); + } + if (!result) return super.query(bounds, filter, polygon, since, limit); if (result.limit_reached || result.stations.length > actualLimit) { return { data: [], examined: actualLimit, limitReached: true }; @@ -62,7 +71,12 @@ export class ApiStationQuery extends StationQuery { } async querySingle(id: string, thisFetch?: typeof fetch): Promise[]> { - const station = await getGolbatStation(id, thisFetch); - return station ? [mapStation(station)] : []; + let station: GolbatStationResult | undefined; + try { + station = await getGolbatStation(id, thisFetch); + } catch (err) { + log.debug("Fort station fetch failed, falling back to SQL: %s", err); + } + return station ? [mapStation(station)] : await super.querySingle(id); } } From e71f1f3da477b93f3546cc39c4bae6c0e908bfee Mon Sep 17 00:00:00 2001 From: James Berry Date: Tue, 4 Aug 2026 14:03:41 +0100 Subject: [PATCH 16/17] fix: pokestop quest rewards arrive as native JSON from the fort api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Golbat's fort API sends quest_rewards / alternative_quest_rewards as real JSON arrays, while parseQuestReward (via the inherited prepare) expects the SQL rows' serialized string form — every quest-bearing pokestop threw and the route 500'd. Re-serialize both fields in the mapper, correct the wire type that hid the mismatch, and move the mapper to its own type-only-import module so it is unit-testable under bare vitest. Co-Authored-By: Claude Fable 5 --- src/lib/server/api/golbatApi.ts | 8 +++- .../queryMapObjects/pokestopApiMapper.ts | 19 +++++++++ .../queryMapObjects/queryPokestopApi.test.ts | 39 +++++++++++++++++++ .../queryMapObjects/queryPokestopApi.ts | 13 +------ 4 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 src/lib/server/queryMapObjects/pokestopApiMapper.ts create mode 100644 src/lib/server/queryMapObjects/queryPokestopApi.test.ts diff --git a/src/lib/server/api/golbatApi.ts b/src/lib/server/api/golbatApi.ts index 88f463b7..6d8785b3 100644 --- a/src/lib/server/api/golbatApi.ts +++ b/src/lib/server/api/golbatApi.ts @@ -29,9 +29,15 @@ export type GolbatGymResult = Omit< export type GolbatIncidentResult = Omit & { confirmed: boolean }; -export type GolbatPokestopResult = Omit, "incident" | "deleted"> & { +export type GolbatPokestopResult = Omit< + MinMapObject, + "incident" | "deleted" | "quest_rewards" | "alternative_quest_rewards" +> & { deleted: boolean; invasions?: GolbatIncidentResult[]; + // native JSON on the wire (arrays of {type, info}), unlike the SQL rows' serialized strings + quest_rewards?: object[] | null; + alternative_quest_rewards?: object[] | null; }; export type GolbatStationResult = Omit< diff --git a/src/lib/server/queryMapObjects/pokestopApiMapper.ts b/src/lib/server/queryMapObjects/pokestopApiMapper.ts new file mode 100644 index 00000000..1a321d9d --- /dev/null +++ b/src/lib/server/queryMapObjects/pokestopApiMapper.ts @@ -0,0 +1,19 @@ +import type { MinMapObject } from "@/lib/mapObjects/mapObjectTypes"; +import type { GolbatPokestopResult } from "@/lib/server/api/golbatApi"; +import type { Incident, PokestopData } from "@/lib/types/mapObjectData/pokestop"; + +// Type-only imports keep this module runnable under bare vitest (no $app/*). +export function mapPokestop(p: GolbatPokestopResult): MinMapObject { + const { deleted, invasions, quest_rewards, alternative_quest_rewards, ...rest } = p; + const pokestop = { + ...rest, + deleted: deleted ? 1 : 0, + // The wire delivers quest rewards as native JSON; parseQuestReward (via the + // inherited prepare) expects the SQL rows' serialized string form. + quest_rewards: quest_rewards != null ? JSON.stringify(quest_rewards) : undefined, + alternative_quest_rewards: + alternative_quest_rewards != null ? JSON.stringify(alternative_quest_rewards) : undefined, + incident: (invasions ?? []).map((i): Incident => ({ ...i })) + } as MinMapObject; + return pokestop; +} diff --git a/src/lib/server/queryMapObjects/queryPokestopApi.test.ts b/src/lib/server/queryMapObjects/queryPokestopApi.test.ts new file mode 100644 index 00000000..2b8ebd18 --- /dev/null +++ b/src/lib/server/queryMapObjects/queryPokestopApi.test.ts @@ -0,0 +1,39 @@ +import { mapPokestop } from "@/lib/server/queryMapObjects/pokestopApiMapper"; +import { describe, expect, it } from "vitest"; + +describe("mapPokestop", () => { + it("re-serializes native-JSON quest rewards to the SQL string shape", () => { + // Golbat's fort API sends quest_rewards as a real JSON array (#385 design), + // while the SQL rows carry a serialized string that parseQuestReward + // (JSON.parse + [0]) expects. + const mapped = mapPokestop({ + id: "stop-1", + lat: 1, + lon: 2, + deleted: false, + quest_rewards: [{ type: 3, info: { amount: 500 } }], + alternative_quest_rewards: [{ type: 2, info: { item_id: 1, amount: 3 } }] + } as never); + + expect(typeof mapped.quest_rewards).toBe("string"); + expect(JSON.parse(mapped.quest_rewards!)[0]).toEqual({ type: 3, info: { amount: 500 } }); + expect(typeof mapped.alternative_quest_rewards).toBe("string"); + expect(JSON.parse(mapped.alternative_quest_rewards!)[0]).toEqual({ + type: 2, + info: { item_id: 1, amount: 3 } + }); + }); + + it("leaves absent quest rewards undefined", () => { + const mapped = mapPokestop({ + id: "stop-2", + lat: 0, + lon: 0, + deleted: false, + quest_rewards: null + } as never); + + expect(mapped.quest_rewards).toBeUndefined(); + expect(mapped.alternative_quest_rewards).toBeUndefined(); + }); +}); diff --git a/src/lib/server/queryMapObjects/queryPokestopApi.ts b/src/lib/server/queryMapObjects/queryPokestopApi.ts index a8102e93..aa497646 100644 --- a/src/lib/server/queryMapObjects/queryPokestopApi.ts +++ b/src/lib/server/queryMapObjects/queryPokestopApi.ts @@ -9,24 +9,15 @@ import { } from "@/lib/server/api/golbatApi"; import { buildPokestopDnfFilters } from "@/lib/server/queryMapObjects/fortDnf"; import type { MapObjectResponse } from "@/lib/server/queryMapObjects/MapObjectQuery"; +import { mapPokestop } from "@/lib/server/queryMapObjects/pokestopApiMapper"; import { PokestopQuery } from "@/lib/server/queryMapObjects/queryPokestop"; import type { FeaturePermissionContext, PermittedPolygon } from "@/lib/services/user/checkPerm"; -import type { Incident, PokestopData } from "@/lib/types/mapObjectData/pokestop"; +import type { PokestopData } from "@/lib/types/mapObjectData/pokestop"; import { getLogger } from "@/lib/utils/logger"; import { booleanPointInPolygon, point } from "@turf/turf"; const log = getLogger("query:pokestop-api"); -function mapPokestop(p: GolbatPokestopResult): MinMapObject { - const { deleted, invasions, ...rest } = p; - const pokestop = { - ...rest, - deleted: deleted ? 1 : 0, - incident: (invasions ?? []).map((i): Incident => ({ ...i })) - } as MinMapObject; - return pokestop; -} - export class ApiPokestopQuery extends PokestopQuery { async query( bounds: Bounds, From 35c29ba12f1569b14977022ee1229c25a4ad0011 Mon Sep 17 00:00:00 2001 From: James Berry Date: Tue, 4 Aug 2026 14:14:47 +0100 Subject: [PATCH 17/17] feat: handle native-JSON fort blobs from newer golbat Golbat is finishing its fort-API native-JSON conversion (UnownHash/Golbat#393): stationed_pokemon, showcase_focus and showcase_rankings switch from serialized strings to real JSON. Accept both wire generations via blobToString so either Golbat works, and type the wire fields honestly. Co-Authored-By: Claude Fable 5 --- src/lib/server/api/golbatApi.ts | 13 +++++++-- .../queryMapObjects/pokestopApiMapper.ts | 28 +++++++++++++++---- .../queryMapObjects/queryPokestopApi.test.ts | 26 +++++++++++++++++ .../server/queryMapObjects/queryStationApi.ts | 3 +- 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/lib/server/api/golbatApi.ts b/src/lib/server/api/golbatApi.ts index 6d8785b3..24ca80c2 100644 --- a/src/lib/server/api/golbatApi.ts +++ b/src/lib/server/api/golbatApi.ts @@ -31,13 +31,21 @@ export type GolbatIncidentResult = Omit & { confirmed: bo export type GolbatPokestopResult = Omit< MinMapObject, - "incident" | "deleted" | "quest_rewards" | "alternative_quest_rewards" + | "incident" + | "deleted" + | "quest_rewards" + | "alternative_quest_rewards" + | "showcase_focus" + | "showcase_rankings" > & { deleted: boolean; invasions?: GolbatIncidentResult[]; // native JSON on the wire (arrays of {type, info}), unlike the SQL rows' serialized strings quest_rewards?: object[] | null; alternative_quest_rewards?: object[] | null; + // native JSON since Golbat's fort blob conversion; strings from older Golbat + showcase_focus?: object | string | null; + showcase_rankings?: object | string | null; }; export type GolbatStationResult = Omit< @@ -46,7 +54,8 @@ export type GolbatStationResult = Omit< > & { is_inactive: boolean; is_battle_available: boolean; - stationed_pokemon?: string | null; // still a serialized string on the wire + // native JSON since Golbat's fort blob conversion; string from older Golbat + stationed_pokemon?: object[] | string | null; }; export type GymScanResponse = { diff --git a/src/lib/server/queryMapObjects/pokestopApiMapper.ts b/src/lib/server/queryMapObjects/pokestopApiMapper.ts index 1a321d9d..f9648311 100644 --- a/src/lib/server/queryMapObjects/pokestopApiMapper.ts +++ b/src/lib/server/queryMapObjects/pokestopApiMapper.ts @@ -2,17 +2,33 @@ import type { MinMapObject } from "@/lib/mapObjects/mapObjectTypes"; import type { GolbatPokestopResult } from "@/lib/server/api/golbatApi"; import type { Incident, PokestopData } from "@/lib/types/mapObjectData/pokestop"; +// The wire delivers blob columns as native JSON (older Golbat sent some of them +// as serialized strings); the SQL rows — and everything downstream of the +// mappers, like parseQuestReward and the inherited prepare() — expect the +// serialized string form. Accepts both wire generations. +export function blobToString(value: object | object[] | string | null | undefined) { + if (value == null) return undefined; + return typeof value === "string" ? value : JSON.stringify(value); +} + // Type-only imports keep this module runnable under bare vitest (no $app/*). export function mapPokestop(p: GolbatPokestopResult): MinMapObject { - const { deleted, invasions, quest_rewards, alternative_quest_rewards, ...rest } = p; + const { + deleted, + invasions, + quest_rewards, + alternative_quest_rewards, + showcase_focus, + showcase_rankings, + ...rest + } = p; const pokestop = { ...rest, deleted: deleted ? 1 : 0, - // The wire delivers quest rewards as native JSON; parseQuestReward (via the - // inherited prepare) expects the SQL rows' serialized string form. - quest_rewards: quest_rewards != null ? JSON.stringify(quest_rewards) : undefined, - alternative_quest_rewards: - alternative_quest_rewards != null ? JSON.stringify(alternative_quest_rewards) : undefined, + quest_rewards: blobToString(quest_rewards), + alternative_quest_rewards: blobToString(alternative_quest_rewards), + showcase_focus: blobToString(showcase_focus), + showcase_rankings: blobToString(showcase_rankings), incident: (invasions ?? []).map((i): Incident => ({ ...i })) } as MinMapObject; return pokestop; diff --git a/src/lib/server/queryMapObjects/queryPokestopApi.test.ts b/src/lib/server/queryMapObjects/queryPokestopApi.test.ts index 2b8ebd18..c6821b47 100644 --- a/src/lib/server/queryMapObjects/queryPokestopApi.test.ts +++ b/src/lib/server/queryMapObjects/queryPokestopApi.test.ts @@ -24,6 +24,32 @@ describe("mapPokestop", () => { }); }); + it("handles both wire generations for showcase blobs", () => { + // newer Golbat sends native JSON, older Golbat a serialized string + const native = mapPokestop({ + id: "s", + lat: 0, + lon: 0, + deleted: false, + showcase_focus: { type: "pokemon", pokemon_id: 25 }, + showcase_rankings: { total_entries: 3, contest_entries: [] } + } as never); + expect(JSON.parse(native.showcase_focus!)).toEqual({ type: "pokemon", pokemon_id: 25 }); + expect(JSON.parse(native.showcase_rankings!)).toEqual({ + total_entries: 3, + contest_entries: [] + }); + + const legacy = mapPokestop({ + id: "s", + lat: 0, + lon: 0, + deleted: false, + showcase_focus: '{"type":"pokemon","pokemon_id":25}' + } as never); + expect(legacy.showcase_focus).toBe('{"type":"pokemon","pokemon_id":25}'); + }); + it("leaves absent quest rewards undefined", () => { const mapped = mapPokestop({ id: "stop-2", diff --git a/src/lib/server/queryMapObjects/queryStationApi.ts b/src/lib/server/queryMapObjects/queryStationApi.ts index 8f2a2d78..f14de8ce 100644 --- a/src/lib/server/queryMapObjects/queryStationApi.ts +++ b/src/lib/server/queryMapObjects/queryStationApi.ts @@ -9,6 +9,7 @@ import { } from "@/lib/server/api/golbatApi"; import { buildStationDnfFilters } from "@/lib/server/queryMapObjects/fortDnf"; import type { MapObjectResponse } from "@/lib/server/queryMapObjects/MapObjectQuery"; +import { blobToString } from "@/lib/server/queryMapObjects/pokestopApiMapper"; import { StationQuery } from "@/lib/server/queryMapObjects/queryStation"; import type { FeaturePermissionContext, PermittedPolygon } from "@/lib/services/user/checkPerm"; import type { StationData } from "@/lib/types/mapObjectData/station"; @@ -23,7 +24,7 @@ function mapStation(s: GolbatStationResult): MinMapObject { ...rest, is_inactive: is_inactive ? 1 : 0, is_battle_available: is_battle_available ? 1 : 0, - raw_stationed_pokemon: stationed_pokemon ?? undefined + raw_stationed_pokemon: blobToString(stationed_pokemon) } as MinMapObject; }