Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
14535f7
ci: improve on the github actions
Fabio1988 Jul 6, 2026
806219d
ci: update github action versions
Fabio1988 Jul 6, 2026
c4179fc
Merge pull request #379 from UnownHash/CI-cleanup
Fabio1988 Jul 6, 2026
e1738b3
feat: parse temp evolution branch quest rewards
Mygod Jul 11, 2026
e2a633a
feat(api): fort map-data — scan/available/by-id, whole-record results…
jfberry Jul 17, 2026
bfffae7
docs: fort map-data and dnf filtering design specs
jfberry Jul 17, 2026
9d13b42
refactor(dnf): remove unused filter surface; stationed_gmax symmetry;…
jfberry Jul 17, 2026
0914013
refactor(dnf): drop power_up_level from the fort filter and lookup
jfberry Jul 17, 2026
5edf582
docs(spec): power_up removal + all-gyms team/slot narrowing
jfberry Jul 17, 2026
4c755cf
feat(api): typed clause groups for the combined fort scan
jfberry Jul 17, 2026
be9ea3b
docs(spec): combined-scan typed groups + part-2 coalescer plan
jfberry Jul 17, 2026
d6548b6
docs(spec): part-2 coalescer rejected on measurement (scans are micro…
jfberry Jul 17, 2026
dbcd986
feat(api): combined GET /api/fort/available — one pass for all fort t…
jfberry Jul 17, 2026
09cd5b9
fix(api): combined available logs one line, not one per type
jfberry Jul 17, 2026
26a7cc1
refactor(api): drop team/slot from gym availability
jfberry Jul 17, 2026
921e06d
docs(spec): maintained fort availability index (max-expiry, no scan)
jfberry Jul 17, 2026
b782010
docs(spec): pin strong Range (not RangeRelaxed) for prune-on-read
jfberry Jul 17, 2026
5aafcce
docs(plan): maintained fort availability index (4 tasks)
jfberry Jul 17, 2026
410bb01
feat(availability): maintained raid index + observe/prune primitive
jfberry Jul 17, 2026
1a70b2d
test(huma): drop stale teams assertion from gym available route test
jfberry Jul 17, 2026
4fa9c5d
feat(availability): maintained station battle index
jfberry Jul 17, 2026
17f6052
feat(availability): maintained pokestop lure/showcase/invasion indexes
jfberry Jul 17, 2026
69bdf40
feat(availability): combined endpoint reads maintained indexes; drop …
jfberry Jul 17, 2026
ed2f30f
fix(availability): one combined log line, accurate route docs, hook-w…
jfberry Jul 17, 2026
3981851
docs: mark maintained availability spec/plan built
jfberry Jul 17, 2026
b208fef
fix(availability): surface type-based showcases (pokemon id 0)
jfberry Jul 17, 2026
ab4ae6a
style(availability): convert key structs directly (staticcheck S1016)
jfberry Jul 17, 2026
2350a9b
docs(claude): add build/test/lint commands + maintained-availability …
jfberry Jul 17, 2026
3fc8d9f
chore(availability): include quest count in the combined log line
jfberry Jul 17, 2026
630c065
refactor(fort): dedup scan-result loops and availability readers
jfberry Jul 17, 2026
b8f6c78
Merge pull request #382 from Mygod/agent/parse-quest-reward-type-20
jfberry Jul 20, 2026
c3acb26
Merge remote-tracking branch 'origin/main' into feat/fort-scan-map-data
jfberry Jul 20, 2026
a3c82e5
feat(fort): dedicated max_fort_results scan cap, separate from pokemon
jfberry Jul 20, 2026
e8c4505
feat(fort): expose confirmed invasion slots 2 and 3 in availability
jfberry Jul 20, 2026
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
6 changes: 3 additions & 3 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v7
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
107 changes: 17 additions & 90 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,115 +3,42 @@ name: publish
on: [push]

env:
REGISTRY: ghcr.io
GO_VERSION: '1.26.x'

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: Install dependencies
run: go get .
- name: Test with the Go CLI
run: go test -v ./...

build:
needs: [test]
runs-on: ${{ matrix.os }}
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- os: ubuntu-latest
arch: linux/amd64
- os: ubuntu-24.04-arm
arch: linux/arm64
steps:
- name: Prepare
run: |
platform=${{ matrix.arch }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: downcase GHCR_REPO
run: |
echo "GHCR_REPO=${{ env.REGISTRY }}/${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.GHCR_REPO }}
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
uses: docker/github-builder/.github/workflows/build.yml@v1
with:
output: image
push: true
cache: true
platforms: linux/amd64,linux/arm64
meta-images: ghcr.io/${{ github.repository }}
meta-tags: |
type=ref,event=branch
set-meta-labels: true
sign: false
secrets:
registry-auths: |
- registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
platforms: ${{ matrix.arch }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1

merge-docker-builds:
needs: [build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: downcase GHCR_REPO
run: |
echo "GHCR_REPO=${{ env.REGISTRY }}/${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.GHCR_REPO }}
tags: |
type=ref,event=branch
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.GHCR_REPO }}@sha256:%s ' *)

- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.GHCR_REPO }}:${{ steps.meta.outputs.version }}
4 changes: 3 additions & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ version: "2"
run:
build-tags:
- go_json
timeout: 5m
timeout: 10m

