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 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). diff --git a/src/lib/server/api/golbatApi.ts b/src/lib/server/api/golbatApi.ts index d83729e4..24ca80c2 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,70 @@ 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" | "deleted" +> & { + 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< + MinMapObject, + | "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< + MinMapObject, + "is_inactive" | "is_battle_available" | "stationed_pokemon" | "raw_stationed_pokemon" +> & { + is_inactive: boolean; + is_battle_available: boolean; + // native JSON since Golbat's fort blob conversion; string from older Golbat + stationed_pokemon?: object[] | string | null; +}; + +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 +87,9 @@ async function callGolbat( path: string, method: "GET" | "POST", body: BodyInit | undefined = undefined, - thisFetch: typeof fetch = fetch + thisFetch: typeof fetch = fetch, + quiet = false, + signal?: AbortSignal ): Promise { const start = performance.now(); const url = new URL(path, config.url); @@ -36,15 +105,19 @@ 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) { - 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 +151,44 @@ 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() { + // 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 new file mode 100644 index 00000000..e9d37c34 --- /dev/null +++ b/src/lib/server/api/golbatFortApi.ts @@ -0,0 +1,69 @@ +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() { + let result: FortAvailability | undefined; + try { + result = await fetchFortAvailability(); + } catch (err) { + log.debug("Fort availability fetch failed: %s", err); + result = undefined; + } + + const nowEnabled = result !== undefined; + const wasEnabled = fortApiEnabled; + + if (nowEnabled !== wasEnabled) { + 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; + + // 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() { + setInterval(() => { + refreshFortAvailability().catch((err) => + log.error("Fort availability refresh failed: %s", err) + ); + }, REFRESH_SECONDS * 1000)?.unref?.(); + + await refreshFortAvailability(); +} diff --git a/src/lib/server/api/queryStats.ts b/src/lib/server/api/queryStats.ts index 31314ffc..9fabb264 100644 --- a/src/lib/server/api/queryStats.ts +++ b/src/lib/server/api/queryStats.ts @@ -1,7 +1,15 @@ +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, + QuestRewardTempEvoBranch +} 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 +82,7 @@ export type ActiveRaidStats = { pokemon_id: number; form: number; temp_evolution_id: number; - count: number; + count?: number; }; type InvasionStatsRow = { @@ -123,7 +131,7 @@ export type TotalQuestStats = { export type ContestStatsEntry = { ranking_standard: number; focus: ContestFocus; - count: number; + count?: number; }; export type MaxBattleStatsEntry = { @@ -131,7 +139,7 @@ export type MaxBattleStatsEntry = { pokemon_id: number; form: number; bread_mode: number; - count: number; + count?: number; }; export type NestStatsEntry = { @@ -298,19 +306,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 +335,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 +461,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 +489,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 +512,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 +630,148 @@ 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 + // 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; + 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 }; + } + // 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 { + ...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: {} }; + 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: 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; + } +} 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"); } diff --git a/src/lib/server/queryMapObjects/fortDnf.test.ts b/src/lib/server/queryMapObjects/fortDnf.test.ts new file mode 100644 index 00000000..79093f3c --- /dev/null +++ b/src/lib/server/queryMapObjects/fortDnf.test.ts @@ -0,0 +1,168 @@ +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 }; + +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("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 }, + 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: 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: 2 ** 31 - 1 } } + ]); + }); +}); + +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..34d05716 --- /dev/null +++ b/src/lib/server/queryMapObjects/fortDnf.ts @@ -0,0 +1,183 @@ +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. 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 +); +// 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 { + 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, 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")) { + // 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 + 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 }]; +} diff --git a/src/lib/server/queryMapObjects/pokestopApiMapper.ts b/src/lib/server/queryMapObjects/pokestopApiMapper.ts new file mode 100644 index 00000000..f9648311 --- /dev/null +++ b/src/lib/server/queryMapObjects/pokestopApiMapper.ts @@ -0,0 +1,35 @@ +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, + showcase_focus, + showcase_rankings, + ...rest + } = p; + const pokestop = { + ...rest, + deleted: deleted ? 1 : 0, + 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/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 }[]; + }; +}; diff --git a/src/lib/server/queryMapObjects/queryGymApi.ts b/src/lib/server/queryMapObjects/queryGymApi.ts new file mode 100644 index 00000000..f7a54509 --- /dev/null +++ b/src/lib/server/queryMapObjects/queryGymApi.ts @@ -0,0 +1,96 @@ +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, + 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 { 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 = { + ...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); + 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 }; + } + + 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[]> { + 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/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; diff --git a/src/lib/server/queryMapObjects/queryPokestopApi.test.ts b/src/lib/server/queryMapObjects/queryPokestopApi.test.ts new file mode 100644 index 00000000..c6821b47 --- /dev/null +++ b/src/lib/server/queryMapObjects/queryPokestopApi.test.ts @@ -0,0 +1,65 @@ +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("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", + 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 new file mode 100644 index 00000000..aa497646 --- /dev/null +++ b/src/lib/server/queryMapObjects/queryPokestopApi.ts @@ -0,0 +1,76 @@ +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, + type PokestopScanResponse +} 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 { PokestopData } from "@/lib/types/mapObjectData/pokestop"; +import { getLogger } from "@/lib/utils/logger"; +import { booleanPointInPolygon, point } from "@turf/turf"; + +const log = getLogger("query:pokestop-api"); + +export class ApiPokestopQuery extends PokestopQuery { + async query( + bounds: Bounds, + filter: FilterPokestop | undefined, + polygon: PermittedPolygon, + since?: 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); + 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 }; + } + + 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[]> { + 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 new file mode 100644 index 00000000..f14de8ce --- /dev/null +++ b/src/lib/server/queryMapObjects/queryStationApi.ts @@ -0,0 +1,83 @@ +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, + type StationScanResponse +} 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"; +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 { + ...rest, + is_inactive: is_inactive ? 1 : 0, + is_battle_available: is_battle_available ? 1 : 0, + raw_stationed_pokemon: blobToString(stationed_pokemon) + } as MinMapObject; +} + +export class ApiStationQuery extends StationQuery { + async query( + bounds: Bounds, + filter: FilterStation | undefined, + polygon: PermittedPolygon, + since?: 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); + 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 }; + } + + 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[]> { + 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); + } +} 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( {