Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1,217 changes: 1,217 additions & 0 deletions docs/superpowers/plans/2026-08-03-golbat-fort-api.md

Large diffs are not rendered by default.

132 changes: 123 additions & 9 deletions src/lib/server/api/golbatApi.ts
Original file line number Diff line number Diff line change
@@ -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<PokemonData>[];
Expand All @@ -13,14 +16,80 @@ 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<GymData>,
"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<Incident, "confirmed"> & { confirmed: boolean };

export type GolbatPokestopResult = Omit<
MinMapObject<PokestopData>,
| "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<StationData>,
"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;

async function callGolbat<T>(
path: string,
method: "GET" | "POST",
body: BodyInit | undefined = undefined,
thisFetch: typeof fetch = fetch
thisFetch: typeof fetch = fetch,
quiet = false,
signal?: AbortSignal
): Promise<T | undefined> {
const start = performance.now();
const url = new URL(path, config.url);
Expand All @@ -36,15 +105,19 @@ async function callGolbat<T>(
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;
}

Expand Down Expand Up @@ -78,3 +151,44 @@ export async function searchGyms(query: string, coords: Coords, range: number) {
};
return await callGolbat<GymData[]>("api/gym/search", "POST", JSON.stringify(body));
}

export async function scanGyms(body: FortScanBody) {
return await callGolbat<GymScanResponse>("api/gym/scan", "POST", JSON.stringify(body));
}

export async function scanPokestops(body: FortScanBody) {
return await callGolbat<PokestopScanResponse>("api/pokestop/scan", "POST", JSON.stringify(body));
}

export async function scanStations(body: FortScanBody) {
return await callGolbat<StationScanResponse>("api/station/scan", "POST", JSON.stringify(body));
}

export async function getGolbatGym(id: string, thisFetch: typeof fetch = fetch) {
return await callGolbat<GolbatGymResult>("api/gym/id/" + id, "GET", undefined, thisFetch);
}

export async function getGolbatPokestop(id: string, thisFetch: typeof fetch = fetch) {
return await callGolbat<GolbatPokestopResult>(
"api/pokestop/id/" + id,
"GET",
undefined,
thisFetch
);
}

export async function getGolbatStation(id: string, thisFetch: typeof fetch = fetch) {
return await callGolbat<GolbatStationResult>("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<FortAvailability>(
"api/fort/available",
"GET",
undefined,
fetch,
true,
AbortSignal.timeout(10_000)
);
}
69 changes: 69 additions & 0 deletions src/lib/server/api/golbatFortApi.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Loading
Loading