linters:
default: none
Expand All @@ -26,6 +26,8 @@ linters:
- (*encoding/json.Decoder).Decode

exclusions:
paths:
- pogo/ # generated protobuf package; excluded to avoid whole-program analysis timeout
rules:
# Entity access pattern: each entity type provides peek*Record /
# get*RecordReadOnly / get*RecordForUpdate even when not currently
Expand Down
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

Golbat is a high-performance Go backend that receives raw protobuf data from Pokemon GO game clients, decodes it, maintains an in-memory cache of all game entities, persists changes to MySQL via write-behind queues, dispatches webhooks, and serves a REST/gRPC API for querying entities with spatial and attribute-based filters.

## Build, Test, Lint

- **Build:** `go build -tags go_json ./...` — the `go_json` tag is required (selects the go-json codec).
- **Test:** `go test ./decoder/` — the `decoder` package holds most logic and its `_test.go` files; `go test .` runs the root-package huma route tests. Some tests need the go-json build behavior: `go test -tags go_json ./...`.
- **Lint:** `golangci-lint run` — CI enforces it (staticcheck included). Run it before pushing. Example: staticcheck `S1016` wants `ApiFoo(k)` conversion instead of a field-by-field literal when two structs have identical fields.

## Project Layout

```
Expand Down Expand Up @@ -390,6 +396,15 @@ This avoids iterating all filters for every pokemon.

The `FortCombinedScanEndpoint` scans all three fort types in one pass and splits results by type.

#### Fort Availability (maintained, not scanned)

The `/api/{pokestop,gym,station}/available` and combined `/api/fort/available` endpoints answer "which distinct filter options are active on any resident fort right now?" (lures, showcases, invasions, raids, battles). These are served from **maintained max-expiry indexes** (`decoder/fort_availability.go`), NOT a `fortLookupCache.Range` scan:

- Each fort update function (`updatePokestopLookup` → lures/showcases, `updatePokestopIncidentLookup` → invasions, `updateGymLookup` → raids, `updateStationLookupWithBattles` → battles) calls an `observe*` that records each active option's *latest* expiry in a small `xsync.Map[optionKey, maxExpiry]` (atomic keep-larger; already-expired observations ignored). These hooks fire during preload too, warming the maps at startup.
- Reads (`GetAvailable*`) use the strong `Map.Range` (not `RangeRelaxed`), emit keys with `exp > now`, and **prune-on-read conditionally** (`Compute` delete-if-still-`<= now`, never a blind `Delete`, so a concurrent refresh isn't dropped).
- **Quests are the exception**: a quest can be *retracted* mid-life (`RemoveQuestsWithinGeofence`, event swap) while its daily `quest_expiry` is still hours away — max-expiry is monotonic and can't retract — so quests use the `reconcile` count aggregate (`questConditionCount` / `reconcileFortQuestConditions`), never the index. `GetAvailablePokestops` sources quests from `GetAvailableQuestConditions()`.
- Accepted trade-off: no periodic reconcile sweep, so a replaced option (egg→hatch, grunt confirm) can over-report until its own expiry passes — bounded and self-healing. Design: `docs/superpowers/specs/2026-07-17-maintained-fort-availability-design.md`.

## Geofence Matching

Area attribution (stats, webhook filtering) matches points against Koji /
Expand Down
1 change: 1 addition & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ url = "http://localhost:4201"
[tuning]
max_pokemon_distance = 100 # Maximum distance in kilometers for searching pokemon
max_pokemon_results = 3000 # Maximum number of pokemon to return
max_fort_results = 9000 # Maximum number of forts (gyms/pokestops/stations) to return per in-memory scan; separate from max_pokemon_results so the fort map-data API can be tuned independently
extended_timeout = false # Extend timeouts for processing, hopefully not needed
write_behind_startup_delay = 120 # Writes will be queued for this quiet time
write_behind_worker_count = 16 # Maximum number of parallel batch writes.
Expand Down
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ type database struct {
type tuning struct {
ExtendedTimeout bool `koanf:"extended_timeout"`
MaxPokemonResults int `koanf:"max_pokemon_results"`
MaxFortResults int `koanf:"max_fort_results"`
MaxPokemonDistance float64 `koanf:"max_pokemon_distance"`
ProfileRoutes bool `koanf:"profile_routes"`
ProfileContention bool `koanf:"profile_contention"` // Enable mutex/block profiling (has overhead)
Expand Down
1 change: 1 addition & 0 deletions config/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func ReadConfig() (configDefinition, error) {
},
Tuning: tuning{
MaxPokemonResults: 3000,
MaxFortResults: 9000,
MaxPokemonDistance: 100,
MaxConcurrentProactiveIVSwitch: 6,
ReduceUpdates: false,
Expand Down
82 changes: 82 additions & 0 deletions decoder/api_completeness_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package decoder

import (
"reflect"
"strings"
"testing"
)

// collectDbColumns walks a struct type — recursing into embedded anonymous
// structs (e.g. Pokestop embeds PokestopData) — and returns every persisted DB
// column: the `db:"..."` tag value, excluding db:"-" (memory-only) fields.
func collectDbColumns(t reflect.Type) []string {
var cols []string
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if f.Anonymous && f.Type.Kind() == reflect.Struct {
cols = append(cols, collectDbColumns(f.Type)...)
continue
}
name := strings.Split(f.Tag.Get("db"), ",")[0]
if name == "" || name == "-" {
continue
}
cols = append(cols, name)
}
return cols
}

// collectJsonFields returns the set of top-level json field names on a struct
// (stripping ,omitempty), skipping json:"-" and untagged fields.
func collectJsonFields(t reflect.Type) map[string]bool {
m := make(map[string]bool)
for i := 0; i < t.NumField(); i++ {
name := strings.Split(t.Field(i).Tag.Get("json"), ",")[0]
if name == "" || name == "-" {
continue
}
m[name] = true
}
return m
}

// TestApiResultsExposeEveryDbColumn locks the whole-record invariant: every
// persisted DB column of a fort record must be exposed on its API result
// struct. Adding a DB column without exposing it fails here rather than
// silently dropping data downstream — e.g. a ReactMap filter key that reads a
// column the API never sent (the quest_item_id bug that motivated this test).
//
// If a column is deliberately internal, add it to that case's `allow` set WITH
// a comment explaining why — do not weaken the assertion.
func TestApiResultsExposeEveryDbColumn(t *testing.T) {
cases := []struct {
name string
dbType reflect.Type
apiType reflect.Type
allow map[string]bool // db columns intentionally not exposed (with reason)
}{
{"pokestop", reflect.TypeOf(Pokestop{}), reflect.TypeOf(ApiPokestopResult{}), nil},
{"station", reflect.TypeOf(Station{}), reflect.TypeOf(ApiStationResult{}), nil},
{"gym", reflect.TypeOf(Gym{}), reflect.TypeOf(ApiGymResult{}), nil},
{"incident", reflect.TypeOf(Incident{}), reflect.TypeOf(ApiPokestopIncident{}), nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
cols := collectDbColumns(c.dbType)
if len(cols) == 0 {
t.Fatalf("no db columns found for %s — reflection is broken, the test would pass vacuously", c.dbType.Name())
}
exposed := collectJsonFields(c.apiType)
for _, col := range cols {
if c.allow[col] {
continue
}
if !exposed[col] {
t.Errorf("DB column %q of %s is not exposed as a json field on %s.\n"+
"Add `json:%q` to %s, or if it is intentionally internal add it to this case's allow set with a reason.",
col, c.dbType.Name(), c.apiType.Name(), col, c.apiType.Name())
}
}
})
}
}
Loading