diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21a8983e1..8e1a4f238 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: run: working-directory: processor steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 diff --git a/API.md b/API.md index 481b99be9..023e1a4fa 100644 --- a/API.md +++ b/API.md @@ -2,6 +2,10 @@ All API endpoints are available through the processor (default port 3030). The processor handles all endpoints directly. +> **Live OpenAPI docs.** This surface is now also documented live via an OpenAPI 3.1 spec at `GET /openapi.json` with interactive docs at `GET /docs` (both public, no secret). The `/api/*` read/feature endpoints are served by [huma](https://github.com/danielgtaylor/huma), and errors on the huma surface are returned as RFC 9457 `application/problem+json` (`status`, `title`, `detail`, `errors[]`) rather than the legacy `{status:"error",message}` shape shown under [Response Format](#response-format). +> +> **New strict `/api/v2` API.** A clean, strict, typed `/api/v2` surface (human-scoped tracking — including the new `incident` type — plus discrete humans/profiles action endpoints) is the **recommended API for new clients**. The v1 endpoints documented below are **frozen and deprecated-but-supported** (no sunset date yet); migrate to `/api/v2` to access new tracking types and the cleaner contract. See the [v1 → v2 migration guide](docs/v1-to-v2-migration-guide.md) for the endpoint/field mapping, and [`docs/v2-api-design.md`](docs/v2-api-design.md) and `/docs` for v2 details. + ## Contents - [Authentication](#authentication) @@ -329,8 +333,8 @@ Use `level: 90` for all levels. | Field | Type | Default | Description | |-------|------|---------|-------------| -| `reward_type` | int | required | Reward type (2=item, 3=stardust, 4=candy, 7=pokemon, 12=mega energy) | -| `reward` | int | 0 | Reward ID (pokemon ID, item ID, or stardust amount) | +| `reward_type` | int | required | Reward type (2=item, 3=stardust, 4=candy, 7=pokemon, 8=pokecoins, 12=mega energy) | +| `reward` | int | 0 | Reward ID (pokemon ID, item ID, or stardust/pokecoin amount) | | `form` | int | 0 | Form ID (for pokemon rewards) | | `shiny` | bool | false | Shiny only | | `amount` | int | 0 | Minimum reward amount | @@ -1041,7 +1045,7 @@ Returns test webhook scenarios from `testdata.json`. The editor can use these as } ``` -Available test scenarios: boring, hundo, great-rank1, great-rank9, ultra1, unencountered, boosted, shiny (pokemon); egg1, level1, egg5, level5, egg6, level3 (raid); invasion, lure, giovanni, kecleon, goldstop, goldlure, showcase, pokemoncontest (pokestop); teamchange (gym); level1, level3 (max_battle); quest-item, quest-stardust, quest-pokemon, quest-energy (quest); edit, new, remove, etc. (fort_update). +Available test scenarios: boring, hundo, great-rank1, great-rank9, ultra1, unencountered, boosted, shiny (pokemon); egg1, level1, egg5, level5, egg6, level3 (raid); invasion, lure, giovanni, kecleon, goldstop, goldlure, showcase, pokemoncontest (pokestop); teamchange (gym); level1, level3 (max_battle); quest-item, quest-stardust, quest-pokecoins, quest-pokemon, quest-energy (quest); edit, new, remove, etc. (fort_update). ### GET/POST /api/dts/reload diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..538e14092 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to PoracleNG are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added + +- **v2 mutes API.** `GET/POST /api/v2/humans/{id}/mutes` and + `DELETE /api/v2/humans/{id}/mutes[?scope=&value=]` expose the in-memory alert + mutes (the `!mute` / alert-button feature) over HTTP, and the v2 full + snapshot now carries a `mutes` array. Mutes remain volatile — they are + cleared by a processor restart. +- **OpenAPI 3.1 spec + interactive docs.** The processor now serves a single + OpenAPI 3.1 document at `GET /openapi.json` with interactive documentation at + `GET /docs` (both public, no `X-Poracle-Secret` required). The spec covers the + entire `/api/*` and `/api/v2/*` surface, generated from the code via + [huma](https://github.com/danielgtaylor/huma) mounted on gin. +- **New strict `/api/v2` API** for tracking and humans/profiles — typed bodies, + `additionalProperties: false`, enforced required fields, and no lenient type + coercion. A malformed request gets a clear `422` instead of a silent guess. + - **Tracking** is human-scoped: `GET|POST /api/v2/humans/{id}/tracking/{type}`, + `GET|PUT|DELETE /api/v2/humans/{id}/tracking/{type}/{uid}`, bulk + `DELETE …/{type}?uid=1,2,3`, and a full snapshot at + `GET /api/v2/humans/{id}/tracking` (human + all-type rules + profiles + + locations + summaries). Supports `?profile=`, `?include_descriptions=`, + `?silent=`, and `?all_profiles=`. + - **humans/profiles** are exposed as discrete, typed action endpoints under + `/api/v2/humans/{id}/…` (enable/disable, admin-disable, language, location, + areas, check-location, locations CRUD, roles, profiles CRUD with a strict + typed `active_hours` schema, and profile switch). +- **New `incident` tracking type** (game `PokestopEvent`, e.g. Showcase) — + available only via the `/api/v2` tracking surface, bringing the v2 tracking + type count to 11 (pokemon, raid, egg, quest, invasion, incident, lure, nest, + gym, fort, maxbattle). +- **New `PUT /api/v2/humans/{id}/locations/{label}`** — update a saved + location's coordinates in place, completing saved-location CRUD (v1 required + delete + re-add to move a location). + +### Changed + +- **Error bodies on the huma `/api` surface are now RFC 9457 + `application/problem+json`** (`status`, `title`, `detail`, `errors[]`), + replacing the old ad-hoc `{ "status": "error", "message": … }` / + `{ "error": … }` bodies. **This is a behavior change for clients that parse + error response bodies.** HTTP status codes are unchanged, though a few + input-validation failures that were manual `400`s now surface as huma + validation `422`s. +- **`include_empty` on fort tracking now defaults to `true`** when omitted, + honoring the `forts` DB column default. The previous gin handler defaulted it + to `false`; API clients that omit `include_empty` now get `true`. + +### Deprecated + +- **v1 tracking/humans/profiles endpoints are frozen and + deprecated-but-supported.** `/api/tracking/*`, `/api/humans/*`, + `/api/profiles/*` (and `/api/tracking/pokemon/refresh`) remain on gin, + unchanged and fully functional — clients migrate to `/api/v2` on their own + schedule. Migration is encouraged to access new tracking types (e.g. + `incident`) and the cleaner strict contract. No sunset date is set yet. diff --git a/CLAUDE.md b/CLAUDE.md index ae320df89..19b523ca4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -343,6 +343,10 @@ Role-loss handling is left to Discord: private-thread visibility inherits View C - `GET /health` — health check - `GET /metrics` — Prometheus metrics - `POST /` — webhook receiver from Golbat (no auth, Golbat doesn't authenticate) +- `GET /openapi.json` — OpenAPI 3.1 spec +- `GET /docs` — interactive API docs + +**huma surface (`/api/*` reads + features, and `/api/v2/*`)**: The `/api/*` read/reload/feature endpoints listed below and the entire `/api/v2/*` surface are served by [huma](https://github.com/danielgtaylor/huma) (mounted on gin via humagin, one instance), and appear in the OpenAPI 3.1 spec at `/openapi.json` / `/docs`. Error bodies on this surface are RFC 9457 `application/problem+json` (`status`, `title`, `detail`, `errors[]`) — replacing the old `{status:"error",message}` / `gin.H{"error":...}` bodies (status codes unchanged; a few manual-400s became validation-422s). `/health`, `/metrics`, pprof, and the webhook receiver `POST /` stay on plain gin by design. ## Command System @@ -543,11 +547,20 @@ All API endpoints are accessed via the processor (port 3030). The processor hand | DELETE | `/api/profiles/{id}/byProfileNo/{profile_no}` | Delete profile | | GET | `/api/snapshots/{messageID}?target={id}` | Inspect a delivered-message snapshot (admin diagnostics; 503 if `[snapshots] enabled = false`) | | GET | `/api/dts/actions` | List registered button actions + their scopes/params (drives the config editor's button UI) | -| GET | `/health` | Health check; returns `{status, version, capabilities}` where `capabilities` is a static feature map (`buttons`, `snapshots`, `autocreate`, `tomlDts`, `buttonResponseObject`) so clients can do explicit feature detection without probing endpoints | +| GET | `/health` | Health check; returns `{status, version, capabilities}` where `capabilities` is a static feature map (`buttons`, `snapshots`, `autocreate`, `tomlDts`, `buttonResponseObject`, `derivedDtsTypes`) so clients can do explicit feature detection without probing endpoints | | GET | `/metrics` | Prometheus metrics | +| GET | `/openapi.json` | OpenAPI 3.1 spec (whole `/api` + `/api/v2` surface; public) | +| GET | `/docs` | Interactive API docs (public) | Tracking types: pokemon, raid, egg, quest, invasion, lure, nest, gym, fort, maxbattle. +**`/api/v2/*` (strict v2 surface)**: Alongside the in-place `/api/*` endpoints above sits a clean, strict, documented v2 API for tracking + humans/profiles. It is served by the same huma instance (typed bodies, `additionalProperties:false`, no lenient coercion, problem+json errors). +- **Tracking** is human-scoped: `GET|POST /api/v2/humans/{id}/tracking/{type}`, `GET|PUT|DELETE /api/v2/humans/{id}/tracking/{type}/{uid}`, bulk `DELETE …/{type}?uid=1,2,3`, and a full snapshot `GET /api/v2/humans/{id}/tracking` (human + all-type rules + profiles + locations + summaries). Query params: `?profile=`, `?include_descriptions=`, `?silent=`, `?all_profiles=`. v2 adds an **11th tracking type, `incident`** (game `PokestopEvent`, e.g. Showcase), not present in v1. +- **humans/profiles** are discrete typed action endpoints under `/api/v2/humans/{id}/…` (enable/disable, admin-disable, language, location, areas, check-location, locations CRUD incl. a **new `PUT …/locations/{label}`** to update coords, roles, profiles CRUD with a strict typed `active_hours` schema, profile switch). Pure action endpoints return a minimal `{"status":"ok"}` ack; resource endpoints return the typed body directly. +- Design doc: `docs/v2-api-design.md`. + +**v1 is frozen**: the v1 `/api/tracking/*`, `/api/humans/*`, `/api/profiles/*` (and `/api/tracking/pokemon/refresh`) endpoints remain on plain gin, unchanged and fully supported (still lenient via the coercion below), but deprecated — clients migrate to `/api/v2` on their own schedule. No sunset date yet. + **Flexible JSON type coercion**: All tracking CRUD POST endpoints accept flexible JSON types for numeric and boolean fields. Third-party clients like ReactMap may send `"clean": false` (boolean) instead of `"clean": 0` (number). The `flexBool` and `flexInt` custom JSON types in `processor/internal/api/tracking.go` handle this coercion transparently: - `flexBool`: accepts `true`/`false`, `0`/`1`, `"0"`/`"1"` — coerces to int (0 or 1) - `flexInt`: accepts numbers, booleans, quoted strings — coerces to int @@ -702,8 +715,9 @@ The store is a separate pogreb instance from the geocoder cache — different wo - `! mute ...` aliases (e.g. `!raid mute id:X`) route through `RouteToMuteFromType`. - `!tracked` shows the active mutes alongside tracking rules. - Mute buttons on alerts (see "Button actions" below) write the same `mute.Entry` shape via `buttonactions.HandleMute`. +- **v2 mutes API** (`internal/api/v2_mutes.go`): `GET|POST /api/v2/humans/{id}/mutes`, `DELETE …/mutes?scope=&value=` (single) and `DELETE …/mutes` (all). Strict schemas, problem+json; mute identity is `(scope, value)` so deletes address via query params; DELETE returns `{deleted:[…]}` like v2 tracking. Area values validate against `AreaLogic` when present, else live `StateMgr` fences (production path). The v2 full snapshot includes a `mutes` array. The API documents the in-memory volatility — entries vanish on restart. Design: `docs/superpowers/specs/2026-06-11-mute-api-design.md`. -Tracking-UID mutes (`!mute id:N` / `!mute raid id:N`) are documented in the design but rejected by the v1 parser — they need `MatchedUser.RuleUID` plumbing in the matcher first. +Tracking-UID mutes are fully wired: the matcher compares `mute.Event.MatchedRuleUID` (populated per matched user in `cmd/processor/helpers.go`) against `tracking`-scope entries, created via `!mute id:N`, alert buttons, or the v2 mutes API. ## Button Actions diff --git a/DTS.md b/DTS.md index 6b132bee8..d9b126f6a 100644 --- a/DTS.md +++ b/DTS.md @@ -210,6 +210,7 @@ These fields are available in every template: | `neighbourhood` | string | Neighbourhood name | | `suburb` | string | Suburb name | | `flag` | string | Country flag emoji | +| `intersection` | string | Nearest street intersection (`Street1 & Street2`) from GeoNames; empty when disabled (`[geocoding] intersection_users`) or none nearby | | `staticMap` | string | Static map tile image URL | | `staticmap` | string | *Deprecated* — alias for `staticMap` | | `imgUrl` | string | Primary icon URL | @@ -420,9 +421,12 @@ Time-remaining fields (`tthd`, `tthh`, `tthm`, `tths`) are in the Common Fields | `megaEvolutions` | array | Mega evolution entries | | `hasMegaEvolutions` | bool | Has mega evolutions | | `pokestopName` | string | Nearby pokestop name (if applicable) | +| `costumeName` | string | Translated costume name (empty when no costume, i.e. `costume == 0` or the `costume_N` translation key is absent). | `distance`, `bearing`, `bearingEmoji`, `userDistanceTrack`, `userTrackDistance` are documented in Common Fields. +**Note:** `fullName` already includes the costume, parenthesised, when the pokemon is wearing one — e.g. `"Pikachu (Holiday 2016)"`. `costumeName` is provided separately for templates that want to style or place the costume text independently of `fullName`. + ### seenType values `{{seenType}}` is normalised from Golbat's raw `seen_type` (see Golbat's @@ -719,13 +723,17 @@ See `examples/dts/rsvpChanges/rsvp-update.json` for an installable starting poin | `pokestopUrl` | string | Pokestop image URL (alias for `pokestop_url`) | | `questString` | string | Translated quest objective | | `questStringEng` | string | English quest objective | +| `quest_task` | string | Legacy PoracleJS alias for `questString` (translated objective) | | `rewardString` | string | All rewards as text (translated) | | `rewardStringEng` | string | English rewards text | +| `quest_reward` | string | Legacy PoracleJS alias for `rewardString` | | `conditionString` | string | Comma-joined completion conditions, translated, e.g. "Excellent Throw, Curve Ball" | | `conditionStringEng` | string | English copy of `conditionString` | +| `quest_conditions` | string | Legacy PoracleJS alias for `conditionString` | | `conditionList` | array | Per-condition objects: `{type, name, formatted}` where `name` is the bare label ("Throw Type") and `formatted` includes the payload ("Excellent Throw"). Falls back to bare name when the webhook payload doesn't carry the data needed for the formatted variant. | | `conditionListEng` | array | English copy of `conditionList` | | `dustAmount` | int | Stardust reward amount | +| `pokecoinAmount` | int | Pokecoin reward amount | | `itemAmount` | int | Item reward amount | | `energyAmount` | int | Mega energy amount (first reward) | | `candyAmount` | int | Candy amount (first reward) | @@ -757,6 +765,8 @@ These are flat top-level strings, not nested under a `rewardData` object: | `itemNamesEng` | string | English item names | | `dustText` | string | Translated stardust text (e.g. "500 Stardust") | | `dustTextEng` | string | English stardust text | +| `pokecoinText` | string | Translated pokecoin text (e.g. "10 Pokécoins") | +| `pokecoinTextEng` | string | English pokecoin text | | `energyMonstersNames` | string | Mega energy reward text (translated) | | `energyMonstersNamesEng` | string | English energy reward text | | `candyMonstersNames` | string | Candy reward text (translated) | @@ -777,7 +787,7 @@ The view passed to `questSummary` is shaped differently from a regular `quest` t | Field | Type | Description | |-------|------|-------------| -| `rewardType` | int | Reward type ID (2=item, 3=stardust, 4=candy, 7=pokemon, 12=mega energy) | +| `rewardType` | int | Reward type ID (2=item, 3=stardust, 4=candy, 7=pokemon, 8=pokecoins, 12=mega energy) | | `reward` | int | Reward ID (item ID for type 2, dust amount for type 3, pokemon ID for types 4/7/12) | | `rewardForm` | int | Pokemon form ID for `rewardType == 7` (so e.g. two different Spinda forms group separately). `0` for all other reward types. | | `rewardName` | string | Translated reward name for the group header. Formatted to match the per-row reward strings from regular `quest` enrichment, **with amounts stripped** for types 2/4/12 because amounts vary across stops within a group. Examples: `"Spinda 01"` (type 7 + form, matches per-row `fullName`), `"Lapras Candy"` (type 4), `"Charizard Mega Energy"` (type 12), `"Razz Berry"` (type 2), `"1500 Stardust"` (type 3 — amount is included because it's part of the group key). | @@ -862,7 +872,11 @@ These aliases are added on top of the pokestop / location / time / weather field ### Showcase fields -These fields are only populated for **Showcase** incidents (`displayType == 9`). Always guard showcase blocks with `{{#if showcasePresent}}`. +**Showcases render via their own `type: "showcase"` template** — a specialised display model (focus + leaderboard), distinct from the plain `incident` card used by Gold-Stop / Kecleon. A bundled default `showcase` template ships in `fallbacks/dts.json`; operators can override it like any other type. (Showcases are still *tracked* as incidents — a `grunt_type="showcase"` incident rule — only the rendered template differs.) + +The `showcase` type resolves the pokestop-identity / time / `displayType` fields (shared with `incident`) **plus** the showcase fields below. It does **not** carry the incident-only aliases `incidentTypeName`, `incidentEmoji`, or `color` — the bundled template hardcodes its title/emoji/colour, so those render empty if used in a showcase template. + +Always guard the leaderboard with `{{#if showcasePresent}}` and the focus line with `{{#if showcaseFocusPresent}}`. #### Top-level showcase fields @@ -874,6 +888,13 @@ These fields are only populated for **Showcase** incidents (`displayType == 9`). | `showcaseLastUpdateFormatted` | string | `showcaseLastUpdate` formatted using the operator's configured time layout. | | `showcase` | array | Up to 3 enriched contestant entries (see per-entry fields below). Empty array when no data. | | `showcaseFirst` | object | Convenience alias for `showcase[0]` (the winner). `nil` when no contestants. | +| `showcaseFocusPresent` | bool | `true` when the contest's featured focus was decoded. Guard focus blocks with `{{#if showcaseFocusPresent}}`. | +| `showcaseFocusType` | string | Raw focus class: `pokemon`, `type`, `alignment`, `class`, `family`, `buddy`, `generation`, `hatched`, `mega`, `shiny`. | +| `showcaseFocusCategory` | string | Translated focus category label, e.g. `Type`, `Buddy`. | +| `showcaseFocusName` | string | Translated featured value, e.g. `Steel` (type focus) or `3+` (buddy focus). Empty for flag focuses (`hatched`/`shiny`/`mega`) — the category conveys it. | +| `showcaseFocusEmoji` | string | Optional emoji key for the focus category (from `util.json` `showcaseFocus`). | + +The focus tells you *what the contest is featuring* (e.g. "Type: Steel"), separate from the `showcase` leaderboard of *who is winning*. Example: `{{#if showcaseFocusPresent}}Featuring {{showcaseFocusCategory}}: {{showcaseFocusName}}{{/if}}`. #### Per-entry fields (each item in `{{#each showcase}}`) diff --git a/README.md b/README.md index e8579a356..684696bb9 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,12 @@ Operators can manage the processor from within Discord or Telegram using `!porac All API endpoints are served by the processor (default port 3030). See [API.md](API.md) for the full reference with request/response examples. +### OpenAPI docs & the v2 API + +The processor serves a live **OpenAPI 3.1** specification at `GET /openapi.json` with interactive documentation at `GET /docs` (both public, no secret required). The whole `/api/*` and `/api/v2/*` surface is documented there. Errors on this surface are returned as RFC 9457 `application/problem+json`. + +A new strict **`/api/v2`** API (tracking + humans/profiles) is the recommended surface for new integrations: typed request bodies, enforced required fields, no lenient coercion, and human-scoped tracking paths (`/api/v2/humans/{id}/tracking/{type}`). v2 adds the new **`incident`** tracking type (e.g. Showcases) and completes saved-location CRUD with `PUT …/locations/{label}`. The v1 `/api/tracking/*`, `/api/humans/*`, and `/api/profiles/*` endpoints are frozen and fully supported, but deprecated — clients are encouraged to migrate to `/api/v2` on their own schedule. A complete endpoint-by-endpoint and field-by-field mapping is in the [v1 → v2 migration guide](docs/v1-to-v2-migration-guide.md). + | Category | Endpoints | Description | |----------|-----------|-------------| | Webhooks | `POST /` | Receive Golbat webhooks | diff --git a/config/config.example.toml b/config/config.example.toml index 75d729614..8d3ba7116 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -228,6 +228,7 @@ disable_nest = false disable_gym = false disable_fort_update = false disable_max_battle = false +disable_showcase = false # disable Showcase (pokestop contest) processing process_confirmed_invasion_lineups = false disable_unconfirmed_invasion = false @@ -269,6 +270,34 @@ channels = [""] user_role = [""] # admins - IDs of admins (can add channels with !channel add and perform other admin functions) admins = [""] +prefix = "!" # prefix for poracle commands +# iv_colors - 6 color codes for pokemon IV rankings +iv_colors = ["#9D9D9D", "#FFFFFF", "#1EFF00", "#0070DD", "#A335EE", "#FF8000"] +dm_log_channel_id = "" # channel ID to log all poracle commands to +dm_log_channel_deletion_time = 0 # time to clean up after (minutes) - 0 = don't delete +message_delete_delay = 0 # extra time (ms) to add to a message 'clean' +unrecognised_command_message = "" # reply to unrecognised commands in DM +unregistered_user_message = "" # reply to unregistered users (empty = shrug) +lost_role_message = "" # message when user loses role-based access + +# admin_channel_id: discord channel ID for operator-facing notices — +# auto-disable events (delivery failure, rate-limit ban), thread keep-alive +# 404s, DTS render errors, state-reload failures. Empty = disabled. +admin_channel_id = "" + +# thread_keep_alive_interval_hours: cadence (in hours) of the background +# sweeper that unarchives Poracle-managed Discord threads so they stay +# accessible. 0 disables. Max 168 (7 days, Discord's archive ceiling). +thread_keep_alive_interval_hours = 24 + +# --------------------------------------------------------------------------- +# Sub-tables of [discord] — KEEP THESE AT THE END of the [discord] section. +# A TOML table / array-of-tables header ([discord.x] / [[discord.x]]) captures +# every key below it until the next header. If you uncomment one of these while +# plain [discord] scalars (prefix, iv_colors, ...) are still below it, those +# scalars get pulled into the sub-table and your config silently breaks. +# --------------------------------------------------------------------------- +# # delegated_admins - grant users admin over specific channels/guilds/categories # target = channel, guild, or category ID # admins = array of user/role IDs who can admin that target @@ -299,31 +328,24 @@ admins = [""] # { "color-red" = "919255733592289341", "color-blue" = "919255750948323340" }, # ] # -# command_security - restrict commands to specific users/roles -# valid commands: monster, pvp, gym, invasion, lure, nest, quest, egg, -# raid, fort, area, location, profile, tracked, script, start, language +# command_security - restrict commands to specific users or roles. +# Discord only (Telegram has no role-based command security). A key with a +# non-empty list restricts that command to the listed role IDs and/or user +# IDs; everyone else gets a shrug. Absent key or empty list = unrestricted. +# +# Gateable whole-command keys: +# monster (!track), raid, egg, quest, gym, lure, +# invasion (covers !invasion and !incident), nest, maxbattle, +# poracle_admin (!poracle-admin) +# Feature keys (restrict a feature inside a command): +# pvp - using PVP filters in !track +# specificgym - tracking a specific gym in !gym +# Any other name (area, location, profile, tracked, ...) is NOT gateable +# here and is silently ignored. # [discord.command_security] -# monster = ["userid", "roleid"] -# pvp = ["roleid"] -prefix = "!" # prefix for poracle commands -# iv_colors - 6 color codes for pokemon IV rankings -iv_colors = ["#9D9D9D", "#FFFFFF", "#1EFF00", "#0070DD", "#A335EE", "#FF8000"] -dm_log_channel_id = "" # channel ID to log all poracle commands to -dm_log_channel_deletion_time = 0 # time to clean up after (minutes) - 0 = don't delete -message_delete_delay = 0 # extra time (ms) to add to a message 'clean' -unrecognised_command_message = "" # reply to unrecognised commands in DM -unregistered_user_message = "" # reply to unregistered users (empty = shrug) -lost_role_message = "" # message when user loses role-based access - -# admin_channel_id: discord channel ID for operator-facing notices — -# auto-disable events (delivery failure, rate-limit ban), thread keep-alive -# 404s, DTS render errors, state-reload failures. Empty = disabled. -admin_channel_id = "" - -# thread_keep_alive_interval_hours: cadence (in hours) of the background -# sweeper that unarchives Poracle-managed Discord threads so they stay -# accessible. 0 disables. Max 168 (7 days, Discord's archive ceiling). -thread_keep_alive_interval_hours = 24 +# raid = ["role_id_1", "user_id_2"] +# poracle_admin = ["role_id_1"] +# pvp = ["role_id_1"] # ---- Discord slash commands (optional, additive surface) ---- # @@ -413,6 +435,16 @@ provider_url = "" # nominatim or photon URL, e.g. # own template like "{{{streetName}}} {{streetNumber}}, {{{city}}}". forward_only = false # when true, disable reverse geocoding lookup cache_detail = 3 # decimal places of lon/lat for caching (3 or 4 for 100x more detail) +# intersection_users - list of GeoNames usernames (https://www.geonames.org) +# used to populate the {{intersection}} DTS field with the nearest street +# intersection. One is picked at random per uncached lookup to spread credit +# usage. Empty disables the feature. Results share the geocoding cache above, +# but each uncached lookup spends a GeoNames credit, so caching/credits apply. +# GeoNames is a separate service from the reverse-geocode provider, so it gets +# its own concurrency limiter and circuit breaker (both sized from the +# [tuning] geocoding_* values). Peak outbound to external geo services can +# therefore reach 2x geocoding_concurrency when both are busy. +intersection_users = [] # Styles for different times of day using tileservercache # Use style: "#(style)" in templates to utilize these day_style = "" @@ -649,11 +681,12 @@ suggest_on_dm = false # when true, DMs that don't match a com # ---- Logging ---- -# Log levels: silly, debug, verbose, info, warn -# Suggestion: start at verbose; info is a bit less logging; debug has more detail +# Log levels (most to least verbose): trace, debug, info, warn +# Suggestion: start at info; debug adds hot-path detail; trace is everything. +# Legacy names are still accepted: "verbose" maps to info, "silly" to trace. [logging] -level = "verbose" # log level: debug, verbose, info, warn +level = "info" # log level: trace, debug, info, warn file_logging_enabled = true # write logs to file filename = "logs/processor.log" # log file path max_size = 50 # max log file size in megabytes diff --git a/docs/superpowers/handoffs/2026-07-18-dts-editor-derived-types.md b/docs/superpowers/handoffs/2026-07-18-dts-editor-derived-types.md new file mode 100644 index 000000000..222e9d7c4 --- /dev/null +++ b/docs/superpowers/handoffs/2026-07-18-dts-editor-derived-types.md @@ -0,0 +1,72 @@ +# Editor Handoff — Derived DTS Types & DTS-Name-Addressed Test Data + +**For:** the DTS template editor (`~/dev/poracle-embed-visualizer`). +**From:** PoracleNG processor, branch `feature/derived-dts-test-data`. +**Date:** 2026-07-18. + +This describes the **server-side** changes now available so the editor can (a) preview every DTS template type — including the *derived* ones that aren't a single raw webhook — and (b) stop hardcoding the DTS-type→webhook-type mapping and stop filtering scenarios client-side. Nothing in the editor is required to change for existing behaviour to keep working (all old calls are back-compatible); this doc lists what you *can now delete/simplify* and what's *newly available*. + +## TL;DR — what you can delete +In `scripts/capture-test-data.mjs` (and wherever the editor mirrors this logic — `src/lib/api-client.js`, `src/components/TestDataPanel.jsx`): +1. **Delete the hardcoded `dtsToWebhookType` map** — the server now returns it (see §3). +2. **Delete the client-side pokestop invasion/lure filter** (the `grunt_type`/`lure_id` sniffing) — the server now splits it (see §2). +3. **Delete the `monsterNoIv→pokemon` / `egg→raid` special-casing** in the enrich call — pass the DTS type name directly (see §1). +4. **Add the newly-previewable types** to the editor's type list: the derived types `monsterChanged`, `incident`, `questSummary`, `weatherchange`, `rsvpChanges`, plus `fort-update` and `maxbattle` (previously omitted from `capture-test-data.mjs`'s `dtsTypes` array). + +## 1. `POST /api/dts/enrich` now accepts DTS type names (incl. derived) +- Request unchanged: `{ type, webhook, language, platform }`. +- `type` now accepts **any DTS template type name** OR a raw webhook type — resolved server-side via the shared alias table. So you can send `type: "monster"`, `"monsterNoIv"`, `"egg"`, `"invasion"`, `"lure"`, `"incident"`, `"monsterChanged"`, `"questSummary"`, `"weatherchange"`, `"rsvpChanges"`, `"maxbattle"`, `"fort-update"`, etc. — **no client-side remap needed**. (`monsterNoIv` now correctly yields the monsterNoIv alias set; `egg` correctly yields the egg template — the two cases you special-cased.) +- Response `variables` now includes the **derived extras** the template reads: + - `monsterChanged` → the `original.*` field bag (`{{original.name}}`, `{{original.iv}}`, …) plus `changeType`/`changeTypeText`. + - `weatherchange` → `enrichedActivePokemons` (the affected-pokemon list) + weather names. + - `questSummary` → the group fields (`rewardType`, `reward`, `count`, `quests`, per-row `withAR`, chunk/chunks). + - `rsvpChanges` → the raid + RSVP fields (reuses the `raid` field set). +- For a derived type, send the **partial** (see §4) as `webhook` — the server enriches it. The two-stage flow (fetch testdata → user edits → enrich → preview) works unchanged; you just fetch the derived partials from the testdata endpoint like any other scenario. + +## 2. `GET /api/dts/testdata?dtsType=` — server filters + tags +- **New query param `?dtsType=`**: resolves via the alias table and returns **only the entries that preview that DTS type**, each tagged with a `dtsType` field on the entry. The server performs the splits you did client-side: + - `?dtsType=invasion` → only grunt invasions; `?dtsType=lure` → only lures (the pokestop invasion/lure split — payload-shape based, server-side). + - `?dtsType=egg` → raid entries with `pokemon_id == 0`; `?dtsType=raid` → the rest (raid/egg split). + - `?dtsType=incident` → the pokestop-event samples (kecleon/gold-stop/pokemon-contest — moved to `type:"incident"`). + - `?dtsType=monsterChanged` / `questSummary` / `weatherchange` / `rsvpChanges` → the derived partials. +- **Legacy `?type=` is unchanged** (returns every entry of that raw type, untagged; takes precedence if both are set). Keep using it if you prefer, but `?dtsType=` removes the need for client-side filtering. +- Response shape: `{ status, testdata, types }` (see §3 for `types`). +- `nest` currently has no bundled sample (returns empty) — expected. + +## 3. The DTS-type→source map (`types`) — drop your hardcoded copy +- Every `GET /api/dts/testdata` response now includes a **`types` object**: the full DTS-type→source map. Each key is a DTS type name; each value is `{ webhookType, templateType, derived }`. + ```jsonc + "types": { + "monster": { "webhookType": "pokemon", "templateType": "monster", "derived": false }, + "monsterNoIv": { "webhookType": "pokemon", "templateType": "monsterNoIv", "derived": false }, + "egg": { "webhookType": "raid", "templateType": "egg", "derived": false }, + "invasion": { "webhookType": "pokestop", "templateType": "invasion", "derived": false }, + "incident": { "webhookType": "incident", "templateType": "incident", "derived": true }, + "monsterChanged": { "webhookType": "monster_changed", "templateType": "monsterChanged", "derived": true }, + "questSummary": { "webhookType": "quest_summary", "templateType": "questSummary", "derived": true }, + "weatherchange": { "webhookType": "weatherchange", "templateType": "weatherchange", "derived": true }, + "rsvpChanges": { "webhookType": "rsvp_changes", "templateType": "rsvpChanges", "derived": true } + // …plus gym, nest, lure, quest, raid, maxbattle, fort-update, showcase, greeting-less types + } + ``` +- **Replace the editor's hardcoded `dtsToWebhookType` map with this.** Prefer `?dtsType=` (server does the resolution + filtering); use `types` only if you still want the client to know the source webhook type per DTS type. + +## 4. Derived-type test "partials" (informational) +The derived testdata entries carry a structured payload in the existing `webhook` field — you generally don't construct these (fetch them via `?dtsType=`), but for reference: +- `monster_changed`: `{ old: , new: }`. +- `weatherchange`: a weather-change webhook with an `affected` (active pokemon) list. +- `quest_summary`: a raid/quest group `{ reward, quests:[…] }` (several quests under one reward). +- `rsvp_changes`: a raid webhook with an `rsvps` list. +The editor can let a user edit these and POST them to `/api/dts/enrich` exactly like a normal scenario. + +## Caveats +- **`rsvpChanges` has no bundled default template** — it's an opt-in feature. Previewing it requires an operator-authored `rsvpChanges` template; without one there's nothing to render. (The enrich/testdata support is present, so the *editor* can still enrich + edit it.) +- **`greeting`** has no webhook source and no test data — omit it from the previewable list. +- `!poracle-test` (the live Discord/Telegram command) now also accepts DTS type names (`!poracle-test monsterChanged,ditto-reveal`) — not editor-relevant, but the same alias table backs both. + +## Quick migration checklist +- [ ] Fetch `types` from `GET /api/dts/testdata`; delete the hardcoded `dtsToWebhookType`. +- [ ] Use `GET /api/dts/testdata?dtsType=`; delete the client-side pokestop/lure filter. +- [ ] Pass the DTS type name straight to `POST /api/dts/enrich`; delete the `monsterNoIv`/`egg` special-cases. +- [ ] Add `monsterChanged`, `incident`, `questSummary`, `weatherchange`, `rsvpChanges`, `fort-update`, `maxbattle` to the previewable type list. +- [ ] Render the derived extras (`original.*`, `enrichedActivePokemons`, quest group, RSVP) in the preview. diff --git a/docs/superpowers/handoffs/2026-07-27-dts-editor-agnostic-help.md b/docs/superpowers/handoffs/2026-07-27-dts-editor-agnostic-help.md new file mode 100644 index 000000000..1e6bab287 --- /dev/null +++ b/docs/superpowers/handoffs/2026-07-27-dts-editor-agnostic-help.md @@ -0,0 +1,84 @@ +# Editor Handoff — Platform-Agnostic (help) Templates & Readonly Visibility + +**For:** the DTS template editor (`~/dev/poracle-embed-visualizer`). +**From:** PoracleNG processor, branch `fix/dts-agnostic-platform-save` (PR #176). +**Date:** 2026-07-27. + +## The bug this closes +Loading a fallback **help** template (e.g. `help/fort`) in the editor and saving it to edit later produced a **duplicate** in the editable-templates list: the readonly fallback **plus** a second `discord` copy. Root cause was two-sided: + +- **Server** rejected `platform=""` on save, forcing help to be saved with a concrete platform. +- **Editor** coerces an empty platform to `"discord"` on load, so the agnostic help fallback (`platform=""`) becomes a `discord`-specific entry that can't shadow the `""` fallback (the override key includes platform). + +The **server half is shipped** (see "Server contract" below). This doc is the **editor half**. + +## Key fact: `help` is the ONLY platform-agnostic type +Every other DTS type — `monster`, `monsterNoIv`, `raid`, `egg`, `quest`, `questSummary`, `invasion`, `incident`, `lure`, `nest`, `gym`, `fort-update`, `maxbattle`, `showcase`, `monsterChanged`, `weatherchange`, `rsvpChanges`, `buttonResponse` — is **platform-specific** and MUST carry a concrete platform (`discord`/`telegram`). Only `help` is agnostic: its bundled fallbacks ship with `platform=""` (from `fallbacks/dts/help/*.json`) and a single entry serves all platforms. + +Hardcode this to match the server: + +```js +const AGNOSTIC_TYPES = new Set(['help']); +const isAgnostic = (type) => AGNOSTIC_TYPES.has(type); +``` + +(The server's equivalent is `dts.IsPlatformAgnosticType`, currently `{help}`. There is no API to query the set — keep this client list in sync if the server ever adds more agnostic types.) + +## Editor changes + +### 1. Stop coercing empty platform to `"discord"` for agnostic types +`src/hooks/useDts.js:8`: + +```js +.map((e) => ({ ...e, id: String(e.id ?? '1'), platform: e.platform || 'discord', language: e.language ?? '' })); +``` + +`e.platform || 'discord'` is what turns the fallback's `""` into `"discord"`. Preserve `""` for agnostic types: + +```js +.map((e) => ({ + ...e, + id: String(e.id ?? '1'), + platform: isAgnostic(e.type) ? (e.platform ?? '') : (e.platform || 'discord'), + language: e.language ?? '', +})); +``` + +Apply the same to the other coercion sites: `useDts.js:180` (`platform: template.platform || 'discord'`) and the new-template default at `useDts.js:15` (`platform: 'discord'` — a **new** help template should default to `""`, not `discord`). + +### 2. Show agnostic entries regardless of the platform tab +The list filters by `t.platform === filters.platform` (`useDts.js:32, 41, 47, 70, 81, 142`). An agnostic help entry (`platform=""`) fails `"" === "discord"` and would vanish from every platform tab. Include agnostic entries in any tab: + +```js +const platformMatches = (t) => isAgnostic(t.type) || t.platform === filters.platform; +``` + +(Or give `help` its own platform-neutral view — but "show in every tab" is the least surprising.) + +### 3. Save agnostic templates with `platform=""` +When saving a help template, POST `platform: ""` (not `"discord"`). With change #1 preserving `""` through state, this happens naturally as long as the save path doesn't re-inject a platform. Server-side the file is then written as `config/dts/help-fort.json` (no platform segment). + +Also fix the **download** filename at `src/App.jsx:319`: + +```js +a.download = `${entry.type}-${entry.id || 'default'}-${entry.platform || 'discord'}.json`; +``` + +Omit the platform segment when empty so an agnostic download is `help-fort.json` (mirrors the server's `entryFilename`). + +### 4. Show readonly (fallback) entries in the list, clearly marked +`GET /api/dts/templates` returns **`readonly: true`** on every bundled/fallback entry. **Surface these in the template list — don't hide them** — with a clear badge such as **"read-only (fallback)"** (and, ideally, an affordance to "copy / override"). This makes it obvious which rows are: + +- editable in place (the user's own `config/dts/` entries, `readonly` absent/false), vs +- read-only fallbacks the user can copy to create an override. + +When the user saves an override of a readonly fallback, the server **drops the readonly entry** from the returned list (the user's copy shadows it), so the row naturally flips from "read-only" to editable — no duplicate. Rendering the `readonly` flag is what makes that transition legible to the user. + +## Server contract (already shipped — PR #176) +- `POST /api/dts/templates` now **accepts `platform=""` for agnostic types** (`help`). Non-agnostic types still return **400** without a platform (unchanged). +- A saved `(help, , "")` override shares the fallback's key and **shadows** it: the editable list then shows exactly **one** `help/` entry (the user's), with the readonly fallback dropped. +- Saved agnostic files are named `help-.json` (no platform segment). +- `GET /api/dts/templates` continues to return `readonly: true` on fallback entries — use it for the badge in change #4. + +## Migration note (existing duplicate) +The duplicate currently visible on the running instance comes from an already-saved `config/dts/help-fort-discord.json` (platform `discord`) written before this fix. Delete it (editor delete button or `rm`) to clear the current duplicate; the fix only prevents **new** ones. diff --git a/docs/superpowers/plans/2026-05-30-huma-api-migration.md b/docs/superpowers/plans/2026-05-30-huma-api-migration.md new file mode 100644 index 000000000..3ad094591 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-huma-api-migration.md @@ -0,0 +1,846 @@ +# Huma 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:** Migrate the `/api/tracking/*`, `/api/humans/*`, and `/api/profiles/*` endpoint groups from hand-written Gin handlers to the huma framework, gaining a generated OpenAPI 3.1 spec + public docs UI, while preserving the legacy JSON wire envelope and lenient tolerance of broken clients. + +**Architecture:** huma is mounted on the *existing* `*gin.Engine` via the `humagin` adapter under the already-authenticated `/api` group, so existing middleware is untouched. Migrated groups move from Gin route registrations to `huma.Register` calls; everything else stays on Gin. `flexBool`/`flexInt` gain `SchemaProvider` methods so huma documents a canonical type per field while still accepting legacy forms; request bodies allow additional properties so unknown fields don't 422. The legacy `huma.NewError` is overridden to emit `{status:"error",message}`. + +**Tech Stack:** Go 1.26, gin-gonic, `github.com/danielgtaylor/huma/v2` (v2.38.0) + `humagin` adapter, sqlx/MySQL, logrus, testify-free table tests via `net/http/httptest`. + +**Spec:** `docs/superpowers/specs/2026-05-30-huma-api-migration-design.md` + +**Conventions for every task:** the four-check gate must pass before each commit — `go build ./... && go vet ./... && go test -count=1 ./... && golangci-lint run ./...` (run from `processor/`). All paths below are relative to the repo root unless prefixed `processor/`. + +--- + +## Phase 0 — Foundation (de-risks the whole approach) + +### Task 1: Add the huma dependency + +**Files:** +- Modify: `processor/go.mod`, `processor/go.sum` + +- [ ] **Step 1: Add the modules** + +Run from `processor/`: +```bash +go get github.com/danielgtaylor/huma/v2@v2.38.0 +go mod tidy +``` + +- [ ] **Step 2: Verify it resolves and the tree still builds** + +Run: `go build ./...` +Expected: exit 0, and `grep huma go.mod` shows `github.com/danielgtaylor/huma/v2 v2.38.0`. + +- [ ] **Step 3: Commit** + +```bash +git add processor/go.mod processor/go.sum +git commit -m "build: add huma v2 dependency" +``` + +### Task 2: Legacy error model + huma config helper + +Override the package-global `huma.NewError` so every huma-generated error serialises as `{"status":"error","message":"..."}` (not RFC 9457), and provide a single constructor for the API's `huma.Config`. + +**Files:** +- Create: `processor/internal/api/huma_setup.go` +- Test: `processor/internal/api/huma_setup_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package api + +import ( + "encoding/json" + "net/http" + "testing" +) + +func TestLegacyErrorModelSerialises(t *testing.T) { + InstallLegacyErrorModel() + err := humaNewError(http.StatusNotFound, "human not found") + if err.GetStatus() != http.StatusNotFound { + t.Fatalf("status = %d, want 404", err.GetStatus()) + } + b, e := json.Marshal(err) + if e != nil { + t.Fatalf("marshal: %v", e) + } + var got map[string]any + _ = json.Unmarshal(b, &got) + if got["status"] != "error" { + t.Errorf("status field = %v, want \"error\"", got["status"]) + } + if got["message"] != "human not found" { + t.Errorf("message field = %v, want \"human not found\"", got["message"]) + } + if _, hasTitle := got["title"]; hasTitle { + t.Errorf("legacy body must not contain RFC9457 \"title\" field: %s", b) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/api/ -run TestLegacyErrorModelSerialises -v` +Expected: FAIL — `InstallLegacyErrorModel`, `humaNewError` undefined. + +- [ ] **Step 3: Implement** + +```go +package api + +import ( + "net/http" + + "github.com/danielgtaylor/huma/v2" +) + +// legacyError is the wire shape PoracleWeb/ReactMap already expect from /api. +// It implements huma.StatusError so huma uses it for every generated error. +type legacyError struct { + StatusCode int `json:"-"` + Status string `json:"status"` // always "error" + Message string `json:"message"` // human-readable detail +} + +func (e *legacyError) Error() string { return e.Message } +func (e *legacyError) GetStatus() int { return e.StatusCode } + +// humaNewError is the value we assign into huma.NewError; kept as a named +// package func so tests can call it directly. +func humaNewError(status int, msg string, _ ...error) huma.StatusError { + if msg == "" { + msg = http.StatusText(status) + } + return &legacyError{StatusCode: status, Status: "error", Message: msg} +} + +// InstallLegacyErrorModel overrides huma's RFC-9457 error model with the +// legacy {status,message} envelope. Call once at startup before registering. +func InstallLegacyErrorModel() { + huma.NewError = humaNewError +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/api/ -run TestLegacyErrorModelSerialises -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add processor/internal/api/huma_setup.go processor/internal/api/huma_setup_test.go +git commit -m "feat(api): legacy {status,message} error model for huma" +``` + +### Task 3: huma API constructor + public docs/spec mounting + +Build the `huma.API` on the existing engine and serve `/openapi.json` + `/docs` at public, unauthenticated top-level paths. + +**Files:** +- Modify: `processor/internal/api/huma_setup.go` +- Test: `processor/internal/api/huma_setup_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +func TestPublicDocsUnauthenticated(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + apiGroup := r.Group("/api") + apiGroup.Use(RequireSecretGin("topsecret")) // gate /api + _ = NewHumaAPI(r, apiGroup, "test-version") // mounts docs on r (public) + + for _, path := range []string{"/openapi.json", "/docs"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("GET %s unauthenticated = %d, want 200", path, w.Code) + } + } +} +``` + +Add imports: `"net/http"`, `"net/http/httptest"`, `"github.com/gin-gonic/gin"`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/api/ -run TestPublicDocsUnauthenticated -v` +Expected: FAIL — `NewHumaAPI` undefined. + +- [ ] **Step 3: Implement** + +```go +import ( + // ...existing... + "github.com/danielgtaylor/huma/v2/adapters/humagin" + "github.com/gin-gonic/gin" +) + +// NewHumaAPI installs the legacy error model, builds a huma API bound to the +// authenticated /api group, declares the X-Poracle-Secret security scheme, and +// serves the OpenAPI spec + docs UI at PUBLIC top-level paths (no secret). +func NewHumaAPI(r *gin.Engine, apiGroup *gin.RouterGroup, version string) huma.API { + InstallLegacyErrorModel() + + cfg := huma.DefaultConfig("PoracleNG API", version) + // Disable huma's built-in mounts; we serve our own public copies on r. + cfg.OpenAPIPath = "" + cfg.DocsPath = "" + cfg.SchemasPath = "" + cfg.Components.SecuritySchemes = map[string]*huma.SecurityScheme{ + "poracleSecret": {Type: "apiKey", In: "header", Name: "X-Poracle-Secret"}, + } + + humaAPI := humagin.NewWithGroup(r, apiGroup, cfg) + + // Public spec + docs (top-level, outside /api, so RequireSecretGin never runs). + r.GET("/openapi.json", func(c *gin.Context) { + spec, err := humaAPI.OpenAPI().YAML() // YAML() returns canonical bytes; use MarshalJSON for JSON + _ = err + _ = spec + b, _ := humaAPI.OpenAPI().MarshalJSON() + c.Data(http.StatusOK, "application/json", b) + }) + r.GET("/docs", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html", []byte(docsHTML)) + }) + return humaAPI +} + +// docsHTML is a minimal Stoplight Elements page pointed at /openapi.json. +const docsHTML = ` +PoracleNG API + + +` +``` + +> **Verify against the pinned version:** confirm the spec accessor is +> `humaAPI.OpenAPI().MarshalJSON()` (huma `OpenAPI` exposes `MarshalJSON`/`YAML`). +> If the method name differs, adjust; the test pins behaviour (200 + JSON body), +> not the accessor name. Remove the dead `YAML()`/`spec` lines once confirmed. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/api/ -run TestPublicDocsUnauthenticated -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add processor/internal/api/huma_setup.go processor/internal/api/huma_setup_test.go +git commit -m "feat(api): huma API constructor + public openapi.json/docs" +``` + +### Task 4: Leniency spike — flex SchemaProvider + additionalProperties + +This is the linchpin: huma validates the parsed body against the operation schema *before* binding, so lenient inputs must be permitted by the schema. Prove all three at once on a throwaway endpoint: (a) `flexInt`/`flexBool` accept `"90"`/`false`/`3`; (b) unknown fields don't 422; (c) the spec shows the `oneOf`. + +**Files:** +- Modify: `processor/internal/api/tracking.go` (add `Schema` methods to `flexInt`/`flexBool`) +- Create: `processor/internal/api/flex_schema_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humagin" + "github.com/gin-gonic/gin" +) + +type spikeBody struct { + N flexInt `json:"n"` + B flexBool `json:"b"` +} +type spikeInput struct{ Body lenient[spikeBody] } +type spikeOutput struct { + Body struct { + Status string `json:"status"` + N int `json:"n"` + B int `json:"b"` + } +} + +func TestLeniencySpike(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + api := NewHumaAPI(r, r.Group("/api"), "test") + huma.Register(api, huma.Operation{ + OperationID: "spike", Method: http.MethodPost, Path: "/spike", + }, func(ctx context.Context, in *spikeInput) (*spikeOutput, error) { + out := &spikeOutput{} + out.Body.Status = "ok" + out.Body.N = in.Body.Value.N.intValue(0) + out.Body.B = in.Body.Value.B.intValue(0) + return out, nil + }) + + cases := []string{ + `{"n":"90","b":false}`, // string int, bool + `{"n":90,"b":3}`, // native int, int-as-bool-field + `{"n":90,"b":true,"extra":1}`, // unknown field must NOT 422 + } + for _, body := range cases { + req := httptest.NewRequest(http.MethodPost, "/api/spike", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("body %s -> %d (%s), want 200", body, w.Code, w.Body.String()) + } + } +} +``` + +(Add `"context"` import.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/api/ -run TestLeniencySpike -v` +Expected: FAIL — `lenient` undefined; flex types lack `Schema`. + +- [ ] **Step 3: Implement the Schema methods and the `lenient` wrapper** + +In `tracking.go`, add (next to the flex types): + +```go +import ( + "reflect" + "github.com/danielgtaylor/huma/v2" +) + +// Schema advertises integer as canonical while accepting numeric strings and +// booleans, so huma's validator permits the legacy forms flexInt unmarshals. +func (flexInt) Schema(huma.Registry) *huma.Schema { + return &huma.Schema{ + OneOf: []*huma.Schema{{Type: "integer"}, {Type: "string"}, {Type: "boolean"}}, + Description: "Canonical: integer. Numeric strings and booleans accepted for legacy clients.", + } +} + +// Schema advertises boolean as canonical while accepting integers (legacy +// bitmask) and numeric strings. +func (flexBool) Schema(huma.Registry) *huma.Schema { + return &huma.Schema{ + OneOf: []*huma.Schema{{Type: "boolean"}, {Type: "integer"}, {Type: "string"}}, + Description: "Canonical: boolean. Integers/strings accepted for legacy clients.", + } +} + +// lenient[T] wraps a request body so huma allows unknown/extra properties +// (matching the pre-huma json.Unmarshal behaviour) instead of huma's default +// additionalProperties:false. Access the decoded value via .Value. +type lenient[T any] struct{ Value T } + +func (l *lenient[T]) UnmarshalJSON(b []byte) error { return json.Unmarshal(b, &l.Value) } +func (l lenient[T]) MarshalJSON() ([]byte, error) { return json.Marshal(l.Value) } + +func (lenient[T]) Schema(r huma.Registry) *huma.Schema { + s := r.Schema(reflect.TypeOf(*new(T)), true, "") + s.AdditionalProperties = true + return s +} +``` + +> **Verify the `additionalProperties` mechanism against v2.38.0.** The +> `lenient[T]` wrapper is the primary approach: it derives the inner struct's +> schema via the registry, then flips `AdditionalProperties` (field type is +> `any`; `true` permits extras). If `r.Schema`'s argument shape or the +> `AdditionalProperties` field type differs in this version, the test in Step 1 +> is the contract — make it pass. Fallback if the wrapper proves awkward: a +> `huma.Config` schema transformer that sets `AdditionalProperties = true` on +> request-body object schemas. Pick whichever passes the test cleanly; record +> the choice in a one-line comment. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/api/ -run TestLeniencySpike -v` +Expected: PASS for all three bodies. + +- [ ] **Step 5: Commit** + +```bash +git add processor/internal/api/tracking.go processor/internal/api/flex_schema_test.go +git commit -m "feat(api): flex SchemaProvider + lenient body wrapper (leniency spike)" +``` + +**Phase 0 exit criteria:** huma builds on the engine, errors use the legacy envelope, docs are public, and lenient bodies validate. The mechanics are proven; the rest is application. + +--- + +## Phase 1 — Tracking group + +### Task 5: Canonical-type & bitmask audit (deliverable, no code) + +Produce the per-field audit that drives every tracking schema and the bitmask decompositions. This prevents inconsistent typing across the 10 types. + +**Files:** +- Create: `docs/superpowers/specs/huma-tracking-field-audit.md` + +- [ ] **Step 1: Build the audit table** + +For each of the 10 request structs (`monsterInsertRequest` in `trackingMonster.go`, and the equivalents in `trackingRaid.go`, `trackingEgg.go`, `trackingQuest.go`, `trackingInvasion.go`, `trackingLure.go`, `trackingNest.go`, `trackingGym.go`, `trackingFort.go`, `trackingMaxbattle.go`), list every JSON field with columns: `field | current Go type | semantics (int / bool / bitmask / string) | canonical wire type | accepted-lenient forms | decompose? (target booleans + bit)`. + +Seed facts (confirm against the structs): +- Bitmask field `clean` (all types): bit 1 auto-delete, bit 2 edit, bit 4 summary (`db/clean.go`). Decompose to `clean:bool` (bit1) + `edit:bool` (bit2) + `summary:bool` (bit4); still accept legacy integer `clean` as the full bitmask. +- `gym`: `slot_changes`, `battle_changes` — DO NOT assume; read the handler validation + bot keywords to determine actual type (bool vs enum vs count) before modeling. +- `raid`/`egg`: `rsvp_changes` is a **3-value enum**, NOT a boolean. Stored `tinyint` `0|1|2`: `0`=`no_rsvp` (none), `1`=`rsvp` (RSVP changes + normal), `2`=`rsvp_only` (only RSVP changes) — per bot keywords `arg.no_rsvp`/`arg.rsvp`/`arg.rsvp_only` and the egg clamp `<0||>2→0`. Model as a **string enum** `"none"|"rsvp"|"rsvp_only"` canonical, ALSO accepting the legacy integer `0|1|2` for old clients (lenient), mapping to the stored int. Do NOT decompose into booleans. +- `quest`: confirm reward fields stay integer/string; `summary` opt-in maps to clean bit 4. +- `fort`: change-type flags. +- Everything else (`pokemon_id`, IVs, CP, level, gender, ranks, distance, weight, size, form): genuine integer → `flexInt` advertising integer. + +- [ ] **Step 2: Commit** + +```bash +git add docs/superpowers/specs/huma-tracking-field-audit.md +git commit -m "docs: per-field canonical-type audit for tracking migration" +``` + +### Task 6: Worked example — migrate `GET /tracking/pokemon/{id}` + +The canonical read-endpoint template. Defines the huma input/output pattern, the `lookupHuman` huma sibling, the legacy success envelope, and the wiring swap. + +**Files:** +- Create: `processor/internal/api/huma_tracking.go` (shared helpers + monster ops) +- Modify: `processor/cmd/processor/main.go` (remove the Gin monster-GET route; ensure huma is constructed) +- Test: `processor/internal/api/huma_tracking_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +func TestHumaListMonster(t *testing.T) { + deps := newTestTrackingDeps(t) // seeds a human "u1" with one pokemon rule, profile 0 + gin.SetMode(gin.TestMode) + r := gin.New() + api := NewHumaAPI(r, r.Group("/api"), "test") + RegisterTrackingMonster(api, deps) + + req := httptest.NewRequest(http.MethodGet, "/api/tracking/pokemon/u1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d (%s)", w.Code, w.Body.String()) + } + var got map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &got) + if got["status"] != "ok" { + t.Errorf("status = %v, want ok", got["status"]) + } + if _, ok := got["pokemon"].([]any); !ok { + t.Errorf("missing pokemon array: %s", w.Body.String()) + } +} +``` + +> `newTestTrackingDeps` is a shared test helper. If one does not already exist +> in the `api` package tests, create it in `huma_tracking_test.go`: build a +> `*TrackingDeps` backed by the existing in-memory mocks (`store.NewMockHuman…` +> per `store/mock_human.go`) and a `TrackingStores` populated with one monster +> rule for id `u1`. Mirror the setup used by `tracking_test.go`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/api/ -run TestHumaListMonster -v` +Expected: FAIL — `RegisterTrackingMonster` undefined. + +- [ ] **Step 3: Implement the shared helpers + monster GET** + +```go +package api + +import ( + "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" +) + +// humaLookupHuman mirrors lookupHuman but takes plain params instead of *gin.Context. +func humaLookupHuman(deps *TrackingDeps, id string, profileQuery *int) (*store.HumanLite, int, error) { + human, err := deps.Humans.GetLite(id) + if err != nil { + return nil, 0, err + } + if human == nil { + return nil, 0, nil + } + profileNo := human.CurrentProfileNo + if profileQuery != nil { + profileNo = *profileQuery + } + return human, profileNo, nil +} + +type listTrackingInput struct { + ID string `path:"id" doc:"Human/channel/webhook id"` + ProfileNo *int `query:"profile_no" doc:"Profile number; defaults to the user's active profile"` +} + +type listMonsterOutput struct { + Body struct { + Status string `json:"status"` + Pokemon any `json:"pokemon"` + } +} + +func RegisterTrackingMonster(api huma.API, deps *TrackingDeps) { + huma.Register(api, huma.Operation{ + OperationID: "list-monster-tracking", + Method: http.MethodGet, + Path: "/tracking/pokemon/{id}", + Summary: "List pokemon tracking rules", + Tags: []string{"tracking"}, + Security: []map[string][]string{{"poracleSecret": {}}}, + }, func(ctx context.Context, in *listTrackingInput) (*listMonsterOutput, error) { + human, profileNo, err := humaLookupHuman(deps, in.ID, in.ProfileNo) + if err != nil { + return nil, humaNewError(http.StatusInternalServerError, err.Error()) + } + if human == nil { + return nil, humaNewError(http.StatusNotFound, "User not found") + } + monsters, err := db.SelectMonstersByIDProfile(deps.DB, human.ID, profileNo) + if err != nil { + return nil, humaNewError(http.StatusInternalServerError, "database error") + } + tr := translatorFor(deps, human) + type monsterWithDesc struct { + db.MonsterTrackingAPI + Description string `json:"description"` + } + result := make([]monsterWithDesc, len(monsters)) + for i := range monsters { + mt := toMonsterTracking(&monsters[i]) + result[i] = monsterWithDesc{ + MonsterTrackingAPI: monsters[i], + Description: deps.RowText.MonsterRowText(tr, mt), + } + } + out := &listMonsterOutput{} + out.Body.Status = "ok" + out.Body.Pokemon = result + return out, nil + }) +} +``` + +This is a verbatim lift of `HandleGetMonster` (`trackingMonster.go`): same +`db.SelectMonstersByIDProfile`, `translatorFor`, `toMonsterTracking`, and +`deps.RowText.MonsterRowText` calls — only the gin context access and the +`trackingJSONOK`/`trackingJSONError` writes are replaced by typed input and +`humaNewError`/the output struct. Each per-type fan-out task (Task 9) lifts its +own `HandleGet` the same way; read that handler for its exact store method +(e.g. `db.SelectRaidsByIDProfile`) and row-text helper. + +- [ ] **Step 4: Run the test** + +Run: `go test ./internal/api/ -run TestHumaListMonster -v` +Expected: PASS. + +- [ ] **Step 5: Swap the wiring in main.go** + +In `processor/cmd/processor/main.go`: construct the huma API once after `apiGroup` is created — `humaAPI := api.NewHumaAPI(r, apiGroup, version)` — then `api.RegisterTrackingMonster(humaAPI, trackingDeps)`. Remove the line `tracking.GET("/pokemon/:id", api.HandleGetMonster(trackingDeps))`. Leave the other monster routes on Gin for now (they migrate in Tasks 7–8). + +- [ ] **Step 6: Build + full gate + commit** + +Run: `go build ./... && go vet ./... && go test -count=1 ./... && golangci-lint run ./...` +Expected: all pass. +```bash +git add processor/internal/api/huma_tracking.go processor/internal/api/huma_tracking_test.go processor/cmd/processor/main.go +git commit -m "feat(api): migrate GET /tracking/pokemon/{id} to huma" +``` + +### Task 7: Worked example — `POST /tracking/pokemon/{id}` (create/update + clean decomposition) + +The richest task: single-object-or-array body, the `clean`/`edit`/`summary` decomposition with legacy-int tolerance, and reuse of the existing diff/insert/update logic. + +**Files:** +- Modify: `processor/internal/api/huma_tracking.go`, `processor/internal/api/tracking.go` (add `collapseClean`) +- Modify: `processor/cmd/processor/main.go` (remove Gin monster POST) +- Test: `processor/internal/api/huma_tracking_test.go` + +- [ ] **Step 1: Write the failing tests** + +```go +func TestCollapseClean(t *testing.T) { + tt := []struct { + name string + clean flexBool + edit, summary *bool + want int + }{ + {"bool true -> bit1", mkFlexBool(true), nil, nil, 1}, + {"bool false -> 0", mkFlexBool(false), nil, nil, 0}, + {"legacy int 3 preserved", mkFlexBoolInt(3), nil, nil, 3}, + {"edit adds bit2", mkFlexBool(true), boolp(true), nil, 3}, + {"summary adds bit4", mkFlexBool(true), nil, boolp(true), 5}, + {"all", mkFlexBool(true), boolp(true), boolp(true), 7}, + {"legacy int OR named", mkFlexBoolInt(1), nil, boolp(true), 5}, + } + for _, c := range tt { + if got := collapseClean(c.clean, c.edit, c.summary); got != c.want { + t.Errorf("%s: collapseClean = %d, want %d", c.name, got, c.want) + } + } +} + +func TestHumaCreateMonsterLenientAndDecomposed(t *testing.T) { + deps := newTestTrackingDeps(t) + r := gin.New() + api := NewHumaAPI(r, r.Group("/api"), "test") + RegisterTrackingMonster(api, deps) + + // single object, boolean clean + named edit, unknown field, string int + body := `{"pokemon_id":25,"min_iv":"90","clean":true,"edit":true,"unknownField":1}` + req := httptest.NewRequest(http.MethodPost, "/api/tracking/pokemon/u1?silent=1", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d (%s)", w.Code, w.Body.String()) + } + // assert the persisted rule has clean == 3 (bit1|bit2) via deps store inspection + saved := deps.Tracking.Monsters.LastInserted() // test helper on the mock + if saved.Clean != 3 { + t.Errorf("persisted clean = %d, want 3", saved.Clean) + } + if saved.MinIV != 90 { + t.Errorf("persisted min_iv = %d, want 90", saved.MinIV) + } +} +``` + +> Helpers `mkFlexBool`/`mkFlexBoolInt`/`boolp` and the mock's `LastInserted()` +> go in the test file; `mkFlexBool(true)` constructs a `flexBool` whose decoded +> value is 1, `mkFlexBoolInt(3)` one whose value is 3. + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./internal/api/ -run 'TestCollapseClean|TestHumaCreateMonster' -v` +Expected: FAIL — `collapseClean` undefined. + +- [ ] **Step 3: Implement `collapseClean` + the POST op** + +In `tracking.go`: +```go +// collapseClean packs the caller-facing booleans (and any legacy integer clean) +// into the storage bitmask: bit1 auto-delete, bit2 edit, bit4 summary. +func collapseClean(clean flexBool, edit, summary *bool) int { + packed := clean.intValue(0) // bool->0/1, legacy int bitmask preserved as-is + if edit != nil && *edit { + packed |= 2 + } + if summary != nil && *summary { + packed |= 4 + } + return packed +} +``` + +In `huma_tracking.go`, change `monsterInsertRequest` (or define a huma-facing +variant) so the body carries `Clean flexBool json:"clean"`, `Edit *bool json:"edit"`, +`Summary *bool json:"summary"`, and build the stored `clean` via +`collapseClean(req.Clean, req.Edit, req.Summary)` where the existing create +handler currently reads `req.Clean.intValue(0)`. Model the body as +`Body lenient[[]monsterInsertRequest]` and, before decoding, normalise a single +JSON object to a one-element array (reuse the existing `rawBody[0]=='['` logic +from `HandleCreateMonster`, applied inside the body wrapper's `UnmarshalJSON` or +a small `normaliseToArray` helper). The diff/insert/update + confirmation + +`reloadState` logic is reused verbatim from `HandleCreateMonster`. + +> Open `trackingMonster.go:148+` and lift the body of `HandleCreateMonster` +> into the huma handler, replacing `c.Param`/`c.Query`/`c.GetRawData` with the +> typed input fields and the decoded `in.Body.Value` slice. Keep every store +> call, diff helper, and `sendConfirmation` call identical. + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/api/ -run 'TestCollapseClean|TestHumaCreateMonster' -v` +Expected: PASS. + +- [ ] **Step 5: Swap wiring** + +Remove `tracking.POST("/pokemon/:id", api.HandleCreateMonster(trackingDeps))` from `main.go`; the huma op is registered by `RegisterTrackingMonster`. + +- [ ] **Step 6: Gate + commit** + +Run the four-check gate. +```bash +git add -A +git commit -m "feat(api): migrate POST /tracking/pokemon/{id} with clean/edit/summary decomposition" +``` + +### Task 8: Worked example — monster DELETE + bulk delete + +**Files:** `processor/internal/api/huma_tracking.go`, `main.go`, `huma_tracking_test.go` + +- [ ] **Step 1: Write failing tests** for `DELETE /tracking/pokemon/{id}/byUid/{uid}` (asserts `{status:ok}` and the row is gone) and `POST /tracking/pokemon/{id}/delete` (body `{"uids":[1,2]}`, asserts both removed). Follow the Task 6 test shape. + +- [ ] **Step 2: Run — expect FAIL** (`RegisterTrackingMonster` doesn't yet register these ops). + +- [ ] **Step 3: Implement** two more `huma.Register` calls inside `RegisterTrackingMonster`: input structs `deleteByUidInput{ ID string \`path:"id"\`; UID int \`path:"uid"\` }` and `bulkDeleteInput{ ID string \`path:"id"\`; Body lenient[struct{ UIDs []int \`json:"uids"\` }] }`. Reuse the delete store calls + `reloadState` from `HandleDeleteMonster`/`HandleBulkDeleteMonster`. + +- [ ] **Step 4: Run — expect PASS.** + +- [ ] **Step 5: Swap wiring** — remove the two Gin DELETE/delete routes for pokemon from `main.go`. + +- [ ] **Step 6: Gate + commit** `feat(api): migrate monster delete + bulk-delete to huma`. + +### Task 9: Fan-out — the other 9 tracking types (one commit each) + +The four operations (GET, POST, DELETE byUid, POST delete) for each remaining type are structurally identical to Tasks 6–8. Apply the same transformation per type, using the Task 5 audit for that type's field schema and bitmask decomposition. + +**Per-type checklist (repeat for each):** `raid`, `egg`, `quest`, `invasion`, `lure`, `nest`, `gym`, `fort`, `maxbattle`. + +- [ ] For type `T`: create `RegisterTracking(api, deps)` in `huma_tracking.go` with the 4 ops, mirroring `RegisterTrackingMonster`. Input path is `/tracking//{id}` (routes: raid, egg, quest, invasion, lure, nest, gym, fort, maxbattle). +- [ ] Reuse the existing `HandleGet`/`HandleCreate`/`HandleDelete`/`HandleBulkDelete` bodies; swap gin context access for typed input; apply `collapseClean` and the type-specific representation from the audit. NOTE: decomposition is NOT one-size-fits-all — `clean` is a bitmask→booleans; `raid`/`egg` `rsvp_changes` is a 3-value ENUM (string `none|rsvp|rsvp_only` + lenient legacy int); `quest` `summary` is the clean bit-4; `gym` slot/battle changes TBD by audit. Read each field's real validation before modeling. +- [ ] Write a per-type test mirroring `TestHumaListMonster` + a lenient-create assertion. +- [ ] Register `RegisterTracking(humaAPI, trackingDeps)` in `main.go` and remove that type's 4 Gin routes. +- [ ] Gate + commit `feat(api): migrate tracking to huma`. + +**Delta table (per-type specifics to honour — fill exact fields from the audit):** + +| Type | Route | Bitmask/flag fields to decompose | Notes | +|---|---|---|---| +| raid | `raid` | `clean`→clean/edit/summary; `rsvp_changes`=**enum** `none\|rsvp\|rsvp_only` (+legacy int 0/1/2) | level/pokemon/team/exclusive/move ints | +| egg | `egg` | `clean`…; `rsvp_changes`=**enum** `none\|rsvp\|rsvp_only` (+legacy int) | level/team/exclusive | +| quest | `quest` | `clean`… incl. `summary` opt-in | reward_type/reward ints, `shiny` bool | +| invasion | `invasion` | `clean`… | grunt_type/gender | +| lure | `lure` | `clean`… | lure_id | +| nest | `nest` | `clean`… | pokemon_id, min_spawn_avg | +| gym | `gym` | `clean`…; `slot_changes`,`battle_changes` | team | +| fort | `fort` | `clean`…; change-type flags | fort_type, include_empty | +| maxbattle | `maxbattle` | `clean`… | pokemon_id, level, gmax, move | + +### Task 10: Tracking aggregate endpoints + +**Files:** `processor/internal/api/huma_tracking.go`, `main.go`, test. + +- [ ] **Step 1:** failing tests for `GET /tracking/all/{id}` and `GET /tracking/allProfiles/{id}` (assert `{status:ok}` + expected top-level keys). +- [ ] **Step 2:** run — FAIL. +- [ ] **Step 3:** implement two ops reusing `HandleGetAllTracking`/`HandleGetAllProfilesTracking`. For `GET /tracking/pokemon/refresh` (a reload alias), register a huma op that calls the same reload function `HandleReload` wraps and returns `{status:ok}`. +- [ ] **Step 4:** run — PASS. +- [ ] **Step 5:** remove the three Gin routes; register the huma ops. +- [ ] **Step 6:** gate + commit `feat(api): migrate tracking aggregate endpoints to huma`. + +**Phase 1 exit:** the entire `/api/tracking/*` group is served by huma and documented in `/openapi.json`; Gin no longer registers any tracking route. + +--- + +## Phase 2 — Humans group + +The humans group uses **two** deps structs: most ops use `trackingDeps`; the four role ops use `roleDeps`. Responses reuse the existing `HumanResponse`/DTO shapes. + +### Task 11: Humans read endpoints + +**Files:** `processor/internal/api/huma_humans.go`, `main.go`, `processor/internal/api/huma_humans_test.go` + +- [ ] **Step 1:** failing tests for `GET /humans/one/{id}` (full record → `HumanResponse` JSON, asserts e.g. `id`, `enabled` are present and unchanged in shape) and `GET /humans/{id}` (available areas). +- [ ] **Step 2:** run — FAIL. +- [ ] **Step 3:** implement `RegisterHumans(api, deps)` with these two ops, reusing `HandleGetOneHuman`/`HandleGetHumanAreas` bodies and the `humanToResponse` adapter so the wire JSON is byte-identical. The `one/{id}` vs `{id}` routing collision is resolved by Gin's router (huma registers via Gin) — assert both routes resolve correctly in the tests. +- [ ] **Step 4:** run — PASS. +- [ ] **Step 5:** remove the two Gin routes; register the ops. +- [ ] **Step 6:** gate + commit `feat(api): migrate humans read endpoints to huma`. + +### Task 12: Humans location & profile mutation endpoints + +- [ ] **Step 1:** failing tests for `GET /humans/{id}/checkLocation/{lat}/{lon}` (float path params), `GET /humans/{id}/locations`, `GET /humans/{id}/locations/{label}`, `POST /humans/{id}/locations/add`, `POST /humans/{id}/locations/{label}/delete` (asserts 409 when referenced), `POST /humans/{id}/setLocation/{lat}/{lon}`, `POST /humans/{id}/setAreas`, `POST /humans/{id}/switchProfile/{profile}`. +- [ ] **Step 2:** run — FAIL. +- [ ] **Step 3:** add these ops to `RegisterHumans`, `{lat}`/`{lon}` as `float64` path fields, reusing the existing handler bodies and the 409 path for referenced locations. +- [ ] **Step 4:** run — PASS. +- [ ] **Step 5:** remove the corresponding Gin routes. +- [ ] **Step 6:** gate + commit `feat(api): migrate humans location/profile mutations to huma`. + +### Task 13: Humans status/language + create endpoints + +- [ ] **Step 1:** failing tests for `POST /humans/{id}/start`, `/stop`, `/adminDisabled`, `/language`, and `POST /humans` (create). +- [ ] **Step 2:** run — FAIL. +- [ ] **Step 3:** add the ops, reusing `HandleStartHuman`/`HandleStopHuman`/`HandleAdminDisabled`/`HandleSetLanguage`/the create handler. `POST /humans` has no `{id}` path param — body-only input. +- [ ] **Step 4:** run — PASS. +- [ ] **Step 5:** remove the Gin routes. +- [ ] **Step 6:** gate + commit `feat(api): migrate humans status/language/create to huma`. + +### Task 14: Humans role endpoints (roleDeps) + +- [ ] **Step 1:** failing tests for `GET /humans/{id}/roles`, `GET /humans/{id}/getAdministrationRoles`, `POST /humans/{id}/roles/add/{roleId}`, `POST /humans/{id}/roles/remove/{roleId}`. +- [ ] **Step 2:** run — FAIL. +- [ ] **Step 3:** implement `RegisterHumanRoles(api, roleDeps)` (separate function because it closes over `roleDeps`, not `trackingDeps`), reusing `HandleGetRoles`/`HandleGetAdministrationRoles`/`HandleAddRole`/`HandleRemoveRole`. +- [ ] **Step 4:** run — PASS. +- [ ] **Step 5:** remove the four Gin role routes; register `RegisterHumanRoles(humaAPI, roleDeps)` in `main.go`. +- [ ] **Step 6:** gate + commit `feat(api): migrate humans role endpoints to huma`. + +**Phase 2 exit:** all `/api/humans/*` routes served by huma; both deps structs wired. + +--- + +## Phase 3 — Profiles group + +### Task 15: Profiles endpoints + +**Files:** `processor/internal/api/huma_profiles.go`, `main.go`, `processor/internal/api/huma_profiles_test.go` + +- [ ] **Step 1:** failing tests for `GET /profiles/{id}` (→ `ProfileResponse` shape), `POST /profiles/{id}/add`, `POST /profiles/{id}/update`, `POST /profiles/{id}/copy/{from}/{to}` (int path params), `DELETE /profiles/{id}/byProfileNo/{profile_no}`. +- [ ] **Step 2:** run — FAIL. +- [ ] **Step 3:** implement `RegisterProfiles(api, deps)` with the five ops, reusing `HandleGetProfiles`/`HandleAddProfile`/`HandleUpdateProfile`/`HandleCopyProfile`/`HandleDeleteProfile` and `profilesToResponse`/`profileToResponse`. +- [ ] **Step 4:** run — PASS. +- [ ] **Step 5:** remove the five Gin profile routes; register `RegisterProfiles(humaAPI, trackingDeps)`. +- [ ] **Step 6:** gate + commit `feat(api): migrate profiles endpoints to huma`. + +**Phase 3 exit:** all three groups served by huma. + +--- + +## Phase 4 — Finalise + +### Task 16: OpenAPI golden test + +**Files:** Create `processor/internal/api/openapi_golden_test.go`, `processor/internal/api/testdata/openapi.golden.json` + +- [ ] **Step 1:** write a test that builds a huma API, registers all three groups (`RegisterTracking*`, `RegisterHumans`, `RegisterHumanRoles`, `RegisterProfiles`) against `newTestTrackingDeps`, marshals `humaAPI.OpenAPI().MarshalJSON()`, and compares to `testdata/openapi.golden.json` (with a `-update` flag pattern to regenerate). +- [ ] **Step 2:** run with update to generate the golden file; eyeball it for the three groups, the `oneOf` flex schemas, the `poracleSecret` scheme, and `additionalProperties:true` on request bodies. +- [ ] **Step 3:** run without update — PASS. +- [ ] **Step 4:** gate + commit `test(api): golden OpenAPI spec for migrated groups`. + +### Task 17: Remove dead Gin handlers + verify no references + +**Files:** `processor/internal/api/trackingMonster.go` … `trackingMaxbattle.go`, `human*.go`, `profile*.go` + +- [ ] **Step 1:** grep for the now-unused `Handle*` functions for the three groups: `grep -rn 'HandleGetMonster\|HandleCreateMonster\|…' processor/` — confirm they are referenced only by their own definitions/tests. +- [ ] **Step 2:** delete the dead Gin handler functions and any now-unused helpers (keep shared helpers like `lookupHuman` only if still used elsewhere; `go vet`/`golangci-lint` unused-function checks will flag stragglers). +- [ ] **Step 3:** run the four-check gate — must be green with no unused-symbol lint errors. +- [ ] **Step 4:** commit `refactor(api): remove Gin handlers superseded by huma`. + +### Task 18: README/docs note + +**Files:** `README.md` (or `API.md`), `CLAUDE.md` API section + +- [ ] **Step 1:** add a short note: the API now publishes an OpenAPI spec at `/openapi.json` and interactive docs at `/docs` (public), with `/api/*` gated by `X-Poracle-Secret`. Note the canonical-vs-lenient field convention (prefer canonical types; legacy forms still accepted). +- [ ] **Step 2:** update the CLAUDE.md API section to mention huma serves tracking/humans/profiles while the rest stays on Gin. +- [ ] **Step 3:** commit `docs: document OpenAPI spec, public docs, and field conventions`. + +**Final exit criteria:** all three groups served by huma with byte-compatible legacy envelopes, lenient bodies, decomposed bitmask fields, a public docs UI, a golden-tested spec, no dead Gin code, and a green four-check gate. + +--- + +## Self-review notes + +- **Spec coverage:** coexistence (Task 3/6), legacy envelope (Task 2 + every op's `{status:ok}`/`humaNewError`), leniency + SchemaProvider + additionalProperties (Task 4), per-field audit (Task 5), clean decomposition (Task 7 + fan-out), all 43 tracking / ~19 humans / 5 profiles routes (Tasks 6–15), public docs (Task 3), security scheme (Task 3), single-or-array body (Task 7), float path params (Task 12/15), `one/{id}` routing (Task 11), two deps structs (Task 14), golden test (Task 16), out-of-scope groups never touched. ✓ +- **Risk-first ordering:** the three flagged risks (validation ordering, additionalProperties mechanism, error override) are all resolved in Phase 0 before any fan-out, so a wrong assumption is caught on one endpoint, not 60. +- **Placeholders:** the per-handler "reuse the existing body" instructions point at concrete existing functions by name; the exact store/helper method names must be read from the current handler being migrated (called out explicitly each time). diff --git a/docs/superpowers/plans/2026-06-03-huma-easy-wins-inplace.md b/docs/superpowers/plans/2026-06-03-huma-easy-wins-inplace.md new file mode 100644 index 000000000..05fba10bf --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-huma-easy-wins-inplace.md @@ -0,0 +1,254 @@ +# Huma Easy-Wins (in-place /api) Implementation Plan + +> **SUPERSEDED CONVENTION — errors.** The error convention below (`{status:"error",message}` via `humaNewError`) was superseded by the master plan (`2026-06-03-huma-full-api-master-plan.md`, Task 0.1): the built surface uses RFC 9457 `application/problem+json` via huma's default error model, and `InstallLegacyErrorModel`/`humaNewError` no longer exist. Everything else in this plan was executed as written (as P1/P2 of the master plan). + +> **For agentic workers:** REQUIRED SUB-SKILL: use superpowers:subagent-driven-development (or executing-plans) to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Document and validate the ~30 "easy-win" `/api/*` endpoints (reloads, read-only data, tile-URL, masterdata, DTS-editor reads, snapshots, summaries, autocreate/run, command) by moving them to huma **in place** — same paths, same success JSON, no client changes — so they appear in the OpenAPI spec at `/openapi.json` + `/docs`. + +**Architecture:** Register these as huma operations on the **existing** `huma.API` created by `api.NewHumaAPI(r, apiGroup, version)` (`internal/api/huma_setup.go`), which is already bound to the authenticated `/api` group and serves the public spec/docs. For each endpoint: define typed input/output structs whose JSON marshals **identically** to the current gin handler's success response, reuse the handler's business logic, register the huma op at the same path, and remove the old gin route. This is independent of the v2 CRUD redesign and of the frozen v1 tracking/humans/profiles contracts (this plan does **not** touch those three groups). + +**Tech Stack:** Go 1.26, gin + `humagin`, `github.com/danielgtaylor/huma/v2` (already a dep), `net/http/httptest` tests. + +**Reference:** triage in this session; the pokemon GET migration (`internal/api/huma_tracking.go` `RegisterTrackingMonster`) is the structural template for an in-place huma op. + +--- + +## Conventions (every task) + +- **Register on the existing instance.** Add `Register(humaAPI, )` functions in the `api` package; call them from `main.go` where `humaAPI` and the relevant deps are in scope. Do **not** create a second huma API. +- **Preserve success JSON exactly.** Read the current handler; define an output struct (or `Body any`) that marshals to the identical shape. Where a handler returns `any` (e.g. stats), use `Body any`. +- **Error bodies normalize to `{status:"error",message}`.** Current handlers use ad-hoc `gin.H{"error": …}`; huma's single global error model (`humaNewError`) emits the legacy envelope. Success is byte-identical; error bodies change shape. This is accepted (internal endpoints, status codes unchanged). Use `humaNewError(code, msg)` for error returns. +- **Security + auth** are inherited from the `/api` gin group; add `Security: []map[string][]string{{"poracleSecret": {}}}` to each op for the docs. +- **Remove the gin route** for each migrated endpoint from `main.go` in the same task. +- **Tags:** group ops with `Tags` (e.g. `reload`, `stats`, `geofence`, `masterdata`, `dts`, `summaries`, `autocreate`, `system`). +- **Pre-commit gate** (from `processor/`): `go build ./... && go vet ./... && go test -count=1 ./... && golangci-lint run ./...` — all green before each commit. +- **Commit trailer:** end each commit message with a blank line then `Co-Authored-By: Claude Opus 4.8 (1M context) `. + +## File structure + +New files in `processor/internal/api/`, one per cluster (keeps each focused): +- `huma_system.go` — health, reloads. +- `huma_data_reads.go` — weather, stats, geocode, geofence reads, masterdata, config/schema, snapshots. +- `huma_tiles.go` — geofence tile-URL endpoints. +- `huma_dts_reads.go` — DTS editor read endpoints. +- `huma_features.go` — autocreate/run + templates(schema/delete), summaries, command. +- Tests alongside as `*_test.go`; one golden-spec test in `huma_easywins_golden_test.go`. + +`main.go` loses the migrated gin route registrations and gains `api.Register(humaAPI, …)` calls. + +--- + +## Task 1: Worked example — reload endpoints (shared pattern) + +The 7 reload endpoints all use `HandleReload(fn)` and return `{status:"ok"}`. One huma op type covers all; register each with its own `fn`. + +**Files:** Create `processor/internal/api/huma_system.go`, `huma_system_test.go`; modify `main.go`. + +- [ ] **Step 1: failing test** (`huma_system_test.go`) +```go +func TestHumaReload_OK(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + humaAPI := NewHumaAPI(r, r.Group("/api"), "test") + called := false + RegisterReload(humaAPI, "test-reload", http.MethodGet, "/reload", func() error { called = true; return nil }) + + req := httptest.NewRequest(http.MethodGet, "/api/reload", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) } + var got map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &got) + if got["status"] != "ok" { t.Errorf("status=%v want ok", got["status"]) } + if !called { t.Error("reload fn not called") } +} + +func TestHumaReload_Error(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + humaAPI := NewHumaAPI(r, r.Group("/api"), "test") + RegisterReload(humaAPI, "test-reload-err", http.MethodGet, "/reload", func() error { return errors.New("boom") }) + req := httptest.NewRequest(http.MethodGet, "/api/reload", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusInternalServerError { t.Fatalf("status=%d", w.Code) } + var got map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &got) + if got["status"] != "error" { t.Errorf("error body = %s", w.Body.String()) } +} +``` + +- [ ] **Step 2: run → FAIL** (`RegisterReload` undefined). `go test ./internal/api/ -run TestHumaReload -v` + +- [ ] **Step 3: implement** (`huma_system.go`) +```go +package api + +import ( + "context" + "net/http" + "github.com/danielgtaylor/huma/v2" +) + +type statusOKOutput struct { + Body struct { + Status string `json:"status"` + } +} + +// RegisterReload registers a reload-style op (returns {"status":"ok"} or the +// legacy error envelope) for the given method/path on the shared huma API. +func RegisterReload(api huma.API, opID, method, path string, fn func() error) { + huma.Register(api, huma.Operation{ + OperationID: opID, Method: method, Path: path, + Summary: "Trigger a reload", Tags: []string{"reload"}, + Security: []map[string][]string{{"poracleSecret": {}}}, + }, func(ctx context.Context, _ *struct{}) (*statusOKOutput, error) { + if err := fn(); err != nil { + return nil, humaNewError(http.StatusInternalServerError, err.Error()) + } + out := &statusOKOutput{} + out.Body.Status = "ok" + return out, nil + }) +} +``` +Note: huma allows GET and POST on the same path via two `huma.Register` calls with distinct `OperationID`s. + +- [ ] **Step 4: run → PASS.** + +- [ ] **Step 5: wire main.go** — replace the 7 gin reload registrations (`apiGroup.{GET,POST}("/reload", …)`, `/geofence/reload` ×2, `/tracking/pokemon/refresh`, `/dts/reload` ×2) with `api.RegisterReload(humaAPI, "", "", "", )` using the same `fn` closures already present. Keep the closures (they call `proc.triggerReloadErr` / `reloadDTS` / geofence reload) intact. + +- [ ] **Step 6: gate + commit** `feat(api): huma in-place for reload endpoints`. + +## Task 2: Worked example — weather (typed query + map response) + +**Files:** `huma_data_reads.go`, `huma_data_reads_test.go`, `main.go`. + +- [ ] **Step 1: failing test** — `GET /api/weather?cell=` returns the same map JSON as `HandleWeather`; missing `cell` → 4xx with legacy error body. (Mirror Task 1's test style; use a stub `WeatherExporter`.) +- [ ] **Step 2: run → FAIL.** +- [ ] **Step 3: implement** +```go +type weatherInput struct { + Cell string `query:"cell" required:"true" doc:"S2 cell id"` +} +type weatherOutput struct{ Body any } // ExportCellWeather returns a map; preserve shape + +func RegisterWeather(api huma.API, weather WeatherExporter) { + huma.Register(api, huma.Operation{ + OperationID: "get-weather", Method: http.MethodGet, Path: "/weather", + Summary: "Weather for an S2 cell", Tags: []string{"data"}, + Security: []map[string][]string{{"poracleSecret": {}}}, + }, func(ctx context.Context, in *weatherInput) (*weatherOutput, error) { + return &weatherOutput{Body: weather.ExportCellWeather(in.Cell)}, nil + }) +} +``` +(`required:"true"` makes huma return its validation error when `cell` is absent — replaces the manual 400.) +- [ ] **Step 4: run → PASS.** +- [ ] **Step 5: wire main.go** — replace `apiGroup.GET("/weather", …)` with `api.RegisterWeather(humaAPI, proc.weather)`. +- [ ] **Step 6: gate + commit** `feat(api): huma in-place for weather`. + +## Task 3: Read-only data batch — stats, geocode, geofence reads, masterdata, config/schema, snapshots, health + +Each is a faithful in-place port following Tasks 1–2. Per endpoint: define input (path/query as needed) + output (`Body any` or a typed struct matching the current response), register, remove the gin route, test (assert success JSON shape + that the route serves under `/api`). Add to `huma_data_reads.go` / `huma_system.go`. + +**Per-endpoint checklist (repeat):** + +| endpoint | method | input | output Body | source handler | tag | +|---|---|---|---|---|---| +| `/health` | GET | none | typed `{status,version,capabilities}` (struct exists: `Capabilities`) | `HandleHealth` | system | +| `/stats/rarity` | GET | none | `any` | `HandleStats(ExportGroups)` | stats | +| `/stats/shiny` | GET | none | `any` | `HandleStats(ExportShinyStats)` | stats | +| `/stats/shiny-possible` | GET | none | `any` | `HandleStats(ExportShinyPossible)` | stats | +| `/geocode/forward` | GET | `q` query (required) | `any` (results slice) | `HandleGeocode` | data | +| `/geofence/all` | GET | none | `{status, geofence}` typed | `HandleGeofenceAll` | geofence | +| `/geofence/all/hash` | GET | none | `{status, areas}` map | hash handler | geofence | +| `/geofence/all/geojson` | GET | none | `{status, geoJSON}` | geojson handler | geofence | +| `/masterdata/monsters` | GET | `locale` query (optional) | `map[string]*poracle2Monster` (pre-marshalled bytes — may use `huma.Register` with a raw body or `Body any`) | `HandleMasterdataMonsters` | masterdata | +| `/masterdata/grunts` | GET | none | `map[string]*poracle2Grunt` | `HandleMasterdataGrunts` | masterdata | +| `/config/schema` | GET | none | `[]ConfigSection` typed | `HandleConfigSchema` | config | +| `/snapshots/{messageID}` | GET | `messageID` path, `target` query (required) | `snapshots.Snapshot` (503 if disabled, 404 if missing) | `HandleSnapshot` | system | + +- [ ] For each row: failing test → run FAIL → implement `Register` (read the handler for the exact response struct/fields and any error codes like snapshots' 404/503, returned via `humaNewError`) → run PASS → remove gin route in main.go → gate + commit per small group (e.g. `feat(api): huma in-place for stats endpoints`). +- [ ] **Note on masterdata:** the current handlers serve pre-marshalled `[]byte` via `c.Data(...)`. For huma, either expose `Body any` of the typed map (re-marshals; same JSON) or, if the pre-marshalled bytes must be preserved verbatim, keep that endpoint on gin and note it. Prefer `Body` typed map unless a test shows a diff. + +## Task 4: Geofence tile-URL endpoints (5) + +All return `{status, url}` JSON (NOT image bytes). Add to `huma_tiles.go`. + +| endpoint | input | source | +|---|---|---| +| `/geofence/{area}/map` | `area` path | `HandleAreaMap` | +| `/geofence/weatherMap/{lat}/{lon}` | `lat`,`lon` float path (+ `weather` optional query) | `HandleWeatherMap` | +| `/geofence/locationMap/{lat}/{lon}` | `lat`,`lon` float path | `HandleLocationMap` | +| `/geofence/distanceMap/{lat}/{lon}/{distance}` | 3 numeric path params | `HandleDistanceMap` | +| `/geofence/overviewMap` | body `{areas: []string}` (POST) | `HandleOverviewMap` | + +- [ ] Per endpoint: TDD port, output `{status, url}` struct, remove gin route, test, gate + commit `feat(api): huma in-place for geofence tile endpoints`. +- [ ] Float path params are fine (`float64` path fields), per the pokemon precedent. + +## Task 5: DTS editor read endpoints (8) + +Add to `huma_dts_reads.go`. All have typed responses per the triage. + +| endpoint | input | source | +|---|---|---| +| `/dts/emoji` | `platform` query (optional) | `HandleDtsEmoji` | +| `/dts/templates` (GET) | `type,platform,language,id` query (optional) | `HandleDtsTemplatesGet` | +| `/dts/templates` (DELETE) | `type,platform,language,id` query (required set) | `HandleDtsTemplatesDelete` | +| `/dts/fields` | none | `HandleDtsFields` | +| `/dts/fields/{type}` | `type` path | `HandleDtsFieldsType` | +| `/dts/partials` | none | `HandleDtsPartials` | +| `/dts/testdata` | `type` query (optional) | `HandleDtsTestdata` | +| `/dts/actions` | none | `HandleDtsActions` | +| `/dts/templates/file` (PUT) | body `{content}` + 4 query | `HandleDtsTemplateFile` | + +- [ ] Per endpoint: TDD port (read each handler for the exact typed response struct), remove gin route, test, gate. Commit in 2–3 logical groups (`feat(api): huma in-place for dts read endpoints`). +- [ ] Skip the MODERATE DTS endpoints (`/dts/templates` POST, `/dts/render`, `/dts/enrich`, `/dts/sendtest`) — out of scope here (see Task 8). + +## Task 6: New feature endpoints — autocreate, summaries, command + +Add to `huma_features.go`. + +| endpoint | method | input | source | +|---|---|---|---| +| `/autocreate/run` | POST | typed `{rule,dry_run,reset,removals,force}` | `HandleAutocreateRun` | +| `/autocreate/templates/{name}` | DELETE | `name` path | delete handler | +| `/autocreate/templates/schema` | GET | none | schema handler | +| `/summaries/{id}` | GET | `id` path | list handler | +| `/summaries/{id}/{alertType}` | GET | 2 path | get handler | +| `/summaries/{id}/{alertType}` | DELETE | 2 path | delete handler | +| `/summaries/{id}/{alertType}/trigger` | POST | 2 path | trigger handler | +| `/command` | POST | typed `commandRequest` | `HandleCommand` | + +- [ ] Per endpoint: TDD port using the existing typed request/response structs (these already exist — reuse them as the huma `Body` types), remove gin route, test, gate. Commit per feature group. +- [ ] Skip `POST /summaries/{id}/{alertType}` (polymorphic `active_hours`) and the autocreate templates save/validate (raw-JSON body) — they're MODERATE (Task 8). + +## Task 7: Golden OpenAPI spec test for the easy-wins surface + +**Files:** `huma_easywins_golden_test.go`, `testdata/openapi-easywins.golden.json`. + +- [ ] Build a huma API, register all easy-win groups against stub deps, marshal `humaAPI.OpenAPI().MarshalJSON()`, compare to a committed golden file (with `-update`). Eyeball: every easy-win path present, `poracleSecret` security on each, tags grouped. Gate + commit. + +## Task 8: MODERATE endpoints — decision record (no code) + +**Files:** append a short section to `docs/v2-api-design.md` or a new `docs/huma-moderate-endpoints.md`. + +- [ ] Record the disposition for the ~15 MODERATE endpoints (freeform `map[string]any` / `json.RawMessage` bodies): which to huma-fy later with `Body any`/`json.RawMessage` (accepting open schemas) vs leave on gin (`config/values`+`validate` recommended to stay on gin until the editor wire format stabilises). No implementation in this plan. + +## Task 9: Docs note + +- [ ] Update `README.md` / CLAUDE.md API section: the listed `/api` read/reload/feature endpoints now appear in the OpenAPI spec (`/openapi.json`, `/docs`); note the error-body normalization to `{status,message}` for migrated endpoints. + +--- + +## Self-review + +- **Scope coverage:** all ~30 EASY endpoints from the triage are assigned (Tasks 1–6); golden test (7); MODERATE explicitly deferred (8); docs (9). Tracking/humans/profiles untouched (correct — they're v2/frozen). +- **Contract fidelity:** success JSON preserved per endpoint (read the handler); the one accepted change — error bodies normalize to `{status,message}` — is called out in Conventions and Task 9. +- **No second huma instance:** every task registers on the existing `NewHumaAPI` instance (Conventions) — avoids the global-`huma.NewError` conflict and reuses public docs. +- **Placeholders:** per-endpoint "read the handler for the exact response struct" is intentional (the handlers already define typed responses; enumerate them at implementation). The two worked examples (reload, weather) carry full code as the copy template. +- **Risk:** masterdata serves pre-marshalled bytes — Task 3 notes the verbatim-bytes caveat and a gin fallback if a test shows a diff. +- **Independence:** this plan stands alone (delivers a documented `/api` read surface) regardless of v2 progress or RFC feedback. diff --git a/docs/superpowers/plans/2026-06-03-huma-full-api-master-plan.md b/docs/superpowers/plans/2026-06-03-huma-full-api-master-plan.md new file mode 100644 index 000000000..2e7dffc7e --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-huma-full-api-master-plan.md @@ -0,0 +1,179 @@ +# Huma Full-API Master Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. +> **Execution:** build **all phases now (P0–P5)**. #138 implementor feedback is welcome but **non-blocking** — fold in any changes if they arrive. **Mega:** build v2 pokemon **without** `pvp_ranking_evolution` for now (it depends on the unmerged `pvp-mega-evolution` branch); add that one field as a follow-up after mega merges to `develop` and this branch rebases. Do **not** base this build on the mega branch (avoids carrying unmerged commits). +> +> **Build kickoff (fresh session):** "Execute this plan, **phases P0–P5**, using superpowers:subagent-driven-development. Companion specs: `docs/v2-api-design.md`, `docs/superpowers/specs/huma-tracking-field-audit.md`. Worktree `PoracleNG-huma-api`, branch `huma-api-migration`. Start at Task 0.1; Task 0.2 reverts the partial v1 pokemon huma migration before anything new is built." + +**Goal:** Migrate the *entire* PoracleNG `/api` HTTP surface to huma in one coordinated effort: document the simple/new endpoints **in place** at `/api/*`, and deliver a **clean, strict `/api/v2`** for the tracking/humans/profiles CRUD — all in a single OpenAPI spec, with `problem+json` errors throughout. + +**Architecture:** **One** huma API instance, mounted on the existing authenticated `/api` gin group via `humagin.NewWithGroup` (`api.NewHumaAPI`). Op paths are relative to `/api`: in-place ops use `/reload`, `/weather`, … (→ `/api/…`); v2 ops use `/v2/tracking/{type}`, `/v2/humans/{id}`, … (→ `/api/v2/…`). One spec at `/openapi.json`, one docs page at `/docs`. v1 tracking/humans/profiles stay on **gin, frozen, untouched**. Errors are RFC 9457 `problem+json` everywhere (the legacy `{status,message}` override is removed). Success bodies are per-op: in-place endpoints preserve their current success JSON; v2 endpoints are bare typed bodies. + +**Tech Stack:** Go 1.26, gin + `humagin`, `huma/v2`, `net/http/httptest`. + +**Companion docs (authoritative for detail):** +- v2 contract: `docs/v2-api-design.md` (+ RFC issue #138) +- v2 field semantics: `docs/superpowers/specs/huma-tracking-field-audit.md` +- In-place easy-wins detail: `docs/superpowers/plans/2026-06-03-huma-easy-wins-inplace.md` (its tasks are P1 here; **its error convention is superseded** — errors are `problem+json`, not `{status,message}`) + +--- + +## Locked decisions (this is the "how it works") + +1. **One huma instance**, mounted at `/api`; in-place ops at `/x`, v2 ops at `/v2/x`. One OpenAPI spec covering both. +2. **Errors: `problem+json` everywhere.** Remove `InstallLegacyErrorModel`/the legacy override; use huma's default error model. Success bodies unchanged per-op. +3. **v1 frozen.** Revert the in-place pokemon huma migration; restore original gin pokemon routes. Remove v1-compat huma machinery (`lenient[T]`, the flex `SchemaProvider` leniency, `monsterRuleRows`, single-or-array) — **not** needed by strict v2. +4. **In-place coverage:** all EASY (~30) + all MODERATE (~15, **including** `config/values`+`validate` with open `any`/`RawMessage` bodies). LEAVE-ON-GIN: webhook `POST /`, `/metrics`, `/openapi.json`, `/docs`, pprof. +5. **v2 = strict:** `additionalProperties:false`, required enforced, **no lenient coercion**. Enums are **pure string** (no legacy-int acceptance); game-master IDs are int. **Human-scoped** resource model `/v2/humans/{id}/tracking/{type}[/{uid}]`, item ops scoped by `(human, uid)` (ownership guard, like v1). +6. **v2 humans/profiles:** **discrete action endpoints** (not PATCH-consolidated), cleaned/typed, under `/api/v2`. +7. **CHANGELOG** items: error-format change to problem+json on the new huma surface; `include_empty` default→true; v1→v2 migration encouragement. + +--- + +## API Surface Inventory (complete — for review) + +Every one of the 124 registered routes is accounted for below. **Built in huma** = A (in-place) + B (v2). **Left on gin** = C (permanent) + D (frozen v1). + +### A. BUILT IN HUMA — in-place at `/api/*` (same paths, same success JSON, `problem+json` errors) + +- **Reloads (6):** `GET|POST /api/reload`, `GET|POST /api/geofence/reload`, `GET|POST /api/dts/reload` +- **Read-only data (12):** `GET /health`, `GET /api/weather`, `GET /api/stats/{rarity,shiny,shiny-possible}`, `GET /api/geocode/forward`, `GET /api/geofence/{all,all/hash,all/geojson}`, `GET /api/masterdata/{monsters,grunts}`, `GET /api/config/schema`, `GET /api/snapshots/{messageID}` +- **Geofence tile-URL (5):** `GET /api/geofence/{area}/map`, `GET /api/geofence/weatherMap/{lat}/{lon}`, `GET /api/geofence/locationMap/{lat}/{lon}`, `GET /api/geofence/distanceMap/{lat}/{lon}/{distance}`, `POST /api/geofence/overviewMap` +- **DTS editor (13):** `GET /api/dts/{emoji,templates,fields,fields/{type},partials,testdata,actions}`, `DELETE /api/dts/templates`, `PUT /api/dts/templates/file`, `POST /api/dts/{render,enrich,sendtest,templates}` +- **Config editor (5):** `GET /api/config/{poracleWeb,templates,values}`, `POST /api/config/{values,validate}` *(open bodies)* +- **Autocreate (6):** `POST /api/autocreate/run`, `GET /api/autocreate/{templates,templates/schema}`, `POST /api/autocreate/{templates,templates/validate}`, `DELETE /api/autocreate/templates/{name}` +- **Summaries (5):** `GET /api/summaries/{id}`, `GET /api/summaries/{id}/{alertType}`, `POST /api/summaries/{id}/{alertType}` *(typed `active_hours`)*, `POST /api/summaries/{id}/{alertType}/trigger`, `DELETE /api/summaries/{id}/{alertType}` +- **Other (5):** `POST /api/command`, `POST /api/test`, `POST /api/deliverMessages`, `POST /api/postMessage`, `POST /api/resolve` + +### B. BUILT IN HUMA — new clean `/api/v2/*` + +- **Tracking (11 types × CRUD), human-scoped:** `GET|POST /api/v2/humans/{id}/tracking/{type}`, `GET|PUT|DELETE /api/v2/humans/{id}/tracking/{type}/{uid}` (scoped by `(human, uid)` — ownership guard), bulk `DELETE …/{type}?uid=`, plus **full snapshot** `GET /api/v2/humans/{id}/tracking` → `{human, tracking:{:[...]}, profiles, locations, summaries}` (`?all_profiles=`, `?include_descriptions=`). Types: `pokemon, raid, egg, quest, invasion, incident (NEW), lure, nest, gym, fort, maxbattle`. +- **Humans (discrete actions):** `POST /api/v2/humans`, `GET /api/v2/humans/{id}`, `GET …/{id}/areas`, `POST …/{id}/{enable,disable,admin-disable,language,location,areas,profile}`, `GET …/{id}/check-location`, locations `GET (list)`, `GET/{label}`, `POST`, **`PUT/{label}` (NEW)**, `DELETE/{label}`, roles `GET`, `POST/DELETE …/{roleId}`, `GET …/{id}/admin-roles`. +- **Profiles:** `GET /api/v2/profiles/{id}`, `POST` (add), `PATCH …/{profile_no}` (active_hours), `DELETE …/{profile_no}`, `POST …/{profile_no}/copy`. + +### C. LEFT ON GIN — permanently (not a huma fit, by design) + +| route | why | +|---|---| +| `POST /` | Golbat webhook receiver — hot path, unauthenticated, mixed-type array | +| `GET /metrics` | Prometheus text exposition | +| `GET /openapi.json`, `GET /docs` | huma's own spec/docs output | +| `GET /debug/pprof/`, `GET /debug/pprof/{name}` | Go pprof, binary/text | + +### D. LEFT ON GIN — frozen v1 (superseded by `/api/v2`; gin until clients migrate, then deprecated) + +- **Tracking v1 (all 10 types):** `GET|POST /api/tracking/{type}/{id}`, `DELETE …/{id}/byUid/{uid}`, `POST …/{id}/delete`, plus `GET /api/tracking/{all/{id},allProfiles/{id},pokemon/refresh}`. +- **Humans v1:** `POST /api/humans` (create), `GET /api/humans/{one/{id},{id},{id}/roles,{id}/getAdministrationRoles,{id}/checkLocation/{lat}/{lon},{id}/locations,{id}/locations/{label}}`, `POST /api/humans/{id}/{start,stop,adminDisabled,language,switchProfile/{profile},setLocation/{lat}/{lon},setAreas,roles/add/{roleId},roles/remove/{roleId},locations/add,locations/{label}/delete}`. +- **Profiles v1:** `GET /api/profiles/{id}`, `POST /api/profiles/{id}/{add,update,copy/{from}/{to}}`, `DELETE /api/profiles/{id}/byProfileNo/{profile_no}`. + +> **Note on `/api/tracking/pokemon/refresh`:** it's a reload alias living under the frozen `/api/tracking` namespace. Kept on gin (D) to avoid splitting that namespace; the documented reload is the huma `GET /api/reload` (A). + +**Coverage confirmation:** A (≈52) + B (new v2) + C (6) + D (≈45 frozen) accounts for all 124 registered routes. Nothing is unclassified. + +--- + +## Phase 0 — Foundation rework + +### Task 0.1: Switch error model to problem+json +**Files:** `internal/api/huma_setup.go`, `huma_setup_test.go`, any test asserting `{status:error,message}`. +- [ ] Remove `InstallLegacyErrorModel` (and its call in `NewHumaAPI`); delete `legacyError`/`humaNewError` OR repoint `humaNewError` to `huma.NewError` so call sites compile. Handlers return errors via `huma.Error404NotFound(...)` etc. (huma's typed constructors). +- [ ] Update/replace tests that asserted the legacy envelope to assert `problem+json` (`status`, `detail`, `errors[]`; no `{status:"error"}`). +- [ ] Keep the `$schema`-suppression (`cfg.CreateHooks = nil`) — still wanted. +- [ ] Gate + commit `refactor(api): problem+json error model for the huma surface`. + +### Task 0.2: Revert in-place pokemon migration (freeze v1) +**Files:** `main.go`, `huma_tracking.go`, `huma_post_monster*.go`, `huma_delete_monster*.go`, `tracking.go`. +- [ ] Restore the gin routes for `GET/POST/DELETE /tracking/pokemon/...` + bulk in `main.go` (the original `api.HandleGetMonster` etc. still exist in `trackingMonster.go`). +- [ ] Remove the huma pokemon ops + v1-compat machinery: `monsterRuleRows`, `lenient[T]`, the flex `SchemaProvider` methods on `flexInt`/`flexBool`, `collapseClean` (re-add in v2 if needed), and now-unused helpers. Keep `flexInt`/`flexBool` themselves (still used by gin v1). +- [ ] Remove the temporary `flex_enum.go` lint exclusion plan (the enum toolkit is reworked in P3). +- [ ] Gate + commit `refactor(api): revert in-place pokemon huma migration (v1 frozen)`. + +### Task 0.3: Confirm single-instance dual-path mount +- [ ] Add a test: register one trivial in-place op (`/ping`) and one v2 op (`/v2/ping`) on the same `NewHumaAPI`, assert both serve and both appear in `OpenAPI().MarshalJSON()`. Confirms the one-instance/two-path-prefix model. Gate + commit. + +--- + +## Phase 1 — In-place EASY endpoints (~30) + +Execute the tasks in `docs/superpowers/plans/2026-06-03-huma-easy-wins-inplace.md` (Tasks 1–7), with these amendments: errors are `problem+json` (Task 0.1), so drop the legacy-error notes; register on the shared instance. Clusters: reloads, read-only data (health/stats/geocode/geofence-reads/masterdata/config-schema/snapshots), tile-URL, DTS reads, feature endpoints (autocreate/run, summaries GET/DELETE/trigger, command). Worked examples (reload, weather) are in that doc. +- [ ] Complete easy-wins Tasks 1–6 (per-cluster commits). +- [ ] Easy-wins Task 7 golden test folded into the master golden test (P5). + +## Phase 2 — In-place MODERATE endpoints (~15) + +Open schemas for freeform fields. Each: typed input for path/query, `Body json.RawMessage` or `Body any` for the freeform part, reuse handler logic, remove gin route, test (parse boundary + success shape), commit per group. + +| endpoint | freeform part | source | +|---|---|---| +| `POST /test` | `webhook` RawMessage | `HandleTest` | +| `POST /dts/render` | `view` map; resp `message` any | render handler | +| `POST /dts/enrich` | `webhook` RawMessage | enrich handler | +| `POST /dts/sendtest` | `template` any, `variables` map | sendtest handler | +| `POST /dts/templates` | `[]DTSEntry` (polymorphic `template`) | save handler | +| `POST /deliverMessages` + `POST /postMessage` | `[]delivery.Job` (`Message` RawMessage) | deliver handler | +| `POST /resolve` | nested optional + per-entity `any` | resolve handler | +| `POST /summaries/{id}/{alertType}` | **typed** `active_hours` (`[]ActiveHourEntry`, see design §2b) — NOT freeform; v1 already validates this shape via `ParseActiveHours` | upsert handler | +| `GET/POST /autocreate/templates`, `POST …/validate` | raw-JSON templates | autocreate template handlers | +| `GET /config/templates`, `GET /config/poracleWeb` | dynamic-keyed map → `Body any` | config handlers | +| `GET/POST /config/values`, `POST /config/validate` | reflection `map[string]any` → open body/resp | config handlers | + +- [ ] Per group: TDD port with open schemas, remove gin route, gate + commit `feat(api): huma in-place for `. +- [ ] Document in the spec that these bodies are intentionally open (`description` noting the freeform contract). + +## Phase 3 — v2 tracking + +Strict, per `docs/v2-api-design.md` + the field audit. Resource model: **human-scoped** `/v2/humans/{id}/tracking/{type}` (GET list `?profile=&include_descriptions=`, POST create), `/v2/humans/{id}/tracking/{type}/{uid}` (GET/PUT/DELETE, **scoped by `(human, uid)`** — like v1's `DeleteByUID(id, uid)`), `?uid=` bulk delete; `?silent=true` on mutations. + +### Task 3.1: Strict v2 building blocks +- [ ] **Strict enum types** — rework/parallel `flex_enum.go`: v2 enums are **string-only** (no int acceptance), `additionalProperties:false`-compatible. Keep the name↔int maps for storage translation. (team, gender, fort_type, rsvp_changes; reward_type/lure_id/league/pvp_ranking_evolution stay **int**.) +- [ ] **Strict request structs** — real `bool`/`int`/string-enum fields; `clean`/`edit`/`summary` bools → packed `clean` column; required `pokemon_id` etc.; `additionalProperties:false`. +- [ ] **Resource helpers** — human-scoped addressing (`{id}` path), `(human, uid)` ownership scoping on item ops, `profile`/`include_descriptions`/`silent` query binding; list → `{rules:[…]}`; create → `{created,updated,unchanged}` (delete → `{deleted}`) with uids. **`?include_descriptions=true` is uniform across reads AND mutations**: when set, each rule in the response (rules/created/updated/unchanged/deleted) gets a `description` (human's language). No assembled `message` field — status is the array placement; the prefixed confirmation message stays the Discord/Telegram push (gated by `silent`). Reuse the rowtext generator + `translatorFor`. +- [ ] Tests for the building blocks; gate + commit. + +### Task 3.2: pokemon v2 (worked example) — GET list, POST create, GET/PUT/DELETE by uid, bulk delete, full snapshot. Faithful to the engine; strict schemas. **Omit `pvp_ranking_evolution`** (depends on the unmerged mega branch) — add it in a follow-up once `pvp-mega-evolution` is in `develop` and this branch rebases. Commit. + +### Task 3.3: Fan-out the other 10 types (raid, egg, quest, invasion, **incident**, lure, nest, gym, fort, maxbattle) +- [ ] Per type: apply the audit's per-field modeling; **invasion** exactly-one-mode (`type_id`|`grunt_id`|`everything`|`boss`) with facade down-translation to the stored grunt-type name; **incident** new type keyed by `display_type` int; `fort.include_empty` default true. One commit per type. + +### Task 3.4: v2 full snapshot — `GET /v2/humans/{id}/tracking` returns `{human, tracking:{:[...]}, profiles, locations, summaries}` (replaces v1 `all/{id}`); `?all_profiles=true` spans all profiles (replaces `allProfiles/{id}`); `?include_descriptions=` adds rowtext. Reuses the per-type list logic + profile/location/summary reads. Commit. + +## Phase 4 — v2 humans/profiles + +**Discrete action endpoints**, cleaned/typed, under `/api/v2`. Mirror v1's actions with proper types + problem+json + strict bodies. Reuse the store/business logic. + +| v2 endpoint | from v1 | shape | +|---|---|---| +| `POST /v2/humans` | create | typed body (id,type,name,…) | +| `GET /v2/humans/{id}` | one/{id} | typed human resource | +| `GET /v2/humans/{id}/areas` | `/{id}` | available areas | +| `POST /v2/humans/{id}/enable` / `/disable` | start/stop | no body | +| `POST /v2/humans/{id}/admin-disable` | adminDisabled | `{disabled: bool}` | +| `POST /v2/humans/{id}/language` | language | `{language: string}` | +| `POST /v2/humans/{id}/location` | setLocation/{lat}/{lon} | `{lat,lon}` floats body | +| `GET /v2/humans/{id}/check-location` | checkLocation | `?lat=&lon=` | +| `POST /v2/humans/{id}/areas` | setAreas | `{areas: []string}` | +| `GET/POST /v2/humans/{id}/locations`, **`PUT …/{label}`** (NEW — update coords), `DELETE …/{label}` | locations CRUD | typed `{label,lat,lon}` | +| `GET /v2/humans/{id}/roles`, `POST/DELETE …/{roleId}` | roles | typed | +| `GET /v2/humans/{id}/admin-roles` | getAdministrationRoles | typed | +| `POST /v2/humans/{id}/profile` | switchProfile/{n} | `{profile_no: int}` | +| `GET /v2/humans/{id}/profiles`, `POST` (add), `PATCH …/{profile_no}` (update active_hours), `DELETE …/{profile_no}`, `POST …/{profile_no}/copy` | profiles (sub-resource of human) | typed | + +- [ ] Field modeling (all DEFINED — see `docs/v2-api-design.md` §2b): `enabled`/admin-disable → bool; `areas` → `[]string`; `location` → `{lat,lon}` floats; `language` → string (validate against locales); `blocked_alerts` → read-only `[]string` enum (`monster|pvp|raid|egg|quest|invasion|lure|nest|gym|fort|maxbattle|specificgym|specificstation`); `active_hours` → typed `[]ActiveHourEntry` (`day 0-6, hours 0-23, mins 0-59, optional step/end_hours/end_mins`, strict ints, no cross-midnight) shared by profile-schedule update **and** `POST /v2/summaries/{id}/{alertType}` (replaces the freeform passthrough). +- [ ] **NEW capability:** `PUT /v2/humans/{id}/locations/{label}` to update a saved location's coords (v1 has no update — only add/delete). Completes locations CRUD. +- [ ] Per cluster (status, location/areas, locations, roles, profiles, schedules): TDD, reuse handlers (add a small store method for the new locations PUT), commit. + +## Phase 5 — Finalize + +- [ ] **Golden OpenAPI test** over the whole spec (in-place + v2), committed `testdata/openapi.golden.json`. +- [ ] **Remove dead code** — any now-unused v1-compat helpers; confirm no orphaned gin handlers for migrated in-place endpoints; lint clean (remove temporary exclusions). +- [ ] **Docs** — README/CLAUDE.md: the `/api` surface and `/api/v2` are documented at `/docs`; note the migrated endpoints, the v1-frozen status, and the v1→v2 encouragement. +- [ ] **CHANGELOG** — problem+json on the huma surface; `include_empty` default→true; new v2 surface + `incident` type. + +--- + +## Self-review +- **Coverage:** every endpoint from the triage is assigned — EASY (P1), MODERATE incl config/values (P2), v2 tracking incl incident (P3), v2 humans/profiles discrete actions (P4), LEAVE-ON-GIN explicitly excluded. v1 frozen via P0.2. +- **Decision fidelity:** problem+json everywhere (P0.1, supersedes easy-wins legacy note); one instance/two path prefixes (P0.3); discrete humans/profiles actions (P4); max in-place coverage (P2). +- **Gating:** P0–P2 independent; P3–P4 wait on #138 — flagged at top and per-phase. +- **Detail strategy:** worked examples live in the companion docs (easy-wins reload/weather; pokemon v2 in 3.2); fan-outs are delta tables driven by the audit — consistent with the prior plans' approach. +- **Open items to finalize at build time:** strict-enum reuse vs rework of `flex_enum.go` (3.1); `active_hours`/`blocked_alerts` shapes (P4); any #138 resource-shape feedback (P3). diff --git a/docs/superpowers/plans/2026-07-15-costume-tracking.md b/docs/superpowers/plans/2026-07-15-costume-tracking.md new file mode 100644 index 000000000..41ee77bd7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-costume-tracking.md @@ -0,0 +1,701 @@ +# Pokémon Costume Tracking 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:** Let operators track Pokémon by costume, display costumes in alerts/`!info`, and expose costume on both API versions. + +**Architecture:** Mirror the existing `form` filter — a new `monsters.costume` column filtered in the matcher — but with a `9000`-wildcard so `0` can mean "no costume". Costume names come from the existing `costume_{id}` translations and `costumes.json`; the costume is woven into `fullName` and surfaced in `!info` via the shared `RecentActivity`. + +**Tech Stack:** Go, MySQL (sqlx), huma (v2 API), gin (v1 API), raymond/DTS. + +## Global Constraints + +- **Wildcard sentinel:** `bot.WildcardID = 9000`. Costume: **9000 = any**, **0 = no costume**, **N = that costume**. (Unlike `form`, where 0 = any.) +- **v1 compatibility:** an absent `costume` in a v1 payload MUST default to **9000**, never `0`. +- Pre-commit gate (run from `processor/`): `go build ./... && go vet ./... && go test -count=1 ./... && golangci-lint run ./...`. Every commit must pass it. +- Costume is present pre-encounter (`seen_type:wild`), so costume filtering is not gated by the encounter-only-stat-skip rule. +- Costume applies to the **spawn's** name only, never PVP/evolution ranking entries. + +--- + +## File structure + +- `internal/gamedata/costumes.go` (new) — `CostumeInfo`, loader, `CostumeTranslationKey`. +- `internal/gamedata/gamedata.go` / loader — hold `Costumes map[int]CostumeInfo`. +- `internal/db/migrations/00XX_add_monster_costume.{up,down}.sql` (new). +- `internal/db/monsters.go` — `MonsterTracking.Costume`, SELECT column. +- `internal/db/tracking_queries.go` — `MonsterTrackingAPI.Costume` + absent→9000 `UnmarshalJSON`. +- `internal/matching/pokemon.go` — `ProcessedPokemon.Costume`, costume filter. +- `internal/enrichment/translate.go` — weave costume into `buildFullName`. +- `internal/enrichment/pokemon.go` — pass costume through; `costumeName`. +- `internal/tracker/recent_activity.go` — `RecordCostume` / `RecentCostumes`. +- `cmd/processor/pokemon.go` — call `RecordCostume`. +- `internal/bot/argmatch.go` — `arg.prefix.costume` + costume name vocabulary. +- `internal/bot/commands/{track,untrack,info}.go` + `internal/rowtext/monster.go`. +- `internal/discordbot/slash/{definitions.go,mappers/track.go,autocomplete/*}`. +- `internal/api/v2_pokemon.go` — `Costume *int`. +- `internal/api/dts_fields.go` + `DTS.md`. +- `internal/i18n/locale/en.json` — new keys. + +--- + +### Task 1: Costume game data + +**Files:** +- Create: `internal/gamedata/costumes.go` +- Modify: `internal/gamedata/utildata.go` (or the game-data holder that already loads `pokemon.json`/`forms.json`) to add `Costumes map[int]CostumeInfo` and load `resources/rawdata/costumes.json` +- Test: `internal/gamedata/costumes_test.go` + +**Interfaces:** +- Produces: `type CostumeInfo struct { ID int; Name, Proto string; NoEvolve bool }`; `func CostumeTranslationKey(id int) string` → `"costume_{id}"`; `GameData.Costumes map[int]CostumeInfo`. + +- [ ] **Step 1: Write the failing test** + +```go +// internal/gamedata/costumes_test.go +package gamedata + +import "testing" + +func TestCostumeTranslationKey(t *testing.T) { + if got := CostumeTranslationKey(1); got != "costume_1" { + t.Errorf("CostumeTranslationKey(1) = %q, want costume_1", got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/gamedata/ -run TestCostumeTranslationKey` +Expected: FAIL — `undefined: CostumeTranslationKey`. + +- [ ] **Step 3: Write minimal implementation** + +```go +// internal/gamedata/costumes.go +package gamedata + +import "fmt" + +// CostumeInfo is one entry from resources/rawdata/costumes.json. +type CostumeInfo struct { + ID int `json:"id"` + Name string `json:"name"` + Proto string `json:"proto"` + NoEvolve bool `json:"noEvolve"` +} + +// CostumeTranslationKey returns "costume_{id}" for a costume ID. +func CostumeTranslationKey(id int) string { + return fmt.Sprintf("costume_%d", id) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/gamedata/ -run TestCostumeTranslationKey` +Expected: PASS. + +- [ ] **Step 5: Add the loader** + +In the game-data loader that already reads `resources/rawdata/*.json` (grep `pokemon.json` under `internal/gamedata` / `internal/resources` for the read site), add: + +```go +// alongside the pokemon/forms load: +costumeBytes, err := os.ReadFile(filepath.Join(rawDir, "costumes.json")) +if err == nil { + var raw map[string]CostumeInfo + if json.Unmarshal(costumeBytes, &raw) == nil { + gd.Costumes = make(map[int]CostumeInfo, len(raw)) + for _, c := range raw { + gd.Costumes[c.ID] = c + } + } +} +``` + +Add `Costumes map[int]CostumeInfo` to the `GameData` struct. + +- [ ] **Step 6: Add a load test** + +```go +// internal/gamedata/costumes_test.go — append +func TestCostumesLoaded(t *testing.T) { + gd := loadTestGameData(t) // reuse the existing gamedata test loader + if gd.Costumes == nil || gd.Costumes[1].Name == "" { + t.Fatalf("costume 1 not loaded; got %+v", gd.Costumes[1]) + } +} +``` +(If no `loadTestGameData` helper exists, mirror the loader used by `gamedata_test.go`.) + +- [ ] **Step 7: Run tests + gate + commit** + +Run: `go test ./internal/gamedata/ && go build ./...` +```bash +git add internal/gamedata/costumes.go internal/gamedata/costumes_test.go internal/gamedata/*.go +git commit -m "feat(gamedata): load costumes.json + CostumeTranslationKey" +``` + +--- + +### Task 2: DB schema + tracking structs + v1 absent→9000 default + +**Files:** +- Create: `internal/db/migrations/00XX_add_monster_costume.up.sql` / `.down.sql` (pick the next number after the highest existing migration) +- Modify: `internal/db/monsters.go` (`MonsterTracking` struct + SELECT), `internal/db/tracking_queries.go` (`MonsterTrackingAPI` + `UnmarshalJSON`) +- Test: `internal/api/costume_unmarshal_test.go` (new) + +**Interfaces:** +- Produces: `MonsterTracking.Costume int` (`db:"costume"`), `MonsterTrackingAPI.Costume int` (`db:"costume" json:"costume"`), and `MonsterTrackingAPI.UnmarshalJSON` defaulting absent costume to 9000. + +- [ ] **Step 1: Migration** + +```sql +-- 00XX_add_monster_costume.up.sql +ALTER TABLE monsters ADD COLUMN costume INT NOT NULL DEFAULT 9000; +``` +```sql +-- 00XX_add_monster_costume.down.sql +ALTER TABLE monsters DROP COLUMN costume; +``` + +- [ ] **Step 2: Struct fields** + +`internal/db/monsters.go` — add to `MonsterTracking` after `Form`: +```go + Costume int `db:"costume"` +``` +`internal/db/tracking_queries.go` — add to `MonsterTrackingAPI` after `Form`: +```go + Costume int `db:"costume" json:"costume"` +``` + +- [ ] **Step 3: SELECT column** + +`internal/db/monsters.go:92` — add `costume,` to the SELECT column list (after `form,`): +```go + `SELECT uid, id, profile_no, pokemon_id, form, costume, distance, +``` +(The generic store INSERT builds from `db` tags, so no INSERT edit is needed — verify by grepping the store; if there's an explicit monster insert column list, add `costume` there too.) + +- [ ] **Step 4: Write the failing test for the absent→9000 default** + +```go +// internal/api/costume_unmarshal_test.go +package api + +import ( + "encoding/json" + "testing" + + "github.com/pokemon/poracleng/processor/internal/db" +) + +func TestMonsterTrackingAPI_CostumeDefaults(t *testing.T) { + cases := []struct { + name string + body string + want int + }{ + {"absent → 9000", `{"pokemon_id":25}`, 9000}, + {"explicit 0 → 0", `{"pokemon_id":25,"costume":0}`, 0}, + {"explicit 5 → 5", `{"pokemon_id":25,"costume":5}`, 5}, + } + for _, c := range cases { + var m db.MonsterTrackingAPI + if err := json.Unmarshal([]byte(c.body), &m); err != nil { + t.Fatalf("%s: %v", c.name, err) + } + if m.Costume != c.want { + t.Errorf("%s: Costume = %d, want %d", c.name, m.Costume, c.want) + } + } +} +``` + +- [ ] **Step 5: Run test to verify it fails** + +Run: `go test ./internal/api/ -run TestMonsterTrackingAPI_CostumeDefaults` +Expected: FAIL — absent case gives 0, want 9000. + +- [ ] **Step 6: Add the defaulting UnmarshalJSON** + +`internal/db/tracking_queries.go` — add: +```go +// UnmarshalJSON defaults an absent costume to the 9000 wildcard ("any") rather +// than the Go zero-value 0 ("no costume"), so v1 clients (ReactMap/PoracleWeb) +// that don't send the field never create no-costume rules. Present values pass +// through verbatim. +func (m *MonsterTrackingAPI) UnmarshalJSON(data []byte) error { + type alias MonsterTrackingAPI + tmp := alias{Costume: 9000} + if err := json.Unmarshal(data, &tmp); err != nil { + return err + } + *m = MonsterTrackingAPI(tmp) + return nil +} +``` +(`encoding/json` is already imported in `monsters.go`; ensure it's imported in `tracking_queries.go`.) + +- [ ] **Step 7: Run test + gate + commit** + +Run: `go test ./internal/api/ -run TestMonsterTrackingAPI_CostumeDefaults && go build ./...` +```bash +git add internal/db/migrations/00XX_add_monster_costume.*.sql internal/db/monsters.go internal/db/tracking_queries.go internal/api/costume_unmarshal_test.go +git commit -m "feat(db): monsters.costume column + API absent→9000 default" +``` + +--- + +### Task 3: Matcher costume filter + +**Files:** +- Modify: `internal/matching/pokemon.go` (`ProcessedPokemon.Costume`, populate it, filter in `matchMonsters`) +- Test: `internal/matching/pokemon_costume_test.go` (new) + +**Interfaces:** +- Consumes: `MonsterTracking.Costume` (Task 2). +- Produces: `ProcessedPokemon.Costume int`; costume filter in `matchMonsters`. + +- [ ] **Step 1: Write the failing test** + +```go +// internal/matching/pokemon_costume_test.go +package matching + +import ( + "testing" + "github.com/pokemon/poracleng/processor/internal/db" +) + +func TestMatchMonsters_Costume(t *testing.T) { + m := &PokemonMatcher{} + data := &ProcessedPokemon{PokemonID: 25, Form: 598, Costume: 1} + mk := func(costume int) []*db.MonsterTracking { + return []*db.MonsterTracking{{ID: "u1", PokemonID: 25, Form: 0, Costume: costume}} + } + if got := m.matchMonsters(data, mk(9000), 25, 0, false, 0, pvpZero()); len(got) != 1 { + t.Error("costume 9000 (any) should match") + } + if got := m.matchMonsters(data, mk(1), 25, 0, false, 0, pvpZero()); len(got) != 1 { + t.Error("costume 1 should match costume-1 spawn") + } + if got := m.matchMonsters(data, mk(2), 25, 0, false, 0, pvpZero()); len(got) != 0 { + t.Error("costume 2 should NOT match costume-1 spawn") + } + if got := m.matchMonsters(data, mk(0), 25, 0, false, 0, pvpZero()); len(got) != 0 { + t.Error("costume 0 (no costume) should NOT match costumed spawn") + } +} +``` +(Add a `pvpZero()` helper returning a zero `pvp.LeagueRank` if the test package doesn't already have one; import `pvp` as needed.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/matching/ -run TestMatchMonsters_Costume` +Expected: FAIL — `ProcessedPokemon` has no `Costume`, and no filter yet. + +- [ ] **Step 3: Add `Costume` to `ProcessedPokemon` and populate it** + +`internal/matching/pokemon.go` — add `Costume int` to `ProcessedPokemon` (after `Form`), and in `ProcessPokemonWebhook` set `processed.Costume = pokemon.Costume`. + +- [ ] **Step 4: Add the filter in `matchMonsters`** + +Immediately after the existing form check (`if monster.Form != 0 && monster.Form != formToCheck { continue }`), add: +```go + // Costume check: 9000 = any; any other value (incl. 0 = no costume) is exact. + if monster.Costume != 9000 && monster.Costume != data.Costume { + continue + } +``` + +- [ ] **Step 5: Run test + gate + commit** + +Run: `go test ./internal/matching/ -run TestMatchMonsters_Costume` +Expected: PASS. +```bash +git add internal/matching/pokemon.go internal/matching/pokemon_costume_test.go +git commit -m "feat(matching): filter pokemon rules by costume (9000=any)" +``` + +--- + +### Task 4: Enrichment — costume in `fullName` + `costumeName` + +**Files:** +- Modify: `internal/enrichment/translate.go` (`buildFullName` + `translateNames` signature), `internal/enrichment/pokemon.go` (pass costume, set `costumeName`) +- Test: `internal/enrichment/translate_costume_test.go` (new) + +**Interfaces:** +- Consumes: `CostumeTranslationKey` (Task 1). +- Produces: `fullName` = `" ()"` when `costume > 0`; `costumeName` field. + +- [ ] **Step 1: Write the failing test** + +```go +// internal/enrichment/translate_costume_test.go +package enrichment +// mirror the harness in translate_test.go / invasion_test.go (newInvasionBundle). +// Assert: buildFullName-with-costume for Pikachu costume 1 → "Pikachu (Holiday 2016)", +// and costume 0 → "Pikachu" unchanged. Use costume_1 = "Holiday 2016" in the bundle. +``` +Write a concrete test using the existing bundle helper: build a translator with `poke_25`="Pikachu", `form_598`="Normal", `costume_1`="Holiday 2016"; call the costume-aware `buildFullName` with costume 1 and assert `"Pikachu (Holiday 2016)"`, with costume 0 assert `"Pikachu"`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/enrichment/ -run Costume` +Expected: FAIL (signature/behaviour missing). + +- [ ] **Step 3: Thread costume through** + +`internal/enrichment/translate.go` — add a `costume int` param to `buildFullName` and, after the mega composition, before returning: +```go + if costume > 0 && tr != nil { + if cn := tr.T(CostumeTranslationKey(costume)); cn != "" && cn != CostumeTranslationKey(costume) { + fullName = fullName + " (" + cn + ")" + } + } +``` +Thread `costume` from `translateNames` (add a `costume int` arg) into both the main `fullName` and `fullNameEng` calls. `BuildFullNameWithAlignment` passes `costume` through to `buildFullName`. **Do not** add costume to the PVP/evolution entry calls in `pokemon.go` (they call `translateNames`/`buildFullName` for candidate ranks — pass `0`). + +- [ ] **Step 4: Set `costumeName` + wire the spawn call** + +`internal/enrichment/pokemon.go` — where `translateNames` is called for the spawn, pass `pokemon.Costume`; and add: +```go + m["costumeName"] = tr.T(gamedata.CostumeTranslationKey(pokemon.Costume)) // "" when 0/unset resolves to key +``` +Guard so costume 0 yields empty `costumeName` (only set when `pokemon.Costume > 0`). + +- [ ] **Step 5: Run test + gate + commit** + +Run: `go test ./internal/enrichment/` +```bash +git add internal/enrichment/translate.go internal/enrichment/pokemon.go internal/enrichment/translate_costume_test.go +git commit -m "feat(enrichment): weave costume into fullName + costumeName" +``` + +--- + +### Task 5: RecentActivity — RecordCostume / RecentCostumes + +**Files:** +- Modify: `internal/tracker/recent_activity.go`, `cmd/processor/pokemon.go` +- Test: `internal/tracker/recent_activity_costume_test.go` (new) + +**Interfaces:** +- Produces: `func (r *RecentActivity) RecordCostume(pokemonID, costume int)`; `func (r *RecentActivity) RecentCostumes(pokemonID int) []int` (sorted, recency-windowed). + +- [ ] **Step 1: Write the failing test** + +```go +// internal/tracker/recent_activity_costume_test.go +package tracker + +import "testing" + +func TestRecentCostumes(t *testing.T) { + r := NewRecentActivity() + r.RecordCostume(25, 1) + r.RecordCostume(25, 8) + r.RecordCostume(25, 0) // no-costume: ignored + got := r.RecentCostumes(25) + if len(got) != 2 { + t.Fatalf("RecentCostumes(25) = %v, want [1 8]", got) + } + if r.RecentCostumes(999) != nil && len(r.RecentCostumes(999)) != 0 { + t.Error("unknown pokemon should have no recent costumes") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/tracker/ -run TestRecentCostumes` +Expected: FAIL — methods undefined. + +- [ ] **Step 3: Implement (two-level map)** + +`recent_activity.go` — add field `costumesByPokemon map[int]map[int]time.Time` (init in `NewRecentActivity`), then: +```go +func (r *RecentActivity) RecordCostume(pokemonID, costume int) { + if pokemonID <= 0 || costume <= 0 { + return + } + r.mu.Lock() + defer r.mu.Unlock() + inner := r.costumesByPokemon[pokemonID] + if inner == nil { + inner = make(map[int]time.Time) + r.costumesByPokemon[pokemonID] = inner + } + inner[costume] = r.now() +} + +func (r *RecentActivity) RecentCostumes(pokemonID int) []int { + r.mu.Lock() + inner := r.costumesByPokemon[pokemonID] + r.mu.Unlock() + if inner == nil { + return nil + } + return r.active(inner) // reuse the existing recency window + sort +} +``` +(If `active` sorts/filters by the same window used elsewhere, reuse it; otherwise mirror its window constant.) + +- [ ] **Step 4: Wire the producer** + +`cmd/processor/pokemon.go` — in `ProcessPokemon`, near the existing `ps.stats.RecordSighting(...)`: +```go + if ps.recentActivity != nil { + ps.recentActivity.RecordCostume(pokemon.PokemonID, pokemon.Costume) + } +``` + +- [ ] **Step 5: Run test + gate + commit** + +Run: `go test ./internal/tracker/ -run TestRecentCostumes` +```bash +git add internal/tracker/recent_activity.go internal/tracker/recent_activity_costume_test.go cmd/processor/pokemon.go +git commit -m "feat(tracker): RecordCostume/RecentCostumes on shared RecentActivity" +``` + +--- + +### Task 6: Argmatcher — `costume:` name resolution + +**Files:** +- Modify: `internal/bot/argmatch.go` (costume prefix param + multi-word vocabulary + resolver) +- Test: `internal/bot/argmatch_costume_test.go` (new) + +**Interfaces:** +- Produces: parsed `parsed.Strings["costume"]` from `costume:`; a `ResolveCostume(name, lang) (id int, ok bool)` that maps a costume name (user lang + English) or numeric string → id. + +- [ ] **Step 1: Write the failing test** + +Mirror `argmatch_test.go`'s multi-word matcher harness (`newMultiWordTestMatcher`), seeding `costume_1`="Holiday 2016". Assert: `costume:holiday_2016` and eager-joined `costume:holiday 2016` resolve to id 1; `costume:0` → 0; `costume:5` → 5. + +- [ ] **Step 2: Run to verify it fails.** Run: `go test ./internal/bot/ -run Costume` → FAIL. + +- [ ] **Step 3: Implement** + +- Add `arg.prefix.costume` handling (a `ParamPrefixString` entry, like `arg.prefix.form`), so `parsed.Strings["costume"]` is captured. +- Seed the costume names into the multi-word vocabulary (same place items/moves/forms are seeded from translations) so `costume:holiday 2016` eager-joins. +- Add `ResolveCostume`: if the value parses as an int, return it; else lowercase-match against `costume_{id}` in user lang and English (mirror `filterByForm`'s translation lookup), returning the id. + +- [ ] **Step 4: Run + commit.** +```bash +git add internal/bot/argmatch.go internal/bot/argmatch_costume_test.go +git commit -m "feat(bot): costume: arg name resolution" +``` + +--- + +### Task 7: `!track` / `!untrack` costume + `!tracked` rowtext + +**Files:** +- Modify: `internal/bot/commands/track.go`, `internal/bot/commands/untrack.go`, `internal/rowtext/monster.go` (the monster rowtext file), `internal/i18n/locale/en.json` +- Test: `internal/bot/commands/track_costume_test.go` (new) + +**Interfaces:** +- Consumes: `ResolveCostume` (Task 6), `MonsterTrackingAPI.Costume` (Task 2), `RowText` monster formatting. + +- [ ] **Step 1: Write the failing test** + +Mirror `track_test.go`: run `!track pikachu costume:holiday_2016` and assert the stored `MonsterTrackingAPI.Costume == 1`; `!track pikachu costume:0` → 0; bare `!track pikachu` → 9000. Assert `!untrack pikachu costume:1` removes only the costume-1 rule. Assert the rowtext for a costume-1 rule contains "Holiday 2016". + +- [ ] **Step 2: Run to verify it fails.** Run: `go test ./internal/bot/commands/ -run Costume` → FAIL. + +- [ ] **Step 3: Implement** + +- `track.go`: after resolving the mon list, read `parsed.Strings["costume"]`, resolve via `ResolveCostume` (default 9000 when absent), set `insert[i].Costume`. On unresolved name, return a 🙅 reply (mirror `applyFormFilter`'s not-found path) — add i18n `msg.costume_not_found`. +- `untrack.go`: include costume in the remove match so `!untrack pikachu costume:1` targets that rule. +- `rowtext/monster.go`: when `Costume != 9000`, append the costume — `tr.T(costume_{id})` for N>0, `msg.no_costume` for 0. +- `en.json`: add `arg.prefix.costume` ("costume"), `msg.costume_not_found`, `msg.no_costume` ("no costume"). + +- [ ] **Step 4: Run + gate + commit.** +```bash +git add internal/bot/commands/track.go internal/bot/commands/untrack.go internal/rowtext/monster.go internal/i18n/locale/en.json internal/bot/commands/track_costume_test.go +git commit -m "feat(bot): !track/!untrack costume + costume in !tracked" +``` + +--- + +### Task 8: `!info costumes` + per-species recently-seen + +**Files:** +- Modify: `internal/bot/commands/info.go`, `internal/i18n/locale/en.json` +- Test: `internal/bot/commands/info_costume_test.go` (new) + +**Interfaces:** +- Consumes: `GameData.Costumes` (Task 1), `RecentActivity.RecentCostumes` (Task 5). + +- [ ] **Step 1: Write the failing test** + +Mirror `info_test.go`. (a) `!info costumes` returns a reply listing costume names (assert it contains "Holiday 2016"). (b) After `ctx`'s RecentActivity has `RecordCostume(25,1)`, `!info pikachu` reply contains a recently-seen costume section with "Holiday 2016". + +- [ ] **Step 2: Run to verify it fails.** → FAIL. + +- [ ] **Step 3: Implement** + +- Add a `case matchSub("msg.info.sub.costumes"):` in the subcommand dispatch → a `showCostumes(ctx)` that lists `GameData.Costumes` sorted by id as `id — `. +- In the per-pokemon path (near `availableForms`), add `availableCostumes(ctx, pokemonID)` reading `ctx.RecentActivity.RecentCostumes(pokemonID)`, formatting `id — name`, under a `msg.info.available_costumes` header. Skip the section when empty. +- `en.json`: `msg.info.sub.costumes` ("costumes"), `msg.info.costumes.header`, `msg.info.available_costumes`. +- Confirm `ctx.RecentActivity` is available to bot commands (it's already passed to `bot/command.go`); if not exposed on `CommandContext`, add it. + +- [ ] **Step 4: Run + commit.** +```bash +git add internal/bot/commands/info.go internal/i18n/locale/en.json internal/bot/commands/info_costume_test.go +git commit -m "feat(bot): !info costumes + per-species recently-seen costumes" +``` + +--- + +### Task 9: Slash `/track` costume option + autocomplete + +**Files:** +- Modify: `internal/discordbot/slash/definitions.go`, `internal/discordbot/slash/mappers/track.go`, `internal/discordbot/slash/autocomplete/` (+ dispatcher wiring) +- Test: `internal/discordbot/slash/mappers/track_test.go` (extend) + +**Interfaces:** +- Consumes: the `/track` mapper → `costume:` token consumed by Task 7's parser. + +- [ ] **Step 1: Write the failing test** + +Extend `mappers/track_test.go`: a `/track` option set with `costume` present emits a `costume:` token (mirror the existing `form` option test at `mappers/track.go:49`). + +- [ ] **Step 2: Run to verify it fails.** → FAIL. + +- [ ] **Step 3: Implement** + +- `definitions.go`: add `stringOpt(bundle, "track.costume", "costume", "Pokemon costume", false, true)` (autocomplete=true), mirroring `track.form` at `definitions.go:500`. +- `mappers/track.go`: after the form block (`:49`), add: +```go + if v, ok := o["costume"]; ok && v.StringValue() != "" { + tokens = append(tokens, "costume:"+v.StringValue()) + } +``` +- Autocomplete: add a `costume` autocomplete provider listing `GameData.Costumes` (label = name, value = id); wire it in the dispatcher's autocomplete switch (mirror the `form` autocomplete). + +- [ ] **Step 4: Run + commit.** +```bash +git add internal/discordbot/slash/definitions.go internal/discordbot/slash/mappers/track.go internal/discordbot/slash/autocomplete/ internal/discordbot/slash/dispatcher.go internal/discordbot/slash/mappers/track_test.go +git commit -m "feat(slash): /track costume option with name autocomplete" +``` + +--- + +### Task 10: v2 Pokémon API `Costume` + +**Files:** +- Modify: `internal/api/v2_pokemon.go` +- Test: `internal/api/v2_pokemon_costume_test.go` (new) + +**Interfaces:** +- Consumes: `valueOr` / `ptrUnless` (existing in `v2_pokemon.go`), `MonsterTrackingAPI.Costume`. + +- [ ] **Step 1: Write the failing test** + +Assert `translateV2Pokemon` (the write mapper) maps: `Costume=nil` → 9000, `Costume=ptr(0)` → 0, `Costume=ptr(5)` → 5; and the read mapper returns `nil` when the stored costume is 9000, `ptr(0)` when 0, `ptr(5)` when 5. (Mirror the existing Form round-trip test if present.) + +- [ ] **Step 2: Run to verify it fails.** → FAIL. + +- [ ] **Step 3: Implement** + +- Add to the v2 rule struct (near `Form` at `:24`): +```go + Costume *int `json:"costume,omitempty" nullable:"true" doc:"Costume id. Omit/null = any (stored 9000). 0 = no costume. N = that costume."` +``` +- Write mapper (near `Form: valueOr(req.Form, 0)` at `:123`): +```go + Costume: valueOr(req.Costume, 9000), +``` +- Read mapper (near `Form: ptrUnless(row.Form, 0)` at `:167`): +```go + Costume: ptrUnless(row.Costume, 9000), +``` + +- [ ] **Step 4: Run + gate + commit.** +```bash +git add internal/api/v2_pokemon.go internal/api/v2_pokemon_costume_test.go +git commit -m "feat(api): v2 pokemon Costume field (9000/0/null semantics)" +``` + +--- + +### Task 11: DTS field metadata + docs + +**Files:** +- Modify: `internal/api/dts_fields.go` (`monsterFields`), `DTS.md` +- Test: `internal/api/dts_fields_costume_test.go` (new) + +**Interfaces:** +- Consumes: `fieldsByType["monster"]`. + +- [ ] **Step 1: Write the failing test** + +```go +// internal/api/dts_fields_costume_test.go +package api + +import "testing" + +func TestMonsterFields_Costume(t *testing.T) { + m := fieldsByType["monster"] + if !hasFieldDef(m.Fields, "costumeName") { + t.Error("monster type should list costumeName") + } +} +``` +(`hasFieldDef` exists from the showcase field test.) + +- [ ] **Step 2: Run to verify it fails.** → FAIL. + +- [ ] **Step 3: Implement** + +- `dts_fields.go` — add to `monsterFields` (near the existing `costume` field at `:155`): +```go + {Name: "costumeName", Type: "string", Description: "Translated costume name (empty when no costume). Note: fullName already includes it parenthesised.", Category: "other"}, +``` +- `DTS.md` — document `costumeName` in the monster field table, and note that `fullName` includes the costume as `(Costume Name)`. + +- [ ] **Step 4: Run + gate + commit.** +```bash +git add internal/api/dts_fields.go DTS.md internal/api/dts_fields_costume_test.go +git commit -m "docs(dts): costumeName field + fullName-includes-costume note" +``` + +--- + +### Task 12: Test data + end-to-end verification + +**Files:** +- Modify: `fallbacks/testdata.json` (a costumed pokemon test entry) +- Manual/gate verification + +- [ ] **Step 1:** Add a `pokemon` testdata entry with `costume: 1` (a costumed Pikachu) so `!poracle-test pokemon,` renders a costumed name. + +- [ ] **Step 2:** Run the full gate: +``` +go build ./... && go vet ./... && go test -count=1 ./... && golangci-lint run ./... +``` +Expected: all pass, 0 lint issues. + +- [ ] **Step 3:** Sanity-check end to end (if an env is available): `!track pikachu costume:holiday_2016` → ✅; `!tracked` shows the costume; `!info costumes` lists all; `!poracle-test pokemon,` renders `Pikachu (Holiday 2016)`. + +- [ ] **Step 4: Commit.** +```bash +git add fallbacks/testdata.json +git commit -m "test: costumed-pokemon testdata + costume tracking e2e verified" +``` + +--- + +## Self-review notes + +- **Spec coverage:** gamedata (T1), DB+v1-default (T2), matcher (T3), fullName+costumeName (T4), RecentActivity (T5), argmatcher (T6), commands+rowtext/!tracked (T7), !info global+recent (T8), slash (T9), v2 API (T10), DTS fields/docs (T11), testdata/e2e (T12). All spec sections mapped. +- **v1 compatibility** is covered by T2's `UnmarshalJSON` default (the shared `MonsterTrackingAPI` is the v1 parse target) + T2's SELECT/insert column wiring. +- **Wildcard consistency:** 9000 = any, 0 = no costume used identically in T2 (default), T3 (matcher), T7 (command default), T10 (v2 valueOr/ptrUnless). +- **Open items for the implementer to confirm against live code:** exact game-data load site (T1 Step 5), whether the monster store INSERT uses an explicit column list (T2 Step 3), `ctx.RecentActivity` exposure on `CommandContext` (T8), and the `active()` recency-window reuse (T5). diff --git a/docs/superpowers/plans/2026-07-15-raid-costume-tracking.md b/docs/superpowers/plans/2026-07-15-raid-costume-tracking.md new file mode 100644 index 000000000..f61405e53 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-raid-costume-tracking.md @@ -0,0 +1,707 @@ +# Raid Costume Tracking 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:** Add costume as a filter dimension on raid tracking (costumed raid bosses), mirroring the pokemon costume feature: DB column, matcher filter, `!raid`/`/raid` command, rowtext, a separate raid-costume recency tracker + `!info` section, and v1/v2 raid API. + +**Architecture:** The raid layers mirror pokemon 1:1. Raid webhooks already carry `costume`, and raid enrichment already *displays* it (fullName/megaName/costumeName), so this plan is the tracking/filter/recency/info/API half only. + +**Tech Stack:** Go, MySQL/sqlx, huma (v2) + gin (v1), discordgo autocomplete, existing `ResolveCostume` / `PrependRecentCostumes` / costume gamedata infra. + +Design spec: `docs/superpowers/specs/2026-07-15-raid-costume-tracking-design.md`. + +## Global Constraints + +- **Sentinel:** `raid.costume` uses **9000 = any** (default; an absent field defaults to 9000), **0 = no costume**, **N = that costume**. This is the same sentinel raid `evolution` already uses. (Raid `form` uses 0=any, but costume must use 9000=any so `0` stays meaningful.) +- **Costume is rule-identity:** `RaidTrackingAPI.Costume` carries **no `diff` tag** (like `Form`). An absent costume MUST default to 9000 at every rule-construction site (v1 build paths, `!raid` command, v2 write) — a Go-zero `0` default creates no-costume rules and duplicate rows on re-submit. +- **Costume applies to every rule a command/API call generates** (single-form path, `pokemon_form` array path, each level). +- **Reuse existing infra:** `ArgMatcher.ResolveCostume`, `autocomplete.PrependRecentCostumes`, `autocomplete.ResolvePokemonID`, `gamedata.CostumeTranslationKey`, `gamedata.GameData.Costumes`. Do not reinvent them. +- **No UPDATE change:** costume is rule-identity, so it is never UPDATE'd in place (a costume change is insert+delete). Raid has no static `UpdateRaid`; verify no raid update lists columns before assuming. +- **Pre-commit gate (from `processor/`), all four must pass:** `go build ./... && go vet ./... && go test -count=1 ./... && golangci-lint run ./...`. + +--- + +### Task 1: DB storage + v1 API create path (+ idempotency) + +**Files:** +- Create: `processor/internal/db/migrations/000007_add_raid_costume.up.sql`, `…down.sql` +- Modify: `processor/internal/db/raids.go` (RaidTracking + LoadRaids) +- Modify: `processor/internal/db/tracking_queries.go` (RaidTrackingAPI + 2 SELECTs + InsertRaid) +- Modify: `processor/internal/api/trackingRaid.go` (raidInsertRequest + both build paths + toRaidTracking) +- Test: `processor/internal/api/tracking_test.go` (raid idempotency) + +**Interfaces:** +- Produces: `db.RaidTracking.Costume int`, `db.RaidTrackingAPI.Costume int` (json `costume`, no diff tag) — consumed by matcher (Task 2), rowtext (Task 3), command (Task 4), v2 (Task 8). + +- [ ] **Step 1: Write the failing idempotency test** + +Append to `tracking_test.go` (mirror `TestCreateMonster_CostumeDefaultIsIdempotent`): + +```go +func TestCreateRaid_CostumeDefaultIsIdempotent(t *testing.T) { + mock := store.NewMockHumanStore() + mock.AddHuman(&store.Human{ID: "u1", Type: "discord:user", Name: "User", Enabled: true, Language: "en", CurrentProfileNo: 1}) + + mockRaids := store.NewMockTrackingStore(store.RaidGetUID, store.RaidSetUID) + minGD := &gamedata.GameData{Monsters: map[gamedata.MonsterKey]*gamedata.Monster{}, Util: &gamedata.UtilData{}} + + deps := &TrackingDeps{ + Humans: mock, + Tracking: &store.TrackingStores{Raids: mockRaids}, + Config: &config.Config{}, + RowText: &rowtext.Generator{DefaultTemplateName: "1", GD: minGD}, + Translations: i18n.NewBundle(), + } + + r := gin.New() + r.POST("/api/tracking/raid/:id", HandleCreateRaid(deps)) + + body := `{"pokemon_id":25}` + for i, want := range []int{1, 1} { // both POSTs => still 1 row + req := httptest.NewRequest(http.MethodPost, "/api/tracking/raid/u1", strings.NewReader(body)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("POST %d: expected 200, got %d: %s", i, w.Code, w.Body.String()) + } + rows := mockRaids.AllRows() + if len(rows) != want { + t.Fatalf("after POST %d: expected %d row(s), got %d (dup?)", i, want, len(rows)) + } + if rows[0].Costume != 9000 { + t.Fatalf("expected Costume=9000 from v1 default, got %d", rows[0].Costume) + } + } +} +``` + +- [ ] **Step 2: Run — verify it fails** + +Run: `go test ./internal/api/ -run TestCreateRaid_CostumeDefaultIsIdempotent -v` +Expected: FAIL to compile (`RaidTrackingAPI` has no `Costume`) — this is the RED signal. + +- [ ] **Step 3: Migration** + +`000007_add_raid_costume.up.sql`: +```sql +ALTER TABLE `raid` + ADD COLUMN `costume` INT NOT NULL DEFAULT 9000; +``` +`000007_add_raid_costume.down.sql`: +```sql +ALTER TABLE `raid` DROP COLUMN `costume`; +``` + +- [ ] **Step 4: DB structs + SQL** + +`db/raids.go` — add to `RaidTracking` after `Form`: +```go + Form int `db:"form"` + Costume int `db:"costume"` +``` +`db/raids.go` `LoadRaids` SELECT — add `costume` after `form`: +```go + `SELECT uid, id, profile_no, pokemon_id, level, team, exclusive, form, costume, evolution, +``` + +`db/tracking_queries.go` `RaidTrackingAPI` — add after `Form` (NO diff tag, like Form): +```go + Form int `db:"form" json:"form"` + Costume int `db:"costume" json:"costume"` +``` +`SelectRaidsByIDProfile` SELECT — add `costume` after `form`: +```go + COALESCE(template, '') AS template, team, pokemon_id, form, costume, +``` +`SelectRaidsByID` SELECT — add `costume` after `form` (same edit in that query). +`InsertRaid` — add `costume` to columns and `raid.Costume` to binds (keep counts balanced): +```go + `INSERT INTO raid (id, profile_no, ping, clean, distance, template, + team, pokemon_id, form, costume, level, exclusive, move, evolution, gym_id, rsvp_changes, + override_location_label, override_areas) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + raid.ID, raid.ProfileNo, raid.Ping, raid.Clean, raid.Distance, raid.Template, + raid.Team, raid.PokemonID, raid.Form, raid.Costume, raid.Level, raid.Exclusive, + raid.Move, raid.Evolution, raid.GymID, raid.RSVPChanges, + nullIfEmpty(raid.OverrideLocationLabel), marshalOverrideAreas(raid.OverrideAreas)` +``` +(18 columns / 18 placeholders / 18 binds — recount after editing.) + +- [ ] **Step 5: v1 API create path** + +`api/trackingRaid.go` `raidInsertRequest` — add after `Form`: +```go + Form flexInt `json:"form"` + Costume flexInt `json:"costume"` +``` +In `HandleCreateRaid`, right after `tmpl, dist, team, clean, excl, move, evo, gymID, rsvp := buildRaidCommon(req)`: +```go + // Costume defaults to 9000 (the "any costume" wildcard) when absent, + // so v1 clients that don't send it never create no-costume rules and + // re-submits diff cleanly (Costume has no `diff` tag). + costume := req.Costume.intValue(9000) +``` +Add `Costume: costume,` to **both** `db.RaidTrackingAPI{…}` literals (the `pokemon_form` loop and the level loop). + +`toRaidTracking` — add `Costume: api.Costume,` to the returned `db.RaidTracking{…}`. + +- [ ] **Step 6: Run — verify GREEN** + +Run: `go test ./internal/api/ -run TestCreateRaid_CostumeDefaultIsIdempotent -v` +Expected: PASS. + +- [ ] **Step 7: Full gate + commit** + +```bash +go build ./... && go vet ./... && go test -count=1 ./internal/api/ ./internal/db/ && golangci-lint run ./internal/api/ ./internal/db/ +git add processor/internal/db processor/internal/api/trackingRaid.go processor/internal/api/tracking_test.go +git commit -m "feat(db): raid.costume column + v1 API absent->9000 default" +``` + +--- + +### Task 2: Matcher + +**Files:** +- Modify: `processor/internal/matching/raid.go` (RaidData + filter) +- Modify: `processor/cmd/processor/raid.go` (populate RaidData.Costume) +- Test: `processor/internal/matching/raid_test.go` + +**Interfaces:** +- Consumes: `db.RaidTracking.Costume` (Task 1). Produces: nothing new. + +- [ ] **Step 1: Write the failing test** + +Append to `raid_test.go` (mirror `TestRaidMatchBasic`; note existing fixtures set `Evolution: 9000`): + +```go +func TestRaidMatchCostume(t *testing.T) { + human := makeHuman("user1") + mk := func(costume int) *db.RaidTracking { + return &db.RaidTracking{ + ID: "user1", ProfileNo: 1, PokemonID: 25, Level: 5, + Team: 4, Exclusive: false, Form: 0, Costume: costume, Evolution: 9000, + Move: 9000, Distance: 0, Template: "1", + } + } + data := &RaidData{ + GymID: "gym1", PokemonID: 25, Form: 0, Costume: 1, Level: 5, + TeamID: 1, Evolution: 0, Move1: 100, Move2: 200, Latitude: 51.0, Longitude: 0.0, + } + matcher := &RaidMatcher{} + check := func(costume, wantN int) { + st := makeRaidTestState([]*db.RaidTracking{mk(costume)}, nil, map[string]*db.Human{"user1": human}) + if got, _ := matcher.MatchRaid(data, st); len(got) != wantN { + t.Errorf("costume=%d: got %d matches, want %d", costume, len(got), wantN) + } + } + check(9000, 1) // any matches the costume-1 boss + check(1, 1) // exact match + check(2, 0) // different costume: no match + check(0, 0) // "no costume" filter: costumed boss must not match +} +``` + +- [ ] **Step 2: Run — verify it fails** + +Run: `go test ./internal/matching/ -run TestRaidMatchCostume -v` +Expected: FAIL to compile (`RaidData`/`RaidTracking` have no `Costume`). + +- [ ] **Step 3: Implement** + +`matching/raid.go` `RaidData` — add after `Form`: +```go + Form int + Costume int +``` +`matching/raid.go` `MatchRaid` — after the evolution check (`if r.Evolution != 9000 && r.Evolution != raid.Evolution { continue }`): +```go + // costume match — 9000 = any; else exact (incl. 0 = no costume) + if r.Costume != 9000 && r.Costume != raid.Costume { + continue + } +``` +`cmd/processor/raid.go` — in the `matching.RaidData{…}` literal, add `Costume: raid.Costume,` (next to `Form: raid.Form`). + +- [ ] **Step 4: Run — verify GREEN + gate + commit** + +```bash +go test ./internal/matching/ -run TestRaidMatchCostume -v +go build ./... && go vet ./... && go test -count=1 ./internal/matching/ ./cmd/... && golangci-lint run ./internal/matching/ ./cmd/... +git add processor/internal/matching/raid.go processor/internal/matching/raid_test.go processor/cmd/processor/raid.go +git commit -m "feat(matching): filter raid rules by costume (9000=any)" +``` + +--- + +### Task 3: rowtext (`!tracked` line) + +**Files:** +- Modify: `processor/internal/rowtext/raid.go` +- Test: `processor/internal/rowtext/raid_test.go` (create if absent, else append) + +**Interfaces:** Consumes `db.RaidTracking.Costume` (Task 1), `g.GD.Costumes`. + +- [ ] **Step 1: Write the failing test** + +Append/create a rowtext test (mirror the monster rowtext costume test; use a `Generator` with `GD.Costumes`): + +```go +func TestRaidRowText_Costume(t *testing.T) { + tr := i18n.NewTranslator("en", map[string]string{ + "poke_25": "Pikachu", + "costume_1": "Holiday 2016", + "msg.no_costume": "no costume", + }) + gd := &gamedata.GameData{ + Monsters: map[gamedata.MonsterKey]*gamedata.Monster{{ID: 25, Form: 0}: {PokemonID: 25}}, + Costumes: map[int]gamedata.CostumeInfo{1: {ID: 1, Name: "Holiday 2016"}}, + } + g := &Generator{GD: gd, DefaultTemplateName: "1"} + + // costume N -> shows the name + if got := g.RaidRowText(tr, &db.RaidTracking{PokemonID: 25, Level: 5, Costume: 1, Move: 9000, Evolution: 9000, Template: "1"}); !strings.Contains(got, "Holiday 2016") { + t.Errorf("costume 1 row should contain the costume name, got: %q", got) + } + // costume 0 -> "no costume" + if got := g.RaidRowText(tr, &db.RaidTracking{PokemonID: 25, Level: 5, Costume: 0, Move: 9000, Evolution: 9000, Template: "1"}); !strings.Contains(got, "no costume") { + t.Errorf("costume 0 row should contain 'no costume', got: %q", got) + } + // costume 9000 (any) -> nothing costume-related + if got := g.RaidRowText(tr, &db.RaidTracking{PokemonID: 25, Level: 5, Costume: 9000, Move: 9000, Evolution: 9000, Template: "1"}); strings.Contains(got, "Holiday 2016") || strings.Contains(got, "no costume") { + t.Errorf("costume 9000 row should not mention costume, got: %q", got) + } +} +``` + +- [ ] **Step 2: Run — verify it fails** (compile error: no `Costume` field until Task 1 merged — this task builds on Task 1). Run `go test ./internal/rowtext/ -run TestRaidRowText_Costume -v`; expect assertion failure (no costume text emitted). + +- [ ] **Step 3: Implement** + +In `rowtext/raid.go` `RaidRowText`, after the `formName` clause (mirror `rowtext/monster.go`'s costume block), add: +```go + // Costume: 9000 (wildcard) omitted; 0 = "no costume"; N>0 = translated name + // (masterfile-name fallback when the gamelocale key is missing). + if raid.Costume != 9000 { + costumeName := tr.T("msg.no_costume") + if raid.Costume != 0 { + key := gamedata.CostumeTranslationKey(raid.Costume) + costumeName = tr.T(key) + if costumeName == key && g.GD != nil { + if info, ok := g.GD.Costumes[raid.Costume]; ok && info.Name != "" { + costumeName = info.Name + } + } + } + s += " | " + costumeName + } +``` +Place it consistently with where monster rowtext puts it (after the name/form, before distance/template). Ensure `gamedata` is imported (it is — `translateMonsterName`). + +- [ ] **Step 4: GREEN + gate + commit** + +```bash +go test ./internal/rowtext/ -run TestRaidRowText_Costume -v +go build ./... && go vet ./... && go test -count=1 ./internal/rowtext/ && golangci-lint run ./internal/rowtext/ +git add processor/internal/rowtext/raid.go processor/internal/rowtext/raid_test.go +git commit -m "feat(rowtext): show costume in raid !tracked line" +``` + +--- + +### Task 4: `!raid` command (add + remove by costume) + +**Files:** +- Modify: `processor/internal/bot/commands/raid.go` +- Test: `processor/internal/bot/commands/raid_test.go` + +**Interfaces:** Consumes `ArgMatcher.ResolveCostume`, `db.RaidTrackingAPI.Costume`. + +- [ ] **Step 1: Write the failing test** + +Append to `raid_test.go`, reusing the existing `raidCtx(t)` helper (it builds a `CommandContext` with a `store.NewMockTrackingStore[db.RaidTrackingAPI]`, an `ArgMatcher`, and a pokemon resolver; assert stored rows via `ctx.Tracking.Raids.SelectByIDProfile("user1", 1)`). `raidCtx`'s `ArgMatcher` seeds the costume vocabulary from `GameData.Costumes`, so its `GameData` must contain `Costumes: {1: {ID:1, Name:"Holiday 2016"}}` and its "en" translator `costume_1: "Holiday 2016"` for `costume:holiday_2016` to resolve — **add these to `raidCtx` if absent** (additive; existing raid tests are unaffected). + +```go +func TestRaid_Costume(t *testing.T) { + ctx := raidCtx(t) + (&RaidCommand{}).Run(ctx, []string{"pikachu", "costume:holiday_2016"}) + rows, _ := ctx.Tracking.Raids.SelectByIDProfile("user1", 1) + if len(rows) != 1 || rows[0].Costume != 1 { + t.Fatalf("expected 1 raid rule with Costume=1, got %+v", rows) + } + + // Bare add defaults to 9000 (any). + ctx2 := raidCtx(t) + (&RaidCommand{}).Run(ctx2, []string{"pikachu"}) + rows2, _ := ctx2.Tracking.Raids.SelectByIDProfile("user1", 1) + if len(rows2) != 1 || rows2[0].Costume != 9000 { + t.Fatalf("bare !raid pikachu should store Costume=9000, got %+v", rows2) + } +} +``` +> The `!raid pikachu` add path resolves the pokemon and builds one rule; confirm `raidCtx`'s resolver knows pikachu (id 25). If `raidCtx` uses a different species, use that species' name + a `costume_N`/`Costumes[N]` pair instead. + +- [ ] **Step 2: Run — verify it fails.** `go test ./internal/bot/commands/ -run TestRaid_Costume -v` → FAIL (costume not parsed/stored). + +- [ ] **Step 3: Implement** + +In `bot/commands/raid.go` `Run`, before the rule-building blocks, resolve the costume (mirror `track.go:73-88`): +```go + // Costume filter — 9000 = any (default), 0 = no costume, N = specific. + costume := 9000 + if costumeArg, ok := parsed.Strings["costume"]; ok { + id, resolved := ctx.ArgMatcher.ResolveCostume(costumeArg, ctx.Language) + if !resolved { + return []bot.Reply{{React: "🙅", Text: tr.Tf("msg.costume_not_found", ctx.EscapeForCode(costumeArg), bot.CommandPrefix(ctx))}} + } + costume = id + } +``` +Add `Costume: costume,` to **both** `db.RaidTrackingAPI{…}` insert literals (the pokemon path ~line 131 and the level/everything path ~line 182). + +**Remove by costume:** in the removal branch, when `parsed.Strings["costume"]` is set, filter the existing rules to those whose `Costume` matches the resolved id before removing (mirror how `!untrack costume:N` narrows monster removal in `untrack.go`). Resolve the same way; skip rules whose `Costume != resolvedCostume`. + +- [ ] **Step 4: GREEN + gate + commit** + +```bash +go test ./internal/bot/commands/ -run 'TestRaid' -v +go build ./... && go vet ./... && go test -count=1 ./internal/bot/... && golangci-lint run ./internal/bot/... +git add processor/internal/bot/commands/raid.go processor/internal/bot/commands/raid_test.go +git commit -m "feat(bot): !raid costume filter (add + remove)" +``` + +--- + +### Task 5: RecentActivity — raid costume bucket + producer + +**Files:** +- Modify: `processor/internal/tracker/recent_activity.go` +- Modify: `processor/cmd/processor/raid.go` (producer) +- Test: `processor/internal/tracker/recent_activity_raidcostume_test.go` (create) + +**Interfaces:** Produces `RecordRaidCostume(pokemonID, costume int)`, `RecentRaidCostumes(pokemonID int) []int` — consumed by `!info` (Task 6) and slash (Task 7). + +- [ ] **Step 1: Write the failing test** + +```go +package tracker + +import "testing" + +func TestRecentRaidCostumes(t *testing.T) { + r := NewRecentActivity() + r.RecordRaidCostume(25, 1) + r.RecordRaidCostume(25, 12) + r.RecordRaidCostume(25, 0) // no-costume: ignored + if got := r.RecentRaidCostumes(25); len(got) != 2 { + t.Fatalf("RecentRaidCostumes(25) = %v, want two entries", got) + } + if len(r.RecentRaidCostumes(999)) != 0 { + t.Error("unknown boss should have no recent raid costumes") + } + // Separate from spawn costumes. + if len(r.RecentCostumes(25)) != 0 { + t.Error("raid costumes must not leak into the spawn RecentCostumes bucket") + } +} +``` + +- [ ] **Step 2: Run — verify it fails.** `go test ./internal/tracker/ -run TestRecentRaidCostumes -v` → FAIL (undefined). + +- [ ] **Step 3: Implement** (mirror `costumesByPokemon`/`RecordCostume`/`RecentCostumes` exactly) + +`recent_activity.go` — add field + init: +```go + costumesByPokemon map[int]map[int]time.Time + raidCostumesByPokemon map[int]map[int]time.Time +``` +```go + costumesByPokemon: make(map[int]map[int]time.Time), + raidCostumesByPokemon: make(map[int]map[int]time.Time), +``` +Add the methods after `RecentCostumes`: +```go +// RecordRaidCostume marks costume as recently seen on a raid boss pokemonID. +func (r *RecentActivity) RecordRaidCostume(pokemonID, costume int) { + if pokemonID <= 0 || costume <= 0 { + return + } + r.mu.Lock() + defer r.mu.Unlock() + inner := r.raidCostumesByPokemon[pokemonID] + if inner == nil { + inner = make(map[int]time.Time) + r.raidCostumesByPokemon[pokemonID] = inner + } + inner[costume] = r.now() +} + +// RecentRaidCostumes returns the recency-windowed costume IDs recently seen on +// raid boss pokemonID. +func (r *RecentActivity) RecentRaidCostumes(pokemonID int) []int { + r.mu.Lock() + inner := r.raidCostumesByPokemon[pokemonID] + r.mu.Unlock() + if inner == nil { + return nil + } + return r.active(inner) +} +``` +`cmd/processor/raid.go` — beside the existing `RecordRaidBoss` call: +```go + if ps.recentActivity != nil { + ps.recentActivity.RecordRaidBoss(raid.PokemonID) + if raid.Costume > 0 { + ps.recentActivity.RecordRaidCostume(raid.PokemonID, raid.Costume) + } + } +``` +(If the existing `RecordRaidBoss` call already sits under an `if ps.recentActivity != nil` guard, just add the costume line inside it.) + +- [ ] **Step 4: GREEN + gate + commit** + +```bash +go test ./internal/tracker/ -run TestRecentRaidCostumes -v +go build ./... && go vet ./... && go test -count=1 ./internal/tracker/ ./cmd/... && golangci-lint run ./internal/tracker/ ./cmd/... +git add processor/internal/tracker/recent_activity.go processor/internal/tracker/recent_activity_raidcostume_test.go processor/cmd/processor/raid.go +git commit -m "feat(tracker): RecordRaidCostume/RecentRaidCostumes bucket + producer" +``` + +--- + +### Task 6: `!info` recently-seen raid costumes section + +**Files:** +- Modify: `processor/internal/bot/commands/info.go` +- Modify: `processor/internal/i18n/locale/en.json` +- Test: `processor/internal/bot/commands/info_raid_costume_test.go` (create) + +**Interfaces:** Consumes `RecentRaidCostumes` (Task 5). + +- [ ] **Step 1: Write the failing test** (mirror `info_costume_test.go`) + +```go +package commands + +import ( + "strings" + "testing" + + "github.com/pokemon/poracleng/processor/internal/bot" + "github.com/pokemon/poracleng/processor/internal/gamedata" + "github.com/pokemon/poracleng/processor/internal/i18n" + "github.com/pokemon/poracleng/processor/internal/tracker" +) + +func infoRaidCostumeCtx(t *testing.T) *bot.CommandContext { + t.Helper() + ctx, _ := testCtx(t) + gd := &gamedata.GameData{ + Monsters: map[gamedata.MonsterKey]*gamedata.Monster{{ID: 25, Form: 0}: {PokemonID: 25, FormID: 0}}, + Moves: map[int]*gamedata.Move{}, Types: map[int]*gamedata.TypeInfo{}, Util: &gamedata.UtilData{}, + Costumes: map[int]gamedata.CostumeInfo{12: {ID: 12, Name: "Party Hat"}}, + } + ctx.Translations.AddTranslator(i18n.NewTranslator("en", map[string]string{"poke_25": "Pikachu", "costume_12": "Party Hat"})) + ctx.Resolver = bot.NewPokemonResolver(gd, ctx.Translations, []string{"en"}, nil) + ctx.GameData = gd + ctx.RecentActivity = tracker.NewRecentActivity() + return ctx +} + +func TestInfo_Pokemon_RecentRaidCostumes(t *testing.T) { + ctx := infoRaidCostumeCtx(t) + ctx.RecentActivity.RecordRaidCostume(25, 12) + replies := (&InfoCommand{}).Run(ctx, []string{"pikachu"}) + if len(replies) == 0 { + t.Fatal("expected a reply") + } + text := replies[0].Text + if !strings.Contains(text, "12 — Party Hat") || !strings.Contains(text, "Recently-seen raid costumes") { + t.Errorf("expected recent raid costume section, got: %q", text) + } +} + +func TestInfo_Pokemon_NoRaidCostumes_SectionOmitted(t *testing.T) { + ctx := infoRaidCostumeCtx(t) + replies := (&InfoCommand{}).Run(ctx, []string{"pikachu"}) + if len(replies) == 0 { + t.Fatal("expected a reply") + } + if strings.Contains(replies[0].Text, "Recently-seen raid costumes") { + t.Errorf("no raid-costume section when none recorded, got: %q", replies[0].Text) + } +} +``` + +- [ ] **Step 2: Run — verify it fails.** `go test ./internal/bot/commands/ -run TestInfo_Pokemon_RecentRaidCostumes -v` → FAIL (`availableRaidCostumes` undefined / section absent). + +- [ ] **Step 3: Implement** + +`info.go` — add a helper mirroring `availableCostumes`: +```go +// availableRaidCostumes returns "id — name" display strings for costumes +// recently seen on raid boss pokemonID (via RecentActivity), sorted by id. +func (c *InfoCommand) availableRaidCostumes(ctx *bot.CommandContext, pokemonID int) []string { + if ctx.RecentActivity == nil { + return nil + } + ids := ctx.RecentActivity.RecentRaidCostumes(pokemonID) + if len(ids) == 0 { + return nil + } + sort.Ints(ids) + tr := ctx.Tr() + result := make([]string, 0, len(ids)) + for _, id := range ids { + result = append(result, fmt.Sprintf("%d — %s", id, costumeName(ctx, tr, id))) + } + return result +} +``` +Render it directly after the spawn "Recently-seen costumes" block: +```go + raidCostumes := c.availableRaidCostumes(ctx, pokemonID) + if len(raidCostumes) > 0 { + sb.WriteByte('\n') + sb.WriteString(tr.T("msg.info.recent_raid_costumes") + "\n") + for _, rc := range raidCostumes { + sb.WriteString(" " + rc + "\n") + } + } +``` +`en.json` — add next to `msg.info.available_costumes`: +```json + "msg.info.recent_raid_costumes": "**Recently-seen raid costumes:**", +``` + +- [ ] **Step 4: GREEN + gate + commit** + +```bash +go test ./internal/bot/commands/ -run 'TestInfo_Pokemon_.*RaidCostume' -v +go build ./... && go vet ./... && go test -count=1 ./internal/bot/... ./internal/i18n/... && golangci-lint run ./internal/bot/... +git add processor/internal/bot/commands/info.go processor/internal/bot/commands/info_raid_costume_test.go processor/internal/i18n/locale/en.json +git commit -m "feat(info): recently-seen raid costumes section" +``` + +--- + +### Task 7: Slash `/raid costume` option + autocomplete boost + +**Files:** +- Modify: `processor/internal/discordbot/slash/definitions.go` (raid option) +- Modify: `processor/internal/discordbot/slash/mappers/raid.go` (emit `costume:`) +- Modify: `processor/internal/discordbot/slash/dispatcher.go` (route + boost) +- Test: `processor/internal/discordbot/slash/mappers/raid_test.go` + `dispatcher_test.go` + +**Interfaces:** Consumes `autocomplete.Costume`, `autocomplete.PrependRecentCostumes`, `autocomplete.ResolvePokemonID`, `RecentRaidCostumes`. + +- [ ] **Step 1: Write the failing tests** + +Mapper test (append to `mappers/raid_test.go`; the file asserts exact token slices via `reflect.DeepEqual` — no `contains` helper). `/raid` with `boss=pikachu` + `costume=1` maps to `["pikachu", "costume:1"]` (boss emits the pokemon name as a bare token; costume appends `costume:1`): +```go +func TestRaidMapper_Costume(t *testing.T) { + tokens, err := Raid([]*discordgo.ApplicationCommandInteractionDataOption{ + {Name: "boss", Type: discordgo.ApplicationCommandOptionString, Value: "pikachu"}, + {Name: "costume", Type: discordgo.ApplicationCommandOptionString, Value: "1"}, + }) + if err != nil { + t.Fatalf("Raid mapper error: %v", err) + } + if !reflect.DeepEqual(tokens, []string{"pikachu", "costume:1"}) { + t.Errorf("tokens=%v, want [pikachu costume:1]", tokens) + } +} +``` +> Confirm the token order matches the mapper's append order (boss token before the costume token). If the mapper emits boss differently, match its actual output. +Dispatcher test (append to `dispatcher_test.go`, mirror `TestRouteAutocomplete_TrackCostume_BoostsRecentForPokemon`): a `/raid costume` autocomplete with sibling `boss=pikachu` and a recorded raid costume boosts it first. Build the interaction with a `boss` (not `pokemon`) option; prime `RecordRaidCostume(25, 1)` on the deps' RecentActivity; use a costume that sorts AFTER the alphabetical-first base entry (id 1 "Holiday 2016" vs base "Flying"). + +- [ ] **Step 2: Run — verify they fail.** + +- [ ] **Step 3: Implement** + +`definitions.go` `raidOptions` — add to the `opts` slice (mirror the `boss` stringOpt with autocomplete=true): +```go + stringOpt(bundle, "raid.costume", "costume", "Raid boss costume", false, true), +``` +`mappers/raid.go` — after the boss/level/team tokens, add: +```go + if costume := getString(o["costume"]); costume != "" { + tokens = append(tokens, "costume:"+costume) + } +``` +`dispatcher.go` `routeAutocomplete` — add a case for `(cmd="raid", opt="costume")`: +```go + case opt == "costume" && cmd == "raid": + base := autocomplete.Costume(context.Background(), d.deps, focused, userLang) + if focused == "" && d.deps != nil && d.deps.RecentActivity != nil { + if pid := autocomplete.ResolvePokemonID(d.deps, siblingOptionString(ic, "boss")); pid > 0 { + base = autocomplete.PrependRecentCostumes(base, d.deps, d.deps.RecentActivity.RecentRaidCostumes(pid), userLang) + } + } + return base +``` + +- [ ] **Step 4: GREEN + gate + commit** + +```bash +go test ./internal/discordbot/slash/... -run 'Raid.*Costume|Costume.*Raid' -v +go build ./... && go vet ./... && go test -count=1 ./internal/discordbot/... && golangci-lint run ./internal/discordbot/... +git add processor/internal/discordbot/slash/definitions.go processor/internal/discordbot/slash/mappers/raid.go processor/internal/discordbot/slash/mappers/raid_test.go processor/internal/discordbot/slash/dispatcher.go processor/internal/discordbot/slash/dispatcher_test.go +git commit -m "feat(slash): /raid costume option + recent-raid-costume autocomplete boost" +``` + +--- + +### Task 8: v2 raid API + +**Files:** +- Modify: `processor/internal/api/v2_raid.go` +- Regenerate: the OpenAPI golden (whatever the repo's golden-update command is; mirror how the v2 pokemon costume change regenerated it) +- Test: `processor/internal/api/v2_raid_test.go` (append round-trip) + +**Interfaces:** Consumes `db.RaidTrackingAPI.Costume` (Task 1). + +- [ ] **Step 1: Write the failing test** + +Append a round-trip test (mirror the v2 pokemon costume test): create a v2 raid rule with `costume` set → stored `Costume` matches; create with `costume` null/omitted → stored 9000; read back → `ptrUnless(9000)` returns null at wildcard, the value otherwise. Follow the existing `v2_raid_test.go` request/response idiom. + +- [ ] **Step 2: Run — verify it fails** (compile: `v2RaidRule` has no `Costume`). + +- [ ] **Step 3: Implement** + +`v2_raid.go` `v2RaidRule` — add after `Form`: +```go + Form *int `json:"form,omitempty" nullable:"true" doc:"..."` + Costume *int `json:"costume,omitempty" nullable:"true" doc:"Costume id. Omit/null = any (stored 9000). 0 = no costume. N = that costume."` +``` +`translateV2Raid` — add after `Form: valueOr(req.Form, 0),`: +```go + Costume: valueOr(req.Costume, 9000), +``` +`raidRowToRule` — add after `Form: ptrUnless(row.Form, 0),`: +```go + Costume: ptrUnless(row.Costume, 9000), +``` + +- [ ] **Step 4: Regenerate OpenAPI golden** + +Regenerate `internal/api/testdata/openapi.golden.json`: +```bash +UPDATE_GOLDEN=1 go test ./internal/api/ -run TestOpenAPIGolden +``` +Then run it without the env var to confirm it passes: `go test ./internal/api/ -run TestOpenAPIGolden`. Inspect the golden diff — it must be **additive** (a `costume` property on the v2 raid rule request/response schemas only). Do NOT hand-edit the golden. + +- [ ] **Step 5: GREEN + full gate + commit** + +```bash +go build ./... && go vet ./... && go test -count=1 ./... && golangci-lint run ./... +git add processor/internal/api/v2_raid.go processor/internal/api/v2_raid_test.go +git commit -m "feat(api): v2 raid Costume field (9000/0/null semantics)" +``` + +--- + +### Final: full gate + +- [ ] From `processor/`: `go build ./... && go vet ./... && go test -count=1 ./... && golangci-lint run ./...` — all green, `0 issues.` diff --git a/docs/superpowers/plans/2026-07-15-recency-forms-costumes-autocomplete.md b/docs/superpowers/plans/2026-07-15-recency-forms-costumes-autocomplete.md new file mode 100644 index 000000000..8fe7ffefc --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-recency-forms-costumes-autocomplete.md @@ -0,0 +1,718 @@ +# Recency-Aware Form & Costume Surfaces — 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:** Surface recently-spawned costumes and forms at the top of the `/track` pickers, and add a "recently seen forms" section to `!info`, reusing the existing `RecentActivity` autocomplete-boost mechanism. + +**Architecture:** Add a per-species recent-forms bucket to `tracker.RecentActivity` (mirror of the existing recent-costumes bucket), feed it from the pokemon webhook handler, add two `Prepend*` boost helpers, wire both into the dispatcher's `/track costume` and `/track form` autocomplete cases, and add a recent-forms section to `!info`. + +**Tech Stack:** Go, discordgo autocomplete, existing `tracker.RecentActivity` + `internal/discordbot/slash/autocomplete` boost pattern. + +Design spec: `docs/superpowers/specs/2026-07-15-recency-forms-costumes-autocomplete-design.md`. + +## Global Constraints + +- **Reuse the boost mechanics verbatim:** recent entries prepended, boost capped at **10**, total capped at **25** (Discord's hard limit), dedup by choice **Value**, and the boost only fires when the **focused text is empty**. No new config/tunables. +- **6-hour recency window** — reuse `RecentActivity.active()`; do not add a new TTL. +- **`RecordForm` and `RecordCostume` skip id ≤ 0.** Form 0 is the "any-form" placeholder (never a trackable value); costume 0 is "no costume" and already skipped. +- **Value contracts unchanged:** boosted **costume** choice `Value` = costume **id as string** (matches `autocomplete.Costume`); boosted **form** choice `Value` = **lowercased translated name** (matches `autocomplete.Form`). Labels come from `costumeLabel` / `formLabel` (user lang → English fallback). +- **Per-species recency needs a pokemon:** the boost only applies when the sibling `pokemon` option resolves to an id > 0; otherwise the base list is returned unchanged. +- **Mirror existing code exactly** — `RecordCostume`/`RecentCostumes`, `PrependActiveItems`, `availableCostumes` are the templates. Do not invent new shapes. +- **Pre-commit gate (from `processor/`), all four must pass:** `go build ./... && go vet ./... && go test -count=1 ./... && golangci-lint run ./...`. + +--- + +### Task 1: RecentActivity recent-forms bucket + +**Files:** +- Modify: `processor/internal/tracker/recent_activity.go` +- Test: `processor/internal/tracker/recent_activity_form_test.go` (create) + +**Interfaces:** +- Consumes: existing `record`/`active` helpers, `now func() time.Time`. +- Produces: `func (r *RecentActivity) RecordForm(pokemonID, form int)` and `func (r *RecentActivity) RecentForms(pokemonID int) []int` — used by the producer (Task 2) and dispatcher (Task 4). + +- [ ] **Step 1: Write the failing test** + +Create `processor/internal/tracker/recent_activity_form_test.go`: + +```go +package tracker + +import "testing" + +func TestRecentForms(t *testing.T) { + r := NewRecentActivity() + r.RecordForm(25, 598) + r.RecordForm(25, 680) + r.RecordForm(25, 0) // any-form placeholder: ignored + got := r.RecentForms(25) + if len(got) != 2 { + t.Fatalf("RecentForms(25) = %v, want two entries [598 680]", got) + } + if len(r.RecentForms(999)) != 0 { + t.Error("unknown pokemon should have no recent forms") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/tracker/ -run TestRecentForms -v` +Expected: FAIL — `RecordForm`/`RecentForms` undefined (build failure). + +- [ ] **Step 3: Write minimal implementation** + +In `recent_activity.go`, add the field to the struct (next to `costumesByPokemon`): + +```go + costumesByPokemon map[int]map[int]time.Time + formsByPokemon map[int]map[int]time.Time +``` + +Initialise it in `NewRecentActivity` (next to `costumesByPokemon`): + +```go + costumesByPokemon: make(map[int]map[int]time.Time), + formsByPokemon: make(map[int]map[int]time.Time), +``` + +Add the two methods after `RecentCostumes` (mirroring it exactly): + +```go +// RecordForm marks form as recently seen on pokemonID. Form 0 (the "any form" +// placeholder) is ignored — it is never a trackable value. +func (r *RecentActivity) RecordForm(pokemonID, form int) { + if pokemonID <= 0 || form <= 0 { + return + } + r.mu.Lock() + defer r.mu.Unlock() + inner := r.formsByPokemon[pokemonID] + if inner == nil { + inner = make(map[int]time.Time) + r.formsByPokemon[pokemonID] = inner + } + inner[form] = r.now() +} + +// RecentForms returns the recency-windowed list of form IDs recently seen on +// pokemonID. +func (r *RecentActivity) RecentForms(pokemonID int) []int { + r.mu.Lock() + inner := r.formsByPokemon[pokemonID] + r.mu.Unlock() + if inner == nil { + return nil + } + return r.active(inner) // reuse the existing recency window logic +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/tracker/ -run TestRecentForms -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add processor/internal/tracker/recent_activity.go processor/internal/tracker/recent_activity_form_test.go +git commit -m "feat(tracker): per-species RecordForm/RecentForms recency bucket" +``` + +--- + +### Task 2: Producer — record spawn form + +**Files:** +- Modify: `processor/cmd/processor/pokemon.go` (the `RecordCostume` call site, ~line 57) + +**Interfaces:** +- Consumes: `RecentActivity.RecordForm` (Task 1), `pokemon.PokemonID`, `pokemon.Form`. +- Produces: nothing new; feeds the recency bucket at runtime. + +**Note:** This is a one-line glue change in a webhook handler with no isolated unit seam (the existing `RecordCostume` producer likewise has no unit test). Verification is build + vet; the behaviour is exercised by Task 1's tested `RecentForms` and Task 4's routing tests. + +- [ ] **Step 1: Add the producer call** + +The current block reads: + +```go + if ps.recentActivity != nil && pokemon.Costume > 0 { + ps.recentActivity.RecordCostume(pokemon.PokemonID, pokemon.Costume) + } +``` + +Change it to also record the form (guard `Form > 0` mirrors the `Costume > 0` guard; `RecordForm` also guards internally): + +```go + if ps.recentActivity != nil { + if pokemon.Costume > 0 { + ps.recentActivity.RecordCostume(pokemon.PokemonID, pokemon.Costume) + } + if pokemon.Form > 0 { + ps.recentActivity.RecordForm(pokemon.PokemonID, pokemon.Form) + } + } +``` + +- [ ] **Step 2: Verify build + vet** + +Run: `go build ./... && go vet ./...` +Expected: no output (success). + +- [ ] **Step 3: Commit** + +```bash +git add processor/cmd/processor/pokemon.go +git commit -m "feat(processor): record spawn form into RecentActivity" +``` + +--- + +### Task 3: Autocomplete boost helpers + exported pokemon resolver + +**Files:** +- Modify: `processor/internal/discordbot/slash/autocomplete/recent_activity_boost.go` +- Modify: `processor/internal/discordbot/slash/autocomplete/form.go` (add exported `ResolvePokemonID`) +- Test: `processor/internal/discordbot/slash/autocomplete/recent_activity_boost_test.go` (append) + +**Interfaces:** +- Consumes: `costumeLabel` (costume.go), `formLabel` (form.go), `resolvePokemonID` (form.go). +- Produces: + - `func PrependRecentCostumes(base []*discordgo.ApplicationCommandOptionChoice, deps *bot.BotDeps, costumeIDs []int, userLang string) []*discordgo.ApplicationCommandOptionChoice` + - `func PrependRecentForms(base []*discordgo.ApplicationCommandOptionChoice, deps *bot.BotDeps, formIDs []int, userLang string) []*discordgo.ApplicationCommandOptionChoice` + - `func ResolvePokemonID(deps *bot.BotDeps, name string) int` + +- [ ] **Step 1: Write the failing tests** + +Append to `recent_activity_boost_test.go`: + +```go +func costumeFormBoostDeps(t *testing.T) *bot.BotDeps { + t.Helper() + bundle := i18n.NewBundle() + bundle.AddTranslator(i18n.NewTranslator("en", map[string]string{ + "poke_25": "Pikachu", + "costume_1": "Holiday 2016", + "costume_8": "Flying", + "form_598": "Normal", + "form_680": "Winter 2023", + })) + bundle.LinkFallbacks() + gd := &gamedata.GameData{ + Costumes: map[int]gamedata.CostumeInfo{ + 1: {ID: 1, Name: "Holiday 2016"}, + 8: {ID: 8, Name: "Flying"}, + }, + Monsters: map[gamedata.MonsterKey]*gamedata.Monster{ + {ID: 25, Form: 598}: {PokemonID: 25}, + {ID: 25, Form: 680}: {PokemonID: 25}, + }, + } + return &bot.BotDeps{Translations: bundle, GameData: gd, Cfg: &config.Config{}} +} + +func TestPrependRecentCostumes_BoostsFirstAndDedups(t *testing.T) { + deps := costumeFormBoostDeps(t) + base := Costume(context.Background(), deps, "", "en") + // Use id 1 ("Holiday 2016"), which sorts AFTER "Flying" alphabetically, so + // seeing it first proves the boost (not just alphabetical order). + out := PrependRecentCostumes(base, deps, []int{1}, "en") + if len(out) == 0 || out[0].Name != "Holiday 2016" { + t.Fatalf("first = %+v, want Holiday 2016 (recent id 1 prepended)", firstName(out)) + } + count := 0 + for _, c := range out { + if c.Value == "1" { + count++ + } + } + if count != 1 { + t.Errorf("costume 1 appears %d times, want 1 (dedup against base)", count) + } +} + +func TestPrependRecentCostumes_EmptyFallsThrough(t *testing.T) { + deps := costumeFormBoostDeps(t) + base := Costume(context.Background(), deps, "", "en") + out := PrependRecentCostumes(base, deps, nil, "en") + if len(out) != len(base) { + t.Errorf("len(out)=%d, len(base)=%d — nil recency should pass through", len(out), len(base)) + } +} + +func TestPrependRecentForms_BoostsFirstAndDedups(t *testing.T) { + deps := costumeFormBoostDeps(t) + base := Form(context.Background(), deps, "pikachu", "", "en") + out := PrependRecentForms(base, deps, []int{680}, "en") + if len(out) == 0 || out[0].Name != "Winter 2023" { + t.Fatalf("first = %+v, want Winter 2023 (recent form 680 prepended)", firstName(out)) + } + count := 0 + for _, c := range out { + if c.Value == "winter 2023" { + count++ + } + } + if count != 1 { + t.Errorf("form 'winter 2023' appears %d times, want 1 (dedup against base)", count) + } +} + +func TestResolvePokemonID(t *testing.T) { + deps := costumeFormBoostDeps(t) + if got := ResolvePokemonID(deps, "pikachu"); got != 25 { + t.Errorf("ResolvePokemonID(pikachu) = %d, want 25", got) + } + if got := ResolvePokemonID(deps, "25"); got != 25 { + t.Errorf("ResolvePokemonID(\"25\") = %d, want 25", got) + } + if got := ResolvePokemonID(deps, ""); got != 0 { + t.Errorf("ResolvePokemonID(\"\") = %d, want 0", got) + } +} +``` + +(`firstName` already exists in this test file; `config`, `gamedata`, `i18n`, `tracker`, `context` are already imported.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/discordbot/slash/autocomplete/ -run 'PrependRecent|ResolvePokemonID' -v` +Expected: FAIL — the three new functions are undefined. + +- [ ] **Step 3: Write minimal implementation** + +In `form.go`, add after `resolvePokemonID`: + +```go +// ResolvePokemonID exposes resolvePokemonID to the dispatcher package so +// per-species recency boosts (RecentCostumes/RecentForms) can key on the +// sibling pokemon option. Accepts the canonical English name or a numeric id; +// returns 0 when unresolved. +func ResolvePokemonID(deps *bot.BotDeps, name string) int { + return resolvePokemonID(deps, name) +} +``` + +In `recent_activity_boost.go`, add `"strconv"` to the import block, then append the two helpers: + +```go +// PrependRecentCostumes prepends the costumes recently seen on the selected +// pokemon (from RecentActivity.RecentCostumes) to the flat costume choice list +// on /track costume. Label = translated costume name, Value = costume id as a +// string (matching autocomplete.Costume). Caps the boost at 10, dedups by +// Value, and stops at Discord's 25-choice limit — same contract as +// PrependActiveItems. +func PrependRecentCostumes(base []*discordgo.ApplicationCommandOptionChoice, deps *bot.BotDeps, costumeIDs []int, userLang string) []*discordgo.ApplicationCommandOptionChoice { + if deps == nil || len(costumeIDs) == 0 || deps.Translations == nil { + return base + } + out := make([]*discordgo.ApplicationCommandOptionChoice, 0, 25) + seen := map[string]bool{} + add := func(name, value string) bool { + if seen[value] { + return false + } + seen[value] = true + out = append(out, &discordgo.ApplicationCommandOptionChoice{Name: name, Value: value}) + return len(out) >= 25 + } + enTr := deps.Translations.For("en") + userTr := deps.Translations.For(userLang) + for i, id := range costumeIDs { + if i >= 10 { + break + } + label := costumeLabel(enTr, userTr, id) + if label == "" { + continue + } + if add(label, strconv.Itoa(id)) { + return out + } + } + for _, c := range base { + v, _ := c.Value.(string) + if add(c.Name, v) { + return out + } + } + return out +} + +// PrependRecentForms prepends the forms recently seen on the selected pokemon +// (from RecentActivity.RecentForms) to autocomplete.Form's alphabetical list on +// /track form. Label/Value follow formLabel (translated name / lowercased +// name). Same 10/25/dedup contract as PrependRecentCostumes. +func PrependRecentForms(base []*discordgo.ApplicationCommandOptionChoice, deps *bot.BotDeps, formIDs []int, userLang string) []*discordgo.ApplicationCommandOptionChoice { + if deps == nil || len(formIDs) == 0 || deps.Translations == nil { + return base + } + out := make([]*discordgo.ApplicationCommandOptionChoice, 0, 25) + seen := map[string]bool{} + add := func(name, value string) bool { + if seen[value] { + return false + } + seen[value] = true + out = append(out, &discordgo.ApplicationCommandOptionChoice{Name: name, Value: value}) + return len(out) >= 25 + } + enTr := deps.Translations.For("en") + userTr := deps.Translations.For(userLang) + for i, id := range formIDs { + if i >= 10 { + break + } + label, value := formLabel(enTr, userTr, id) + if value == "" { + continue + } + if add(label, value) { + return out + } + } + for _, c := range base { + v, _ := c.Value.(string) + if add(c.Name, v) { + return out + } + } + return out +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/discordbot/slash/autocomplete/ -run 'PrependRecent|ResolvePokemonID' -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add processor/internal/discordbot/slash/autocomplete/recent_activity_boost.go processor/internal/discordbot/slash/autocomplete/form.go processor/internal/discordbot/slash/autocomplete/recent_activity_boost_test.go +git commit -m "feat(slash): PrependRecentCostumes/PrependRecentForms boost helpers" +``` + +--- + +### Task 4: Wire boosts into the dispatcher + +**Files:** +- Modify: `processor/internal/discordbot/slash/dispatcher.go` (`routeAutocomplete`, the `form` and `costume` cases) +- Test: `processor/internal/discordbot/slash/dispatcher_test.go` (append) + +**Interfaces:** +- Consumes: `autocomplete.ResolvePokemonID`, `autocomplete.PrependRecentCostumes`, `autocomplete.PrependRecentForms`, `RecentActivity.RecentCostumes`, `RecentActivity.RecentForms`, existing `siblingOptionString`. + +- [ ] **Step 1: Write the failing tests** + +Append to `dispatcher_test.go`. Build an autocomplete interaction with a `pokemon` sibling option (mirror the interaction construction already used in this file around the `HandleAutocomplete` tests): + +```go +func costumeFormRouteDeps(t *testing.T) *bot.BotDeps { + t.Helper() + bundle := i18n.NewBundle() + bundle.AddTranslator(i18n.NewTranslator("en", map[string]string{ + "poke_25": "Pikachu", + "costume_1": "Holiday 2016", + "costume_8": "Flying", + "form_598": "Normal", + "form_680": "Winter 2023", + })) + bundle.LinkFallbacks() + gd := &gamedata.GameData{ + Costumes: map[int]gamedata.CostumeInfo{ + 1: {ID: 1, Name: "Holiday 2016"}, + 8: {ID: 8, Name: "Flying"}, + }, + Monsters: map[gamedata.MonsterKey]*gamedata.Monster{ + {ID: 25, Form: 598}: {PokemonID: 25}, + {ID: 25, Form: 680}: {PokemonID: 25}, + }, + } + ra := tracker.NewRecentActivity() + ra.RecordCostume(25, 1) // "Holiday 2016" — sorts after "Flying", proves boost + ra.RecordForm(25, 680) // "Winter 2023" — sorts after "Normal", proves boost + return &bot.BotDeps{Translations: bundle, GameData: gd, Cfg: &config.Config{}, RecentActivity: ra} +} + +func trackPokemonSiblingIC(pokemon string) *discordgo.InteractionCreate { + return &discordgo.InteractionCreate{Interaction: &discordgo.Interaction{ + Type: discordgo.InteractionApplicationCommandAutocomplete, + Data: discordgo.ApplicationCommandInteractionData{ + Name: "track", + Options: []*discordgo.ApplicationCommandInteractionDataOption{ + {Name: "pokemon", Type: discordgo.ApplicationCommandOptionString, Value: pokemon}, + }, + }, + }} +} + +func TestRouteAutocomplete_TrackCostume_BoostsRecentForPokemon(t *testing.T) { + d := NewDispatcher(Config{}) + d.bundle = testBundle(t) + d.cfgRoot = &config.Config{} + d.deps = costumeFormRouteDeps(t) + ic := trackPokemonSiblingIC("pikachu") + out := d.routeAutocomplete("track", "costume", "", "en", ic) + if len(out) == 0 || out[0].Name != "Holiday 2016" { + t.Errorf("/track costume empty focused: first=%+v, want Holiday 2016 (recent costume 1 for pikachu)", firstName(out)) + } +} + +func TestRouteAutocomplete_TrackForm_BoostsRecentForPokemon(t *testing.T) { + d := NewDispatcher(Config{}) + d.bundle = testBundle(t) + d.cfgRoot = &config.Config{} + d.deps = costumeFormRouteDeps(t) + ic := trackPokemonSiblingIC("pikachu") + out := d.routeAutocomplete("track", "form", "", "en", ic) + if len(out) == 0 || out[0].Name != "Winter 2023" { + t.Errorf("/track form empty focused: first=%+v, want Winter 2023 (recent form 680 for pikachu)", firstName(out)) + } +} + +func TestRouteAutocomplete_TrackCostume_NoPokemonNoBoost(t *testing.T) { + d := NewDispatcher(Config{}) + d.bundle = testBundle(t) + d.cfgRoot = &config.Config{} + d.deps = costumeFormRouteDeps(t) + // No sibling pokemon → flat alphabetical list ("Flying" first), recency not + // applied. The recent costume ("Holiday 2016", id 1) must NOT be boosted to + // the top. + out := d.routeAutocomplete("track", "costume", "", "en", trackPokemonSiblingIC("")) + if len(out) == 0 || out[0].Name != "Flying" { + t.Errorf("/track costume with no pokemon should be flat/alphabetical (Flying first), got first=%+v", firstName(out)) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/discordbot/slash/ -run 'TrackCostume|TrackForm' -v` +Expected: FAIL — the costume/form cases don't boost yet (first entry is alphabetical, not the recent one). + +- [ ] **Step 3: Update the dispatcher cases** + +Replace the existing `form` case: + +```go + case opt == "form" && cmd == "track": + pokemonValue := siblingOptionString(ic, "pokemon") + base := autocomplete.Form(context.Background(), d.deps, pokemonValue, focused, userLang) + if focused == "" && d.deps != nil && d.deps.RecentActivity != nil { + if pid := autocomplete.ResolvePokemonID(d.deps, pokemonValue); pid > 0 { + base = autocomplete.PrependRecentForms(base, d.deps, d.deps.RecentActivity.RecentForms(pid), userLang) + } + } + return base +``` + +Replace the existing `costume` case: + +```go + case opt == "costume" && cmd == "track": + base := autocomplete.Costume(context.Background(), d.deps, focused, userLang) + if focused == "" && d.deps != nil && d.deps.RecentActivity != nil { + if pid := autocomplete.ResolvePokemonID(d.deps, siblingOptionString(ic, "pokemon")); pid > 0 { + base = autocomplete.PrependRecentCostumes(base, d.deps, d.deps.RecentActivity.RecentCostumes(pid), userLang) + } + } + return base +``` + +Update the two doc comments above these cases to note the recency boost (the old comment says costume "doesn't cascade from the selected pokemon option" — it now optionally does, for recency only). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/discordbot/slash/ -run 'TrackCostume|TrackForm' -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add processor/internal/discordbot/slash/dispatcher.go processor/internal/discordbot/slash/dispatcher_test.go +git commit -m "feat(slash): boost recent costumes/forms to top of /track pickers" +``` + +--- + +### Task 5: `!info` recently-seen forms section + +**Files:** +- Modify: `processor/internal/bot/commands/info.go` (add `availableRecentForms` helper + render it) +- Modify: `processor/internal/i18n/locale/en.json` (add `msg.info.recent_forms`) +- Test: `processor/internal/bot/commands/info_recent_forms_test.go` (create) + +**Interfaces:** +- Consumes: `ctx.RecentActivity.RecentForms`, `ctx.GameData`, `gamedata.FormTranslationKey`. +- Produces: `func (c *InfoCommand) availableRecentForms(ctx *bot.CommandContext, pokemonID int) []string`. + +- [ ] **Step 1: Write the failing test** + +Create `processor/internal/bot/commands/info_recent_forms_test.go`, mirroring `info_costume_test.go` exactly (integration-style via `cmd.Run`, using `testCtx`): + +```go +package commands + +import ( + "strings" + "testing" + + "github.com/pokemon/poracleng/processor/internal/bot" + "github.com/pokemon/poracleng/processor/internal/gamedata" + "github.com/pokemon/poracleng/processor/internal/i18n" + "github.com/pokemon/poracleng/processor/internal/tracker" +) + +// infoFormCtx mirrors infoCostumeCtx (info_costume_test.go) but wires a named +// form (680 → "Winter 2023") so !info pikachu can exercise the recent-forms +// section. +func infoFormCtx(t *testing.T) *bot.CommandContext { + t.Helper() + ctx, _ := testCtx(t) + + gd := &gamedata.GameData{ + Monsters: map[gamedata.MonsterKey]*gamedata.Monster{ + {ID: 25, Form: 0}: {PokemonID: 25, FormID: 0}, + {ID: 25, Form: 680}: {PokemonID: 25, FormID: 680}, + }, + Moves: map[int]*gamedata.Move{}, + Types: map[int]*gamedata.TypeInfo{}, + Util: &gamedata.UtilData{}, + } + + ctx.Translations.AddTranslator(i18n.NewTranslator("en", map[string]string{ + "poke_25": "Pikachu", + "form_680": "Winter 2023", + })) + + ctx.Resolver = bot.NewPokemonResolver(gd, ctx.Translations, []string{"en"}, nil) + ctx.GameData = gd + ctx.RecentActivity = tracker.NewRecentActivity() + + return ctx +} + +func TestInfo_Pokemon_RecentlySeenForms(t *testing.T) { + ctx := infoFormCtx(t) + ctx.RecentActivity.RecordForm(25, 680) + + cmd := &InfoCommand{} + replies := cmd.Run(ctx, []string{"pikachu"}) + if len(replies) == 0 { + t.Fatal("expected at least one reply, got none") + } + text := replies[0].Text + if !strings.Contains(text, "680 — Winter 2023") { + t.Errorf("expected 'id — name' recent form line, got: %q", text) + } + if !strings.Contains(text, "Recently-seen forms") { + t.Errorf("expected a recently-seen forms header, got: %q", text) + } +} + +func TestInfo_Pokemon_NoRecentForms_SectionOmitted(t *testing.T) { + ctx := infoFormCtx(t) + + cmd := &InfoCommand{} + replies := cmd.Run(ctx, []string{"pikachu"}) + if len(replies) == 0 { + t.Fatal("expected at least one reply, got none") + } + if strings.Contains(replies[0].Text, "Recently-seen forms") { + t.Errorf("expected no recent-forms section when none recorded, got: %q", replies[0].Text) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/bot/commands/ -run 'TestInfo_Pokemon_RecentlySeenForms|TestInfo_Pokemon_NoRecentForms' -v` +Expected: FAIL — `availableRecentForms` undefined / no recent-forms section rendered. + +- [ ] **Step 3: Implement the helper and render it** + +Add the helper after `availableCostumes` in `info.go`: + +```go +// availableRecentForms returns "id — name" display strings for forms recently +// seen on pokemonID (via RecentActivity), sorted by id. Mirrors +// availableCostumes; returns nil when RecentActivity isn't wired or nothing +// has been seen recently. +func (c *InfoCommand) availableRecentForms(ctx *bot.CommandContext, pokemonID int) []string { + if ctx.RecentActivity == nil { + return nil + } + ids := ctx.RecentActivity.RecentForms(pokemonID) + if len(ids) == 0 { + return nil + } + sort.Ints(ids) + + tr := ctx.Tr() + enTr := ctx.Translations.For("en") + result := make([]string, 0, len(ids)) + for _, id := range ids { + key := gamedata.FormTranslationKey(id) + name := tr.T(key) + if name == key { + name = enTr.T(key) + } + if name == key { + continue // unresolved form name — skip rather than show "form_N" + } + result = append(result, fmt.Sprintf("%d — %s", id, name)) + } + return result +} +``` + +Render it in the `!info ` body **immediately before** the recently-seen-costumes block (approved order: recent forms → recent costumes → available forms). Insert directly above the existing `costumes := c.availableCostumes(...)` block: + +```go + // Recently-seen forms for tracking (form:) + recentForms := c.availableRecentForms(ctx, pokemonID) + if len(recentForms) > 0 { + sb.WriteByte('\n') + sb.WriteString(tr.T("msg.info.recent_forms") + "\n") + for _, f := range recentForms { + sb.WriteString(" " + f + "\n") + } + } +``` + +- [ ] **Step 4: Add the i18n key** + +In `processor/internal/i18n/locale/en.json`, add next to `msg.info.available_costumes` (wording parallels the costume header "Recently-seen costumes:"): + +```json + "msg.info.recent_forms": "Recently-seen forms:", +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/bot/commands/ -run 'TestInfo_Pokemon_RecentlySeenForms|TestInfo_Pokemon_NoRecentForms' -v` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +git add processor/internal/bot/commands/info.go processor/internal/i18n/locale/en.json processor/internal/bot/commands/info_test.go +git commit -m "feat(info): recently-seen forms section in !info" +``` + +--- + +### Final: full gate + +- [ ] Run the complete pre-commit gate from `processor/`: + +```bash +go build ./... && go vet ./... && go test -count=1 ./... && golangci-lint run ./... +``` + +Expected: build/vet clean, all tests pass, `0 issues.` from the linter. diff --git a/docs/superpowers/plans/2026-07-16-info-forms-costumes-consistency.md b/docs/superpowers/plans/2026-07-16-info-forms-costumes-consistency.md new file mode 100644 index 000000000..aaf277723 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-info-forms-costumes-consistency.md @@ -0,0 +1,530 @@ +# `!info` Forms & Costumes Consistency 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:** Make `!info ` forms/costumes consistent — add a raid-forms recency tracker + section, render costume sections copy-pasteable, truncate the long available-forms roster with `!info forms`/`costumes` reveal subcommands, and add a `/raid form` slash option. + +**Architecture:** Mirror the existing raid-costume recency + `/raid costume` slash work; refactor the `!info` costume sections to the copy-pasteable format the form sections already use. + +**Tech Stack:** Go, `tracker.RecentActivity`, `bot/commands/info.go`, discordgo autocomplete. + +Design spec: `docs/superpowers/specs/2026-07-16-info-forms-costumes-consistency-design.md`. + +## Global Constraints + +- **Copy-pasteable format:** costume/form recency lines are `ctx.Code(" :")` where `` is the translated name lowercased with spaces→underscores; unresolved names are **skipped** (mirrors `availableRecentForms`). `pokeName` = `ctx.Translations.For("en").T(gamedata.PokemonTranslationKey(pokemonID))`. +- **Recency mirrors the raid-costume trio** exactly: `RecordRaidForm`/`RecentRaidForms` skip id ≤ 0, same mutex + `active()` window, separate bucket (must not leak into spawn `RecentForms` or raid costumes). +- **Only the available-forms roster truncates** (cap **10**); recency sections show in full. +- **Section order** in `!info `: recently-seen forms → raid forms → costumes → raid costumes → available forms. +- **Sub-routes** are detected in `pokemonInfo` via a trailing `args` keyword; they must NOT be added to the top-level `Run` switch (that would create a global `!info forms`). +- **`/raid form` mirrors `/raid costume`** (definitions option, mapper token, dispatcher boost from the `boss` sibling). +- **Pre-commit gate (from `processor/`):** `go build ./... && go vet ./... && go test -count=1 ./... && golangci-lint run ./...`. + +--- + +### Task 1: RecentActivity raid-forms bucket + producer + +**Files:** +- Modify: `processor/internal/tracker/recent_activity.go` +- Modify: `processor/cmd/processor/raid.go` +- Test: `processor/internal/tracker/recent_activity_raidform_test.go` (create) + +**Interfaces:** Produces `RecordRaidForm(pokemonID, form int)`, `RecentRaidForms(pokemonID int) []int` — consumed by `!info` (Task 3) and `/raid form` (Task 4). + +- [ ] **Step 1: Write the failing test** (mirror `recent_activity_raidcostume_test.go`) + +```go +package tracker + +import "testing" + +func TestRecentRaidForms(t *testing.T) { + r := NewRecentActivity() + r.RecordRaidForm(25, 598) + r.RecordRaidForm(25, 680) + r.RecordRaidForm(25, 0) // any-form placeholder: ignored + if got := r.RecentRaidForms(25); len(got) != 2 { + t.Fatalf("RecentRaidForms(25) = %v, want two entries", got) + } + if len(r.RecentRaidForms(999)) != 0 { + t.Error("unknown boss should have no recent raid forms") + } + // Separate from spawn forms and from raid costumes. + if len(r.RecentForms(25)) != 0 { + t.Error("raid forms must not leak into spawn RecentForms") + } +} +``` + +- [ ] **Step 2: Run — verify it fails.** `go test ./internal/tracker/ -run TestRecentRaidForms -v` → FAIL (undefined). + +- [ ] **Step 3: Implement** (mirror `raidCostumesByPokemon`/`RecordRaidCostume`/`RecentRaidCostumes`) + +`recent_activity.go` — field + init: +```go + raidCostumesByPokemon map[int]map[int]time.Time + raidFormsByPokemon map[int]map[int]time.Time +``` +```go + raidCostumesByPokemon: make(map[int]map[int]time.Time), + raidFormsByPokemon: make(map[int]map[int]time.Time), +``` +Methods (after `RecentRaidCostumes`): +```go +// RecordRaidForm marks form as recently seen on a raid boss pokemonID. +func (r *RecentActivity) RecordRaidForm(pokemonID, form int) { + if pokemonID <= 0 || form <= 0 { + return + } + r.mu.Lock() + defer r.mu.Unlock() + inner := r.raidFormsByPokemon[pokemonID] + if inner == nil { + inner = make(map[int]time.Time) + r.raidFormsByPokemon[pokemonID] = inner + } + inner[form] = r.now() +} + +// RecentRaidForms returns the recency-windowed form IDs recently seen on raid +// boss pokemonID. +func (r *RecentActivity) RecentRaidForms(pokemonID int) []int { + r.mu.Lock() + inner := r.raidFormsByPokemon[pokemonID] + r.mu.Unlock() + if inner == nil { + return nil + } + return r.active(inner) +} +``` +`cmd/processor/raid.go` — add beside the existing raid-costume producer (inside the same `if ps.recentActivity != nil {` block): +```go + if raid.Form > 0 { + ps.recentActivity.RecordRaidForm(raid.PokemonID, raid.Form) + } +``` + +- [ ] **Step 4: GREEN + gate + commit** + +```bash +go test ./internal/tracker/ -run TestRecentRaidForms -v +go build ./... && go vet ./... && go test -count=1 ./internal/tracker/ ./cmd/... && golangci-lint run ./internal/tracker/ ./cmd/... +git add processor/internal/tracker/recent_activity.go processor/internal/tracker/recent_activity_raidform_test.go processor/cmd/processor/raid.go +git commit -m "feat(tracker): RecordRaidForm/RecentRaidForms bucket + producer" +``` + +--- + +### Task 2: Copy-pasteable costume sections + raid-forms section + reorder + +**Files:** +- Modify: `processor/internal/bot/commands/info.go` +- Modify: `processor/internal/i18n/locale/en.json` +- Test: `processor/internal/bot/commands/info_raid_form_test.go` (create) + extend costume tests + +**Interfaces:** Consumes `RecentRaidForms` (Task 1). Produces `availableRecentRaidForms`, `costumeTrackLines`. + +- [ ] **Step 1: Write the failing tests** + +Add to a new `info_raid_form_test.go` (reuse an `!info` test ctx helper that primes GameData + translations + RecentActivity — mirror `infoRaidCostumeCtx`/`infoFormCtx`; include `poke_25`, `form_680`="Winter 2023", `costume_1`="Holiday 2016", `GameData.Costumes{1}`, and Monsters `{25,0}`/`{25,680}`): + +```go +func TestInfo_Pokemon_RecentRaidForms(t *testing.T) { + ctx := infoFormCostumeCtx(t) // helper priming forms + costumes + RecentActivity + ctx.RecentActivity.RecordRaidForm(25, 680) + text := (&InfoCommand{}).Run(ctx, []string{"pikachu"})[0].Text + if !strings.Contains(text, "Recently-seen raid forms") || !strings.Contains(text, "form:winter_2023") { + t.Errorf("expected copy-pasteable recent raid forms section, got: %q", text) + } +} + +func TestInfo_Pokemon_CostumesCopyPasteable(t *testing.T) { + ctx := infoFormCostumeCtx(t) + ctx.RecentActivity.RecordCostume(25, 1) // spawn costume + ctx.RecentActivity.RecordRaidCostume(25, 1) // raid costume + text := (&InfoCommand{}).Run(ctx, []string{"pikachu"})[0].Text + if !strings.Contains(text, "costume:holiday_2016") { + t.Errorf("costume sections must be copy-pasteable 'costume:', got: %q", text) + } + if strings.Contains(text, "1 — Holiday 2016") { + t.Errorf("costume sections must NOT use the old 'id — name' format, got: %q", text) + } +} +``` + +- [ ] **Step 2: Run — verify they fail.** + +- [ ] **Step 3: Implement** + +Add a shared costume line helper + a raid-forms helper in `info.go`: +```go +// costumeTrackLines builds copy-pasteable " costume:" strings for +// the given (sorted) costume ids — name lowercased with spaces→underscores, +// mirroring availableRecentForms's form format. Unresolved names are skipped. +func (c *InfoCommand) costumeTrackLines(ctx *bot.CommandContext, pokemonID int, ids []int) []string { + tr := ctx.Tr() + enTr := ctx.Translations.For("en") + pokeName := enTr.T(gamedata.PokemonTranslationKey(pokemonID)) + result := make([]string, 0, len(ids)) + for _, id := range ids { + name := costumeName(ctx, tr, id) + if name == "" || name == gamedata.CostumeTranslationKey(id) { + continue + } + trackingName := strings.ReplaceAll(strings.ToLower(name), " ", "_") + result = append(result, ctx.Code(fmt.Sprintf("%s costume:%s", pokeName, trackingName))) + } + return result +} + +// availableRecentRaidForms mirrors availableRecentForms but sources RecentRaidForms. +func (c *InfoCommand) availableRecentRaidForms(ctx *bot.CommandContext, pokemonID int) []string { + if ctx.RecentActivity == nil { + return nil + } + ids := ctx.RecentActivity.RecentRaidForms(pokemonID) + if len(ids) == 0 { + return nil + } + sort.Ints(ids) + tr := ctx.Tr() + enTr := ctx.Translations.For("en") + pokeName := enTr.T(gamedata.PokemonTranslationKey(pokemonID)) + result := make([]string, 0, len(ids)) + for _, id := range ids { + key := gamedata.FormTranslationKey(id) + name := tr.T(key) + if name == key { + name = enTr.T(key) + } + if name == key { + continue + } + trackingName := strings.ReplaceAll(strings.ToLower(name), " ", "_") + result = append(result, ctx.Code(fmt.Sprintf("%s form:%s", pokeName, trackingName))) + } + return result +} +``` +Rewrite `availableCostumes` and `availableRaidCostumes` to use the shared helper: +```go +func (c *InfoCommand) availableCostumes(ctx *bot.CommandContext, pokemonID int) []string { + if ctx.RecentActivity == nil { + return nil + } + ids := ctx.RecentActivity.RecentCostumes(pokemonID) + if len(ids) == 0 { + return nil + } + sort.Ints(ids) + return c.costumeTrackLines(ctx, pokemonID, ids) +} +func (c *InfoCommand) availableRaidCostumes(ctx *bot.CommandContext, pokemonID int) []string { + if ctx.RecentActivity == nil { + return nil + } + ids := ctx.RecentActivity.RecentRaidCostumes(pokemonID) + if len(ids) == 0 { + return nil + } + sort.Ints(ids) + return c.costumeTrackLines(ctx, pokemonID, ids) +} +``` +In `pokemonInfo`, insert the raid-forms section between the spawn-forms and spawn-costumes blocks (final order: forms → raid forms → costumes → raid costumes → available forms): +```go + // Recently-seen raid forms + recentRaidForms := c.availableRecentRaidForms(ctx, pokemonID) + if len(recentRaidForms) > 0 { + sb.WriteByte('\n') + sb.WriteString(tr.T("msg.info.recent_raid_forms") + "\n") + for _, f := range recentRaidForms { + sb.WriteString(" " + f + "\n") + } + } +``` +`en.json` — add: +```json + "msg.info.recent_raid_forms": "**Recently-seen raid forms:**", +``` + +- [ ] **Step 4: GREEN + gate + commit** + +```bash +go test ./internal/bot/commands/ -run 'TestInfo_Pokemon_(RecentRaidForms|CostumesCopyPasteable)' -v +go build ./... && go vet ./... && go test -count=1 ./internal/bot/... ./internal/i18n/... && golangci-lint run ./internal/bot/... +git add processor/internal/bot/commands/info.go processor/internal/bot/commands/info_raid_form_test.go processor/internal/i18n/locale/en.json +git commit -m "feat(info): copy-pasteable costume sections + recently-seen raid forms" +``` + +--- + +### Task 3: Roster truncation + `!info forms`/`costumes` sub-routes + +**Files:** +- Modify: `processor/internal/bot/commands/info.go` +- Modify: `processor/internal/i18n/locale/en.json` +- Test: `processor/internal/bot/commands/info_subroute_test.go` (create) + +**Interfaces:** Consumes the Task 2 helpers + `availableForms`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestInfo_Pokemon_FormsTruncated(t *testing.T) { + ctx := manyFormsCtx(t) // ctx whose GameData.Monsters gives species 25 >10 named forms + text := (&InfoCommand{}).Run(ctx, []string{"pikachu"})[0].Text + if !strings.Contains(text, "More than 10 forms") { + t.Errorf("expected roster truncation hint, got: %q", text) + } +} + +func TestInfo_Pokemon_FormsSubroute(t *testing.T) { + ctx := manyFormsCtx(t) + ctx.RecentActivity.RecordForm(25, 680) + text := (&InfoCommand{}).Run(ctx, []string{"pikachu", "forms"})[0].Text + // Full roster (no truncation hint) AND recent forms. + if strings.Contains(text, "More than 10 forms") { + t.Errorf("!info pikachu forms must show the full roster untruncated, got: %q", text) + } + if !strings.Contains(text, "form:winter_2023") { + t.Errorf("!info pikachu forms should include recent forms, got: %q", text) + } +} + +func TestInfo_Pokemon_CostumesSubroute(t *testing.T) { + ctx := infoFormCostumeCtx(t) + ctx.RecentActivity.RecordCostume(25, 1) + ctx.RecentActivity.RecordRaidCostume(25, 8) // a second, raid-only costume + text := (&InfoCommand{}).Run(ctx, []string{"pikachu", "costumes"})[0].Text + if !strings.Contains(text, "costume:holiday_2016") { + t.Errorf("!info pikachu costumes should show combined recent costumes, got: %q", text) + } +} +``` +> `manyFormsCtx` primes `GameData.Monsters` with >10 `{25, N}` entries and matching `form_N` translations. Reuse/extend the Task 2 ctx helper. + +- [ ] **Step 2: Run — verify they fail.** + +- [ ] **Step 3: Implement** + +**Roster truncation** — in `pokemonInfo`'s available-forms block: +```go + forms := c.availableForms(ctx, pokemonID) + if len(forms) > 0 { + sb.WriteByte('\n') + sb.WriteString(tr.T("msg.info.available_forms") + "\n") + const formCap = 10 + shown := forms + if len(shown) > formCap { + shown = shown[:formCap] + } + for _, f := range shown { + sb.WriteString(" " + f + "\n") + } + if len(forms) > formCap { + enTr := ctx.Translations.For("en") + pokeName := enTr.T(gamedata.PokemonTranslationKey(pokemonID)) + hintCmd := ctx.Code(bot.CommandPrefix(ctx) + tr.T("cmd.info") + " " + pokeName + " " + tr.T("msg.info.sub.forms")) + sb.WriteString(" " + tr.Tf("msg.info.more_forms", formCap, hintCmd) + "\n") + } + } +``` + +**Sub-route detection** — in `pokemonInfo`, after the `form:` extraction loop and before `name := strings.Join(nameArgs, " ")`, peel off a trailing forms/costumes keyword: +```go + tr := ctx.Tr() + enTr := ctx.Translations.For("en") + subMatch := func(key, tok string) bool { + return tok == strings.ToLower(tr.T(key)) || tok == strings.ToLower(enTr.T(key)) + } + var subMode string + if len(nameArgs) > 1 { + last := strings.ToLower(nameArgs[len(nameArgs)-1]) + switch { + case subMatch("msg.info.sub.forms", last): + subMode = "forms" + nameArgs = nameArgs[:len(nameArgs)-1] + case subMatch("msg.info.sub.costumes", last): + subMode = "costumes" + nameArgs = nameArgs[:len(nameArgs)-1] + } + } +``` +(If `tr`/`enTr` are already declared later in `pokemonInfo`, hoist them here and remove the duplicate declarations.) + +After `pokemonID` is resolved, branch to the sub-views: +```go + switch subMode { + case "forms": + return c.pokemonFormsFull(ctx, pokemonID) + case "costumes": + return c.pokemonCostumesFull(ctx, pokemonID) + } +``` + +**Sub-view renderers**: +```go +// pokemonFormsFull renders !info forms: recent forms (spawn + raid) +// plus the full available-forms roster (untruncated). +func (c *InfoCommand) pokemonFormsFull(ctx *bot.CommandContext, pokemonID int) []bot.Reply { + tr := ctx.Tr() + var sb strings.Builder + writeSection := func(header string, lines []string) { + if len(lines) == 0 { + return + } + if sb.Len() > 0 { + sb.WriteByte('\n') + } + sb.WriteString(tr.T(header) + "\n") + for _, l := range lines { + sb.WriteString(" " + l + "\n") + } + } + writeSection("msg.info.recent_forms", c.availableRecentForms(ctx, pokemonID)) + writeSection("msg.info.recent_raid_forms", c.availableRecentRaidForms(ctx, pokemonID)) + writeSection("msg.info.available_forms", c.availableForms(ctx, pokemonID)) + if sb.Len() == 0 { + return []bot.Reply{{Text: tr.T("msg.info.no_form_data")}} + } + return []bot.Reply{{Text: sb.String()}} +} + +// pokemonCostumesFull renders !info costumes: the combined recently-seen +// costumes (spawn + raid), deduped, copy-pasteable. +func (c *InfoCommand) pokemonCostumesFull(ctx *bot.CommandContext, pokemonID int) []bot.Reply { + tr := ctx.Tr() + seen := map[int]bool{} + var ids []int + if ctx.RecentActivity != nil { + for _, id := range append(ctx.RecentActivity.RecentCostumes(pokemonID), ctx.RecentActivity.RecentRaidCostumes(pokemonID)...) { + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + } + sort.Ints(ids) + lines := c.costumeTrackLines(ctx, pokemonID, ids) + if len(lines) == 0 { + return []bot.Reply{{Text: tr.T("msg.info.no_costume_data")}} + } + var sb strings.Builder + sb.WriteString(tr.T("msg.info.available_costumes") + "\n") + for _, l := range lines { + sb.WriteString(" " + l + "\n") + } + return []bot.Reply{{Text: sb.String()}} +} +``` + +`en.json` — add: +```json + "msg.info.sub.forms": "forms", + "msg.info.more_forms": "More than {0} forms — do {1} for the full list", + "msg.info.no_form_data": "No form data for that pokemon.", +``` +(`msg.info.no_costume_data` already exists.) + +- [ ] **Step 4: GREEN + gate + commit** + +```bash +go test ./internal/bot/commands/ -run 'TestInfo_Pokemon_(FormsTruncated|FormsSubroute|CostumesSubroute)' -v +go build ./... && go vet ./... && go test -count=1 ./internal/bot/... ./internal/i18n/... && golangci-lint run ./internal/bot/... +git add processor/internal/bot/commands/info.go processor/internal/bot/commands/info_subroute_test.go processor/internal/i18n/locale/en.json +git commit -m "feat(info): truncate forms roster + !info forms/costumes subroutes" +``` + +--- + +### Task 4: `/raid form` slash option + autocomplete boost + +**Files:** +- Modify: `processor/internal/discordbot/slash/definitions.go` +- Modify: `processor/internal/discordbot/slash/mappers/raid.go` +- Modify: `processor/internal/discordbot/slash/dispatcher.go` +- Regenerate: slash fixtures (`testdata/raid.json`, `internal/bot/testdata/parity.yaml`) — a new slash option changes the definition snapshot + parity coverage. Update them the way Task 7 of the raid-costume plan did (run the snapshot/parity tests, apply their regenerated output; do NOT hand-craft). +- Test: `mappers/raid_test.go` + `dispatcher_test.go` + +**Interfaces:** Consumes `autocomplete.Form`, `autocomplete.PrependRecentForms`, `autocomplete.ResolvePokemonID`, `RecentRaidForms` (Task 1). + +- [ ] **Step 1: Write the failing tests** + +Mapper test (exact `reflect.DeepEqual`, mirror `TestRaidMapper_Costume`): +```go +func TestRaidMapper_Form(t *testing.T) { + tokens, err := Raid([]*discordgo.ApplicationCommandInteractionDataOption{ + {Name: "boss", Type: discordgo.ApplicationCommandOptionString, Value: "pikachu"}, + {Name: "form", Type: discordgo.ApplicationCommandOptionString, Value: "alolan"}, + }) + if err != nil { + t.Fatalf("Raid mapper error: %v", err) + } + if !reflect.DeepEqual(tokens, []string{"pikachu", "form:alolan"}) { + t.Errorf("tokens=%v, want [pikachu form:alolan]", tokens) + } +} +``` +Dispatcher routing test — mirror `TestRouteAutocomplete_RaidCostume_BoostsRecentForBoss` **exactly** (same deps builder, same `boss`-option interaction builder it uses), swapping costume→form: install a FRESH `RecentActivity` that primes ONLY the raid-forms bucket (so it discriminates `RecentRaidForms` from spawn `RecentForms`), record form **680** "Winter 2023" (sorts after the base-alphabetical first form → first-position proves boosting), route `("raid", "form", "", "en", )`, and assert `out[0].Name == "Winter 2023"`. + +```go +func TestRouteAutocomplete_RaidForm_BoostsRecentForBoss(t *testing.T) { + d := NewDispatcher(Config{}) + d.bundle = testBundle(t) + d.cfgRoot = &config.Config{} + d.deps = costumeFormRouteDeps(t) // GameData.Monsters {25,680} + form_680="Winter 2023" (add if absent, additive) + d.deps.RecentActivity = tracker.NewRecentActivity() + d.deps.RecentActivity.RecordRaidForm(25, 680) + ic := + out := d.routeAutocomplete("raid", "form", "", "en", ic) + if len(out) == 0 || out[0].Name != "Winter 2023" { + t.Errorf("/raid form empty focused: first=%+v, want Winter 2023 (recent raid form)", firstName(out)) + } +} +``` +> Read `TestRouteAutocomplete_RaidCostume_BoostsRecentForBoss` in `dispatcher_test.go` and reuse its `boss`-option interaction builder verbatim. The FRESH-RecentActivity + raid-only recording is what makes the test discriminate the raid bucket (a regression to spawn `RecentForms` would leave the boost empty → base-alphabetical first → FAIL). + +- [ ] **Step 2: Run — verify they fail.** + +- [ ] **Step 3: Implement** + +`definitions.go` `raidOptions` — add before the costume option (or adjacent): +```go + stringOpt(bundle, "raid.form", "form", "Raid boss form", false, true), +``` +`mappers/raid.go` — after the costume emit: +```go + if form := getString(o["form"]); form != "" { + tokens = append(tokens, "form:"+form) + } +``` +`dispatcher.go` `routeAutocomplete` — add a case (mirror the `(cmd="raid", opt="costume")` case): +```go + case opt == "form" && cmd == "raid": + base := autocomplete.Form(context.Background(), d.deps, siblingOptionString(ic, "boss"), focused, userLang) + if focused == "" && d.deps != nil && d.deps.RecentActivity != nil { + if pid := autocomplete.ResolvePokemonID(d.deps, siblingOptionString(ic, "boss")); pid > 0 { + base = autocomplete.PrependRecentForms(base, d.deps, d.deps.RecentActivity.RecentRaidForms(pid), userLang) + } + } + return base +``` + +- [ ] **Step 4: Regenerate fixtures + GREEN + full gate + commit** + +```bash +# regenerate slash definition snapshot + parity fixture per their test's update mechanism +go test ./internal/discordbot/slash/... -run 'Raid.*Form|Form.*Raid' -v +go build ./... && go vet ./... && go test -count=1 ./... && golangci-lint run ./... +git add processor/internal/discordbot/slash/definitions.go processor/internal/discordbot/slash/mappers/raid.go processor/internal/discordbot/slash/mappers/raid_test.go processor/internal/discordbot/slash/dispatcher.go processor/internal/discordbot/slash/dispatcher_test.go processor/internal/discordbot/slash/testdata/raid.json processor/internal/bot/testdata/parity.yaml +git commit -m "feat(slash): /raid form option + recent-raid-form autocomplete boost" +``` + +--- + +### Final: full gate + +- [ ] From `processor/`: `go build ./... && go vet ./... && go test -count=1 ./... && golangci-lint run ./...` — all green, `0 issues.` diff --git a/docs/superpowers/plans/2026-07-18-derived-dts-test-data.md b/docs/superpowers/plans/2026-07-18-derived-dts-test-data.md new file mode 100644 index 000000000..b6e673417 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-derived-dts-test-data.md @@ -0,0 +1,221 @@ +# Derived DTS Test Data 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:** Make every DTS template type — including the derived ones (`monsterChanged`, `incident`, `questSummary`, `weatherchange`, `rsvpChanges`) — previewable via the editor's `/api/dts/enrich` and testable via live `!poracle-test`, by unifying the two enrichment paths, adding structured test "partials", and addressing test data by DTS type name. + +**Architecture:** One shared per-type dispatch (`enrichForType`) that both `/api/dts/enrich` (flatten → variables) and `/api/test` (wrap → RenderJob → deliver) consume; derived types carry a structured payload in the existing `webhook` field and reuse production render construction; a canonical DTS↔source alias table drives enrich, testdata, and `!poracle-test`. + +**Tech Stack:** Go, huma, the existing `enricher.*` methods, `dts.NewLayeredView`, `RenderJob`. + +Design spec: `docs/superpowers/specs/2026-07-18-derived-dts-test-data-design.md`. + +## Global Constraints + +- **One enrichment implementation.** After Task 1, the per-type "webhook/partial → `enrichResult`" logic lives ONLY in `enrich.go`'s `enrich*` functions. `test.go` and `EnrichWebhook` both call them. Do not leave a parallel copy in `test.go`. +- **`enrichResult` is the shared unit:** `{ templateType, base, perLang, perUser, webhookFields, tilePending }` (+ new `extras map[string]any` for derived state: `original`, `affected`, quest group, rsvp). perUser is caller-supplied context (editor = synthetic `_editor`; live = real target) — the shared `enrich*` returns base/perLang; each surface adds perUser. +- **Derived types reuse production render construction** — `dts.BuildOriginalView` (monsterChanged), `DispatchQuestSummary` grouping (questSummary), the `weatherchange`/`rsvpChanges` `RenderJob` shapes (`weather.go:223`, `raid.go:266`). Do not re-derive rendering. +- **Canonical alias table is the single source of truth** for DTS↔source resolution, used by `EnrichWebhook`, `/api/dts/testdata`, and `!poracle-test`. +- **Back-compat:** existing `/api/dts/enrich` by webhook type, `/api/dts/testdata?type=`, and every current `!poracle-test ,` keep working unchanged. +- **Pre-commit gate (from `processor/`):** `go build ./... && go vet ./... && go test -count=1 ./... && golangci-lint run ./...`. + +--- + +### Task 1: Unify enrichment — `test.go` calls the shared `enrich*` core + +**Files:** +- Modify: `processor/cmd/processor/enrich.go` (add `extras` to `enrichResult`; expose a shared builder) +- Modify: `processor/cmd/processor/test.go` (replace per-type enrichment with calls to `enrich*`) +- Test: `processor/cmd/processor/enrich_test.go` (assert parity), existing `test.go` tests must stay green + +**Interfaces:** +- Produces: `enrichResult.extras map[string]any`; a helper `func (ps *ProcessorService) renderJobFromEnrich(r *enrichResult, target webhook.MatchedUser, alertType string, raw json.RawMessage) RenderJob` used by test.go. + +- [ ] **Step 1: Add `extras` to `enrichResult`** (`enrich.go`): +```go +type enrichResult struct { + templateType string + base map[string]any + perLang map[string]any + perUser map[string]any + webhookFields map[string]any + tilePending *staticmap.TilePending + extras map[string]any // derived-type state: original, affected, rsvp, group. Nil for plain types. +} +``` + +- [ ] **Step 2: Move the synthetic-user PVP perUser out of `enrichPokemon`** so `enrich*` returns only base/perLang/templateType/tilePending. In `EnrichWebhook`, after obtaining the result for pokemon, compute the synthetic `_editor` perUser (the block currently at `enrich.go:119-132`) and set `result.perUser`. `enrichPokemon` no longer sets perUser. + +- [ ] **Step 3: Add the RenderJob wrapper** (`test.go`): +```go +// renderJobFromEnrich wraps a shared enrichResult into a delivery RenderJob for +// a single test target. perUser is computed here with the REAL target user +// (unlike the editor's synthetic user). +func (ps *ProcessorService) renderJobFromEnrich(r *enrichResult, target webhook.MatchedUser, alertType string, raw json.RawMessage, isPokemon, isEncountered bool) RenderJob { + matched := []webhook.MatchedUser{target} + perLang := map[string]map[string]any{} + if r.perLang != nil { + perLang[target.Language] = r.perLang + } + var perUser map[string]map[string]any + if isPokemon && ps.enricher.PVPDisplay != nil && r.perLang != nil { + perUser = ps.enricher.PokemonPerUser(perLang, matched) + } + return RenderJob{ + AlertType: alertType, + TemplateType: r.templateType, + IsPokemon: isPokemon, + IsEncountered: isEncountered, + Enrichment: r.base, + PerLangEnrichment: perLang, + PerUserEnrichment: perUser, + WebhookFields: r.webhookFields, + MatchedUsers: matched, + MatchedAreas: []webhook.MatchedArea{}, + TileGate: ps.newTileGate(r.tilePending), + LogReference: "test", + } +} +``` + +- [ ] **Step 4: Rewrite each `test.go` per-type handler** to call the matching `enrich*` and `renderJobFromEnrich`. Example (`processTestPokemon`): +```go +func (ps *ProcessorService) processTestPokemon(raw json.RawMessage, target webhook.MatchedUser) error { + r, err := ps.enrichPokemon(raw, target.Language) + if err != nil { + return err + } + if ps.renderCh == nil { + return fmt.Errorf("render queue not available") + } + isEnc := false + if v, ok := r.extras["encountered"].(bool); ok { isEnc = v } + ps.renderCh <- ps.renderJobFromEnrich(r, target, "pokemon", raw, true, isEnc) + return nil +} +``` +(Add `extras["encountered"] = processed.Encountered` in `enrichPokemon`.) Do the same for raid/egg/quest/invasion/lure/nest/gym/fort/maxbattle handlers. The showcase handler (test.go:~215) keeps its `TemplateType:"showcase"` but sources enrichment from `enrichInvasion`-style logic; leave showcase behaviour identical. + +- [ ] **Step 5: Parity test** (`enrich_test.go`): for each webhook type with a bundled sample, assert `enrich` produces the same `templateType` and non-empty `base` that the old path did (golden-ish: assert key fields like `name`/`fullName` present). Run existing `test.go` command tests — all must stay green (no behaviour change). + +- [ ] **Step 6: Gate + commit** +```bash +go build ./... && go vet ./... && go test -count=1 ./cmd/... && golangci-lint run ./cmd/... +git commit -am "refactor(test): unify poracle-test enrichment onto the shared enrich* core" +``` + +--- + +### Task 2: Canonical DTS↔source alias table + DTS-name resolution in `EnrichWebhook` + +**Files:** +- Create: `processor/cmd/processor/dts_alias.go` +- Modify: `processor/cmd/processor/enrich.go` (`EnrichWebhook` resolves DTS names) +- Test: `processor/cmd/processor/dts_alias_test.go` + +**Interfaces:** +- Produces: `type dtsSource struct { WebhookType string; TemplateType string; Derived bool }`; `func dtsAlias(name string) (dtsSource, bool)`; `func dtsTypeMap() map[string]dtsSource` (for the API to expose). + +- [ ] **Step 1: Write the failing test** — `dtsAlias("monster")` → `{WebhookType:"pokemon", TemplateType:"monster"}`; `dtsAlias("egg")` → `{WebhookType:"raid", TemplateType:"egg"}`; `dtsAlias("monsterChanged")` → `{WebhookType:"monster-changed", Derived:true}`; `dtsAlias("pokemon")` (a webhook type) resolves too; unknown → `false`. + +- [ ] **Step 2: Implement the table** (`dts_alias.go`) covering: monster→pokemon, monsterNoIv→pokemon, monsterChanged→monster-changed(derived), raid→raid, egg→raid, rsvpChanges→rsvp-changes(derived), quest→quest, questSummary→quest-summary(derived), invasion→pokestop/invasion, incident→incident(derived-ish: moved samples), showcase→showcase, lure→pokestop/lure, weatherchange→weather-change(derived), gym→gym, nest→nest, maxbattle→max_battle. Include identity entries for raw webhook types so both `monster` and `pokemon` resolve. + +- [ ] **Step 3: Resolve in `EnrichWebhook`** — before the switch, `if src, ok := dtsAlias(webhookType); ok { webhookType = src.WebhookType or route derived }`. Keep the existing switch for non-derived; add derived cases in Tasks 4–7. + +- [ ] **Step 4: Gate + commit.** + +--- + +### Task 3: incident — move samples + render the incident template + +**Files:** +- Modify: `fallbacks/testdata.json` (retype the pokestop-incident samples to `incident`) +- Modify: `processor/cmd/processor/enrich.go` + `test.go` (an `incident` path rendering `TemplateType:"incident"`) +- Test: `processor/cmd/processor/*_test.go` + +- [ ] **Step 1:** Add an `enrichIncident(raw, lang)` that reuses the invasion enrichment (it's a PokestopEvent) but sets `templateType:"incident"`. Wire `case "incident"` into `EnrichWebhook` and a `processTestIncident` into test.go (via `renderJobFromEnrich`, `alertType:"incident"`). +- [ ] **Step 2:** In `fallbacks/testdata.json`, move the incident-flavoured pokestop samples (`kecleon`, `goldstop`, `pokemoncontest`, …) to `type:"incident"` entries (keep the webhook payloads). Leave true invasions and lures as pokestop. +- [ ] **Step 3:** Test: `EnrichWebhook("incident", , "en", "discord")` returns `templateType:"incident"` and incident-only fields (`incidentTypeName`); `!poracle-test incident,kecleon` enqueues a RenderJob with `TemplateType:"incident"`. Gate + commit. + +--- + +### Task 4: weatherchange partial + builder + testdata + +**Files:** `enrich.go` (`enrichWeatherChange`), `test.go` (`processTestWeatherChange`), `fallbacks/testdata.json`, tests. + +**Reuse:** the `weatherchange` `RenderJob` construction at `cmd/processor/weather.go:223` and its enrichment (the `consumeWeatherChanges` path builds `[]webhook.ActivePokemonEntry`). + +- [ ] **Step 1:** Define the partial shape and add a `weather-change` sample to `fallbacks/testdata.json`: the cell/old→new weather fields + `affected: [ {pokemon_id, form, ...}, ... ]` (a short list, per the requirement). +- [ ] **Step 2:** `enrichWeatherChange(raw, lang)` parses the partial, runs the same weather enrichment `consumeWeatherChanges` uses to produce the base/perLang + the affected-pokemon list into `extras["affected"]`; `templateType:"weatherchange"`. Read `weather.go:38-230` and reuse its enrichment calls rather than re-deriving. +- [ ] **Step 3:** `processTestWeatherChange` wraps via `renderJobFromEnrich` with `alertType:"weather"`, `TemplateType:"weatherchange"`. Wire `case "weather-change"`/`"weatherchange"` into `EnrichWebhook` + test.go + the alias table. +- [ ] **Step 4:** Test: enrich returns `weatherchange` template + the affected list in variables; live path enqueues it. Gate + commit. + +--- + +### Task 5: questSummary partial + builder + testdata + +**Files:** `enrich.go`, `test.go`, `fallbacks/testdata.json`, tests. + +**Reuse:** `cmd/processor/quest_summary_dispatch.go:27 DispatchQuestSummary(humanID, alertType)` and its grouping/render (`TemplateType:"questSummary"`). + +- [ ] **Step 1:** Add a `quest-summary` sample: `{ reward:{type,amount,...}, quests:[, ...] }`. +- [ ] **Step 2:** Factor the group→RenderJob construction out of `DispatchQuestSummary` into a reusable builder (e.g. `buildQuestSummaryRenderJob(group, target) RenderJob`) if it's currently inline, so both the scheduler and the test path call it. `enrichQuestSummary`/`processTestQuestSummary` synthesise a group from the partial and call it. +- [ ] **Step 3:** Test: `!poracle-test quest-summary,stardust` renders one `questSummary` job grouping the sample quests; enrich returns the group variables. Gate + commit. + +--- + +### Task 6: monsterChanged partial + builder + testdata + +**Files:** `enrich.go`, `test.go`, `fallbacks/testdata.json`, tests. + +**Reuse:** `internal/dts/original_view.go:16 BuildOriginalView(prior tracker.EncounterState, gd, tr) map[string]any` and the change RenderJob shape at `cmd/processor/pokemon.go:392` (`IsChange:true`, `OriginalView`, `ChangeType`). + +- [ ] **Step 1:** Add a `monster-changed` sample: `{ old:, new: }` (e.g. species/form/encountered shift). +- [ ] **Step 2:** `enrichMonsterChanged`: enrich `new` via `enrichPokemon`; build `OriginalView` from `old` (map the old webhook to a `tracker.EncounterState`, then `dts.BuildOriginalView`); set `extras["original"]`, `templateType:"monsterChanged"`. +- [ ] **Step 3:** `processTestMonsterChanged`: `renderJobFromEnrich(..., isPokemon=true)` then set `job.IsChange=true`, `job.OriginalView=extras["original"]`, `job.ChangeType="test"`, `job.TemplateType="monsterChanged"`, `job.ReplyKey=`. Wire the alias + cases. +- [ ] **Step 4:** Test: enrich returns `monsterChanged` template with `original.*` fields; live path enqueues an `IsChange` job with a populated `OriginalView`. Gate + commit. + +--- + +### Task 7: rsvpChanges partial + builder + testdata + +**Files:** `enrich.go`, `test.go`, `fallbacks/testdata.json`, tests. + +**Reuse:** the `rsvpChanges` RenderJob at `cmd/processor/raid.go:266` (`TemplateType:"rsvpChanges"`, `OverrideCleanTTH`). + +- [ ] **Step 1:** Add an `rsvp-changes` sample: `{ raid:, rsvps:[{timeslot, going, maybe}, ...] }`. +- [ ] **Step 2:** `enrichRsvpChanges`: enrich the raid via `enrichRaid`; attach the rsvp fields; `templateType:"rsvpChanges"`; `extras["overrideCleanTTH"]` = latest timeslot. +- [ ] **Step 3:** `processTestRsvpChanges`: wrap via `renderJobFromEnrich(alertType:"raid")`, set `job.TemplateType="rsvpChanges"`, `job.OverrideCleanTTH=extras[...]`, `job.EditKey`/`ReplyKey` per the raid convention (`raidlife:{gymID}:{raidEnd}`). Wire alias + cases. +- [ ] **Step 4:** Test + gate + commit. + +--- + +### Task 8: `GET /api/dts/testdata?dtsType=` — server-side filter + tags + map endpoint + +**Files:** +- Modify: `processor/internal/api/dts_testdata.go`, `processor/internal/api/huma_dts_reads.go` +- Test: `processor/internal/api/*_test.go` + +- [ ] **Step 1:** Extend the testdata read to accept `?dtsType=`: resolve via the alias table (exposed from the processor to the API — pass `dtsTypeMap()` in, or replicate the table in `internal/api`), return only the entries that preview that DTS type. Do the pokestop→invasion/lure split server-side (the logic the editor currently does in `capture-test-data.mjs`: invasion = has `grunt_type`/`character`/`display_type`; lure = has `lure_id`/`lure_expiration`). Tag each returned entry with `dtsType`. +- [ ] **Step 2:** Add a discoverable map to the response (or a sibling `GET /api/dts/testdata/types`) returning the full DTS-type→source table so the editor drops its hardcoded copy. +- [ ] **Step 3:** Keep `?type=` working unchanged (back-compat). Update the OpenAPI golden (`UPDATE_GOLDEN=1 go test ./internal/api/ -run TestOpenAPIGolden`). +- [ ] **Step 4:** Test: `?dtsType=invasion` returns only invasion scenarios; `?dtsType=incident` returns the moved samples; `?dtsType=monsterChanged` returns the partial; tags present; `?type=pokestop` unchanged. Gate + commit. + +--- + +### Task 9: `!poracle-test` accepts DTS type names + +**Files:** the `!poracle-test` bot command (`internal/bot/commands/*` — the poracle-test handler) + `POST /api/test` handler. +- [ ] Resolve the leading `type` token via the alias table before dispatch, so `!poracle-test monsterChanged,species-shift` and `!poracle-test weatherchange,clear-to-rain` work alongside the existing webhook-type forms. Test both forms. Gate + commit. + +--- + +### Task 10: Editor handoff doc + +**Files:** Create `docs/superpowers/handoffs/2026-07-18-dts-editor-derived-types.md`. +- [ ] Write the complete server contract for the editor agent (see the spec's "Editor handoff doc — required contents"): DTS-name-addressable `/api/dts/enrich`; `GET /api/dts/testdata?dtsType=` + tags + the map endpoint; the derived-type entries + partial shapes; and the exact editor **delete list** (`dtsToWebhookType` map, pokestop invasion/lure filter, `monsterNoIv`/`egg` special-casing in `capture-test-data.mjs`; address by DTS type in `api-client.js`/`TestDataPanel.jsx`). Note `fort-update`/`maxbattle` + derived types are now selectable. Commit. + +--- + +### Final: full gate +- [ ] From `processor/`: `go build ./... && go vet ./... && go test -count=1 ./... && golangci-lint run ./...` — all green, `0 issues`. diff --git a/docs/superpowers/specs/2026-05-30-huma-api-migration-design.md b/docs/superpowers/specs/2026-05-30-huma-api-migration-design.md new file mode 100644 index 000000000..b48cd631e --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-huma-api-migration-design.md @@ -0,0 +1,295 @@ +# Huma API Migration — tracking, humans, profiles + +**Date:** 2026-05-30 +**Branch:** `huma-api-migration` (worktree off `develop`) +**Status:** Design — awaiting review + +## Goal + +Migrate the `/api/tracking/*`, `/api/humans/*`, and `/api/profiles/*` endpoint +groups from hand-written Gin handlers to the [huma](https://huma.rocks) +framework (`github.com/danielgtaylor/huma/v2`). The driver is **documentation +discoverability**: huma generates an OpenAPI 3.1 spec and hosted docs UI from +the Go types, giving integrators (PoracleWeb, ReactMap, third parties) a +single source of truth. A secondary driver is using the migration as an +opportunity to present a **cleaner, type-honest API** while remaining tolerant +of the broken/legacy clients the current `flexBool`/`flexInt` coercion exists +to serve. + +This is a full migration of the three named groups only. Everything else stays +on Gin (see Out of Scope). + +## Decisions (locked) + +| Topic | Decision | +|---|---| +| Scope | Full migration of tracking (~43 routes), humans (~19), profiles (5). Nothing else. | +| Coexistence | `humagin` mounted on the **existing** `*gin.Engine`; migrated groups move from Gin wiring → `huma.Register`. | +| Wire format | **Preserve the legacy envelope.** `{status:"ok", ...}` on success; `huma.NewError` overridden to emit `{status:"error", message:"..."}`. `authError` is unchanged (emitted by existing Gin middleware before huma). | +| Leniency | flex coercion stays as a tolerance layer; each field declares its **canonical** schema via `SchemaProvider`; request bodies set `additionalProperties: true`. | +| Type cleanup | Per-field canonical-type audit; document the truest type; decompose packed bitmask fields to caller-facing booleans, collapse to the storage column internally; always keep accepting the legacy form. | +| Docs exposure | Public, unauthenticated `/openapi.json` + `/docs`; `X-Poracle-Secret` declared as an apiKey security scheme; `/api/*` itself stays gated. | +| Testing | Table-driven handler tests (envelope + leniency + bitmask collapse), a golden-file test over `openapi.json`, error-path tests. Existing 4-check gate stays green. | + +## Architecture + +### Coexistence (approach A) + +`main.go` continues to build the same `*gin.Engine` with the same global +middleware (`gin.Recovery`, `CORSMiddleware`, `RequestLogger`, `IPFilter`) and +the same `/api` route group carrying `RequireSecretGin`. After that group is +created, a single huma API is bound to it. The snippet below is **illustrative** +— exact `huma.Config` field paths (security-scheme location, how to disable the +built-in docs/spec routes) are confirmed against the installed huma version +during implementation: + +```go +humaCfg := huma.DefaultConfig("PoracleNG API", version) +// disable huma's built-in docs + spec auto-mount; we serve them ourselves +// at public top-level paths (see below). +api.OverrideHumaError() // install legacy {status,message} error model +// declare the apiKey security scheme (X-Poracle-Secret) on the spec's components +humaAPI := humagin.NewWithGroup(r, apiGroup, humaCfg) +``` + +- Huma operations register as ordinary Gin routes under `apiGroup`, so they + inherit the existing middleware unchanged. No auth/CORS/logging duplication. +- The tracking/humans/profiles route registrations are **removed** from the + Gin wiring in `main.go` and re-expressed as `huma.Register(...)` calls in the + `api` package (one registration function per group, or per type for + tracking). The corresponding old `gin.HandlerFunc` handlers for these three + groups are deleted once replaced. +- **Docs are public**: `r.GET("/openapi.json", ...)` and a docs-UI handler are + registered directly on `r` (top level, outside `apiGroup`), so they require + no secret. The spec advertises `poracleSecret` so the docs' "Authorize" box + works and every operation shows its security requirement. + +### Handler shape + +The dependency-injection pattern is preserved; only the HTTP edge changes. +`TrackingDeps` and `roleDeps` (the humans/roles endpoints use a separate deps +struct) are reused verbatim. + +```go +type listMonsterInput struct { + ID string `path:"id"` + ProfileNo int `query:"profile_no"` +} +type listMonsterOutput struct { + Body struct { + Status string `json:"status"` // always "ok" + Pokemon []monsterTrackingDTO `json:"pokemon"` + } +} + +func registerMonster(api huma.API, deps *TrackingDeps) { + huma.Register(api, huma.Operation{ + OperationID: "list-monster-tracking", + Method: http.MethodGet, + Path: "/tracking/pokemon/{id}", + Summary: "List pokemon tracking rules for a user", + Tags: []string{"tracking"}, + Security: []map[string][]string{{"poracleSecret": {}}}, + }, func(ctx context.Context, in *listMonsterInput) (*listMonsterOutput, error) { + // identical body logic, reading in.ID / in.ProfileNo + // returns &listMonsterOutput{...} or huma.Error404NotFound(...) + }) +} +``` + +- Inputs: typed structs with `path:`/`query:`/`header:` tags and an optional + `Body` field. Outputs: typed structs with a `Body` field whose first member + is `Status string json:"status"`. +- Business helpers (`lookupHuman`, `reloadState`, `sendConfirmation`, + `validateOverrideFields`, store calls) are reused — they never touched Gin. + `lookupHuman` gets a small huma-flavoured sibling that takes `(id, profileNo)` + rather than `*gin.Context`, so the gin and huma versions share the underlying + store logic. + +### File layout + +New files in `processor/internal/api/`: + +- `huma_setup.go` — huma config, `OverrideHumaError`, security scheme, docs + + spec mounting helpers. +- `huma_tracking.go` (+ per-type registration, mirroring the existing + `trackingMonster.go` … split) — tracking operations. +- `huma_humans.go` — humans operations. +- `huma_profiles.go` — profiles operations. +- `flex.go` — `flexBool`/`flexInt` gain `Schema(...)` methods + the per-field + canonical-type machinery (moved out of `tracking.go` or extended in place). + +Old gin handlers for the three migrated groups are removed once their huma +replacements pass tests. + +## Leniency & type cleanup + +### flex types as a tolerance layer + +`flexBool`/`flexInt` keep their existing `UnmarshalJSON` (accept +`true`/`false`/`0`/`1`/`"1"`/numbers). They gain a `Schema` method so huma's +validator — which validates the parsed body against the operation schema +*before* binding — permits those forms instead of rejecting them with 422: + +```go +func (flexInt) Schema(huma.Registry) *huma.Schema { + return &huma.Schema{ + OneOf: []*huma.Schema{{Type: "integer"}, {Type: "string"}, {Type: "boolean"}}, + Description: "Canonical form: integer. Numeric strings and booleans accepted for legacy clients.", + } +} +``` + +The **canonical** type advertised is decided per field (below), not blanket +integer. Request body structs set `additionalProperties: true` (huma defaults +to `false`) so unknown/extra fields from lenient clients are not rejected — +matching current behaviour. This is set per request struct, not globally. + +### Per-field canonical-type audit (deliverable) + +Before/with the migration, produce a field-by-field table for every request +struct in the three groups, classifying each field as **genuine-int**, +**genuine-bool**, **bitmask-int**, or **string/other**, with its documented +canonical type and accepted-lenient forms. The audit output lives in this spec +(appendix, filled during planning) and drives the `Schema` methods. + +Observed starting points: + +- `monsterInsertRequest`: nearly all fields are genuine integers + (`pokemon_id`, IVs, CP, level, gender, ranks, distance, weight, size) → + `flexInt` advertising **integer** is correct. `clean` is the lone `flexBool` + and is actually a **bitmask** (see below), not a boolean. + +### `clean` bitmask decomposition (the template pattern) + +`clean` is a bitmask (`db/clean.go`): bit 1 = auto-delete, bit 2 = edit, +bit 4 = summary. API callers historically understood `clean` only as a +true/false "clean it up" toggle (bit 1); bits 2 and 4 are set through other +surfaces (bot `edit` mode, quest `summary` keyword) and are not part of any +caller's mental model. So: + +- **Wire (documented):** + - `clean: boolean` → bit 1 (auto-delete) + - `edit: boolean` → bit 2 (added where used: raid/egg rsvp) + - `summary: boolean` → bit 4 (added where used: quest) +- **Back-compat tolerance:** still accept a legacy **integer** `clean` and + interpret it as the full packed bitmask, so any caller that sent `clean:3` + keeps working. +- **Collapse rule (handler/DTO layer):** + ``` + packed = 0 + packed |= cleanAsInt // if clean arrived as an integer (legacy) + packed |= 1 if cleanBool // if clean arrived as a boolean true + packed |= 2 if editBool + packed |= 4 if summaryBool + ``` +- **Storage unchanged:** the single `clean` int column, the matcher, and + `IsClean`/`IsEdit`/`IsSummary` are untouched. + +The audit applies the *principle* — model the caller's mental model, stay +lenient about the legacy form — but the right representation is per-field and +must be read from each field's actual handler validation + bot keywords, NOT +assumed. It is NOT uniformly "boolean-on-the-wire": +- **Bitmask → named booleans**: `clean` (bits 1/2/4 → `clean`/`edit`/`summary`), + always also accepting the legacy integer bitmask. +- **Enum → string enum**: `raid`/`egg` `rsvp_changes` is a 3-value enum + (`tinyint 0|1|2` = `no_rsvp`/`rsvp`/`rsvp_only`, per the `!raid`/`!egg` + keywords) — model as a string enum `"none"|"rsvp"|"rsvp_only"`, ALSO accepting + the legacy integer `0|1|2`. NOT a boolean. +- **Genuine bool / int / count**: `gym` `slot_changes`/`battle_changes`, `fort` + change flags, etc. — TBD by reading the handler; do not assume. +Storage columns and matcher logic are untouched in every case. + +## Wire format (legacy envelope) + +- **Errors:** `OverrideHumaError` reassigns the package-level `huma.NewError` + to a custom `StatusError` whose JSON body is `{"status":"error","message":...}` + with the same status codes huma would have used (422 validation, 404, etc.). + Validation detail strings are folded into `message`. +- **authError:** unchanged. `RequireSecretGin` runs as Gin middleware *before* + huma sees the request and already emits `{"status":"authError","reason":...}`. +- **Success:** every output `Body` struct begins with `Status string json:"status"` + set to `"ok"`, followed by the existing per-endpoint fields. Response DTOs + (`HumanResponse`, `ProfileResponse`, the tracking-list shapes) are reused + unchanged as nested body types so the wire JSON is byte-compatible with today. + +## Endpoint inventory + +**Tracking** (`trackingDeps`): per type `GET /tracking/{type}/{id}`, +`POST /tracking/{type}/{id}`, `DELETE /tracking/{type}/{id}/byUid/{uid}`, +`POST /tracking/{type}/{id}/delete` for the 10 types (pokemon, raid, egg, +quest, invasion, lure, nest, gym, fort, maxbattle) = 40, plus +`GET /tracking/all/{id}`, `GET /tracking/allProfiles/{id}`, and +`GET /tracking/pokemon/refresh` (a reload alias) = **43**. + +**Humans** (mixed `trackingDeps` + `roleDeps`): `GET /humans/one/{id}`, +`GET /humans/{id}`, `GET /humans/{id}/roles`, +`GET /humans/{id}/getAdministrationRoles`, +`GET /humans/{id}/checkLocation/{lat}/{lon}`, `GET /humans/{id}/locations`, +`GET /humans/{id}/locations/{label}`, `POST /humans/{id}/locations/add`, +`POST /humans/{id}/locations/{label}/delete`, `POST /humans/{id}/start`, +`POST /humans/{id}/stop`, `POST /humans/{id}/adminDisabled`, +`POST /humans/{id}/language`, `POST /humans/{id}/switchProfile/{profile}`, +`POST /humans/{id}/setLocation/{lat}/{lon}`, `POST /humans/{id}/setAreas`, +`POST /humans/{id}/roles/add/{roleId}`, `POST /humans/{id}/roles/remove/{roleId}`, +plus `POST /humans` (create) — **~19**. + +**Profiles** (`trackingDeps`): `GET /profiles/{id}`, +`DELETE /profiles/{id}/byProfileNo/{profile_no}`, `POST /profiles/{id}/add`, +`POST /profiles/{id}/update`, `POST /profiles/{id}/copy/{from}/{to}` — **5**. + +### Known special cases + +- **Tracking POST accepts a single object OR an array** (current + `rawBody[0]=='['` branch). Model the body as an array type with a wrapper + that also accepts a single object (custom `UnmarshalJSON` on the wrapper, + same trick as the flex types). Diff/insert/update logic is reused. +- **Float path params** (`{lat}`, `{lon}`) → typed `float64` path fields. +- **`silent` / `suppressMessage` query flags** → typed bool/string query fields + (kept lenient: presence-based, as today). +- **Routing coexistence**: `GET /humans/one/{id}` and `GET /humans/{id}` share + a level; this already works on Gin and humagin registers via Gin, so the + same router resolves it — verified by test, not assumed. +- **Two deps structs** in the humans group (`trackingDeps`, `roleDeps`); both + are captured by the registration closures, no change to either. + +## Testing + +- **Handler tests** (`httptest` against the huma API): for each operation, + assert the legacy envelope shape and that leniency holds — send `clean:false`, + `clean:3`, `"min_iv":"90"`, `edit:true`, and an unknown field, asserting the + collapse rule and acceptance. +- **OpenAPI golden test**: marshal the generated spec and compare to a + committed `openapi.golden.json`; schema drift shows up in diffs and the spec + is reviewable in PRs. +- **Error-path tests**: malformed body / missing required field → 422 with + `{status:error,message}`; missing human → 404 same shape; bad secret → 401 + `authError` (exercises the Gin-middleware path in front of huma). +- **Gate**: `go build ./... && go vet ./... && go test -count=1 ./... && + golangci-lint run ./...` stays green. + +## Out of scope (explicitly unchanged) + +Webhook receiver `POST /`, `/health`, `/metrics`, geofence/tile/image +endpoints, `/api/dts/*`, `/api/config/*`, `/api/masterdata/*`, +`/api/snapshots/*`, `/api/autocreate/*`, `/api/command`, `/api/test`, +`/api/stats/*`, `/api/weather`, `/api/geocode/*`, `/api/deliverMessages`, +`/api/resolve`, `/api/reload`, `/api/geofence/reload`. The full-API switch to +huma and any client-side changes are future work. + +## Risks & open items + +- **huma validation ordering**: huma validates the parsed body against the + schema before binding; the `SchemaProvider` `OneOf` is what keeps lenient + forms from 422-ing. Confirm with a test sending `clean:false` against an + integer-canonical-but-lenient field early in implementation (de-risks the + whole approach). +- **`additionalProperties:true` mechanism**: confirm the cleanest way to set + it per request struct in the installed huma version (struct-level option vs + registry transformer); spike if needed. +- **Error model override surface**: `huma.NewError` is package-global; confirm + the override doesn't leak into non-migrated huma usage (there is none today, + so safe) and is set once at startup. +- **Audit completeness**: the per-field audit must cover all 10 tracking + request structs plus humans/profiles bodies before the schemas are + considered final; partial audit = inconsistent canonical typing. diff --git a/docs/superpowers/specs/2026-06-11-mute-api-design.md b/docs/superpowers/specs/2026-06-11-mute-api-design.md new file mode 100644 index 000000000..1d3e1cc05 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-mute-api-design.md @@ -0,0 +1,112 @@ +# Mute API (v2) — Design + +**Date:** 2026-06-11 +**Branch:** `mute-api` +**Status:** Approved (brainstorm 2026-06-11) + +## Goal + +Expose the existing in-memory mute store (`internal/mute`, GitHub #109) over HTTP so API +clients (PoracleWeb and custom integrations) can list, create, and remove a user's mutes. +Today mutes are reachable only via bot commands (`!mute`/`!unmute`), alert buttons, and — +indirectly — `POST /api/command`. + +## Decisions (locked) + +1. **In-memory semantics kept.** Mutes remain volatile and are lost on restart, exactly as + the package documents. The API documents this on the wire; it does not add persistence. +2. **Per-user v2 surface only.** Endpoints live under `/api/v2/humans/{id}/mutes` following + the v2 conventions (strict bodies, problem+json, `X-Poracle-Secret`). No global + all-users admin listing. +3. **Snapshot inclusion.** `GET /api/v2/humans/{id}/tracking` gains a `mutes` array so one + call shows everything affecting a user's alerts. + +## Approach + +REST resource with **composite-key addressing**. A mute has no uid — its identity is +`(scope, value)` per human (the store replaces on same-key Add). Item-level DELETE +addresses entries by `?scope=&value=` query params rather than path segments, because +values include area names with spaces and opaque fort ids; precedent is v2 tracking's +`DELETE …/{type}?uid=` bulk form. + +Rejected alternatives: synthetic item paths `/mutes/{scope}/{value}` (path-encoding +hazards for zero gain); action-style `POST /mutes/delete` (breaks v2 resource +conventions). + +## Endpoints + +All on the shared huma instance; errors RFC 9457 `problem+json`; unknown body and query +params rejected (422); human must exist (404 otherwise). + +| Method | Path | Behaviour | +|---|---|---| +| `GET` | `/api/v2/humans/{id}/mutes` | `{mutes:[…]}` — **active** entries only (expired-but-unswept are filtered) | +| `POST` | `/api/v2/humans/{id}/mutes` | Body `{scope, value?, duration_min?}` → `{mute:, replaced:bool}`. Re-muting the same `(scope,value)` replaces the entry (extends expiry) and sets `replaced:true`, mirroring `Store.Add` | +| `DELETE` | `/api/v2/humans/{id}/mutes?scope=&value=` | Remove one entry → `{deleted:[]}`; 404 when no matching mute exists | +| `DELETE` | `/api/v2/humans/{id}/mutes` | No params: remove **all** the user's mutes → `{deleted:[…]}` (empty array when none) | + +`DELETE` with `scope` but a missing-yet-required `value` (any scope except `everything`) +is a 422, as is `value` without `scope`. `scope=everything` takes no value in DELETE just +as in POST. + +Response-shape conformance: DELETE returns the removed entries (`{deleted:[…]}`), +mirroring v2 tracking's delete shape. POST returns `{mute:, replaced:bool}` — a +deliberate typed struct rather than the humans-action `{status:ok}`, permitted by the +design doc's "status plus extra fields keep their own typed structs" rule; it returns the +canonical entry (computed `expires_at`) so clients don't need a follow-up GET, and +`replaced` mirrors the bot's muted/re-muted distinction. Unknown human → 404 via the same +`GetLite` lookup as v2 tracking's `resolveHuman`. + +## Item schema + +```json +{ "scope": "gym", "value": "fae12cd34…", "expires_at": 1781190000, "remaining_secs": 3540 } +``` + +- `scope` — string enum: `gym | pokemon | area | pokestop | station | tracking | everything`. + These are the existing `mute.Scope*` constants, already user-visible in command syntax. +- `value` — string; `null` for `everything`. Pokemon dex ids and tracking-rule uids are + numeric **strings** on the wire (the store compares strings; one honest representation). +- `expires_at` — unix seconds. +- `remaining_secs` — derived convenience for UIs (`Entry.RemainingAt`), never negative. + +Field descriptions state the volatility: in-memory, cleared by processor restart. + +## Create validation (mirrors the bot parser) + +- `scope` required, in the enum. +- `value` required for every scope except `everything`, where it must be **absent**. +- `pokemon` / `tracking` values must parse as positive integers. +- `area` values validated case-insensitively against loaded geofence names (as + `!mute area` does); stored as given. +- `duration_min` optional int, default **60**, bounds **1–10080** (one week). +- A `tracking` uid is **not** ownership-checked: a wrong uid never matches anything + (harmless), and checking would mean scanning all ten rule-type stores per call. + +## Snapshot change + +`GET /api/v2/humans/{id}/tracking` response gains `"mutes": […]` — always present, +`[]` when none, same item schema and active-only filtering as the list endpoint. + +## Implementation shape + +- New `internal/api/v2_mutes.go` + `v2_mutes_test.go`, following the `RegisterV2*` + pattern; registered from `main.go` alongside the other v2 humans sub-resources and in + `registerAllHumaOpsForTest` for the golden spec. +- Deps: the existing `*mute.Store` (already on `ProcessorService`), `store.HumanStore` + (existence check), and the state manager (area-name validation). +- Store addition: `ListActive(humanID string, now int64) []Entry` — like `List` but + skipping expired entries, so list/snapshot don't surface entries the sweeper hasn't + reaped yet. No other store changes. +- Snapshot: `v2_snapshot.go` adds the `Mutes` field, populated via the same helper that + converts `mute.Entry` → wire item. +- No state reload, no dispatcher interaction, no DB. + +## Testing + +- httptest unit tests: create (fresh + replace), default and bounded duration, every + validation rejection (bad scope, missing/forbidden value, non-numeric pokemon/tracking, + unknown area, duration out of bounds), unknown human 404, list filters expired entries, + delete one / delete all / delete miss 404, snapshot includes mutes. +- Golden OpenAPI spec regenerated; new schemas pinned. +- Docs: API surface notes in CLAUDE.md (Mute Infrastructure section) + CHANGELOG entry. diff --git a/docs/superpowers/specs/2026-07-14-showcase-support-design.md b/docs/superpowers/specs/2026-07-14-showcase-support-design.md new file mode 100644 index 000000000..e5b705b3a --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-showcase-support-design.md @@ -0,0 +1,301 @@ +# Showcase Support — Design & Implementation Plan + +Status: Phase 1 + Phase 2 implemented (this PR); Phase 3 pending +Date: 2026-07-14 + +## Summary + +Pokémon GO **Showcases** (pokéstop contests) currently cannot produce a correct +alert on a modern Golbat. This spec adds a showcase ingestion path driven by the +Golbat **`pokestop`** webhook, gated on `showcase_expiry > now`, that reuses the +existing **incident** downstream (the `incident` template type, `translateShowcaseRankings`, +and v2 `/incident` tracking) by synthesising the `display_type = 9` classification. +It is **not** a new tracking type — no new table, store, matcher, command, or +tracking API. + +## Background — why showcases are broken today + +Golbat emits showcase signals on **two** webhooks, and each carries only half of +what an alert needs: + +- **`pokestop` webhook** (`decoder/pokestop_state.go` `createPokestopWebhooks`) — + carries the showcase **content**: `showcase_focus`, `showcase_expiry`, + `showcase_rankings`, `showcase_pokemon_*`, `showcase_ranking_standard`. **No + `display_type`.** It is a *snapshot* multiplexing lure + power-up + showcase; + every fire includes all three classes' fields regardless of which changed. +- **`invasion` webhook** (`decoder/incident_state.go` `createIncidentWebhooks`, + fed from GMO fort `PokestopDisplays` at `gmo_decode.go`) — *does* fire for + contests with `display_type = 9` (`INCIDENT_CONTEST`), but is a **bare + envelope**: expiration + `display_type=9` + `character=0` + empty lineup. **No + showcase content.** + +PoracleNG's entire showcase feature keys on the `invasion` / `display_type >= 7` +path (the one with no content): + +- `isIncident := gruntTypeID == 0 && displayType >= 7` — `cmd/processor/invasion.go:166`. +- `ResolveGruntTypeName(0, 9, gd)` → `"showcase"` (via `gd.Util.PokestopEvent[9]`) — + `internal/matching/invasion.go:92-99`. Only then can a v2 `/incident` rule + (`grunt_type="showcase"`) match. +- `translateShowcaseRankings` reads `showcase_rankings` and sets `showcasePresent`, + `showcase[]`, `showcaseFirst` — `internal/enrichment/invasion.go:306-350`. +- Showcase DTS fields live only on the `incident` template type — + `internal/api/dts_fields.go:279-308`. + +Consequences of the mismatch: + +- The content-rich `pokestop` webhook has no `display_type`, so `routePokestop` + (`internal/webhook/receiver.go:144-170`, which only peeks `lure_expiration`, + `incident_expiration`, `incident_grunt_type`) drops it into `ProcessInvasion` + as a degenerate `grunt_type=0` invasion that resolves to the name `"0"`, + matches nobody, and whose parsed `showcase_rankings` never reach the + (unselected) incident template. +- The `display_type=9` invasion webhook classifies correctly but has no rankings + → `showcasePresent=false` → an empty showcase card. + +**Neither webhook alone renders a real showcase.** The downstream is already +correct; it just never receives `display_type=9` *together with* the content. + +## Data model (verified from Golbat source + production logs) + +### Showcase fields on the `pokestop` webhook + +| Field | Meaning | +|-------|---------| +| `showcase_focus` | JSON object; `type` key names the focus class, remaining keys depend on it. The authoritative "what is featured". | +| `showcase_expiry` | **Unix seconds when the contest ends.** The *only* active/ended signal — there is no boolean flag. | +| `showcase_rankings` | `{total_entries, last_update, contest_entries[≤3]}` leaderboard snapshot. | +| `showcase_pokemon_id` / `_form_id` / `_type_id` | Deprecated flat mirrors, populated **only** for `pokemon` / `type` focus; **null for all other focus types**. | +| `showcase_ranking_standard` | Ranking metric enum: `1`=MIN (smallest wins), `2`=MAX (largest wins). | + +### `showcase_focus` types (10) — `decoder/pokestop_showcase.go:42-123` + +`pokemon` (`pokemon_id`, optional `pokemon_form`), `type` (`pokemon_type_1`, +optional `pokemon_type_2`), `alignment` (`pokemon_alignment`), `class` +(`pokemon_class`), `family` (`pokemon_family`), `buddy` (`min_level`), +`generation` (`generation`), `hatched` (`hatched` bool), `mega` +(`temp_evolution`, `restriction`), `shiny` (`shiny` bool). + +### Critical properties + +1. **`showcase_expiry` is the only active/ended signal.** Active iff + `showcase_expiry != null && showcase_expiry > now`. +2. **Fields linger stale.** Golbat has no `ExpireShowcase` and no cron that nulls + showcase fields on end. After a contest ends the stored record keeps the last + `showcase_focus` / `showcase_expiry` (now past) / `showcase_rankings` until a + new contest overwrites them — and those stale fields ride along on any + unrelated `pokestop` webhook fire (e.g. a lure change). **Same class of bug as + the stale-lure issue already fixed (PR #160).** +3. **The flat `showcase_pokemon_*` fields are insufficient.** Production data + includes a `buddy` focus (`{"min_level":3,"type":"buddy"}`) with both flat + fields null. The enrichment **must parse `showcase_focus` JSON**, not the flat + columns. + +### Production log findings (operator scanner, 2026-07-14) + +- Focus types seen: `type` and `buddy`. (`type` populates `flat_type_id`; `buddy` + has all flat fields null.) +- `showcase_ranking_standard = 2` (MAX) throughout. +- **Staleness is the norm:** all sampled `showcase_focus`-bearing webhooks except + one carried a *past* `showcase_expiry` — stale remnants on lure webhooks. The + `showcase_expiry > now` gate is the majority case, not an edge case. +- Fire frequency ≤ 2 per stop in the sampled window (small sample) → fire-once + dedup likely adequate for MVP; edit-mode is a nice-to-have. +- **Open:** whether the scanner also emits `display_type:9` invasion webhooks was + not measured (grep #1 not run). Suppression (below) is designed to be safe + either way. + +## Design decisions + +| # | Decision | Rationale | +|---|----------|-----------| +| D1 | **Drive showcases from the `pokestop` webhook.** | It is the only source with the content (focus, expiry, rankings). | +| D2 | **Reuse the incident downstream by classifying showcases as `display_type=9`.** No new tracking type. | `ResolveGruntTypeName→"showcase"`, v2 `/incident` tracking, and `translateShowcaseRankings` already handle `display_type=9`. | +| D2b | **Render via a dedicated `showcase` template type** (display-only; `AlertType` stays `incident`). | A showcase is a specialised display model (leaderboard + focus), distinct from the plain Gold-Stop/Kecleon incident card. Tracking/rate-limit/blocked-alerts semantics stay tied to `incident`; only the selected template differs (same pattern as `rsvpChanges` vs `raid`). A bundled default `showcase` template ships in `fallbacks/dts.json`. | +| D3 | **Gate on `showcase_expiry > now`.** | Fields linger stale (see property 2); without this every lure/power-up snapshot carrying a dead showcase fires. Direct analogue of the lure gate. | +| D4 | **Parse `showcase_focus` for all 10 focus types.** | Flat fields are null for `buddy` and 7 other classes (confirmed in prod). A small switch produces a translated "featured" descriptor. | +| D5 | **Dedicated `ProcessShowcase` handler, not raw-reinjection into `ProcessInvasion`.** | The `pokestop` and `invasion` webhooks use different field names (`name` vs `pokestop_name`, `showcase_expiry` vs `incident_expire_timestamp`). A dedicated handler parses the pokéstop shape correctly, then builds an invasion-style `matching.InvasionData` with `GruntType="showcase"`, `DisplayType=9`, and reuses the invasion matcher + incident enrichment + incident template. | +| D6 | **Suppress the content-less `display_type=9` invasion webhook** (if the scanner sends it). | Otherwise showcase trackers get an empty incident card from the invasion webhook alongside the real one from the pokéstop webhook. Gate in `ProcessInvasion`: if `isIncident && displayType==9 && ShowcaseRankings empty` → drop. **Kept as a safety net** — operator observed no `display_type:9` invasion webhooks, but was outside the showcase window so it can't be relied on (O1). | +| D7 | **Edit-mode in scope (Phase 1), reusing the raid-RSVP path.** EditKey `showcase::`; `OverrideCleanTTH = showcase_expiry`; dedup on `(pokestop_id, showcase_expiry, rank-1 fingerprint)`. | Showcases first fire with an **empty** leaderboard (contest start), then re-fire on rank-1 movement. Edit collapses all fires of one contest into a single message that fills in and updates in place. See "Edit-mode tracking" below. | + +## Structure decision — reuse incident, no dedicated showcase structure (yet) + +The load-bearing choice: **showcases piggyback entirely on the incident/invasion +*tracking* structure; we create no dedicated showcase table, command, matcher, +API, or rowtext.** This is deliberate — defer a dedicated structure until there's +user feedback justifying it. + +Distinguish three layers so the tradeoff is clear: + +- **Tracking / subscription (REUSED, not extended).** A showcase rule *is* a v2 + `/incident` rule (`grunt_type="showcase"`) in the `invasion` table. The only + filter it can express is "showcase or not" → **match-all**. Anything finer + (filter by focus — "Water-type showcases", "rare pokemon in top 3") is **not + possible** without a dedicated tracking structure. This is the refinement we're + consciously deferring. +- **Ingestion (NEW, but thin and not a "structure").** Parsing the showcase + fields and a `ProcessShowcase` handler are unavoidable — Golbat only sends the + content on the `pokestop` webhook. This is a webhook reader, not a tracking + structure; it does not lock anything in. +- **Display / enrichment & template (REUSED + extendable).** The `incident` + template and its enrichment already render arbitrary showcase content, so the + *alert itself* can still be made as rich as we like (focus line, leaderboard, + ranking standard — Phase 2). Display refinement is **not** blocked by this + choice; only tracking-granularity is. + +**Why this is a safe, low-commitment first step:** because ingestion goes through +a dedicated `ProcessShowcase` handler, adding a real showcase tracking structure +later means pointing that handler at a new matcher + migrating existing +`grunt_type="showcase"` incident rules — the webhook parsing, expiry gate, dedup, +edit path, and enrichment all carry over. We are not painting ourselves into a +corner; we're choosing match-all-via-incident now and keeping the door open. + +## Characteristics (new-tracking interview, resolved) + +- **Webhook source:** new *ingestion* path on an existing envelope (`pokestop`); + reuses an existing *tracking* type (incident). → new handler + routing; **no** + new table/store/matcher/command/API/rowtext. +- **Pokemon-by-ID / form / numeric ranges / list filters:** N/A for MVP (match + all showcases). Focus-based filtering is a Phase-3 option (D4 enables it). +- **Time-bound expiry (#6):** **yes** — `showcase_expiry`. Enrichment computes + `tth`/`disappearTime` from it. +- **Edit support (#7):** **in scope (Phase 1)** — EditKey shape decided (D7); mechanism below. +- **Auto-delete on TTH (#8):** inherit whatever the incident template/rule sets; + no special work. +- **Bound to a POI (#10):** pokéstop (already the incident identity). +- **Repeat-firing webhook (#13):** **yes** — dedup key includes `showcase_expiry` + (D7). +- **Translation (#15):** **yes** — focus descriptor needs translated pokémon / + type / class names (D4). + +## Edit-mode tracking (how it works in the existing structure) + +Showcases need edit because a contest **first fires with an empty leaderboard** +(the pokéstop webhook fires on `showcase_expiry` change = contest start, before +any entries), then re-fires only on **rank-1 movement**. Without edit, a user +gets an empty card plus one new card per rank-1 change. **No new tracking +structure is required** — this reuses the raid-RSVP edit path verbatim: + +1. **Rule storage.** Showcases are tracked as v2 `/incident` rules + (`grunt_type="showcase"`) in the existing `invasion` table, which already has a + `clean` column. The **edit bit** is `clean & 2` (`db/clean.go`; `3` = edit+clean). + The user opts into edit the same way raid RSVP does — no schema change. +2. **Stable EditKey.** `ProcessShowcase` sets + `RenderJob.EditKey = "showcase::"`. It is constant + across every fire of the same contest (same stop, same end time), so the empty + → filling → rank-shuffle fires all resolve to the same message. +3. **Generic render/delivery path — already wired.** `RenderAlert` (the render + call the incident/showcase type uses) already threads `EditKey` + (`cmd/processor/render.go:173`). `delivery.FairQueue` looks the key up in + `delivery.MessageTracker`; if a prior message exists for that (EditKey, target) + it **edits in place**, else sends new and tracks it (tracking happens only when + `clean/edit` bit is set). Invasion simply never populated `EditKey` — that's the + only gap. +4. **TTL = contest end.** `RenderJob.OverrideCleanTTH = showcase_expiry` + (`render.go:186`) keeps the message editable, and clean-deletes it at contest + end if the clean bit is also set. +5. **Dedup must let updates through.** `CheckShowcase` keys on + `(pokestop_id, showcase_expiry, rank-1 fingerprint)` — where the fingerprint is + the top entry's `pokemon_id` + `score` + `total_entries`, mirroring Golbat's own + rank-1 fire trigger. A key on `(pokestop, expiry)` alone would collapse every + re-fire into the first empty card; including the fingerprint lets each + meaningful leaderboard change reach the edit path. + +Non-edit rules still work — they just send a fresh message per rank-1 change +(few, since Golbat only fires on rank-1 movement). Edit is **recommended** for +showcase rules and worth documenting as such. + +## Implementation plan + +### Phase 1 — ingestion, correctness & edit (MVP: real, self-updating showcase alerts) + +- [ ] **T1. Webhook parsing.** Add showcase fields to the pokéstop-shape parse. + Add a `ShowcaseWebhook` struct (or extend the lure parse) in + `internal/webhook/types.go` with `pokestop_id`, `name`, `url`, `latitude`, + `longitude`, `updated`, `showcase_expiry` (int64), `showcase_focus` + (`json.RawMessage`), `showcase_rankings` (`json.RawMessage`), + `showcase_pokemon_id/_form_id/_type_id` (nullable int), `showcase_ranking_standard`. +- [ ] **T2. Routing.** In `routePokestop` (`internal/webhook/receiver.go:144-170`) + add a showcase branch **independent** of the lure/invasion branches (a stop can + have a lure *and* a showcase): if `showcase_expiry` present (or + `showcase_rankings` present), call `ProcessShowcase(raw)`. Do **not** fold it + into the `lure_expiration <= 0` fallback. +- [ ] **T3. Handler + gate + dedup + edit.** New `cmd/processor/showcase.go` + `ProcessShowcase`: parse `ShowcaseWebhook`; **drop if `showcase_expiry <= now`** + (the core gate — mirrors `hasActiveLure`); dedup via new + `tracker.DuplicateCache.CheckShowcase(pokestopID, showcaseExpiry, rank1Fingerprint)`; + build `matching.InvasionData` with `GruntType="showcase"`, `DisplayType=9`, + `Expiration=showcase_expiry`, `ShowcaseRankings=`; call the existing + `InvasionMatcher.Match`; enqueue a `RenderJob` with `AlertType="incident"`, + `TemplateType="incident"`, **`EditKey="showcase::"`**, + and **`OverrideCleanTTH=showcase_expiry`** (see "Edit-mode tracking"). Reuse + `filterBlocked` / `filterValidation` / `filterMuted`. +- [ ] **T4. Suppress content-less invasion showcases** (D6). In + `cmd/processor/invasion.go`, after `isIncident` is computed: if + `displayType==9 && len(inv.ShowcaseRankings)==0` → debug-log and return. +- [ ] **T5. Config toggle.** `DisableShowcase bool` in `[general]` + (`internal/config/config.go`), `disable_showcase` in `config.example.toml`, and + a schema `Field` in `internal/api/config_schema.go`. (Alternatively reuse + `DisableInvasion` — **open question O4**.) `ProcessShowcase` returns early when + disabled. +- [ ] **T6. Test data.** Add `showcase` scenarios (active `type` focus, active + `buddy` focus, and an already-expired one) to `fallbacks/testdata.json`; wire + `case "showcase"` in `cmd/processor/test.go` and `validHooks` in + `bot/commands/poracletest.go`. Confirm `!poracle-test showcase,type` delivers. +- [ ] **T7. Tests.** Pure-predicate + handler tests: expiry gate (active vs + stale), dedup, the D6 suppression, and a `ResolveGruntTypeName(0,9)→"showcase"` + regression. Lock a real production webhook (the Sephardic Temple sample) like + `TestHasActiveLure_RealShowcaseStopWithStaleLure`. + +### Phase 2 — focus enrichment & display content — DONE (this PR) + +- [x] **T8. Parse `showcase_focus`** (`internal/enrichment/showcase.go` + `ShowcaseFocusTranslate`). All 10 focus classes enumerated in util.json + `showcaseFocus`; category labels via i18n `showcase_focus_{type}`; specific + value resolved per class. +- [x] **T9. DTS field metadata.** `showcaseFocusPresent/Type/Category/Name/Emoji` + added to `incidentFields` (`internal/api/dts_fields.go`). + +**Enum caveat — the values do NOT map by index (verified against Golbat proto).** +Value resolution deliberately avoids assuming the game-proto enum equals a +gamelocale key number: +- **alignment** — `ContestPokemonAlignmentFocusProto`: `0 unset, 1 PURIFIED, 2 + SHADOW` — **reversed** from gamelocale `alignment_1`=Shadow / `alignment_2`=Purified. + Mapped explicitly. +- **generation** — proto `PokedexGenerationId` (`GEN1=1..GEN8=8, GEN8A=9 Hisui, + GEN9=10 Paldea, MELTAN=1002`) doesn't line up with the 1..9 gen numbering. + Mapped explicitly (proto 1-8 → gen 1-8, proto 10 → gen 9; Hisui/Meltan → no label). +- **class** — `HoloPokemonClass` `0 normal, 1 legendary, 2 mythic, 3 ultra beast`. +- **type / pokemon / family** — proto ids match existing `poke_type_{id}` / + `poke_{id}` keys, reused directly. + +### Phase 3 — pending +- [ ] Focus line in the default `incident` template + a `showcaseRankingStandard` + (MIN/MAX) label are minor polish left for a follow-up. + +### Phase 3 — optional follow-ups (defer) + +- [ ] **T11. Focus-based tracking filters.** Let users track showcases by featured + `type`/`pokemon`/`class` (extends v2 incident tracking with a focus filter). + **Decided out for now (O3)** — match-all only, since there is no new showcase + command or table. Revisit only if operators ask. + +## Open questions — RESOLVED (operator, 2026-07-14) + +- **O1 — `display_type:9` invasion webhooks?** None observed, but operator was + outside the showcase window, so it can't be relied on. → **Keep D6/T4 as a + safety net** (drop content-less `display_type=9` invasions if they arrive). +- **O2 — fire-once vs edit-mode?** → **Edit-mode, Phase 1.** Showcases first + appear empty and re-fire; edit collapses them into one self-updating message + (see "Edit-mode tracking"). Reuses the raid-RSVP path; no new structure. +- **O3 — focus-based tracking filters?** → **No.** Match-all only (no new showcase + command/table). T11 deferred indefinitely. +- **O4 — dedicated `disable_showcase` toggle?** → **Yes**, add it (in preparation), + separate from `disable_invasion`. + +## What is explicitly NOT needed (reused from incident) + +New DB migration/table, new store + UID accessors, new matcher, new tracking API +endpoints, new bot command, new rowtext, `trackingTables`/`backup`/`human_queries` +additions. Showcases are stored and tracked as incident rules +(`grunt_type="showcase"`) and rendered by the `incident` template — all of which +already exist. diff --git a/docs/superpowers/specs/2026-07-15-costume-tracking-design.md b/docs/superpowers/specs/2026-07-15-costume-tracking-design.md new file mode 100644 index 000000000..87d121e25 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-costume-tracking-design.md @@ -0,0 +1,195 @@ +# Pokémon Costume Tracking — Design + +Status: draft / for review +Date: 2026-07-15 + +## Summary + +Add **costume** as a first-class dimension of Pokémon tracking: filter tracking +rules by costume, resolve costume names in commands, surface a Pokémon's +recently-seen costumes (and a global costume list) in `!info`, weave the costume +into the displayed `fullName`, and expose it on the v2 Pokémon API. + +## Background + +Costumed Pokémon (e.g. costumed Pikachu) are spawning heavily. In the webhook, +**costume is independent of form** — the observed Pikachu are all `form: 598` +("Normal") with `costume` varying (`1` = Holiday 2016, `8` = …). So form-based +tracking cannot distinguish or exclude them; a dedicated costume filter is needed. + +Two real operator needs: +1. Track a **specific** costume (e.g. only Holiday-2016 Pikachu). +2. **Exclude** costumes (track normal Pikachu without the costume-spawn flood). + +## Data model (verified) + +- **`resources/rawdata/costumes.json`** — 87 entries `{id, name, proto, noEvolve}` + (`0` = "Unset"/no costume, `1` = "Holiday 2016", …). This is the master list. +- **`costume_{id}` gamelocale keys** (87) — translated costume names. +- The webhook `costume` int is already parsed (`webhook/types.go`) and exposed as + a raw `costume` field in enrichment; it is already used in the icon URL. It is + present even **pre-encounter** (`seen_type: wild` carries it), so costume + filtering works regardless of the encounter-only-stat-skip rule. +- `fullName` does **not** currently include the costume — `buildFullName` + (`enrichment/translate.go:82`) composes base name + form + mega, and + `BuildFullNameWithAlignment` adds the alignment prefix. + +## Decisions (from design discussion) + +| # | Decision | +|---|----------| +| D1 | **Filter sentinel:** the `monsters.costume` column uses **9000 = any** (the existing `bot.WildcardID`), **0 = explicitly no costume**, **N = that costume**. Default 9000 keeps existing rules unchanged; `costume:0` expresses "no costume" to dodge the spam. (Deliberately different from `form`, where 0 = any — because costume 0 is a meaningful "no costume" webhook value.) | +| D2 | **Costume is an independent filter from form** — both apply in the matcher. | +| D3 | **Command syntax:** `costume:`, name resolved via the existing multi-word vocabulary + underscore-substitution (same path as items/moves/forms) — `costume:holiday_2016`, eager-joined `costume:holiday 2016`, or `costume:1`. `costume:0` = no costume. | +| D4 | **`!info costumes`** lists all costumes (global reference). **`!info `** shows a **recently-seen** costume list for that species, sourced from a `RecentActivity` map (mirrors the slash-autocomplete recency mechanism). | +| D5 | **Display:** weave the costume into `fullName`, **parenthesised** — `Pikachu (Holiday 2016)` — so every existing `{{fullName}}` template shows it with no edits. Applied to the **spawn's** name only, not PVP/evolution ranking entries. Also add a standalone `costumeName` field. No default-template change required. | +| D6 | **v2 Pokémon API** gains a nullable `Costume *int` mirroring `Form` (create + update writable; omit/null = wildcard 9000). | +| D7 | **v1 Pokémon API compatibility:** an absent `costume` must default to **9000 (any)**, not the Go zero-value `0` — otherwise v1 clients (ReactMap/PoracleWeb) that don't send the field would silently create "no costume" rules. Present values pass through verbatim (incl. 9000 / 0 / N). | + +## Components + +### 1. Gamedata — load `costumes.json` +Add `Costumes map[int]CostumeInfo` (`{ID, Name, Proto, NoEvolve}`) to the game +data, loaded from `resources/rawdata/costumes.json` at startup (mirror the +existing rawdata loaders). Display names come from `costume_{id}` translations; +`costumes.json` is the id enumeration (+ `noEvolve` for future evolution logic). +A `CostumeTranslationKey(id) → "costume_{id}"` helper alongside the existing +`FormTranslationKey` etc. + +### 2. DB storage +- Migration `0000NN_add_monster_costume.{up,down}.sql`: + `ALTER TABLE monsters ADD COLUMN costume INT NOT NULL DEFAULT 9000;` + (existing rows ⇒ 9000 = any, so no behavioural change.) +- `db.MonsterTracking` gains `Costume int \`db:"costume"\``; `MonsterTrackingAPI` + gains a `Costume` field that **defaults to 9000 when absent from the JSON** + (see §9 — a parse-time default, not the Go zero-value), so no-costume (`0`) is + only ever stored when explicitly requested. + +### 3. Matcher (`matching/pokemon.go`) +In `matchMonsters`, alongside the existing form check: +``` +if rule.Costume != 9000 && rule.Costume != webhook.Costume { continue } +``` +`MonsterData` / the parsed pokemon already carry the webhook `Costume`; thread it +into `matchMonsters` next to `Form`. 9000 = any; any other value (incl. 0) is an +exact match. Independent of the form check. + +### 4. Enrichment / display +- Thread the webhook `costume` into `translateNames` → `buildFullName`. When + `costume > 0`, append the translated `costume_{id}` name **parenthesised** + after the base+form+mega composition: `" ()"`. Do the same + for `fullNameEng`. **Do not** apply to the PVP/evolution ranking entries + (`pokemon.go` PVP path) — those are hypothetical rank rows, not the costumed + spawn. +- Add `m["costumeName"]` (per-language, via `costume_{id}`); keep the raw + `costume` id. +- Register `costumeName` in the monster DTS field metadata (`dts_fields.go`). + +### 5. Commands (`!` and `/`) +- **Argmatcher:** add a `costume` prefix param (`arg.prefix.costume`) and a + costume multi-word vocabulary (like items/moves) so `costume:` resolves + via `costume_{id}` (user lang + English fallback) → id. `costume:0` and + `costume:` accepted directly. +- **`!track` / `!untrack`:** parse the costume arg, store the id (default 9000), + include it in the diff/insert. Removal by costume supported. +- **Slash `/track`:** add a `costume` option with **name autocomplete** (labels = + costume names, resolves to id). Mapper emits `costume:`. +- **`!tracked` line / rowtext (required):** the monster rule description + rendered by `rowtext` — used by `!tracked`, the tracking-API responses, and the + command confirmation — **must** include the costume: the translated costume + name for a specific costume, "no costume" for `0`, and nothing at wildcard + (9000). Without this a costume rule is indistinguishable from a normal rule in + `!tracked`. + +### 6. `!info` +- **`!info costumes`** — new subcommand (`msg.info.sub.costumes`): list all + costumes (`id — name`) from `costumes.json` / `costume_{id}`. +- **`!info `** — new "recently-seen costumes" section mirroring + `availableForms` (`info.go:403`), listing `id — name` from + `RecentActivity.RecentCostumes(pokemonID)`. Guides the operator to + `costume:`. + +### 7. RecentActivity (`tracker/recent_activity.go`) +Reuse the **shared** `RecentActivity` (the same instance the slash-command +autocomplete and `bot/command.go` already use — always constructed at startup, +not gated on slash). Chosen deliberately: it's the project's common recency model. + +Two extensions beyond the existing flat `map[int]time.Time` categories: +- **Two-level key:** add `costumesByPokemon map[int]map[int]time.Time` + (pokémon id → costume id → last seen) plus a small two-key `record` / `active` + variant (the existing helpers are single-int-keyed). +- **New producer:** `RecordCostume(pokemonID, costume)` called from + `cmd/processor/pokemon.go` `ProcessPokemon` (only when `costume > 0`) — the + pokemon path does **not** currently feed `RecentActivity` at all (it only + touches `stats`), so this is new wiring on that handler. + +`RecentCostumes(pokemonID) []int` returns the recency-windowed costume ids for +`!info`. In-memory only ⇒ resets on restart (same as the existing autocomplete +recency); only costumes seen since startup appear — acceptable and +self-maintaining. + +### 8. v2 Pokémon API (`api/v2_pokemon.go`) +Add a nullable `Costume *int` field mirroring `Form`, and ensure it is fully +writable and reads back with correct wildcard semantics: +- **Create (`POST`) and update (`PUT`)** accept `costume`. `valueOr(req.Costume, 9000)` + on write: omitted / `null` ⇒ **9000 = any**; `0` ⇒ **no costume**; `N` ⇒ that + costume. So a client can *add* a costume filter, clear it (send `null`), or + demand no-costume (`0`). +- **Read**: `ptrUnless(row.Costume, 9000)` — returned as `null` when at the + wildcard (9000), and as the literal value otherwise (including `0`). The null + field therefore round-trips as "match any", consistent with `form`. +- Doc string states the 9000 / 0 / null semantics explicitly. + +### 9. v1 Pokémon tracking API (compatibility — must not regress) +The lenient v1 API (`/api/tracking/pokemon/*`, used by ReactMap / PoracleWeb) +parses into the shared `MonsterTrackingAPI`. **Critical:** the wildcard is `9000`, +not the Go zero-value `0`, and v1 clients will not send a `costume` field — so an +absent field would default to `0` = "no costume" and silently make every +v1-created rule match only non-costumed pokémon. + +Requirement: **an absent `costume` in a v1 payload must default to 9000 (any).** +Mechanism: a custom `UnmarshalJSON` on `MonsterTrackingAPI` (or equivalent parse +default) that pre-sets `Costume = 9000` before decoding, so: +- field absent ⇒ stays **9000** (any) — existing v1 rules and clients unchanged; +- field present ⇒ passes through verbatim, including `9000` (any), `0` (no + costume), and `N` (specific). + +This lets v1 clients that *do* know about costume add/clear it, while never +accidentally creating "no costume" rules for the clients that don't. A test must +cover: v1 payload with no `costume` → stored as 9000; `costume:0` → 0; +`costume:5` → 5. + +### 10. i18n +New keys in `internal/i18n/locale/en.json`: `msg.info.sub.costumes`, +`msg.info.available_costumes` (header for the per-species section), +`msg.info.costumes.header` (global list header), `arg.prefix.costume`, +`msg.no_costume` (label for costume 0 in rowtext), plus any command help text. + +## Testing + +- Matcher: costume 9000 (any) matches all; `costume:1` matches only costume 1; + `costume:0` matches only non-costumed; costume independent of form. +- Enrichment: `fullName` = `"Pikachu (Holiday 2016)"` for costume 1; unchanged + for costume 0; PVP entries unaffected; `costumeName` populated. +- Command: `!track pikachu costume:holiday_2016` stores costume 1; + `!track pikachu costume:0` stores 0; `!untrack` by costume; rowtext shows it. +- v2 API: round-trip of `Costume` (present / null=wildcard / 0). +- `!info costumes` lists all; `!info pikachu` shows recently-seen after a + `RecordCostume`. +- RecentActivity: record + retrieve; recency window. + +## Out of scope / deferred + +- `noEvolve` costume evolution semantics (data captured, not yet used). +- Costume-based filtering on other tracking types (raid/quest/etc.) — pokemon only. +- Negative/exclusion syntax beyond `costume:0`. + +## Affected files (reference) + +`internal/gamedata/*` (costumes loader), `internal/db/migrations/` + `db/monsters.go`, +`matching/pokemon.go`, `enrichment/translate.go` + `enrichment/pokemon.go`, +`bot/argmatch.go` + `bot/commands/{track,untrack,info,tracked}.go` + +`rowtext/*`, `discordbot/slash/{definitions,mappers/track,autocomplete}.go`, +`tracker/recent_activity.go` + `cmd/processor/pokemon.go`, `api/v2_pokemon.go`, +`api/tracking.go` + `api/trackingMonster.go` (v1 `MonsterTrackingAPI` costume + +absent→9000 default), `api/dts_fields.go`, `i18n/locale/en.json`, `DTS.md`. diff --git a/docs/superpowers/specs/2026-07-15-raid-costume-tracking-design.md b/docs/superpowers/specs/2026-07-15-raid-costume-tracking-design.md new file mode 100644 index 000000000..328d6a03c --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-raid-costume-tracking-design.md @@ -0,0 +1,112 @@ +# Raid Costume Tracking — Design + +Status: draft / for review +Date: 2026-07-15 +Mirrors: `docs/superpowers/specs/2026-07-15-costume-tracking-design.md` (pokemon costume), applied to the **raid** tracking type. + +## Summary + +Add **costume** as a filter dimension of **raid** tracking (costumed raid bosses, +e.g. costumed Pikachu raids): filter raid rules by costume, resolve costume +names in `!raid`/`/raid`, surface a boss species' recently-seen **raid** +costumes in `!info`, and expose costume on the v1 and v2 raid APIs. + +**Display is already done.** Raid enrichment already weaves the boss costume into +`fullName`/`megaName`/`costumeName` (shipped in `fix(enrichment): raid & maxbattle +boss names now include costume`). This design is the **tracking / filter / +recency / !info / API** half only. + +## Background + +Raid webhooks already carry `costume` (`webhook.RaidWebhook.Costume`, +`webhook/types.go`). Costumed raid bosses spawn during events, so operators want +to (1) track a specific costumed raid, or (2) exclude costumes. The raid matcher +currently filters on pokemon_id / level / form / evolution / team but not +costume, and the raid table has no costume column. + +## Decisions (mirror pokemon costume, confirmed for raids) + +| # | Decision | +|---|----------| +| D1 | **Filter sentinel:** `raid.costume` uses **9000 = any** (`bot.WildcardID`, the same sentinel raid `evolution` already uses), **0 = explicitly no costume**, **N = that costume**. Default 9000; absent in a payload ⇒ 9000. (Raid `form` uses 0=any, but costume needs 9000=any so `0` stays meaningful as "no costume" — identical to pokemon costume.) | +| D2 | **Costume is independent from form/evolution/level** — all apply in the matcher. | +| D3 | **Command syntax:** `!raid costume:`, resolved via the existing `ArgMatcher.ResolveCostume` (default 9000). `costume:0` = no costume. `/raid` gains a `costume` option with name autocomplete. Costume applies to every rule the command generates (single-form and multi-form `pokemon_form` paths). | +| D4 | **Recency — SEPARATE raid tracker.** New `RecordRaidCostume(pokemonID, costume)` / `RecentRaidCostumes(pokemonID)` bucket, fed only by raid webhooks (distinct from the spawn-fed `RecordCostume`). `/raid costume` autocomplete boosts raid-seen costumes; `/track costume` keeps boosting spawn-seen costumes. | +| D5 | **`!info `** gains a distinct **"Recently-seen raid costumes"** section (parallel to the existing spawn "Recently-seen costumes" section), sourced from `RecentRaidCostumes`. Each guarded — shown only when non-empty. | +| D6 | **rowtext / `!tracked`:** the raid rule description shows the costume — translated name for N>0, "no costume" for 0, nothing at 9000 — reusing the monster rowtext costume logic (translate → masterfile-name fallback). | +| D7 | **v2 raid API** gains a nullable `Costume *int` (create + update writable; omit/null ⇒ wildcard 9000; 0 ⇒ no costume; N ⇒ that costume; read returns null at 9000). Mirrors v2 pokemon costume, **not** raid form's 0-semantic. | +| D8 | **v1 raid API compatibility:** an absent `costume` defaults to **9000**, not Go-zero 0 — otherwise v1 clients (ReactMap/PoracleWeb) that don't send it would create no-costume raid rules and, because costume is rule-identity (no `diff` tag), duplicate rows on re-submit. The `raidInsertRequest.Costume` is `flexInt`; both build paths default via `req.Costume.intValue(9000)`. | + +## Components + +### 1. DB storage +- Migration `0000NN_add_raid_costume.{up,down}.sql`: + `ALTER TABLE raid ADD COLUMN costume INT NOT NULL DEFAULT 9000;` +- `db.RaidTracking` (`db/raids.go`) gains `Costume int \`db:"costume"\``. +- `db.RaidTrackingAPI` (`db/tracking_queries.go`) gains `Costume int \`db:"costume" json:"costume"\`` — **no `diff` tag** (rule identity, like `Form`). +- SQL sites updated in lock-step: `LoadRaids` SELECT (`db/raids.go`), `SelectRaidsByIDProfile` + `SelectRaidsByID` SELECT, `InsertRaid` INSERT (cols+binds), raid UPDATE (cols+binds). Column/placeholder counts must balance. + +### 2. Matcher (`matching/raid.go`) +- `RaidData` gains `Costume int`, populated from `raid.Costume` in the raid handler (`cmd/processor/raid.go`). +- In `MatchRaid`, alongside the form (`r.Form != raid.Form && r.Form != 0`) and evolution (`r.Evolution != 9000 && ...`) checks: + ```go + if r.Costume != 9000 && r.Costume != raid.Costume { + continue + } + ``` +- Egg matching is unaffected (eggs have no boss/costume). + +### 3. Command (`bot/commands/raid.go`, slash) +- Parse the `costume:` arg via `ArgMatcher.ResolveCostume` (numeric fast-path; else `costume_{id}` user-lang + English match). Default 9000; unresolved name ⇒ 🙅 (mirrors `!track`). +- Apply the resolved costume to every generated raid rule (the single-form path and each entry of the `pokemon_form` multi-form path). +- `!raid remove … costume:N` — selective removal by costume (mirror `!untrack costume:N`). +- Slash `/raid`: add a `costume` option (autocomplete=true) to `raidOptions` (`definitions.go`); `mappers/raid.go` emits `costume:`; the dispatcher routes `(cmd="raid", opt="costume")` to a costume autocomplete that boosts `RecentRaidCostumes(pid)` where `pid` is resolved from the sibling **`boss`** option (`/raid` has no separate `pokemon`/`form` option — the boss name carries the species/form), reusing `PrependRecentCostumes` + `ResolvePokemonID`. +- Store empty/default template behavior unchanged. + +### 4. rowtext (`rowtext/raid.go`) +- Add a costume clause mirroring `rowtext/monster.go`: omit at 9000, `msg.no_costume` at 0, translated `costume_{id}` name (masterfile-name fallback) for N>0. Requires `RaidTracking.Costume`. + +### 5. Recency (`tracker/recent_activity.go`, `cmd/processor/raid.go`, `!info`, slash) +- New `raidCostumesByPokemon map[int]map[int]time.Time` + `RecordRaidCostume(pokemonID, costume int)` (skips ≤0) + `RecentRaidCostumes(pokemonID int) []int`, mirroring the costume trio 1:1. +- Producer: `RecordRaidCostume(raid.PokemonID, raid.Costume)` in the raid webhook handler (`cmd/processor/raid.go`), guarded `costume > 0` and `recentActivity != nil`. +- `!info `: `availableRaidCostumes(ctx, pokemonID)` helper (mirrors `availableCostumes`, `id — name`), rendered as a "Recently-seen raid costumes" section under a new `msg.info.recent_raid_costumes` key, placed after the spawn "Recently-seen costumes" section. +- `/raid costume` autocomplete boost: dispatcher case for `(cmd="raid", opt="costume")` prepends `RecentRaidCostumes(pid)` when the field is empty and the sibling `boss` option resolves to a pokemon id. + +### 6. v1 raid API (`api/trackingRaid.go`) +- `raidInsertRequest` gains `Costume flexInt \`json:"costume"\``. +- Both build paths set `Costume: req.Costume.intValue(9000)` — the single-form path and each `pokemon_form` entry. +- `toRaidTracking` copies `Costume`. +- Regression test: v1 raid payload with no `costume` → stored 9000; `costume:0` → 0; `costume:5` → 5; re-POST of an existing rule → no duplicate row (idempotency). + +### 7. v2 raid API (`api/v2_raid.go`) +- `v2RaidRule` gains `Costume *int` (nullable, doc'd 9000/0/null semantics). +- Write: `valueOr(req.Costume, 9000)`; read: `ptrUnless(row.Costume, 9000)`. +- OpenAPI golden regenerated (additive). + +### 8. DTS field metadata +- `costumeName` is already a raid DTS field (added in the display fix). No change needed here beyond confirming it's present. + +### 9. i18n +- New key `msg.info.recent_raid_costumes` (header, e.g. "Recently-seen raid costumes:"). Reuses existing `costume_{id}`, `arg.prefix.costume`, `msg.no_costume`. + +## Testing +- Matcher: costume 9000 matches all; `costume:1` only costume 1; `costume:0` only non-costumed; independent of form/evolution/level. +- Command: `!raid pikachu costume:holiday_2016` stores 1 (single + multi-form paths); `costume:0` → 0; remove by costume; rowtext shows it. +- v1 API: absent → 9000; 0 → 0; 5 → 5; idempotent re-POST (no dup row). +- v2 API: round-trip of Costume (present / null=wildcard / 0). +- Recency: `RecordRaidCostume`/`RecentRaidCostumes` record + retrieve, skip ≤0, per-species, window; separate from spawn `RecentCostumes`. +- `/raid costume` autocomplete: boosts recent raid costumes first (proves boost, not alphabetical); no pokemon → flat list. +- `!info `: raid-costume section appears after `RecordRaidCostume`, absent when empty, distinct from the spawn section. + +## Out of scope / deferred +- Costume on egg tracking (eggs have no boss). +- Costume filtering on other types beyond pokemon (done) and raid (this). +- Negative/exclusion syntax beyond `costume:0`. +- Display changes (already shipped). + +## Affected files (reference) +`db/migrations/` + `db/raids.go` + `db/tracking_queries.go`, `matching/raid.go` ++ `cmd/processor/raid.go`, `bot/commands/raid.go` + `bot/commands/*` (remove path), +`rowtext/raid.go`, `tracker/recent_activity.go`, `bot/commands/info.go`, +`discordbot/slash/{definitions,mappers/track or raid,dispatcher,autocomplete}`, +`api/trackingRaid.go`, `api/v2_raid.go` (+ OpenAPI golden), `i18n/locale/en.json`. diff --git a/docs/superpowers/specs/2026-07-15-recency-forms-costumes-autocomplete-design.md b/docs/superpowers/specs/2026-07-15-recency-forms-costumes-autocomplete-design.md new file mode 100644 index 000000000..6446815b4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-recency-forms-costumes-autocomplete-design.md @@ -0,0 +1,132 @@ +# Recency-Aware Form & Costume Surfaces — Design + +Status: draft / for review +Date: 2026-07-15 +Depends on: `feature/costume-tracking` (PR #162) — reuses `RecentActivity.RecentCostumes` and the costume autocomplete added there. + +## Summary + +Make the `/track` form and costume pickers, and `!info `, surface what +is **actually spawning now** instead of a static alphabetical list: + +1. `/track costume` autocomplete boosts the chosen pokemon's **recently-seen + costumes** to the top. +2. `RecentActivity` gains a **per-species recent-forms** dimension (mirror of the + existing recent-costumes one), fed from the pokemon webhook handler. +3. `/track form` autocomplete boosts the chosen pokemon's **recently-seen forms** + to the top. +4. `!info ` gains a **"recently seen forms"** section, parallel to the + existing "recently seen costumes" section. + +## Background + +The project already has a mature recency-boost pattern for autocomplete: +`tracker.RecentActivity` keeps 6-hour rolling buckets (`ActiveRaidBosses`, +`ActiveQuestItems`, `ActiveInvasionGrunts`, …), and the dispatcher prepends them +via `PrependActivePokemon` / `PrependActiveItems` / `PrependActiveGrunts` +(`internal/discordbot/slash/autocomplete/recent_activity_boost.go`). Each boost: +prepends the recent entries (cap 10), dedups against the base list, hard-caps at +Discord's 25 choices, and only fires when the focused option is empty. + +The costume work added a **two-level, per-species** recency bucket — +`costumesByPokemon map[int]map[int]time.Time` with `RecordCostume(pokemonID, +costume)` / `RecentCostumes(pokemonID)` — plus `availableCostumes` in `!info`. +This design extends that shape to forms and wires both into the two pickers. + +Established facts (verified): +- Autocomplete providers receive `deps *bot.BotDeps` (has `GameData`, + `Translations`, `RecentActivity`) and the raw `*discordgo.InteractionCreate`. +- The dispatcher already reads the sibling `pokemon` option for the form case via + `siblingOptionString(ic, "pokemon")` and passes it to `autocomplete.Form`. +- `autocomplete.Form` is species-scoped and **skips form 0** (the "any form" + placeholder). `autocomplete.Costume` is a flat global list and **keeps id 0** + ("no costume") as a real choice. +- `RecordCostume` skips costume ≤ 0; `RecentCostumes` returns id list for a + species. Producer is wired in `cmd/processor/pokemon.go` beside stats. + +## Decisions + +| # | Decision | +|---|----------| +| D1 | **Reuse the 6h boost pattern verbatim** — boost cap 10, total cap 25, dedup, only on empty focused. No new tunables. | +| D2 | **Costume recency is per-species** — `/track costume` boosts `RecentCostumes(pokemonID)` for the sibling-selected pokemon. If no pokemon is selected yet, the picker stays the current flat global list (recency needs a species key). The base list is unchanged; recency only reorders/prepends. | +| D3 | **New per-species recent-forms bucket** — `formsByPokemon map[int]map[int]time.Time`, `RecordForm(pokemonID, form)`, `RecentForms(pokemonID)`, mirroring the costume methods 1:1 (same mutex, same `active()` window, same lazy inner-map init). | +| D4 | **`RecordForm` skips form ≤ 0.** Form 0 is the "any form" placeholder — the form picker already omits it and it isn't a trackable value, so recording it would surface a non-choice. (Mirrors `RecordCostume`'s skip of ≤ 0.) | +| D5 | **Producer** — `RecordForm(pokemon.PokemonID, pokemon.Form)` called in `cmd/processor/pokemon.go` immediately beside the existing `RecordCostume`, on every processed pokemon webhook. | +| D6 | **`/track form` boost** — when a pokemon is selected and the focused text is empty, prepend `RecentForms(pokemonID)` above `autocomplete.Form`'s alphabetical list. Recent forms are always a subset of that species' forms, so dedup keeps them from repeating. | +| D7 | **`!info` recent forms — its own section.** Add a "recently seen forms" section listing `id — name` from `RecentForms(pokemonID)`, directly parallel to the existing "recently seen costumes" section, and **leave the full `availableForms` list intact**. Forms and costumes stay structurally identical in `!info`. | +| D8 | **Value/label contracts unchanged.** Boosted costume choices use the existing `Costume` provider's contract (label = translated costume name, value = id string); boosted form choices use `Form`'s contract (label = translated form name, value = lowercased name). So the text parser downstream sees exactly what it does today. | + +## Components + +### 1. `RecentActivity` — recent forms (`internal/tracker/recent_activity.go`) +Add, mirroring the costume trio: +- field `formsByPokemon map[int]map[int]time.Time` (init in the constructor) +- `RecordForm(pokemonID, form int)` — no-op when `pokemonID <= 0 || form <= 0`; + lazily inits the inner map; records under `r.mu`. +- `RecentForms(pokemonID int) []int` — returns the windowed form ids via the + existing `active()` helper (same 6h TTL, sorted for determinism as + `RecentCostumes` is). + +### 2. Producer (`cmd/processor/pokemon.go`) +Beside the existing `RecordCostume` call, add +`ps.RecentActivity.RecordForm(pokemon.PokemonID, pokemon.Form)` (guarded the same +way — only when RecentActivity is wired). Records the spawn's actual form. + +### 3. Costume autocomplete boost +- **Dispatcher** (`dispatcher.go`, `case opt == "costume" && cmd == "track"`): + read `siblingOptionString(ic, "pokemon")`, resolve to id, and when focused is + empty prepend `RecentCostumes(pokemonID)`. +- **Boost helper** (`recent_activity_boost.go`): add + `PrependRecentCostumes(base, deps, costumeIDs []int, userLang)` — resolves each + id via `costumeLabel` (user lang → English fallback), value = id string, cap + 10 / 25 / dedup, mirroring `PrependActiveItems`. +- The base `autocomplete.Costume` stays flat/global and unchanged. + +### 4. Form autocomplete boost +- **Dispatcher** (`case opt == "form" && cmd == "track"`): after building + `autocomplete.Form(...)`, when focused is empty and a pokemon is resolved, + prepend `RecentForms(pokemonID)`. +- **Boost helper**: add `PrependRecentForms(base, deps, formIDs []int, userLang)` + — resolves each id via `formLabel`, value = lowercased name, same cap/dedup + rules. Skips ids that don't resolve to a named form (defensive). + +### 5. `!info` recent forms section (`internal/bot/commands/info.go`) +- `availableForms`-style helper `availableRecentForms(ctx, pokemonID) []string` + returning `"id — name"` (name via the existing `formName`/`formLabel` + resolution with masterfile fallback), sourced from `RecentForms(pokemonID)`. +- Render it as a "recently seen forms" section under the new i18n key + `msg.info.recent_forms`, placed **immediately before** the recently-seen-costumes + section (matching the approved output order: recent forms → recent costumes → + available forms). Guard: omit the section when the list is empty or + `RecentActivity` is nil. + +## i18n +One new key in `internal/i18n/locale/en.json`: `msg.info.recent_forms` (header +for the recent-forms section, parallel to the costume section's +`msg.info.available_costumes`). No new keys needed for autocomplete (labels come +from existing form/costume translations). + +## Testing +- `RecentActivity`: `RecordForm`/`RecentForms` — record + retrieve, skip form ≤ 0, + per-species isolation, recency window (mirror the costume test). +- `PrependRecentCostumes` / `PrependRecentForms`: recent-first ordering, dedup + against base, 10/25 caps, empty-input passthrough, nil-deps safety (mirror the + existing boost tests). +- Dispatcher routing: costume case now reads the sibling pokemon; form case still + cascades; both only boost on empty focused (unit-test the boost decision, or + assert via the provider given a stubbed RecentActivity). +- `!info`: recent-forms section appears after `RecordForm`, absent when empty. + +## Out of scope / deferred +- Recency for other pickers (raid boss forms, etc.) — pokemon `/track` only. +- Persisting recency across restart (all RecentActivity buckets are in-memory by + design; resets on restart, self-heals within 6h). +- Weighting/ordering recent entries by frequency — recency (last-seen) only, + matching every existing boost. + +## Affected files (reference) +`internal/tracker/recent_activity.go` (+ test), `cmd/processor/pokemon.go`, +`internal/discordbot/slash/autocomplete/recent_activity_boost.go` (+ test), +`internal/discordbot/slash/dispatcher.go`, `internal/bot/commands/info.go`, +`internal/i18n/locale/en.json`. diff --git a/docs/superpowers/specs/2026-07-16-info-forms-costumes-consistency-design.md b/docs/superpowers/specs/2026-07-16-info-forms-costumes-consistency-design.md new file mode 100644 index 000000000..b5b37f97b --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-info-forms-costumes-consistency-design.md @@ -0,0 +1,93 @@ +# `!info` Forms & Costumes Consistency — Design + +Status: draft / for review +Date: 2026-07-16 +Builds on: pokemon costume, recency, and raid-costume features (raid costume recency + `!info` raid-costume section already shipped). + +## Summary + +Make the `!info ` forms/costumes surface consistent and less cluttered: +1. **Symmetric recency** — add a separate raid-**forms** recency tracker + section, so `!info` shows four parallel recency sections: recently-seen forms, raid forms, costumes, raid costumes. +2. **Copy-pasteable costumes** — render the costume recency sections as `pokemon costume:` (like the form sections), so they paste straight into `!track`/`!raid`. +3. **Truncate the long roster + reveal subcommands** — the big "available forms" roster truncates at 10 inline with a hint; `!info forms` shows the full roster and `!info costumes` shows the full recently-seen costumes. + +## Background + +Current `!info ` (`bot/commands/info.go` `pokemonInfo`) renders, in order: +- Recently-seen forms (spawn) — copy-pasteable `pokemon form:` (`availableRecentForms`) +- Recently-seen costumes (spawn) — `id — name` (`availableCostumes`) +- Recently-seen raid costumes — `id — name` (`availableRaidCostumes`) +- Available forms (full roster, untruncated) — copy-pasteable `pokemon form:` (`availableForms`) + +Gaps vs. the desired consistency: +- No raid-**forms** recency (raid webhooks carry `form`; only spawn forms are tracked). +- Costume sections use `id — name`, not the copy-pasteable format forms use. +- The available-forms roster is very long for some species (e.g. Pikachu) and is shown in full. + +`!info` subcommands (`!info costumes`, `!info moves`, …) dispatch at the top level by `args[0]`. `!info pikachu forms` currently falls to `pokemonInfo(["pikachu","forms"])`, which ignores `args[1:]`. + +## Decisions (confirmed) + +| # | Decision | +|---|----------| +| D1 | **Raid-forms recency (separate).** New `RecordRaidForm(pokemonID, form)` / `RecentRaidForms(pokemonID)` bucket (mirror `RecordRaidCostume`/`RecentRaidCostumes`, skip form ≤ 0), fed from the raid handler. A "Recently-seen raid forms" section in `!info`, copy-pasteable `pokemon form:`, placed right after the spawn "Recently-seen forms" section. | +| D2 | **Costume sections copy-pasteable.** Both `availableCostumes` (spawn) and `availableRaidCostumes` render `ctx.Code(" costume:")` — the costume name lowercased with spaces→underscores (mirrors `availableRecentForms`'s form format), so a user pastes it into `!track`/`!raid`. Names resolve via the existing `costumeName(ctx, tr, id)` (translate → masterfile fallback); unresolved ids are skipped. | +| D3 | **Section order** in `!info `: recently-seen forms → recently-seen raid forms → recently-seen costumes → recently-seen raid costumes → available forms (roster). Forms grouped, costumes grouped. | +| D4 | **Recency sections shown in full** (bounded by the 6h window). Only the **available-forms roster** truncates. | +| D5 | **Roster truncation.** When `availableForms` has more than **10** entries, `!info ` shows the first 10 followed by a hint line: "More than 10 forms — do `!info forms`" (localized). ≤ 10 → shown in full, no hint. | +| D6 | **`!info forms`** — new sub-route (detected in `pokemonInfo` when `args[1]` matches the "forms" subword): shows the recently-seen forms (spawn + raid) **and** the full available-forms roster (untruncated) — a complete "forms for this species" view, no other sections. | +| D7 | **`!info costumes`** — new sub-route (`args[1]` matches the existing "costumes" subword): shows pikachu's **recently-seen costumes, spawn + raid combined and deduped**, copy-pasteable, untruncated. (Costumes have no per-species roster in the data; recency is the only per-species costume data.) | +| D8 | **`/raid form` slash option** — add a `form` option to `/raid` (autocomplete=true), mirroring the just-added `/raid costume`. `mappers/raid.go` emits `form:`; the dispatcher routes `(cmd="raid", opt="form")` to `autocomplete.Form` cascading from the sibling `boss` option, and boosts `RecentRaidForms(pid)` when the field is empty. The text command (`!raid pikachu form:alolan`) already supports form via `applyFormFilter`; this is the slash surface only. | + +## Components + +### 1. RecentActivity — raid forms (`tracker/recent_activity.go`) +Add, mirroring the raid-costume trio: +- field `raidFormsByPokemon map[int]map[int]time.Time` (+ constructor init) +- `RecordRaidForm(pokemonID, form int)` — no-op when `pokemonID <= 0 || form <= 0` +- `RecentRaidForms(pokemonID int) []int` — via the existing `active()` window +Producer: `RecordRaidForm(raid.PokemonID, raid.Form)` in `cmd/processor/raid.go`, beside the existing `RecordRaidCostume`/`RecordRaidBoss` calls (guarded `form > 0` and `recentActivity != nil`). +Consumed by both the `!info` raid-forms section AND the `/raid form` autocomplete boost (§5). + +### 2. `!info` sections (`bot/commands/info.go`) +- **New helper** `availableRecentRaidForms(ctx, pokemonID) []string` — copy-pasteable `pokemon form:` from `RecentRaidForms` (mirror `availableRecentForms`). +- **Rewrite** `availableCostumes` and `availableRaidCostumes` to emit `ctx.Code(" costume:")` (copy-pasteable) instead of `id — name`. Extract the shared costume-line formatting so both use it (name resolution + lowercase/underscore). +- **Render order** (D3): spawn forms, raid forms, spawn costumes, raid costumes, then available forms. +- **Roster truncation** (D5): if `len(forms) > 10`, print the first 10 and a hint line; else print all. + +### 3. Sub-routing (`bot/commands/info.go` `pokemonInfo`) +After resolving the pokemon from `args[0]`, if `len(args) > 1` and `args[1]` matches the "forms" or "costumes" subword (via the same `tr.T`/`enTr.T` match used at the top level), branch: +- "forms" → render only the full available-forms roster (untruncated) for that species. +- "costumes" → render only the combined recently-seen costumes (spawn + raid, deduped) for that species. +- "forms" → render the recently-seen forms (spawn + raid) followed by the full available-forms roster (untruncated) for that species. +- "costumes" → render the combined recently-seen costumes (spawn + raid, deduped) for that species. +Otherwise render the normal `!info ` view. + +### 5. `/raid form` slash option (`definitions.go`, `mappers/raid.go`, `dispatcher.go`) +Mirror the `/raid costume` slash work (just shipped): +- `definitions.go` `raidOptions` — add `stringOpt(bundle, "raid.form", "form", "Raid boss form", false, true)` (autocomplete=true). +- `mappers/raid.go` — emit a `form:` token when the form option is set. +- `dispatcher.go` `routeAutocomplete` — add `(cmd="raid", opt="form")`: `base := autocomplete.Form(ctx, deps, siblingOptionString(ic,"boss"), focused, userLang)`, then when `focused == ""` and the boss resolves, `base = autocomplete.PrependRecentForms(base, deps, RecentRaidForms(pid), userLang)`. (Reuses the existing `autocomplete.Form` + `PrependRecentForms` + `ResolvePokemonID`.) +The text command already handles `!raid pikachu form:alolan` via `applyFormFilter` — no command/DB change needed. + +### 4. i18n (`i18n/locale/en.json`) +New keys: +- `msg.info.recent_raid_forms` — "**Recently-seen raid forms:**" (header) +- `msg.info.sub.forms` — the "forms" subword (matches `!info forms`) +- `msg.info.more_forms` — the truncation hint, `Tf`-formatted: `"More than {0} forms — do {1} for the full list"` where `{0}` = the cap (10) and `{1}` = the localized `!info forms` command string (inline-code wrapped) +- (reuse `msg.info.sub.costumes` for the "costumes" subword; reuse `msg.info.recent_forms` / `msg.info.available_costumes` / `msg.info.recent_raid_costumes` / `msg.info.available_forms` headers) + +## Testing +- RecentActivity: `RecordRaidForm`/`RecentRaidForms` — record + retrieve, skip form ≤ 0, per-species, window; separate from spawn `RecentForms` and from raid costumes. +- `!info `: raid-forms section appears after `RecordRaidForm`, absent when empty; costume sections render `pokemon costume:` (copy-pasteable), not `id — name`; section order is forms → raid forms → costumes → raid costumes → available forms. +- Truncation: species with > 10 available forms shows 10 + the hint; ≤ 10 shows all, no hint. +- Sub-routes: `!info forms` shows recent forms (spawn + raid) + the full roster (more than the truncated inline view); `!info costumes` shows the combined recently-seen costumes; neither collides with the global `!info costumes`/`!info forms` top-level dispatch. +- `/raid form`: mapper emits `form:`; the dispatcher boosts recent raid forms first for the selected boss (proves boost, not alphabetical); no boss → base list. + +## Out of scope / deferred +- A global `!info forms` (all forms across all species) — only the per-species `!info forms`. +- Truncating the recency sections (D4: shown in full). +- Egg form/costume (eggs have no boss). + +## Affected files (reference) +`tracker/recent_activity.go` (+ test), `cmd/processor/raid.go`, `bot/commands/info.go` (+ tests), `i18n/locale/en.json`, `discordbot/slash/definitions.go` + `mappers/raid.go` + `dispatcher.go` (+ tests) + slash testdata fixtures (`testdata/raid.json`, `parity.yaml`). diff --git a/docs/superpowers/specs/2026-07-18-derived-dts-test-data-design.md b/docs/superpowers/specs/2026-07-18-derived-dts-test-data-design.md new file mode 100644 index 000000000..78a358e75 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-derived-dts-test-data-design.md @@ -0,0 +1,165 @@ +# Testing Derived DTS Types (+ unified enrichment + DTS-name addressing) — Design + +Status: draft / for review +Date: 2026-07-18 +Editor under review: `~/dev/poracle-embed-visualizer` (the DTS template editor). + +## Summary + +Make every DTS template type previewable and testable — including the **derived** +types that aren't a single raw webhook (`monsterChanged`, `incident`, +`questSummary`, `weatherchange`, `rsvpChanges`) — through **both** consumer +surfaces: +1. the DTS editor's two-stage flow (`GET /api/dts/testdata` → user edits → + `POST /api/dts/enrich` → enriched variables → preview), and +2. live `!poracle-test` / `POST /api/test` (render + deliver). + +Along the way: **unify** the two duplicated per-type enrichment implementations +into one shared dispatch, and make test data **addressable by DTS template +type name** (server-provided mapping) so the editor stops hardcoding +`monster→pokemon` and stops filtering scenarios client-side. + +## Background + +### Two duplicated enrichment paths (today) +- `cmd/processor/enrich.go` `EnrichWebhook(type, raw, lang, platform) → variables` + — a per-type switch (pokemon/raid/egg/quest/invasion/lure/nest/gym/ + fort_update/max_battle) calling `enricher.Pokemon/Raid/Quest/…`. Powers the + editor's `POST /api/dts/enrich`. +- `cmd/processor/test.go` — its **own** parallel per-type handlers, also calling + `enricher.Pokemon/Raid/Invasion/…`, building `RenderJob`s. Powers live + `POST /api/test` (`!poracle-test`). + +The two must be kept in lock-step by hand; a derived type would otherwise have +to be implemented twice. + +### Test data shape +`fallbacks/testdata.json` (+ `config/testdata.json` override) is +`[]TestDataEntry{ type, test, location, webhook }` where `webhook` is +`json.RawMessage` (any JSON — already flexible enough for a "partial"). + +### Derived DTS types have no test path (verified) +`fieldsByType` registers `monsterChanged`, `incident`, `questSummary`, +`weatherchange`, `rsvpChanges` as real editable types, but nothing renders them: +the reachable `TemplateType`s are only monster/raid/egg/invasion/showcase/quest/ +gym/nest/fort-update/maxbattle/lure. Each derived type needs extra state: +`monsterChanged` = old+new sighting (`OriginalView`); `weatherchange` = a +weather-change event + affected-pokemon list; `questSummary` = a grouped digest; +`rsvpChanges` = a raid + RSVP update; `incident` = pokestop-event samples routed +to the incident template. + +### Editor's current coupling (`poracle-embed-visualizer`) +`scripts/capture-test-data.mjs` hardcodes `dtsToWebhookType` +(`monster→pokemon`, `monsterNoIv→pokemon`, `egg→raid`, `invasion/lure→pokestop`, +`fort-update→fort_update`, `maxbattle→max_battle`, …); it **filters** pokestop +scenarios client-side (sniffing `grunt_type`/`lure_id`) to split invasion vs +lure; and it special-cases `monsterNoIv→pokemon`/`egg→raid` when calling enrich. +`src/lib/api-client.js` exposes `enrichWebhook(type, webhook, lang)`, +`getTestData(type)`, `getFields(type)`. All of this is server-derivable. + +## Decisions + +| # | Decision | +|---|----------| +| D1 | **Unify enrichment.** One shared dispatch `enrichForType(name, partial, lang, platform) → EnrichedResult{ Layers, TemplateType, Extras }` (Extras carries `OriginalView`, the quest group, the affected list, the RSVP context). `EnrichWebhook` becomes a thin wrapper returning the flattened `variables`. `test.go`'s live path calls the **same** dispatch and wraps the result in a `RenderJob`. A derived type is added exactly once. | +| D2 | **Derived types as test "partials".** The `webhook` field holds a structured payload per derived type: `monster-changed: {old, new}`; `weather-change: {…cell old/new weather…, affected:[pokemon…]}`; `quest-summary: {reward, quests:[quest…]}`; `rsvp-changes: {raid, rsvps:[…]}`. `incident`: **move** the existing kecleon/gold-stop/etc. pokestop-incident samples into `incident`-typed entries that render the `incident` template (no runtime routing). | +| D3 | **Canonical DTS-name addressing.** One server-side alias table maps every DTS template type ↔ its test source, resolving `monster↔pokemon`, `monsterNoIv←pokemon`, `egg←raid`, plus the derived names. The shared dispatch accepts a DTS type name OR a webhook type name via this table. Single source of truth, used by the enrich endpoint, testdata endpoint, and `!poracle-test`. | +| D4 | **`GET /api/dts/testdata` becomes DTS-type-aware.** It accepts a DTS type (e.g. `?dtsType=invasion`) and returns exactly the entries that preview that type — the **server** does the pokestop invasion/lure split and any filtering, and tags each returned entry with the DTS type(s) it can preview. The editor drops its hardcoded map + client-side filtering. (The legacy `?type=` query stays for back-compat.) | +| D5 | **`POST /api/dts/enrich` accepts DTS type names for all types**, including the derived ones — it runs the unified dispatch, so it returns the enriched variables (incl. `original.*`, affected list, group) the editor needs to preview a derived template. | +| D6 | **`!poracle-test` / `POST /api/test` accept DTS type names too** (`!poracle-test monsterChanged,species-shift`), resolved via the same alias table; the derived partials render+deliver live. | +| D7 | **Editor handoff doc is a deliverable.** A standalone instruction doc for the editor agent describing every new/changed server contract (the DTS-name-addressable enrich, the DTS-type-aware testdata endpoint + its tags, the derived-type entries, and exactly which editor code to delete: the hardcoded map, the filtering, the special-casing). | + +## Components (PoracleNG) + +### 1. Alias / mapping table (`cmd/processor/enrich.go` or a small new file) +A single canonical structure: for each DTS type — its render `TemplateType`, its +test-source category, and whether it's a "derived" (partial) type. Drives D3/D4/ +D5/D6. Exposed to the editor (see §3). Rough shape: +`monster→pokemon(encountered)`, `monsterNoIv→pokemon(unencountered)`, +`monsterChanged→monster-changed(partial)`, `raid→raid`, `egg→raid(egg)`, +`rsvpChanges→rsvp-changes(partial)`, `quest→quest`, +`questSummary→quest-summary(partial)`, `invasion→pokestop(invasion)`, +`incident→incident(moved samples)`, `showcase→showcase`, `lure→pokestop(lure)`, +`weatherchange→weather-change(partial)`, `gym→gym`, `nest→nest`, +`maxbattle→max_battle`. (`greeting` has no webhook source — omit.) + +### 2. Unified dispatch (`enrich.go`) +`enrichForType(name, partial, lang, platform) (EnrichedResult, error)` resolves +`name` via the alias table, parses the partial, runs the real `enricher.*` +methods, and returns `{ Layers (base+perLang+perUser), TemplateType, Extras }`. +- `EnrichWebhook` → calls it, flattens `Layers` into the `variables` map (as + today), and additionally merges `Extras` so the editor sees `original.*` etc. +- `test.go` → calls it, then builds the `RenderJob` (setting `IsChange`/ + `OriginalView`/`OverrideCleanTTH`/`TemplateType` from `Extras`) and delivers. +Derived-type builders live here, reusing production render construction where it +is already factored (`dts.BuildOriginalView`, the quest-summary grouping, the +weather-change enrichment) rather than re-implementing. + +### 3. `GET /api/dts/testdata` (huma_dts_reads.go / dts_testdata.go) +- Accept `?dtsType=`; resolve via the alias table; return the entries for + that DTS type, server-side filtered (e.g. pokestop→invasion vs lure) and each + tagged with `dtsType`(s). Keep `?type=` working. +- Optionally add a `GET /api/dts/testdata/types` (or include the map in the + existing response) so the editor can discover the full DTS-type→source map and + drop its hardcoded copy. + +### 4. `POST /api/dts/enrich` (huma_dts_writes.go) +No signature change — it already takes `{type, webhook, language, platform}`. +`type` now accepts any DTS name (resolved by the unified dispatch), and the +response `variables` includes derived extras. Remove the need for the editor's +client-side special-casing. + +### 5. `POST /api/test` + `!poracle-test` (cmd/processor/test.go, bot command) +Route through the unified dispatch; accept DTS type names; support the derived +partials end-to-end (render + deliver). + +### 6. Test data (`fallbacks/testdata.json`) +- Move the pokestop-incident samples (kecleon, gold-stop, pokemon-contest, …) + to `incident`-typed entries. +- Add `monster-changed`, `weather-change`, `quest-summary`, `rsvp-changes` + sample entries (partials) — `weather-change` includes a short affected-pokemon + list, per the requirement. + +### 7. Editor handoff doc (D7) +`docs/superpowers/handoffs/2026-07-18-dts-editor-derived-types.md` — lives **in +this (PoracleNG) repo**; the operator points the editor agent at it to read. +The complete server contract the editor must consume. + +## Editor handoff doc — required contents +- The DTS-name-addressable `POST /api/dts/enrich` (accepts every DTS type incl. + derived; `variables` now carries `original.*` / affected / group). +- The DTS-type-aware `GET /api/dts/testdata?dtsType=` (server filters + tags); + and the discoverable DTS-type→source map endpoint. +- The new derived-type test entries + their partial shapes. +- **Delete list** for the editor: the hardcoded `dtsToWebhookType` map, the + client-side pokestop invasion/lure filter, and the `monsterNoIv→pokemon`/ + `egg→raid` special-casing in `capture-test-data.mjs`; adjust `api-client.js`/ + `TestDataPanel.jsx` to address by DTS type. +- Note that `fort-update`/`maxbattle` (and the derived types) are now selectable. + +## Testing +- Unified dispatch: `enrichForType` returns identical `Layers`/`TemplateType` + for a webhook type and its DTS alias (`pokemon` == `monster`); the derived + builders populate `Extras` correctly (OriginalView for monster-changed; + affected list for weather-change; group for quest-summary; RSVP for rsvp). +- `/api/dts/enrich` by DTS name (incl. derived) returns the expected variables. +- `/api/dts/testdata?dtsType=invasion` returns only invasion scenarios (not + lure); `?dtsType=incident` returns the moved samples; tags present. +- `/api/test` + `!poracle-test` render+deliver each derived type from its + partial (fake dispatcher assertion, mirroring existing test-command tests). +- Back-compat: existing `?type=` and webhook-type enrich unchanged. + +## Out of scope / deferred +- `greeting` test data (no webhook source). +- Changing the on-disk testdata *file* format (stays `[]TestDataEntry`; partials + ride in the existing `webhook` field). +- Editor-side implementation (covered by the handoff doc; a separate effort in + the editor repo). + +## Affected files (PoracleNG) +`cmd/processor/enrich.go` (+ alias table, unified dispatch, derived builders), +`cmd/processor/test.go` (route through dispatch), `internal/api/dts_testdata.go` ++ `huma_dts_reads.go` (dtsType query + tags + map endpoint), +`internal/api/huma_dts_writes.go` (enrich by DTS name), the `!poracle-test` +command, `fallbacks/testdata.json` (move incident + add 4 partials), and the +editor handoff doc. diff --git a/docs/superpowers/specs/huma-tracking-field-audit.md b/docs/superpowers/specs/huma-tracking-field-audit.md new file mode 100644 index 000000000..491f8ef34 --- /dev/null +++ b/docs/superpowers/specs/huma-tracking-field-audit.md @@ -0,0 +1,368 @@ +# Tracking API — Per-field Canonical-type Audit + +**Purpose**: drive the huma migration for all 10 tracking-rule POST endpoints. +Every field was verified against the actual Go source (`trackingXxx.go` handler, +`commands/xxx.go` bot keywords, `db/migrations/*.sql` schema). No field was guessed. + +**Principle**: model the caller's mental model, stay lenient about legacy forms, +keep the stored DB value/semantics unchanged. + +--- + +## Common fields (present on every type) + +| field | json | Go type | DB type | semantics class | server default | canonical wire form | lenient-accepts | required? | notes | +|---|---|---|---|---|---|---|---|---|---| +| `uid` | `uid` | `flexInt` | auto-increment, omit on insert | id | — | integer | string, bool | no | present means update; absent means insert | +| `profile_no` | `profile_no` | `flexInt` | `int(11) NOT NULL DEFAULT 1` | genuine-int | query param profileNo | integer | string, bool | no | falls back to active profile if omitted | +| `distance` | `distance` | `flexInt` | `int(11) NOT NULL` | genuine-int (metres, 0=area-based) | 0 | integer | string, bool | no | capped at 40 000 000 | +| `template` | `template` | `any` | `text DEFAULT NULL` | string/id | server default (config `default_template_name`) | string | numeric (coerced to string), omit | no | empty string or omitted → server default | +| `clean` | `clean` | `flexBool` | `tinyint(1) NOT NULL DEFAULT 0` | bitmask (bit1=clean, bit2=edit, bit4=summary) | 0 | boolean (bit1 only) | integer bitmask 0–7, string | no | see Special representations; huma adds `edit` and `summary` boolean siblings | +| `ping` | `ping` | not in insert struct (always set to `""`) | `text NOT NULL` | string | `""` | — | — | no | server-managed; callers do not send this | +| `override_location_label` | `override_location_label` | `string` | `VARCHAR(64) NULL` (migration 4) | string/id (saved-location label) | `""` (null) | string | — | no | mutually exclusive with `override_areas`; requires `distance > 0` | +| `override_areas` | `override_areas` | `[]string` | `TEXT NULL` (migration 4, stored as JSON array) | list | nil (null) | array of strings | — | no | mutually exclusive with `override_location_label` and `distance > 0` | + +--- + +## Pokemon (`monsters` table) + +Request struct: `monsterInsertRequest` (gin) / `monsterRuleRequest` (huma, already migrated). + +| field | json | Go type | DB type | semantics class | server default | canonical wire form | lenient-accepts | required? | notes | +|---|---|---|---|---|---|---|---|---|---| +| `pokemon_id` | `pokemon_id` | `flexInt` | `int(11) NOT NULL` | genuine-int (Pokédex ID) | — | integer | string, bool | **yes** | handler returns 400 if absent | +| `form` | `form` | `flexInt` | `int(11) NOT NULL` | genuine-int (form ID, 0=any) | 0 | integer | string, bool | no | | +| `min_iv` | `min_iv` | `flexInt` | `int(11) NOT NULL` | genuine-int (−1–100, −1=no lower bound) | −1 | integer | string, bool | no | | +| `max_iv` | `max_iv` | `flexInt` | `int(11) NOT NULL` | genuine-int (0–100) | 100 | integer | string, bool | no | | +| `min_cp` | `min_cp` | `flexInt` | `int(11) NOT NULL` | genuine-int | 0 | integer | string, bool | no | | +| `max_cp` | `max_cp` | `flexInt` | `int(11) NOT NULL` | genuine-int | 9000 | integer | string, bool | no | | +| `min_level` | `min_level` | `flexInt` | `int(11) NOT NULL` | genuine-int (0–55) | 0 | integer | string, bool | no | | +| `max_level` | `max_level` | `flexInt` | `int(11) NOT NULL` | genuine-int (0–55) | 55 | integer | string, bool | no | | +| `atk` | `atk` | `flexInt` | `int(11) NOT NULL` | genuine-int (0–15) | 0 | integer | string, bool | no | minimum ATK IV | +| `def` | `def` | `flexInt` | `int(11) NOT NULL` | genuine-int (0–15) | 0 | integer | string, bool | no | minimum DEF IV | +| `sta` | `sta` | `flexInt` | `int(11) NOT NULL` | genuine-int (0–15) | 0 | integer | string, bool | no | minimum STA IV | +| `max_atk` | `max_atk` | `flexInt` | `int(11) NOT NULL` | genuine-int (0–15) | 15 | integer | string, bool | no | | +| `max_def` | `max_def` | `flexInt` | `int(11) NOT NULL` | genuine-int (0–15) | 15 | integer | string, bool | no | | +| `max_sta` | `max_sta` | `flexInt` | `int(11) NOT NULL` | genuine-int (0–15) | 15 | integer | string, bool | no | | +| `gender` | `gender` | `flexInt` | `int(11) NOT NULL` | enum 0=any / 1=male / 2=female / 3=genderless | 0 | integer (or string "any"\|"male"\|"female"\|"genderless") | bool | no | see Special representations | +| `min_weight` | `min_weight` | `flexInt` | `int(11) NOT NULL` | genuine-int (grams) | 0 | integer | string, bool | no | | +| `max_weight` | `max_weight` | `flexInt` | `int(11) NOT NULL` | genuine-int (grams) | 9 000 000 | integer | string, bool | no | | +| `min_time` | `min_time` | `flexInt` | `int(11) NOT NULL DEFAULT 0` | genuine-int (seconds remaining) | 0 | integer | string, bool | no | | +| `rarity` | `rarity` | `flexInt` | `int(11) NOT NULL DEFAULT −1` | genuine-int (−1=any, 1–6) | −1 | integer | string, bool | no | | +| `max_rarity` | `max_rarity` | `flexInt` | `int(11) NOT NULL DEFAULT 6` | genuine-int (1–6) | 6 | integer | string, bool | no | | +| `size` | `size` | `flexInt` | `int(11) NOT NULL DEFAULT −1` | genuine-int (−1=any, 1–5) | −1 | integer | string, bool | no | | +| `max_size` | `max_size` | `flexInt` | `int(11) NOT NULL DEFAULT 5` | genuine-int (1–5) | 5 | integer | string, bool | no | | +| `pvp_ranking_league` | `pvp_ranking_league` | `flexInt` | `int(11) NOT NULL DEFAULT 0` | enum 0=none / 500=little / 1500=great / 2500=ultra | 0 | integer (or string "none"\|"little"\|"great"\|"ultra") | bool | no | see Special representations; 0 means IV-mode (no PVP) | +| `pvp_ranking_best` | `pvp_ranking_best` | `flexInt` | `int(11) NOT NULL DEFAULT 1` | genuine-int (best/lowest rank to alert on) | 1 | integer | string, bool | no | | +| `pvp_ranking_worst` | `pvp_ranking_worst` | `flexInt` | `int(11) NOT NULL DEFAULT 4096` | genuine-int (worst/highest rank to alert on) | 4096 | integer | string, bool | no | | +| `pvp_ranking_min_cp` | `pvp_ranking_min_cp` | `flexInt` | `int(11) NOT NULL DEFAULT 1` | genuine-int (CP floor) | 0 (handler uses `intValue(0)`) | integer | string, bool | no | DB DEFAULT is 1; handler writes 0 when field omitted — NEEDS DECISION on whether to align | +| `pvp_ranking_cap` | `pvp_ranking_cap` | `flexInt` | `int(11) NOT NULL DEFAULT 0` | genuine-int (level cap; 0=league default) | 0 | integer | string, bool | no | | + +--- + +## Raid (`raid` table) + +Request struct: `raidInsertRequest`. + +| field | json | Go type | DB type | semantics class | server default | canonical wire form | lenient-accepts | required? | notes | +|---|---|---|---|---|---|---|---|---|---| +| `pokemon_id` | `pokemon_id` | `flexInt` | `int(11) NOT NULL` | genuine-int (Pokédex ID; 9000=any) | 9000 | integer | string, bool | no | 9000 means "track by level, not specific pokemon" | +| `pokemon_form` | `pokemon_form` | `[]pokemonFormPair` | not a DB column; expansion input | list of {pokemon_id, form} objects | — | array of objects | — | no | mutual with pokemon_id + form; produces one row per pair | +| `level` | `level` | `json.RawMessage` | `int(11) NOT NULL` | genuine-int (raid tier; 9000=any) | `[0]` (→ parsed as 9000 when pokemon_id≠9000) | integer or array of integers | — | no | accepts int or `[int,…]` for multi-level expansion | +| `form` | `form` | `flexInt` | `int(11) NOT NULL` | genuine-int (0=any) | 0 | integer | string, bool | no | | +| `team` | `team` | `flexInt` | `int(11) NOT NULL` | enum 0=Harmony / 1=Mystic / 2=Valor / 3=Instinct / 4=any | 4 (clamped to 0–4 else 4) | integer (or string "harmony"\|"mystic"\|"valor"\|"instinct"\|"any") | bool | no | see Special representations | +| `exclusive` | `exclusive` | `flexBool` | `tinyint(1) DEFAULT 0` | genuine-bool (EX-eligible only) | false/0 | boolean | integer (0/1), string | no | stored as IntBool | +| `move` | `move` | `flexInt` | `int(11) NOT NULL DEFAULT 9000` | genuine-int (move ID; 9000=any) | 9000 | integer | string, bool | no | | +| `evolution` | `evolution` | `flexInt` | `int(11) NOT NULL DEFAULT 9000` | genuine-int (evolution ID; 9000=any) | 9000 | integer | string, bool | no | | +| `gym_id` | `gym_id` | `*string` | `varchar(255) DEFAULT NULL` | string/id (gym identifier) | null | string | — | no | null/empty means any gym | +| `rsvp_changes` | `rsvp_changes` | `flexInt` | `tinyint(8) NOT NULL DEFAULT 0` | enum 0=none / 1=rsvp / 2=rsvp_only | 0 (clamped; out-of-range → 0) | string "none"\|"rsvp"\|"rsvp_only" | integer 0–2 | no | see Special representations; bot keywords: `arg.no_rsvp`(0) `arg.rsvp`(1) `arg.rsvp_only`(2) | + +--- + +## Egg (`egg` table) + +Request struct: `eggInsertRequest`. Shares all fields except `pokemon_id`, `form`, `move`, `evolution`. + +| field | json | Go type | DB type | semantics class | server default | canonical wire form | lenient-accepts | required? | notes | +|---|---|---|---|---|---|---|---|---|---| +| `level` | `level` | `json.RawMessage` | `int(11) NOT NULL` | genuine-int (egg tier; ≥1) | `[0]` → 400 if lvl<1 | integer or array of integers | — | **yes** (must be ≥1) | same multi-level expansion as raid; handler returns 400 if level < 1 | +| `team` | `team` | `flexInt` | `int(11) NOT NULL` | enum 0=Harmony / 1=Mystic / 2=Valor / 3=Instinct / 4=any | 4 | integer (or string) | bool | no | same enum as raid | +| `exclusive` | `exclusive` | `flexBool` | `tinyint(1) DEFAULT 0` | genuine-bool (EX egg) | false/0 | boolean | integer, string | no | | +| `gym_id` | `gym_id` | `*string` | `varchar(255) DEFAULT NULL` | string/id | null | string | — | no | | +| `rsvp_changes` | `rsvp_changes` | `flexInt` | `tinyint(8) NOT NULL DEFAULT 0` | enum 0=none / 1=rsvp / 2=rsvp_only | 0 | string "none"\|"rsvp"\|"rsvp_only" | integer 0–2 | no | same enum + clamping as raid | + +--- + +## Quest (`quest` table) + +Request struct: `questInsertRequest`. + +| field | json | Go type | DB type | semantics class | server default | canonical wire form | lenient-accepts | required? | notes | +|---|---|---|---|---|---|---|---|---|---| +| `reward_type` | `reward_type` | `flexInt` | `int(11) NOT NULL` | enum 2=item / 3=stardust / 4=candy / 7=pokemon / 12=mega_energy | — | integer (or string "item"\|"stardust"\|"candy"\|"pokemon"\|"mega_energy") | bool | **yes** | handler returns 400 on any value not in {2,3,4,7,12}; see Special representations | +| `reward` | `reward` | `flexInt` | `int(11) NOT NULL` | genuine-int (item ID, pokemon ID, stardust amount; 0=any) | 0 | integer | string, bool | no | semantics depend on reward_type | +| `form` | `form` | `flexInt` | `int(11) NOT NULL DEFAULT 0` | genuine-int (form ID; 0=any) | 0 | integer | string, bool | no | only meaningful when reward_type=7 (pokemon) | +| `shiny` | `shiny` | `flexBool` | `tinyint(1) DEFAULT 0` | genuine-bool | false/0 | boolean | integer, string | no | stored as IntBool | +| `amount` | `amount` | `flexInt` | `int(11) NOT NULL DEFAULT 0` | genuine-int (min amount; 0=any) | 0 | integer | string, bool | no | meaningful for reward_type 2 (item), 4 (candy), 12 (mega_energy); stardust uses `reward` not `amount` | +| `clean` (summary bit) | via `clean` bitmask bit4 | — | same `clean` column | bitmask bit 4 | — | — | — | no | `!quest summary` sets bit4 on `clean`; the huma layer exposes this as a dedicated `summary` boolean sibling (same pattern as pokemon) | + +--- + +## Invasion (`invasion` table) + +Request struct: `invasionInsertRequest`. + +| field | json | Go type | DB type | semantics class | server default | canonical wire form | lenient-accepts | required? | notes | +|---|---|---|---|---|---|---|---|---|---| +| `grunt_type` | `grunt_type` | `*string` | `varchar(255) NOT NULL` | string/id (canonical grunt-type name: "dragon", "giovanni", "everything", etc.) | — | string | — | **yes** | handler returns 400 if nil or empty; values are lowercased canonical names derived from grunt template strings; "everything" matches all | +| `gender` | `gender` | `flexInt` | `int(11) NOT NULL` | enum 0=any / 1=male / 2=female | 0 | integer (or string "any"\|"male"\|"female") | bool | no | see Special representations; `ParamGender` in bot | + +--- + +## Lure (`lures` table) + +Request struct: `lureInsertRequest`. + +| field | json | Go type | DB type | semantics class | server default | canonical wire form | lenient-accepts | required? | notes | +|---|---|---|---|---|---|---|---|---|---| +| `lure_id` | `lure_id` | `flexInt` | `int(11) NOT NULL` | enum 0=any / 501–506=specific lure types | — | integer (or string "any"\|"glacial"\|"mossy"\|"rainy"\|"magnetic"\|"golden"\|"sparkly") | bool | **yes** (must be in valid set) | handler returns 400 for unknown IDs; valid: {0, 501, 502, 503, 504, 505, 506}; see Special representations for name mapping | + +--- + +## Nest (`nests` table) + +Request struct: `nestInsertRequest`. + +| field | json | Go type | DB type | semantics class | server default | canonical wire form | lenient-accepts | required? | notes | +|---|---|---|---|---|---|---|---|---|---| +| `pokemon_id` | `pokemon_id` | `flexInt` | `int(11) NOT NULL` | genuine-int (Pokédex ID; 0=any) | 0 | integer | string, bool | no | 0 means any pokemon | +| `form` | `form` | `flexInt` | `int(11) NOT NULL` | genuine-int (0=any) | 0 | integer | string, bool | no | | +| `min_spawn_avg` | `min_spawn_avg` | `flexInt` | `int(11) NOT NULL` | genuine-int (min hourly spawn rate, 0=any) | 0 | integer | string, bool | no | | + +--- + +## Gym (`gym` table) + +Request struct: `gymInsertRequest`. + +| field | json | Go type | DB type | semantics class | server default | canonical wire form | lenient-accepts | required? | notes | +|---|---|---|---|---|---|---|---|---|---| +| `team` | `team` | `flexInt` | `int(11) NOT NULL` | enum 0=Harmony / 1=Mystic / 2=Valor / 3=Instinct / 4=any | **no default — required** | integer (or string "harmony"\|"mystic"\|"valor"\|"instinct"\|"any") | bool | **yes** | handler returns 400 if absent (`!req.Team.isSet()`) or out-of-range (0–4) | +| `slot_changes` | `slot_changes` | `flexBool` | `tinyint(1) NOT NULL` (no DB default) | genuine-bool (alert on slot/defender changes) | false/0 | boolean | integer (0/1), string | no | bot keyword `arg.slot_changes` | +| `battle_changes` | `battle_changes` | `flexBool` | `tinyint(1) NOT NULL DEFAULT 0` | genuine-bool (alert on battle start/end) | false/0 | boolean | integer (0/1), string | no | bot keyword `arg.battle_changes`; gated by `Config.Tracking.EnableGymBattle` | +| `gym_id` | `gym_id` | `*string` | `varchar(255) DEFAULT NULL` | string/id (gym identifier) | null | string | — | no | null/empty means any gym; permission-gated via `specificgym` feature | + +--- + +## Fort (`forts` table) + +Request struct: `fortInsertRequest`. Note: no `clean` column in `forts` table (schema confirms it is absent). + +| field | json | Go type | DB type | semantics class | server default | canonical wire form | lenient-accepts | required? | notes | +|---|---|---|---|---|---|---|---|---|---| +| `fort_type` | `fort_type` | `*string` | `varchar(255) NOT NULL DEFAULT 'everything'` | enum "pokestop" / "gym" / "everything" | "everything" | string "pokestop"\|"gym"\|"everything" | — | no | handler returns 400 for unrecognised values; note: bot command also accepts "station" but the API `validFortTypes` does NOT include "station" — see NEEDS DECISION below | +| `include_empty` | `include_empty` | `flexBool` | `tinyint(1) NOT NULL DEFAULT 1` | genuine-bool (include forts with no edit detail) | DB defaults 1 but handler uses `intValue(0)` → **false** | boolean | integer, string | no | NEEDS DECISION: DB DEFAULT is 1 (true) but handler default is 0 (false); see notes | +| `change_types` | `change_types` | `any` | `varchar(255) NOT NULL DEFAULT '[]'` | list (JSON-encoded array of strings) | `[]` | array of strings | string (passed through), omit | no | stored as JSON string in DB; values: "location", "new", "removal", "image_url", "name", "description"; empty array matches any change type | + +--- + +## Maxbattle (`maxbattle` table) + +Request struct: `maxbattleInsertRequest`. + +| field | json | Go type | DB type | semantics class | server default | canonical wire form | lenient-accepts | required? | notes | +|---|---|---|---|---|---|---|---|---|---| +| `pokemon_id` | `pokemon_id` | `flexInt` | `int(11) NOT NULL DEFAULT 9000` | genuine-int (Pokédex ID; 9000=by level) | 9000 | integer | string, bool | no | 9000 means "track by level" | +| `level` | `level` | `flexInt` | `int(11) NOT NULL DEFAULT 9000` | genuine-int (max battle tier; 9000=any; 90=all for specific pokemon) | 9000 (or required if pokemon_id=9000) | integer | string, bool | no | handler requires level ≥1 when pokemon_id=9000; 90 used by bot for specific-pokemon "all levels" | +| `form` | `form` | `flexInt` | `int(11) NOT NULL DEFAULT 0` | genuine-int (0=any) | 0 | integer | string, bool | no | | +| `move` | `move` | `flexInt` | `int(11) NOT NULL DEFAULT 9000` | genuine-int (move ID; 9000=any) | 9000 | integer | string, bool | no | | +| `gmax` | `gmax` | `flexBool` | `tinyint(1) NOT NULL DEFAULT 0` | genuine-bool (Gigantamax only) | false/0 | boolean | integer (0/1), string | no | bot keyword `arg.gmax`; stored as int 0/1 | +| `evolution` | `evolution` | `flexInt` | `int(11) NOT NULL DEFAULT 9000` | genuine-int (evolution ID; 9000=any) | 9000 | integer | string, bool | no | | +| `station_id` | `station_id` | `*string` | `varchar(255) DEFAULT NULL` | string/id (power spot station identifier) | null | string | — | no | null/empty means any station | + +--- + +## Special representations + +### `clean` — bitmask (all 10 types) + +DB column: `tinyint(1) NOT NULL DEFAULT 0` (despite the name "tinyint(1)", the stored range is 0–7). + +| bit | integer value | boolean field | meaning | +|-----|---------------|---------------|---------| +| 1 | 1 | `clean` | auto-delete message on TTH expiry | +| 2 | 2 | `edit` | track message for in-place editing (RSVP, etc.) | +| 4 | 4 | `summary` | buffer and group delivery (quest summary scheduler) | + +**Canonical wire** (huma new callers): send `"clean": true` (bit1), `"edit": true` (bit2), `"summary": true` (bit4) as separate booleans. Any combination is valid. +**Lenient-accepts** (legacy clients): send an integer `0–7` in `"clean"` (e.g. `"clean": 3` = clean+edit). `collapseClean()` ORs the booleans over the integer. +**Note**: `forts` table has no `clean` column at all; the fort request struct has no `clean`/`edit`/`summary` fields. +**Note**: `quest summary` keyword maps exclusively to bit 4 on the quest `clean` column; it does not use a separate DB field. + +--- + +### `rsvp_changes` — enum (raid and egg) + +DB column: `tinyint(8) NOT NULL DEFAULT 0`. + +| integer | string name | bot keyword | +|---------|-------------|-------------| +| 0 | `none` | `arg.no_rsvp` (default) | +| 1 | `rsvp` | `arg.rsvp` | +| 2 | `rsvp_only` | `arg.rsvp_only` | + +Handler clamps: any value outside 0–2 is silently reset to 0. +**Canonical wire**: string `"none"` \| `"rsvp"` \| `"rsvp_only"`. +**Lenient-accepts**: integer 0, 1, or 2. + +--- + +### `pvp_ranking_league` — enum (pokemon only) + +DB column: `int(11) NOT NULL DEFAULT 0`. + +| integer | string name | league CP cap | +|---------|-------------|---------------| +| 0 | `none` | n/a (IV mode) | +| 500 | `little` | 500 CP | +| 1500 | `great` | 1500 CP | +| 2500 | `ultra` | 2500 CP | + +Note: the stored integer IS the CP cap value, not a sequential index. +**Canonical wire**: string `"none"` \| `"little"` \| `"great"` \| `"ultra"`. +**Lenient-accepts**: integer 0, 500, 1500, or 2500. + +--- + +### `team` — enum (raid, egg, gym) + +DB column: `int(11) NOT NULL`. + +| integer | string name | +|---------|-------------| +| 0 | `harmony` (grey / no team) | +| 1 | `mystic` (blue) | +| 2 | `valor` (red) | +| 3 | `instinct` (yellow) | +| 4 | `any` | + +Raid/egg handler default: 4 (clamped: out-of-range → 4). +Gym handler: **required** (no default; returns 400 if absent or out of range 0–4). +**Canonical wire**: string. +**Lenient-accepts**: integer 0–4. + +--- + +### `gender` — enum (pokemon and invasion) + +| integer | string name | +|---------|-------------| +| 0 | `any` | +| 1 | `male` | +| 2 | `female` | +| 3 | `genderless` (pokemon only; invasion uses 0–2) | + +**Canonical wire**: string. +**Lenient-accepts**: integer. + +--- + +### `reward_type` — enum (quest only) + +| integer | string name | +|---------|-------------| +| 2 | `item` | +| 3 | `stardust` | +| 4 | `candy` | +| 7 | `pokemon` | +| 12 | `mega_energy` | + +Handler returns 400 for any value not in this set. +**Canonical wire**: string. +**Lenient-accepts**: integer from the set above. + +--- + +### `lure_id` — enum (lure only) + +| integer | string name | note | +|---------|-------------|------| +| 0 | `any` | any lure type | +| 501 | `normal` | ordinary lure | +| 502 | `glacial` | | +| 503 | `mossy` | | +| 504 | `magnetic` | | +| 505 | `rainy` | | +| 506 | `golden` | | + +Note: string name for 501 is "normal" based on the item ID. If lure names differ in util.json, the string enum values should be derived from there — **NEEDS DECISION** on exact string names for 501–506. Integer IDs are definitive. +**Canonical wire**: string (or integer). +**Lenient-accepts**: integer from the set above. + +--- + +### `fort_type` — enum (fort only) + +Valid values as enforced by the API handler (`validFortTypes`): `"pokestop"`, `"gym"`, `"everything"`. +The bot command also parses a `"station"` keyword (maps to `fortType = "station"`) but the API +handler does NOT include it in `validFortTypes` and will return 400 if sent. +**NEEDS DECISION**: should `"station"` be added to `validFortTypes` to align bot and API behaviour? + +--- + +### `change_types` — JSON-string list (fort only) + +Stored as a JSON-encoded string array in a `varchar(255)` column (default `'[]'`). +Valid string values (matching Golbat's `change_type` / `edit_types[]` field names): +`"location"`, `"new"`, `"removal"`, `"image_url"`, `"name"`, `"description"`. +Note: the bot keyword `photo` maps to `"image_url"` (the internal Golbat field name, not the user-facing keyword). +Empty array `[]` means match any change type. +**Canonical wire**: JSON array of strings. +**Lenient-accepts**: a raw JSON string (passed through as-is by the current handler). + +--- + +### `slot_changes` / `battle_changes` — genuine-bool (gym only) + +Both are stored as `tinyint(1)` (IntBool). They are independent flags, not a bitmask. +- `slot_changes`: true = alert when a defender is added/removed from a gym slot. +- `battle_changes`: true = alert when a battle starts/ends. Gated by `Config.Tracking.EnableGymBattle`. +**Canonical wire**: boolean. +**Lenient-accepts**: integer (0/1), string. + +--- + +## NEEDS DECISION flags + +1. **`fort_type` + `"station"`**: The bot command (`arg.station`) produces `fortType = "station"` and stores it in the DB. The API `validFortTypes` set is `{"pokestop","gym","everything"}` — it returns 400 for `"station"`. The fort matcher uses a simple string compare, so "station" rows in the DB would only match Golbat `fort_update` webhooks whose `fort_type` is literally `"station"`. Decision needed: should "station" be added to `validFortTypes`, or is it intentionally blocked at the API layer? + +2. **`include_empty` handler default vs DB default**: The `forts` DB column has `DEFAULT 1` (true), but the handler calls `req.IncludeEmpty.intValue(0)` → default **false** when the field is omitted. New rows inserted without `include_empty` get 0 in the DB even though the schema default is 1. Decision needed: align handler to default true, or update DB schema default to 0? + +3. **`pvp_ranking_min_cp` server default**: DB `DEFAULT 1`; handler writes `intValue(0)` → 0 when omitted. Decision needed: should the huma canonical default document 0 or 1? + +4. **`lure_id` string names**: The integer→string mapping for lure IDs 501–506 should be confirmed against `resources/data/util.json` lure entries (the canonical UI display names). The table above uses common names but the exact English strings from util.json should be the canonical enum values. + +--- + +## SIGNED-OFF DECISIONS (2026-05-31) + +Global modeling: **string enums + lenient legacy int** for all enum fields, **booleans** for all genuine-bool fields, each still accepting the legacy integer/0-1 form via flex coercion. Stored DB values/semantics unchanged. Apply across all 10 types in the fan-out. + +Per the NEEDS DECISION items above: + +1. **`fort_type` "station" → PRESERVE (do NOT add to the API).** The huma fort endpoint keeps `validFortTypes = {pokestop, gym, everything}` and rejects `station` (422/400), exactly as the gin handler does today. Document the bot-accepts / API-rejects split in the fort endpoint description. **Follow-up:** file a separate issue about reconciling the bot/API/`station` support — NOT part of this migration. + +2. **`include_empty` → HONOR DB INTENT (default true).** The huma fort handler must default `include_empty` to **true** when the field is omitted (the DB column is `DEFAULT 1`). This is a deliberate behavior change from the current gin handler (which defaults false). **Requires a changelog/CHANGELOG note** that API clients omitting `include_empty` now get `true`. + +3. **`pvp_ranking_min_cp` → PRESERVE (default 0).** Document canonical default 0; no behavior change. (DB `DEFAULT 1` is dead because the handler always writes the column.) + +4. **`lure_id` string names → derive from `resources/data/util.json`** during the lure migration; do not hardcode guessed names. + +Enum string-value names are derived from the bot keywords / util.json: +- `team`: `harmony|mystic|valor|instinct|any` (0–4) +- `rsvp_changes`: `none|rsvp|rsvp_only` (0–2) +- `gender`: `any|male|female|genderless` (0–3; invasion omits genderless) +- `pvp_ranking_league`: `none|little|great|ultra` (0/500/1500/2500) +- `reward_type`: `item|stardust|candy|pokemon|mega_energy` (2/3/4/7/12) +- `fort_type`: `pokestop|gym|everything` +- `lure_id`: `any` + names-from-util.json (0/501–506) diff --git a/docs/v1-to-v2-migration-guide.md b/docs/v1-to-v2-migration-guide.md new file mode 100644 index 000000000..3413037f3 --- /dev/null +++ b/docs/v1-to-v2-migration-guide.md @@ -0,0 +1,166 @@ +# v1 → v2 API Migration Guide + +Audience: third-party client authors (PoracleWeb, ReactMap, custom integrations) moving off the +frozen v1 tracking/humans/profiles endpoints onto `/api/v2`. + +**v1 status:** frozen and deprecated-but-supported — it keeps working exactly as today, with no +sunset date yet. New tracking types (starting with `incident`) and new capabilities (saved-location +update) ship **only** on v2. + +**The live contract:** the OpenAPI 3.1 spec at `GET /openapi.json` and the interactive docs at +`GET /docs` (both public, no secret) describe every v2 endpoint and field, including the +omit-to-wildcard semantics summarised below. This guide is the map from old to new; the spec is the +source of truth. Design rationale: [`v2-api-design.md`](v2-api-design.md). + +**Auth is unchanged:** send `X-Poracle-Secret` exactly as for v1. + +## The big behavioural differences + +1. **Strict, not lenient.** v2 rejects unknown body fields and unknown query parameters with `422`. + No `"1"`-for-`1` or `true`-for-`1` coercion — send the documented type. +2. **Errors are RFC 9457 `application/problem+json`** (`{title, status, detail, errors[]}`), not + v1's ad-hoc `{status:"error", message}` shapes. +3. **Omit means "any".** Never send magic sentinel numbers (`9000`, `-1`, `4096`, …) to mean + "no constraint" — omit the field instead. Symmetrically, reads return `null` for any filter at + its wildcard/default. GET → PUT round-trips unchanged. +4. **Create bodies are always an array** of rule objects (a single rule is a one-element array). + v1's single-object-or-array flexibility is gone. +5. **`?silent=true`** on mutations replaces v1's `silent` + `suppressMessage` pair. + **`?include_descriptions=true`** works uniformly on every tracking read *and* mutation. + +## Endpoint mapping — tracking + +`{type}` ∈ `pokemon, raid, egg, quest, invasion, incident (NEW), lure, nest, gym, fort, maxbattle`. + +| v1 | v2 | Notes | +|---|---|---| +| `GET /api/tracking/{type}/{id}` | `GET /api/v2/humans/{id}/tracking/{type}` | `?profile=` defaults to the active profile. Returns `{rules:[…]}` | +| `POST /api/tracking/{type}/{id}` | `POST /api/v2/humans/{id}/tracking/{type}` | Body is an **array** of rules. Returns `{created, updated, unchanged}` | +| — | `GET /api/v2/humans/{id}/tracking/{type}/{uid}` | NEW — fetch one rule | +| — | `PUT /api/v2/humans/{id}/tracking/{type}/{uid}` | NEW — full replace (omitted fields reset to defaults; no PATCH) | +| `DELETE /api/tracking/{type}/{id}/byUid/{uid}` | `DELETE /api/v2/humans/{id}/tracking/{type}/{uid}` | Returns `{deleted:[…]}` | +| `POST /api/tracking/{type}/{id}/delete` (body `[uids]`) | `DELETE /api/v2/humans/{id}/tracking/{type}?uid=1,2,3` | Bulk delete via query | +| `GET /api/tracking/all/{id}` | `GET /api/v2/humans/{id}/tracking` | Full snapshot: `{human, tracking:{:[…]}, profiles, locations, summaries}` | +| `GET /api/tracking/allProfiles/{id}` | `GET /api/v2/humans/{id}/tracking?all_profiles=true` | | +| `GET /api/tracking/pokemon/refresh` | `GET`/`POST /api/reload` | Reload alias; the documented reload endpoint | + +Item operations are scoped by `(human, uid)` exactly like v1's `byUid` — you cannot touch a uid +that doesn't belong to the addressed human. + +## Endpoint mapping — humans + +| v1 | v2 | Notes | +|---|---|---| +| `POST /api/humans` | `POST /api/v2/humans` | Typed body | +| `GET /api/humans/one/{id}` | `GET /api/v2/humans/{id}` | | +| `GET /api/humans/{id}` (available areas) | `GET /api/v2/humans/{id}/areas` | | +| `POST /api/humans/{id}/start` | `POST /api/v2/humans/{id}/enable` | No body | +| `POST /api/humans/{id}/stop` | `POST /api/v2/humans/{id}/disable` | No body | +| `POST /api/humans/{id}/adminDisabled` | `POST /api/v2/humans/{id}/admin-disable` | Body `{disabled: bool}` | +| `POST /api/humans/{id}/language` | `POST /api/v2/humans/{id}/language` | Body `{language: string}`, validated against available locales | +| `POST /api/humans/{id}/setLocation/{lat}/{lon}` | `POST /api/v2/humans/{id}/location` | Body `{lat, lon}` floats | +| `GET /api/humans/{id}/checkLocation/{lat}/{lon}` | `GET /api/v2/humans/{id}/check-location?lat=&lon=` | | +| `POST /api/humans/{id}/setAreas` | `POST /api/v2/humans/{id}/areas` | Body `{areas: []string}` | +| `POST /api/humans/{id}/switchProfile/{n}` | `POST /api/v2/humans/{id}/profile` | Body `{profile_no: int}` | +| `GET /api/humans/{id}/locations` | `GET /api/v2/humans/{id}/locations` | | +| `GET /api/humans/{id}/locations/{label}` | `GET /api/v2/humans/{id}/locations/{label}` | | +| `POST /api/humans/{id}/locations/add` | `POST /api/v2/humans/{id}/locations` | Body `{label, lat, lon}` | +| — | `PUT /api/v2/humans/{id}/locations/{label}` | **NEW** — update coords (v1 forced delete + re-add). Body `{lat, lon}` | +| `POST /api/humans/{id}/locations/{label}/delete` | `DELETE /api/v2/humans/{id}/locations/{label}` | `409` if referenced by a rule's `override_location_label` | +| `GET /api/humans/{id}/roles` | `GET /api/v2/humans/{id}/roles` | | +| `POST /api/humans/{id}/roles/add/{roleId}` | `POST /api/v2/humans/{id}/roles/{roleId}` | | +| `POST /api/humans/{id}/roles/remove/{roleId}` | `DELETE /api/v2/humans/{id}/roles/{roleId}` | | +| `GET /api/humans/{id}/getAdministrationRoles` | `GET /api/v2/humans/{id}/admin-roles` | | + +## Endpoint mapping — profiles + +Profiles are a sub-resource of the human in v2. + +| v1 | v2 | Notes | +|---|---|---| +| `GET /api/profiles/{id}` | `GET /api/v2/humans/{id}/profiles` | v2 returns `active_hours` as a **typed array**, not a JSON string | +| `POST /api/profiles/{id}/add` | `POST /api/v2/humans/{id}/profiles` | | +| `POST /api/profiles/{id}/update` | `PATCH /api/v2/humans/{id}/profiles/{profile_no}` | Updates `active_hours` (typed, validated) | +| `DELETE /api/profiles/{id}/byProfileNo/{n}` | `DELETE /api/v2/humans/{id}/profiles/{n}` | | +| `POST /api/profiles/{id}/copy/{from}/{to}` | `POST /api/v2/humans/{id}/profiles/{to}/copy` | Source in body: `{from_profile: int}`; target is the path `{profile_no}` | + +## Field-semantics changes + +### `clean` bitmask → three booleans + +v1 stores message-lifecycle flags packed in one int (`clean`): bit 1 = clean-delete, bit 2 = edit, +bit 4 = summary. v2 unpacks them: + +| v1 `clean` value | v2 equivalent | +|---|---| +| `0` | omit all three | +| `1` | `"clean": true` | +| `2` | `"edit": true` | +| `3` | `"clean": true, "edit": true` | +| `4` (quest) | `"summary": true` | + +### Enums: magic int → string + +| field | v1 | v2 | +|---|---|---| +| `team` | `0–4` | `"harmony" \| "mystic" \| "valor" \| "instinct" \| "any"` | +| `gender` | `0–3` | `"any" \| "male" \| "female" \| "genderless"` | +| `fort_type` | string | `"pokestop" \| "gym" \| "everything"` (unchanged values, now enforced) | +| `rsvp_changes` | `0–2` | `"none" \| "rsvp" \| "rsvp_only"` | + +Game-master dictionary IDs stay **integers** (`pokemon_id`, `form`, `move`, `reward_type`, +`lure_id`, invasion `type_id`/`grunt_id`, incident `display_type`, `pvp_ranking_league`, +`pvp_ranking_evolution`). + +### Sentinels → omit / null + +Stop sending these v1 magic values; omit the field instead (and expect `null` on read): + +| v1 sentinel | meant | v2 request | +|---|---|---| +| `min_iv: -1`, `rarity: -1`, `size: -1` | no floor | omit | +| `max_iv: 100`, `max_level: 55`, `max_atk/def/sta: 15`, `max_rarity: 6`, `max_size: 5` | no ceiling | omit | +| `max_cp: 9000` | no CP cap | omit | +| `pvp_ranking_worst: 4096` | no rank limit | omit | +| `max_weight: 9000000` | no weight cap | omit | +| raid/maxbattle `pokemon_id: 9000` | any boss (track by level) | omit `pokemon_id` | +| raid/maxbattle `level: 90` | all tiers | omit `level` | +| `form: 0`, `pvp_ranking_league: 0`, … | any / off | omit | + +`distance: 0` keeps its v1 meaning — "use the profile's geofence areas instead of a radius" — and +is **not** a sentinel to avoid. + +### Type-specific changes + +- **invasion** — v2 requires **exactly one** targeting mode per rule: `type_id` (int poke-type, + optional `gender`) | `grunt_id` (int exact character) | `everything: true` | `boss: true`. The + facade translates down to the same stored grunt-type names v1 wrote. +- **incident (NEW)** — pokestop events (e.g. Showcases) split out of invasion into their own type, + keyed by the game's `display_type` int. Not available on v1. +- **fort** — `include_empty` now defaults to **`true`** when omitted (v1 defaulted false). +- **quest** — `reward_type` (proto int: `2`=item, `3`=stardust, `4`=candy, `7`=pokemon, + `12`=mega_energy) is required; `reward`, `amount`, `form`, `shiny` optional. +- **pokemon** — `pvp_ranking_evolution` (int mega/temp-evolution discriminator: `0` base, `1` Mega, + `2` Mega X, `3` Mega Y) is new on v2. +- **`active_hours`** (profile PATCH and `POST /api/summaries/{id}/{alertType}`) — now a validated + typed array of `{day 0–6, hours 0–23, mins 0–59, optional step/end_hours/end_mins}` entries, + strict ints, no cross-midnight ranges. v1's freeform JSON passthrough is gone. + +### Response shapes + +- Reads return the resource directly (`{rules:[…]}`, `{human:…}`, the snapshot) — no + `{status:"ok"}` envelope. +- Tracking mutations return the diff: `{created, updated, unchanged}` (POST/PUT) or `{deleted}` + (DELETE), each entry carrying its `uid`; with `?include_descriptions=true` each entry also + carries its human-readable `description`. +- Pure actions (enable/disable/language/areas/locations/roles/profile mutations) return the shared + `{status:"ok"}`. + +## Suggested migration order + +1. Point reads at the snapshot (`GET /api/v2/humans/{id}/tracking`) — it replaces `all/{id}` and + `allProfiles/{id}` in one call and returns profiles/locations/summaries you previously fetched + separately. +2. Move tracking mutations type by type, dropping your sentinel-value handling as you go. +3. Move humans/profiles/locations actions last — they're mechanical renames per the tables above. +4. Adopt `problem+json` error handling once, for both v2 and the huma-migrated `/api/*` endpoints. diff --git a/docs/v2-api-design.md b/docs/v2-api-design.md new file mode 100644 index 000000000..dc84b589e --- /dev/null +++ b/docs/v2-api-design.md @@ -0,0 +1,289 @@ +# PoracleNG v2 API — Design + +**Status:** Draft (for implementor review) +**Date:** 2026-06-01 +**Branch:** `huma-api-migration` (worktree) + +> The **[API Shape](#api-shape-for-implementor-review)** section below is written to be extracted verbatim into a GitHub issue for third-party implementor (ReactMap, PoracleWeb, custom clients) comment before we build. Everything outside that section is internal rationale and open decisions. + +--- + +## 1. Why v2 (and why freeze v1) + +The existing `/api/*` surface is undocumented, accreted, and tolerant of malformed input by necessity (the `flexBool`/`flexInt` coercion exists because real clients send wrong types). An attempt to retrofit OpenAPI docs onto it in place meant paying two costs on every endpoint — faithfully reproducing v1's quirks **and** cleaning up the representation — while still mutating v1's contract. + +Decision: build a **clean, strict, documented v2** surface and **freeze v1** untouched for existing clients. v1 keeps working exactly as today; clients migrate to v2 on their own schedule. We will encourage all users of the v1 API to move to v2 so they can access new tracking types; v1 is deprecated-but-supported. + +v2 is a **clean HTTP facade over the same store/matcher/business logic** — no domain rewrite. Where v2 exposes richer or cleaner inputs than the engine stores natively, the v2 handler translates them down to the existing stored representation (see Invasion/Incident). + +## 2. Principles + +- **Strict, not lenient.** Proper types, `additionalProperties: false`, required fields enforced, no silent coercion. A malformed request gets a clear `422`, not a guess. (v1 stays lenient for legacy clients.) +- **Game-master dictionary values are integers.** Any value that is a masterfile / proto ID whose set grows with the game stays an `int` (`pokemon_id`, `form`, `move`, `reward_type`, `lure_id`, invasion `type_id`/`grunt_id`, incident `display_type`). We do **not** stringify these. +- **Fixed Poracle/UI categories are string enums.** Small, stable, human-named sets read better as words (`team`, `gender`, `fort_type`, `rsvp_changes`). +- **One honest representation per field.** No bitmask packed into one field on the wire (`clean` → `clean`/`edit`/`summary` booleans); no enum hidden as a magic int where it's really a named category. +- **Resources keyed by `uid`.** A tracking rule's `uid` is unique per type across all users, so a rule is addressable as `/tracking/{type}/{uid}` without the owning user in the path. +- **OpenAPI 3.1 is the contract.** Generated from the code (huma), served publicly; the spec is the source of truth. + +--- + +## API Shape (for implementor review) + +> **This section is the RFC.** It describes the proposed PoracleNG v2 HTTP API. Feedback wanted on: resource shapes, field naming/types, the invasion/incident split, and anything that would make integration harder. v1 is unaffected by anything here. + +### Base, versioning, auth + +- Base path: `/api/v2`. The existing `/api/*` (v1) is unchanged and remains available. +- Auth: `X-Poracle-Secret: ` request header (same secret as v1). Unauthenticated requests get `401`. +- Docs: OpenAPI spec at `GET /openapi.json`; interactive docs at `GET /docs` (both public, no secret). One spec covers the huma-served `/api/*` endpoints and all of `/api/v2`. + +### Errors (RFC 9457 `application/problem+json`) + +All errors use a standard problem document: + +```json +{ + "title": "Unprocessable Entity", + "status": 422, + "detail": "validation failed", + "errors": [ + { "message": "expected integer", "location": "body.rules[0].min_iv", "value": "ninety" } + ] +} +``` + +**Success responses — three categories, one shared status schema.** The success shape is consistent *within* each category: + +1. **Pure action endpoints** (no resource to return) → a minimal `{ "status": "ok" }` acknowledgement. This covers reloads, summary delete/trigger, `dts/templates` delete, `dts/sendtest`, the deliver-messages-style acks, and all v2 humans/profiles/locations action mutations (enable, disable, admin-disable, language, set-areas, set-location, role add/remove, switch-profile, profile add/update/delete/copy, location PUT/DELETE, etc.). Every one of these is emitted by the **single shared `statusOKOutput` Go type** (`internal/api/huma_system.go`, built via `okStatus()`), so the OpenAPI spec references **one** `StatusOKOutputBody` schema for all of them rather than a per-area duplicate. Responses that carry the status *plus* extra fields (`{status,backup}`, `{status,queued}`, `{status,saved}`, `{status,url}`) are deliberately **not** this type and keep their own typed structs. +2. **Resource/data reads** → the typed resource/data body directly (e.g. `{human:…}`, `{rules:[…]}`, geofence/dts/summary payloads). Never wrapped in a `{ "status": "ok" }` envelope. +3. **v2 tracking mutations** → the diff (`{created,updated,unchanged}` for create/update, `{deleted}` for delete). Also never status-wrapped. + +So: *is `{"status":"ok"}` the consistent success response?* Yes — it is the consistent shape for the **pure-action** category, and as of the unification all such endpoints reference the one shared `StatusOK` schema. Reads and tracking mutations consistently return their resource/diff instead. + +### Resource model + +Tracking rules are **sub-resources of the human** (the human *is* the user). `uid` is unique per type, and every item operation is **scoped by `(human, uid)`** — the ownership guard v1 enforces (`WHERE id=? AND uid=?`); you cannot touch a uid that isn't the addressed human's. `{type}` ∈ `pokemon`, `raid`, `egg`, `quest`, `invasion`, `incident`, `lure`, `nest`, `gym`, `fort`, `maxbattle`. + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/api/v2/humans/{id}/tracking` | **Full snapshot** — human + all-type rules + profiles + locations + summaries | +| `GET` | `/api/v2/humans/{id}/tracking/{type}` | List one type | +| `POST` | `/api/v2/humans/{id}/tracking/{type}` | Create rule(s) | +| `GET` | `/api/v2/humans/{id}/tracking/{type}/{uid}` | Fetch one rule | +| `PUT` | `/api/v2/humans/{id}/tracking/{type}/{uid}` | Full-replace one rule | +| `DELETE` | `/api/v2/humans/{id}/tracking/{type}/{uid}` | Delete one rule | +| `DELETE` | `/api/v2/humans/{id}/tracking/{type}?uid=1,2,3` | Bulk delete | + +- `{id}` (the human) is **always in the path** — required, and the ownership scope for every op. `profile` is a query param (`?profile={n}`, defaults to the human's active profile). +- **Create** body is an **array** of rule objects (a single rule is a one-element array); the owner is the path `{id}`, not repeated per rule. +- **List one type** returns `{ "rules": [ , … ] }`. **Full snapshot** (`…/tracking`, no `{type}`) returns `{ "human": {…}, "tracking": { "pokemon": [...], "raid": [...], … }, "profiles": [...], "locations": [...], "summaries": [...] }` — replaces v1's `all/{id}`; `?all_profiles=true` spans every profile (v1's `allProfiles/{id}`). +- **Create** returns `{ "created": [], "updated": [], "unchanged": [] }` (POST keeps v1's diff/upsert behaviour). Delete returns `{ "deleted": [] }`. +- **`?include_descriptions=true`** works uniformly on **every** tracking endpoint — reads **and** mutations. When set, each rule object in the response (in `rules`, or `created`/`updated`/`unchanged`/`deleted`) carries a `description` (its human-readable rowtext, in the human's language). The status (added/updated/removed) is conveyed by which array the rule is in, so there is **no** separate assembled `message` field. (The prefixed, assembled confirmation message remains purely the Discord/Telegram push, gated by `?silent`.) +- **PUT** is a **full replace**: the body fully specifies the rule's filter fields; omitted fields reset to documented defaults. (No `PATCH`/partial-update in v2.) +- Mutations (`POST`/`PUT`/`DELETE`) accept **`?silent=true`** (default false) — apply without notifying the user. (Single param; replaces v1's `silent`+`suppressMessage`.) +- Unknown body **and** query params are rejected (`422`) — v2 is strict. Every rule carries its `uid` (int) in responses. +- **Consistency note:** humans/profiles/locations are likewise under `/api/v2/humans/{id}/…` (see §2b), so everything user-scoped shares one prefix. + +### Field conventions + +- `snake_case` field names (familiar, matches the data model). +- Integers for game-master IDs and numeric ranges; booleans for flags; string enums for fixed categories; strings for free text/ids. +- All filter fields are **optional with documented defaults** unless marked **required**. Omitting a range field means "no constraint" at its documented default. + +### Wildcard & sentinel conventions + +**Request rule: to match "any", OMIT the field — never send the magic number.** A clean v2 client should not have to know the internal sentinel values. Every optional filter field, when omitted, is materialised to the engine's documented wildcard/sentinel by the handler. The OpenAPI `description` of each such field states both the omit-to-wildcard behaviour *and* the stored sentinel + its meaning (so the value is legible if you do read one back). You may still send the sentinel explicitly — it round-trips — but you never need to. + +The sentinels (verified against the matcher's "match any" semantics): + +| sentinel | meaning | where it appears | +|---|---|---| +| `9000` | project-wide "any / track-by-level" — the engine treats this id/move/evolution as "match anything" | raid & maxbattle `pokemon_id`, `move`, `evolution`; raid/maxbattle `level` placeholder (see below) | +| `-1` | "no lower bound / any" | pokemon `min_iv`, `rarity`, `size` | +| `100` / `55` / `15` / `6` / `5` | the ceiling of the range, i.e. "no upper bound" | pokemon `max_iv` (100), `max_level` (55), `max_atk`/`max_def`/`max_sta` (15), `max_rarity` (6), `max_size` (5) | +| `4096` | "no upper rank limit" (PVP ranks never exceed it) | pokemon `pvp_ranking_worst` | +| `9000000` | "no upper weight" | pokemon `max_weight` | +| `0` | context-dependent "any / none / no floor" — e.g. `form` 0 = any form, `pvp_ranking_league` 0 = IV-mode (no PVP), nest `pokemon_id`/`min_spawn_avg` 0 = any, quest `reward` 0 = all items/pokemon/candy/mega-energy of that `reward_type` | most types | +| `0` (distance) | **NOT zero metres** — `distance` 0 means "match by the profile's geofence AREAS instead of a radius". A positive `distance` switches to a haversine radius. | common field, all types | + +**raid / maxbattle `level` is derived from `pokemon_id`** (the matcher reads `level` only in by-level mode — `matching/raid.go:65`, `matching/maxbattle.go:48`): + +| input `pokemon_id` / `level` | stored | meaning | +|---|---|---| +| omitted / omitted | `9000 / 90` | everything (any boss, any tier) | +| omitted / 5 | `9000 / 5` | any boss at tier 5 | +| 149 / (any) | `149 / 9000` | that boss, any tier (level ignored) | + +So `90` is the by-level "all tiers" value; `9000` is the specific-boss `level` **placeholder** (level unused). `9000` is kept (rather than, say, 0) so v2 rows are byte-identical to bot/v1 rows — same dedup, no surprise duplicate rules. An explicit by-level `level < 1` is rejected `422`; a `level` supplied alongside a specific `pokemon_id` is ignored (not an error), matching the bot. On read, `level` is hidden as `null` when stored ∈ {`9000`, `90`} (no meaningful tier). + +**Catch-all selectors** (not omit-to-wildcard — set explicitly): + +- **invasion** uses an exactly-one-of mode model (`type_id` | `grunt_id` | `everything` | `boss`); there is no omit-to-wildcard. `everything: true` matches **every** invasion; `boss: true` matches only boss encounters. +- **fort** `fort_type` defaults to the catch-all string `"everything"` (matches all fort types) on omission — this one IS omit-to-wildcard, mirroring the DB column default. `change_types` empty/omitted = match any change type. +- **lure** `lure_id` is required, but the in-set value `0` is the "any lure type" wildcard — send `0` explicitly (a required field has no omit-to-wildcard). + +**Response rule: a field at its wildcard/default is emitted as `null`.** Symmetrically, tracking responses emit `null` for any filter at its wildcard/default — a rule shows only its meaningful filters; `uid`, required fields, and the active invasion/incident mode are always present. Responses round-trip: GET a rule, PUT it back unchanged. + +Concretely, the projection rules are: + +- **`uid`** — always present. +- **Required fields** (`pokemon.pokemon_id`, `quest.reward_type`, `incident.display_type`, `gym.team`, `lure.lure_id`, `egg.level`) — always present with their value; never nulled. +- **Active invasion/incident mode** — exactly one of `type_id` (+ `gender` when meaningful) / `grunt_id` / `everything` / `boss` is reconstructed and stays present; the inactive mode fields are omitted. Invasion `gender` is `null` at its `any` default (shown only as male/female in `type_id` mode). +- **Every other optional filter** — `null` when the stored value equals its documented wildcard/default (the same sentinels listed above; enums null at their default — `team`/`gender` `any`, `rsvp_changes` `none`, `fort_type` `everything`; bools null when `false`; `include_empty` null at its `true` default; `change_types`/`override_areas` null when empty; `gym_id`/`station_id`/`override_location_label` null when unset). Otherwise the meaningful value is emitted. + +The wire form is **present-but-null** (e.g. `"min_iv": null`), not an omitted key, so a client can tell a filter is explicitly "unset/any". Because `null` (or an omitted key) on the request side materialises to the same documented default, a GET → PUT of the returned body leaves the stored rule unchanged. Implementation: the per-type `ToRule` projects wildcards to nil via the `ptrUnless`/`ptrUnlessSlice` helpers; the request structs keep `omitempty` (so request strictness — optional, not required — is preserved) plus `nullable:"true"` (so the response/OpenAPI schema models the field as nullable, `type: [X, "null"]`), and the rule-envelope marshaller re-emits the nilled optional fields as explicit `null`. + +### Common fields (most tracking types) + +| field | type | notes | +|---|---|---| +| `uid` | int | response/identifier; required in `PUT` body | +| `distance` | int | metres; `0` = use the profile's areas instead of a radius | +| `template` | string | template name; empty = server default | +| `clean` | bool | auto-delete the alert on expiry | +| `edit` | bool | keep the message updated in place | +| `summary` | bool | route into the summary digest (where supported) | +| `ping` | string | mention string appended to the alert | +| `override_location_label` | string? | use a saved named location instead of the profile location | +| `override_areas` | string[] | restrict this rule to these geofence areas | + +(`clean`/`edit`/`summary` map to the stored `clean` bitmask: bit 1 / 2 / 4.) + +### Per-type fields + +**pokemon** — `pokemon_id`* (int), `form` (int), `min_iv`/`max_iv` (int), `min_cp`/`max_cp` (int), `min_level`/`max_level` (int), `atk`/`def`/`sta` & `max_atk`/`max_def`/`max_sta` (int, 0–15), `gender` (enum `any|male|female|genderless`), `rarity`/`max_rarity` (int), `size`/`max_size` (int), `pvp_ranking_league` (int — the CP cap: `0|500|1500|2500`), `pvp_ranking_best`/`pvp_ranking_worst` (int), `pvp_ranking_min_cp` (int), `pvp_ranking_cap` (int), `pvp_ranking_evolution` (int — temp-evolution/mega PVP discriminator selecting which evolution's PVP rank the rule alerts on: `0`=base form, `1`=Mega, `2`=Mega X, `3`=Mega Y; omit for base form. Maps to the `pvp_ranking_evolution` column from the merged `pvp-mega-evolution` work; returned as `null` when `0`). + +**raid** — `pokemon_id` (int, omit = track by level / any boss; stored `9000`), `form` (int), `level` (int, by-level only — omit for any tier; derived from `pokemon_id`, see Wildcard conventions), `team` (enum `harmony|mystic|valor|instinct|any`), `exclusive` (bool), `move` (int), `evolution` (int), `gym_id` (string), `rsvp_changes` (enum `none|rsvp|rsvp_only`). + +**egg** — `level` (int), `team` (enum), `exclusive` (bool), `gym_id` (string), `rsvp_changes` (enum). + +**quest** — `reward_type`* (int — proto id: `2`=item, `3`=stardust, `4`=candy, `7`=pokemon, `12`=mega_energy), `reward` (int — the rewarded item/pokemon id; **omit or send 0 to match ANY reward of that category**, e.g. `{reward_type:2}` = all items, `{reward_type:7}` = all pokemon — the v2 equivalent of `!quest all items`/`all pokemon`), `amount` (int), `form` (int — for pokemon-reward forms), `shiny` (bool). + +**invasion** (Rocket grunts) — target via **exactly one** mode per rule: `type_id` (int, grunt poke-type — `gender` (enum `any|male|female`) applies only here) | `grunt_id` (int, the exact grunt character, implies type+gender) | `everything` (bool) | `boss` (bool). + +**incident** (events) — `display_type`* (int — game `PokestopEvent` id, e.g. `9` = Showcase; names documented in the field description). + +**lure** — `lure_id` (int — game item id: `0`=any, `501`=normal, `502`=glacial, `503`=mossy, `504`=magnetic, `505`=rainy, `506`=sparkly). + +**nest** — `pokemon_id` (int), `form` (int), `min_spawn_avg` (int). + +**gym** — `team` (enum), `slot_changes` (bool), `battle_changes` (bool), `gym_id` (string). + +**fort** — `fort_type` (enum `pokestop|gym|everything`), `include_empty` (bool, **default `true`**), `change_types` (string[] of `location|new|removal|image_url|name|description`). + +**maxbattle** — `pokemon_id` (int, omit = track by level / any boss; stored `9000`), `level` (int, by-level only — omit for any tier; derived from `pokemon_id`, see Wildcard conventions), `gmax` (bool), `move` (int). + +\* = required. + +### Examples + +Create two pokemon rules for a human: +``` +POST /api/v2/humans/123456/tracking/pokemon?profile=1 +[ + { "pokemon_id": 149, "min_iv": 95, "gender": "female", "clean": true }, + { "pokemon_id": 384, "pvp_ranking_league": 1500, "pvp_ranking_best": 1, "pvp_ranking_worst": 5, "edit": true } +] +``` +Track a specific grunt by character id, and (separately) any female grass grunt: +``` +POST /api/v2/humans/123456/tracking/invasion?profile=1 +[ { "grunt_id": 41 }, { "type_id": 12, "gender": "female" } ] +``` +Track Showcase incidents: +``` +POST /api/v2/humans/123456/tracking/incident?profile=1 +[ { "display_type": 9 } ] +``` +Full snapshot (tracking + profiles + locations + summaries): +``` +GET /api/v2/humans/123456/tracking?include_descriptions=true +``` +Delete a rule (scoped to this human): +``` +DELETE /api/v2/humans/123456/tracking/raid/80921 +``` + +### Questions for implementors + +1. Resource shape: is `?user=&profile=` on the collection comfortable, or would you prefer `/api/v2/users/{id}/tracking/{type}`? +2. Create response: is `{created, updated, unchanged}` useful, or do you only want the resulting rules? +3. Enum-as-string vs id-as-int split (above): does it match how you think about these fields? +4. Invasion two-axis model (`type_id` vs `grunt_id`) and the separate `incident` type — does this fit your use cases? +5. Anything in v1 you rely on that isn't represented here? + +--- + +## 2b. Humans, profiles & shared schemas (v2) + +humans/profiles v2 uses **discrete, typed action endpoints** under `/api/v2` (not PATCH-consolidated), mirroring v1's actions with proper types + strict bodies + `problem+json`. Endpoint list is in the master plan (P4). The schemas that previously had no real definition are pinned here. + +### `active_hours` (profile schedules **and** summary posting) — proper typed schema + +Today this is stored as freeform JSON and the API dumps whatever the client sends into the column. v2 defines and **validates** it. It is an **array of schedule entries** (`[]` or absent = no schedule). Each entry (derived from `db.ActiveHourEntry`): + +| field | type | required | bounds | +|---|---|---|---| +| `day` | int | yes | `0`–`6` (0 = Sunday) | +| `hours` | int | yes | `0`–`23` | +| `mins` | int | yes | `0`–`59` | +| `step` | int | no | `≥ 0` hours; `> 0` ⇒ this is a **range** entry, else **single-fire** | +| `end_hours` | int | required iff `step > 0` | `0`–`23` | +| `end_mins` | int | required iff `step > 0` | `0`–`59` | + +- **Single-fire**: `{day, hours, mins}` → fires once that day at `HH:MM`. +- **Range**: adds `{step, end_hours, end_mins}` → fires at `HH:MM`, `+step h`, … up to and including `end`. **No cross-midnight** — `end` must be ≥ start (reject otherwise, `422`). +- v2 is **strict ints** (no `"00"` string coercion — that was the v1 leniency) with the bounds above. Same schema is shared by `POST /v2/summaries/{id}/{alertType}` and the profile-schedule update endpoint. (Confirm `day` indexing against the scheduler at build: comment indicates `0 = Sunday`, matching Go `time.Weekday`.) + +### `blocked_alerts` (read-only on the human resource) + +`[]string`, **derived from `command_security` during reconciliation — not settable via the API**. Appears in `GET /v2/humans/{id}`. Enum values: `monster` (= pokemon alerts), `pvp`, `raid`, `egg`, `quest`, `invasion`, `lure`, `nest`, `gym`, `fort`, `maxbattle`, `specificgym`, `specificstation`. (Note the `monster`↔pokemon token mismatch is a v1 carry-over; documented, not "fixed," since it's an internal-derived read field.) + +### Saved locations — full CRUD (one **new** capability) + +A saved-locations API already exists (`user_locations`: `label` → `lat`/`lon`), but only **C/R/D** — there is no update. v2 completes CRUD: + +| method | path | body | note | +|---|---|---|---| +| GET | `/v2/humans/{id}/locations` | — | list | +| GET | `/v2/humans/{id}/locations/{label}` | — | one | +| POST | `/v2/humans/{id}/locations` | `{label, lat, lon}` | create (was `…/locations/add`) | +| **PUT** | `/v2/humans/{id}/locations/{label}` | `{lat, lon}` | **NEW** — update a saved location's coordinates | +| DELETE | `/v2/humans/{id}/locations/{label}` | — | delete (`409` if referenced by a rule's `override_location_label`) | + +The **PUT** is net-new functionality (v1 forces delete+re-add to move a saved location). + +## 3. Internal: mapping to the engine (facade) + +v2 handlers translate clean inputs to the existing stored representation; the matcher and DB schema are unchanged: + +- **Enums** (`team`, `gender`, `fort_type`, `rsvp_changes`): v2 accepts the string, stores the existing int/string the column holds (name↔value maps from the field audit). +- **Invasion**: `type_id` → stored grunt-type name; `grunt_id` → resolve to its (type, gender) and store that; `everything`/`boss` → the existing catch-all names. `incident.display_type` → resolve to the event name the matcher already matches on. +- **`clean`/`edit`/`summary`** → collapse to the stored `clean` bitmask. +- **`reward_type`/`lure_id`** → stored as the integer they already are. + +No changes to `internal/matching/*` or the DB schema in v2 scope. + +## 4. Disposition of the in-place migration work + +Done on this branch for the (now-superseded) in-place approach; triage: +- **Reuse for v2:** huma setup/constructor + public docs; the **field audit** (`huma-tracking-field-audit.md`); the enum value maps; huma mechanics learned (`$schema` suppression, schema-provider gotchas). +- **Drop (v1-compat only):** legacy `{status,message}` error override, `flexBool`/`flexInt` lenient coercion, `lenient[T]` + `additionalProperties:true`, single-object-or-array body, the temporary lint exclusion. +- **Revert:** restore v1's original gin routes for pokemon (GET/POST/DELETE/bulk) removed from `main.go`, so v1 is byte-for-byte its old self. + +## 5. Open decisions + +- [ ] **List/Create response shapes** — `{rules:[…]}` vs bare array; `{created,updated,unchanged}` vs just the rules. (Proposed above; confirm.) +- [ ] **Collection scoping** — `?user=&profile=` vs `/users/{id}/tracking/{type}`. (Proposed query; confirm.) +- [ ] **humans & profiles v2 shape** — not yet designed; tracking first. (humans: registration/areas/locations/profile switch; profiles: CRUD.) Separate design pass. +- [ ] **Pagination/filtering** on list endpoints — out of scope for v1 parity, but the `{rules:[…]}` wrapper reserves room. + +**Decided (no longer open):** +- **Update verb** — `PUT` full-replace only; no `PATCH` in v2 scope. +- **Strictness** — strict throughout: unknown body and query params both rejected (`422`). +- **v1 deprecation** — v1 stays fully supported with no sunset date yet; add a `Deprecation` marker + link to v2 on v1 responses once v2 is established; set a hard sunset date later. +- **quest / nest / invasion fields** — quest (`reward_type`,`reward`,`amount`,`form`,`shiny`), nest `min_spawn_avg` = int, invasion exactly-one-mode (`type_id`|`grunt_id`|`everything`|`boss`). + +## 6. Remaining design walkthrough + +The per-type field tables above are derived by applying the agreed classification to the field audit. Still to confirm interactively: the `quest` reward fields, `nest.min_spawn_avg` type/precision, and the humans/profiles surface. Everything else is considered decided pending implementor feedback from the GitHub issue. diff --git a/docs/v2-rfc-issue.md b/docs/v2-rfc-issue.md new file mode 100644 index 000000000..bc2eb5b35 --- /dev/null +++ b/docs/v2-rfc-issue.md @@ -0,0 +1,119 @@ + + + + +--- + +## RFC: PoracleNG v2 API + +We're adding a **clean, strict, documented v2 API** (`/api/v2`) alongside the existing API. **v1 is unaffected** — it keeps working exactly as today; this is a new surface you can adopt on your own schedule. We'd love feedback from client/integration authors **before** we build it. + +### Why + +The current API is undocumented and, by necessity, tolerant of malformed input (it silently coerces wrong types). v2 is the opposite: an OpenAPI 3.1 contract generated from the server, strict validation with clear errors, and one honest representation per field. We'll be encouraging all v1 API users to move to v2 so they can access new tracking types; v1 stays supported (deprecation only later, with notice). + +### Conventions + +- **Auth:** `X-Poracle-Secret: ` header (same secret as v1). +- **Errors:** RFC 9457 `application/problem+json`: + ```json + { "title": "Unprocessable Entity", "status": 422, "detail": "validation failed", + "errors": [ { "message": "expected integer", "location": "body.rules[0].min_iv", "value": "ninety" } ] } + ``` +- **Success:** typed body directly — no `{ "status": "ok" }` wrapper. +- **Strict:** unknown body/query fields are rejected (`422`). No coercion — send the right types. +- **Field types:** game-master dictionary IDs (and numeric ranges) are **integers** (`pokemon_id`, `move`, `reward_type`, `lure_id`, invasion `type_id`/`grunt_id`, incident `display_type`, …); fixed categories are **string enums** (`team`, `gender`, `fort_type`, `rsvp_changes`); flags are **booleans**. +- **Docs:** OpenAPI at `/api/v2/openapi.json`, interactive docs at `/api/v2/docs` (public). + +### Resource model + +Tracking rules are **sub-resources of the human** (the human is the user). `uid` is unique per type; every item op is **scoped by `(human, uid)`** — you can't touch a uid that isn't the addressed human's (matches v1's ownership guard). `{type}` ∈ `pokemon, raid, egg, quest, invasion, incident, lure, nest, gym, fort, maxbattle`. + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/api/v2/humans/{id}/tracking` | Full snapshot — human + all-type rules + profiles + locations + summaries | +| `GET` | `/api/v2/humans/{id}/tracking/{type}` | List one type | +| `POST` | `/api/v2/humans/{id}/tracking/{type}` | Create rule(s) — body is an array | +| `GET` | `/api/v2/humans/{id}/tracking/{type}/{uid}` | Fetch one rule | +| `PUT` | `/api/v2/humans/{id}/tracking/{type}/{uid}` | Full-replace one rule | +| `DELETE` | `/api/v2/humans/{id}/tracking/{type}/{uid}` | Delete one rule | +| `DELETE` | `/api/v2/humans/{id}/tracking/{type}?uid=1,2,3` | Bulk delete | + +- `{id}` (the human) is always in the path; `profile` is `?profile={n}` (defaults to active). +- List → `{ "rules": [ … ] }`. **Snapshot** (`…/tracking`, no type) → `{ "human": {…}, "tracking": { "": [...] }, "profiles": [...], "locations": [...], "summaries": [...] }` (`?all_profiles=true` spans all profiles; replaces v1 `all/{id}` + `allProfiles/{id}`). +- Create → `{ "created": [...], "updated": [...], "unchanged": [...] }`; Delete → `{ "deleted": [...] }` (each rule carries its `uid`; POST keeps v1's diff/upsert). +- **`?include_descriptions=true`** works on **every** tracking endpoint (reads **and** mutations): when set, each rule in the response (`rules` / `created` / `updated` / `unchanged` / `deleted`) gets a `description` (human-readable rowtext, in the human's language). Status is conveyed by which array the rule's in — no separate `message` field. (The assembled confirmation message stays the Discord/Telegram push, gated by `?silent`.) +- `PUT` is a full replace; omitted fields reset to defaults. +- Mutations accept `?silent=true` to apply without notifying the user (single param; replaces v1's `silent`+`suppressMessage`). + +### Common rule fields + +`distance` (int; `0` = use profile areas), `template` (string), `clean`/`edit`/`summary` (bool), `ping` (string), `override_location_label` (string), `override_areas` (string[]). + +### Per-type fields (`*` = required) + +- **pokemon** — `pokemon_id`* , `form`, `min_iv`/`max_iv`, `min_cp`/`max_cp`, `min_level`/`max_level`, `atk`/`def`/`sta` & `max_atk`/`max_def`/`max_sta`, `rarity`/`max_rarity`, `size`/`max_size` (all int), `gender` (enum `any|male|female|genderless`), `pvp_ranking_league` (int — CP cap `0|500|1500|2500`), `pvp_ranking_best`/`pvp_ranking_worst`/`pvp_ranking_min_cp`/`pvp_ranking_cap` (int), `pvp_ranking_evolution` (int — mega/evolution discriminator: `0`=default, `2`=Mega X, `3`=Mega Y; *prospective*). +- **raid** — `pokemon_id` (int, `0`=any), `form`, `level`, `move`, `evolution` (int), `team` (enum `harmony|mystic|valor|instinct|any`), `exclusive` (bool), `gym_id` (string), `rsvp_changes` (enum `none|rsvp|rsvp_only`). +- **egg** — `level` (int), `team` (enum), `exclusive` (bool), `gym_id` (string), `rsvp_changes` (enum). +- **quest** — `reward_type`* (int: `2`=item,`3`=stardust,`4`=candy,`7`=pokemon,`12`=mega_energy), `reward` (int), `amount` (int), `form` (int), `shiny` (bool). +- **invasion** — exactly one mode: `type_id` (int poke-type, + optional `gender` enum) | `grunt_id` (int, exact grunt — implies type+gender) | `everything` (bool) | `boss` (bool). +- **incident** — `display_type`* (int — game event id, e.g. `9` = Showcase; names documented). +- **lure** — `lure_id` (int item id: `0`=any, `501`=normal … `506`=sparkly). +- **nest** — `pokemon_id`, `form`, `min_spawn_avg` (all int). +- **gym** — `team` (enum), `slot_changes` (bool), `battle_changes` (bool), `gym_id` (string). +- **fort** — `fort_type` (enum `pokestop|gym|everything`), `include_empty` (bool, default `true`), `change_types` (string[] of `location|new|removal|image_url|name|description`). +- **maxbattle** — `pokemon_id`, `level`, `move` (int), `gmax` (bool). + +### Examples + +``` +POST /api/v2/humans/123456/tracking/pokemon?profile=1 +[ { "pokemon_id": 149, "min_iv": 95, "gender": "female", "clean": true }, + { "pokemon_id": 384, "pvp_ranking_league": 1500, "pvp_ranking_best": 1, "pvp_ranking_worst": 5, "edit": true } ] + +POST /api/v2/humans/123456/tracking/invasion?profile=1 +[ { "grunt_id": 41 }, { "type_id": 12, "gender": "female" } ] + +POST /api/v2/humans/123456/tracking/incident?profile=1 +[ { "display_type": 9 } ] + +GET /api/v2/humans/123456/tracking?include_descriptions=true # full snapshot +DELETE /api/v2/humans/123456/tracking/raid/80921 +``` + +### Humans, profiles & schedules (v2) + +Discrete, typed endpoints under `/api/v2` (problem+json, strict): + +**Humans:** `POST /api/v2/humans` (create) · `GET …/humans/{id}` (resource; includes read-only `blocked_alerts`) · `GET …/{id}/areas` · `POST …/{id}/{enable|disable|admin-disable|language|location|areas|profile}` · `GET …/{id}/check-location?lat=&lon=` · **saved locations** `GET` (list), `GET/{label}`, `POST {label,lat,lon}`, **`PUT/{label} {lat,lon}` (NEW — edit a saved location)**, `DELETE/{label}` · **roles** `GET`, `POST|DELETE …/{roleId}`, `GET …/{id}/admin-roles`. + +**Profiles:** `GET /api/v2/humans/{id}/profiles` · `POST` (add) · `PATCH …/{profile_no}` (active_hours) · `DELETE …/{profile_no}` · `POST …/{profile_no}/copy`. + +**`active_hours` — now a real typed schema** (shared by profile schedules and `POST /summaries/{id}/{alertType}`; replaces the old freeform-JSON passthrough). An array of entries: + +| field | type | required | bounds | +|---|---|---|---| +| `day` | int | yes | 0–6 (0 = Sunday) | +| `hours` | int | yes | 0–23 | +| `mins` | int | yes | 0–59 | +| `step` | int | no | ≥0 hours; `>0` ⇒ range entry | +| `end_hours` / `end_mins` | int | iff `step>0` | 0–23 / 0–59 | + +Single-fire `{day,hours,mins}`, or range (adds `step`/`end_*`, fires every `step` hours to `end`, no cross-midnight). Strict ints (drops v1's `"00"` string coercion). + +**`blocked_alerts`** is read-only on the human resource (derived from Discord roles / `command_security`, not API-settable): `monster`(=pokemon)`|pvp|raid|egg|quest|invasion|lure|nest|gym|fort|maxbattle|specificgym|specificstation`. + +### Questions we'd love your input on + +1. **Collection scoping** — is `?user=&profile=` on the collection comfortable, or would you prefer `/api/v2/users/{id}/tracking/{type}`? +2. **Create response** — is `{created, updated, unchanged}` useful, or do you just want the resulting rules? +3. **int vs string-enum split** — does the game-master-id-as-int / fixed-category-as-string-enum split match how you think about these fields? Any field you'd flip? +4. **invasion two-axis** (`type_id` vs `grunt_id`) and the **separate `incident` type** — does this fit your use cases? +5. **humans/profiles shape** — we kept **discrete action endpoints** (enable/disable/language/location/areas/profile) rather than a consolidated `PATCH`. Does that suit your client, and is the typed `active_hours` schema right? +6. **Anything in v1 you depend on** that isn't represented here? + +Thanks! Comments here or on the linked design doc. diff --git a/fallbacks/dts.json b/fallbacks/dts.json index aecb460cc..4ff446842 100644 --- a/fallbacks/dts.json +++ b/fallbacks/dts.json @@ -625,5 +625,40 @@ ] } } + }, + { + "id": 1, + "language": "en", + "type": "showcase", + "default": true, + "platform": "discord", + "template": { + "embed": { + "title": "\ud83c\udfaa Showcase at {{{pokestopName}}}", + "url": "{{{googleMapUrl}}}", + "color": "03AEB6", + "description": "Ends: {{disappearTime}} ({{#if tthh}}{{tthh}}h {{/if}}{{tthm}}m {{tths}}s)\nAddress: {{{addr}}}\n[Google]({{{googleMapUrl}}}) | [Apple]({{{appleMapUrl}}}){{#if showcaseFocusPresent}}\n\n\ud83c\udfaf **Featuring {{showcaseFocusCategory}}**{{#if showcaseFocusName}}: {{showcaseFocusName}}{{/if}}{{/if}}{{#if showcasePresent}}\n\n\ud83c\udfc6 **Top contestants** ({{showcaseTotalEntries}} entries):\n{{#each showcase}}{{rank}}. {{fullName}}{{#if shiny}} \u2728{{/if}}{{#if costumeName}} ({{costumeName}}){{/if}} \u2014 {{scoreFormatted}}\n{{/each}}{{/if}}", + "thumbnail": { + "url": "{{{imgUrl}}}" + }, + "image": { + "url": "{{{staticMap}}}" + } + } + } + }, + { + "id": 1, + "language": "en", + "type": "showcase", + "default": true, + "platform": "telegram", + "template": { + "content": "\ud83c\udfaa Showcase at {{{pokestopName}}}\nEnds: {{disappearTime}} ({{#if tthh}}{{tthh}}h {{/if}}{{tthm}}m {{tths}}s)\nAddress: {{{addr}}}\n[Google]({{{googleMapUrl}}}) | [Apple]({{{appleMapUrl}}}){{#if showcaseFocusPresent}}\n\n\ud83c\udfaf Featuring {{showcaseFocusCategory}}{{#if showcaseFocusName}}: {{showcaseFocusName}}{{/if}}{{/if}}{{#if showcasePresent}}\n\n\ud83c\udfc6 Top contestants ({{showcaseTotalEntries}} entries):\n{{#each showcase}}{{rank}}. {{fullName}}{{#if shiny}} \u2728{{/if}}{{#if costumeName}} ({{costumeName}}){{/if}} \u2014 {{scoreFormatted}}\n{{/each}}{{/if}}", + "sticker": "{{{stickerUrl}}}", + "parse_mode": "Markdown", + "location": true, + "webpage_preview": true + } } ] diff --git a/fallbacks/testdata.json b/fallbacks/testdata.json index f031930ce..c66717c12 100644 --- a/fallbacks/testdata.json +++ b/fallbacks/testdata.json @@ -64,6 +64,73 @@ } } }, + { + "type": "pokemon", + "test": "costume", + "location": "current", + "webhook": { + "spawnpoint_id": "47decbc1f19", + "pokestop_id": "None", + "pokestop_name": null, + "encounter_id": "4387497020362672221", + "pokemon_id": 25, + "latitude": 51.274644760338, + "longitude": 1.0592872653170444, + "disappear_time": 1775478965, + "disappear_time_verified": true, + "first_seen": 1775475471, + "last_modified_time": 1775475472, + "gender": 1, + "cp": 718, + "form": 598, + "costume": 1, + "individual_attack": 3, + "individual_defense": 1, + "individual_stamina": 5, + "pokemon_level": 18, + "move_1": 219, + "move_2": 45, + "weight": 5.309950828552246, + "size": 3, + "height": 0.3768548369407654, + "weather": 0, + "capture_1": 0, + "capture_2": 0, + "capture_3": 0, + "shiny": false, + "display_pokemon_id": null, + "display_pokemon_form": null, + "is_event": 0, + "seen_type": "encounter", + "pvp": { + "great": [ + { + "pokemon": 25, + "form": 598, + "cap": 50, + "value": 1695271, + "level": 45.5, + "cp": 1496, + "percentage": 0.94663, + "rank": 2154, + "capped": true + } + ], + "ultra": [ + { + "pokemon": 25, + "form": 598, + "cap": 50, + "value": 1833475, + "level": 50, + "cp": 1580, + "percentage": 0.78296, + "rank": 3941 + } + ] + } + } + }, { "type": "pokemon", "test": "hundo", @@ -1148,7 +1215,7 @@ } }, { - "type": "pokestop", + "type": "incident", "test": "kecleon", "location": "current", "webhook": { @@ -1170,7 +1237,7 @@ } }, { - "type": "pokestop", + "type": "incident", "test": "goldstop", "location": "current", "webhook": { @@ -1192,7 +1259,7 @@ } }, { - "type": "pokestop", + "type": "incident", "test": "pokemoncontest", "location": "current", "webhook": { @@ -1250,6 +1317,49 @@ "incident_expire_timestamp": 1689184800 } }, + { + "type": "showcase", + "test": "type", + "location": "current", + "webhook": { + "pokestop_id": "showcase-type-test.16", + "latitude": 51.28, + "longitude": 1.08, + "name": "Contest Hall", + "url": "http://lh3.googleusercontent.com/showcase-type", + "updated": 1784062072, + "showcase_focus": { "type": "type", "pokemon_type_1": 9 }, + "showcase_pokemon_type_id": 9, + "showcase_ranking_standard": 2, + "showcase_expiry": 1784084400, + "showcase_rankings": { + "total_entries": 3, + "last_update": 1784062072, + "contest_entries": [ + { "rank": 1, "score": 1032.54, "pokemon_id": 679, "form": 3042, "costume": 0, "gender": 1, "shiny": false, "temp_evolution": 0, "temp_evolution_finish_ms": 0, "alignment": 0, "badge": 0 }, + { "rank": 2, "score": 1032.2, "pokemon_id": 51, "form": 62, "costume": 0, "gender": 1, "shiny": false, "temp_evolution": 0, "temp_evolution_finish_ms": 0, "alignment": 0, "badge": 0 }, + { "rank": 3, "score": 789.8, "pokemon_id": 809, "form": 0, "costume": 0, "gender": 3, "shiny": true, "temp_evolution": 0, "temp_evolution_finish_ms": 0, "alignment": 0, "badge": 0 } + ] + } + } + }, + { + "type": "showcase", + "test": "buddy", + "location": "current", + "webhook": { + "pokestop_id": "showcase-buddy-test.16", + "latitude": 51.29, + "longitude": 1.09, + "name": "Buddy Contest", + "url": "http://lh3.googleusercontent.com/showcase-buddy", + "updated": 1784062072, + "showcase_focus": { "type": "buddy", "min_level": 3 }, + "showcase_ranking_standard": 2, + "showcase_expiry": 1784084400, + "showcase_rankings": { "total_entries": 0, "last_update": 1784062072, "contest_entries": [] } + } + }, { "type": "fort_update", "test": "edit", @@ -1510,6 +1620,31 @@ "updated": 1774471518 } }, + { + "type": "quest", + "test": "quest-pokecoins", + "location": "current", + "webhook": { + "pokestop_id": "4273655acfdc4b5380f3d217232367ff.16", + "latitude": 33.774889, + "longitude": -118.192531, + "pokestop_name": "Street Art", + "type": 4, + "target": 3, + "template": "challenge_catch_easy", + "title": "quest_catch_pokemon_plural", + "conditions": [], + "rewards": [ + { + "info": { + "amount": 10 + }, + "type": 8 + } + ], + "updated": 1774471518 + } + }, { "type": "quest", "test": "quest-pokemon", @@ -1575,5 +1710,255 @@ ], "updated": 1774471518 } + }, + { + "type": "quest_summary", + "test": "stardust", + "location": "current", + "webhook": { + "reward": { + "type": 3, + "reward": 1500, + "form": 0 + }, + "quests": [ + { + "pokestop_id": "e1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1.16", + "latitude": 34.052235, + "longitude": -118.243683, + "pokestop_name": "Pershing Square Fountain", + "type": 4, + "target": 10, + "template": "challenge_catch_easy", + "title": "quest_catch_pokemon_plural", + "conditions": [], + "rewards": [ + { + "info": { + "amount": 1500 + }, + "type": 3 + } + ], + "with_ar": false, + "updated": 1774471518 + }, + { + "pokestop_id": "e2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2.16", + "latitude": 34.055100, + "longitude": -118.246200, + "pokestop_name": "Angels Flight Railway", + "type": 4, + "target": 5, + "template": "challenge_catch_easy", + "title": "quest_catch_pokemon_plural", + "conditions": [], + "rewards": [ + { + "info": { + "amount": 1500 + }, + "type": 3 + } + ], + "with_ar": false, + "updated": 1774471518 + }, + { + "pokestop_id": "e3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3.16", + "latitude": 34.049500, + "longitude": -118.250700, + "pokestop_name": "Grand Central Market Entrance", + "type": 13, + "target": 3, + "template": "challenge_berry_moderate", + "title": "quest_catch_feed_plural", + "conditions": [], + "rewards": [ + { + "info": { + "amount": 1500 + }, + "type": 3 + } + ], + "with_ar": true, + "updated": 1774471518 + } + ] + } + }, + { + "type": "weatherchange", + "test": "rain", + "location": "current", + "webhook": { + "s2_cell_id": "5222463073676417024", + "latitude": 51.5, + "longitude": -0.1, + "gameplay_condition": 2, + "old_gameplay_condition": 1, + "coords": [ + [51.4995, -0.1005], + [51.4995, -0.0995], + [51.5005, -0.0995], + [51.5005, -0.1005] + ], + "affected": [ + { + "pokemon_id": 129, + "form": 0, + "iv": 100, + "cp": 979, + "latitude": 51.5001, + "longitude": -0.0998, + "disappear_time": 1751900000 + }, + { + "pokemon_id": 7, + "form": 0, + "iv": 82.22, + "cp": 612, + "latitude": 51.4998, + "longitude": -0.1002, + "disappear_time": 1751900300 + }, + { + "pokemon_id": 25, + "form": 0, + "iv": 91.11, + "cp": 450, + "latitude": 51.5003, + "longitude": -0.0996, + "disappear_time": 1751900600 + } + ] + } + }, + { + "type": "monster_changed", + "test": "ditto-reveal", + "location": "keep", + "webhook": { + "old": { + "spawnpoint_id": "47decbc1f19", + "pokestop_id": "None", + "pokestop_name": null, + "encounter_id": "9912873645123456789", + "pokemon_id": 590, + "latitude": 51.274644760338, + "longitude": 1.0592872653170444, + "disappear_time": 1775478000, + "disappear_time_verified": true, + "first_seen": 1775475000, + "last_modified_time": 1775475000, + "gender": 1, + "cp": null, + "form": 0, + "costume": 0, + "individual_attack": null, + "individual_defense": null, + "individual_stamina": null, + "pokemon_level": null, + "move_1": null, + "move_2": null, + "weight": null, + "size": null, + "height": null, + "weather": 0, + "capture_1": 0, + "capture_2": 0, + "capture_3": 0, + "shiny": null, + "display_pokemon_id": null, + "display_pokemon_form": null, + "is_event": 0, + "seen_type": "wild", + "pvp": null + }, + "new": { + "spawnpoint_id": "47decbc1f19", + "pokestop_id": "None", + "pokestop_name": null, + "encounter_id": "9912873645123456789", + "pokemon_id": 132, + "latitude": 51.274644760338, + "longitude": 1.0592872653170444, + "disappear_time": 1775478965, + "disappear_time_verified": true, + "first_seen": 1775475000, + "last_modified_time": 1775478100, + "gender": 3, + "cp": 654, + "form": 0, + "costume": 0, + "individual_attack": 15, + "individual_defense": 14, + "individual_stamina": 13, + "pokemon_level": 20, + "move_1": 221, + "move_2": 48, + "weight": 4.0, + "size": 3, + "height": 0.3, + "weather": 0, + "capture_1": 0, + "capture_2": 0, + "capture_3": 0, + "shiny": false, + "display_pokemon_id": null, + "display_pokemon_form": null, + "is_event": 0, + "seen_type": "encounter", + "pvp": null + } + } + }, + { + "type": "rsvp_changes", + "test": "level5", + "location": "current", + "webhook": { + "gym_id": "c3a1f2ea8d03748fdbc7fa72c6a15772.16", + "gym_name": "RSVP Test Gym", + "gym_url": "https://lh3.googleusercontent.com/G_JAaxOS90g-hVniIQuF9Tr5H1IIsrh_G3PDQI5Tm560YsKCu6u9V5UucRTr6vZnDgkANFp2QYvw_7ZF-2XA-Lgo9A", + "latitude": 51.189153, + "longitude": 0.892118, + "team_id": 1, + "spawn": 1775471928, + "start": 1775475528, + "end": 1775478228, + "level": 5, + "pokemon_id": 895, + "cp": 28624, + "gender": 3, + "form": 3215, + "alignment": 0, + "costume": 0, + "evolution": 0, + "move_1": 202, + "move_2": 82, + "ex_raid_eligible": 0, + "is_exclusive": 0, + "sponsor_id": 0, + "partner_id": "", + "power_up_points": 10, + "power_up_level": 0, + "power_up_end_timestamp": 0, + "ar_scan_eligible": 0, + "rsvps": [ + { + "timeslot": 4102444800000, + "going_count": 4, + "maybe_count": 2 + }, + { + "timeslot": 4102448400000, + "going_count": 7, + "maybe_count": 1 + } + ], + "raid_seed": "5845571837887094592" + } } ] diff --git a/processor/cmd/processor/autocreate_api.go b/processor/cmd/processor/autocreate_api.go deleted file mode 100644 index 6956c8651..000000000 --- a/processor/cmd/processor/autocreate_api.go +++ /dev/null @@ -1,73 +0,0 @@ -package main - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "github.com/pokemon/poracleng/processor/internal/config" - "github.com/pokemon/poracleng/processor/internal/discordbot" -) - -// autocreateRunRequest is the POST /api/autocreate/run body. -type autocreateRunRequest struct { - Rule string `json:"rule"` // empty → all rules - DryRun bool `json:"dry_run"` - Reset bool `json:"reset"` - Removals bool `json:"removals"` - Force bool `json:"force"` -} - -// handleAutocreateRun implements POST /api/autocreate/run. Authenticated -// via the same x-poracle-secret middleware applied to the /api/* group. -// -// Body: {"rule": "uk-areas", "dry_run": false, ...} ("rule" empty → all rules) -// Reply: {"status": "ok", "rules": [SyncOneRuleResult, ...]} -func handleAutocreateRun(cfg *config.Config, bot *discordbot.Bot) gin.HandlerFunc { - return func(c *gin.Context) { - var req autocreateRunRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": err.Error()}) - return - } - - if bot == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"status": "error", "message": "discord bot not running"}) - return - } - - rules := cfg.Autocreate.Rules - if req.Rule != "" { - var matched []config.AutocreateRule - for _, r := range rules { - if r.Name == req.Rule { - matched = append(matched, r) - break - } - } - if len(matched) == 0 { - c.JSON(http.StatusNotFound, gin.H{"status": "error", "message": "rule not found"}) - return - } - rules = matched - } - if len(rules) == 0 { - c.JSON(http.StatusOK, gin.H{"status": "ok", "rules": []discordbot.SyncOneRuleResult{}}) - return - } - - opts := discordbot.SyncRuleOptions{ - DryRun: req.DryRun, - Reset: req.Reset, - Removals: req.Removals, - Force: req.Force, - } - - results := make([]discordbot.SyncOneRuleResult, 0, len(rules)) - session := bot.Session() - for _, r := range rules { - results = append(results, bot.SyncOneRule(session, r, opts)) - } - c.JSON(http.StatusOK, gin.H{"status": "ok", "rules": results}) - } -} diff --git a/processor/cmd/processor/autocreate_templates_api.go b/processor/cmd/processor/autocreate_templates_api.go index f426d5358..bd0ab91a4 100644 --- a/processor/cmd/processor/autocreate_templates_api.go +++ b/processor/cmd/processor/autocreate_templates_api.go @@ -1,15 +1,6 @@ package main import ( - "encoding/json" - "errors" - "io" - "net/http" - "os" - - "github.com/gin-gonic/gin" - - "github.com/pokemon/poracleng/processor/internal/config" "github.com/pokemon/poracleng/processor/internal/discordbot" ) @@ -24,139 +15,8 @@ type channelTemplatesEnums struct { BackupNamePrefix string `json:"backupNamePrefix"` } -// handleGetChannelTemplates implements GET /api/autocreate/templates. -// Returns the live channelTemplate.json contents as a typed array. -// A missing file yields {"status":"ok","templates":[]}. -func handleGetChannelTemplates(cfg *config.Config) gin.HandlerFunc { - return func(c *gin.Context) { - raw, err := discordbot.LoadChannelTemplatesRaw(cfg.BaseDir) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()}) - return - } - // Round-trip via json.RawMessage so the response carries a real - // JSON array (not a base64-encoded string) regardless of the - // editor's deserialiser. - c.Data(http.StatusOK, "application/json", buildOKEnvelope("templates", raw)) - } -} - -// channelTemplatesPostRequest is the body shared by POST .../templates and -// POST .../templates/validate. The Templates field is treated as a raw -// JSON array so unknown fields survive a write through the API (any -// future field the bot adds can be set in the editor before the bot -// supports decoding it). -type channelTemplatesPostRequest struct { - Templates json.RawMessage `json:"templates"` -} - -// handlePostChannelTemplates implements POST /api/autocreate/templates — -// validate + write. On success returns the backup filename so the -// operator can roll back if the change was a mistake. -func handlePostChannelTemplates(cfg *config.Config) gin.HandlerFunc { - return func(c *gin.Context) { - req, raw, ok := readTemplatesBody(c) - if !ok { - return - } - _ = req // body shape verified - - if errs := discordbot.ValidateChannelTemplatesRaw(raw); hasBlockingErrors(errs) { - c.JSON(http.StatusBadRequest, gin.H{"status": "error", "errors": errs}) - return - } - backup, err := discordbot.SaveChannelTemplatesRaw(cfg.BaseDir, raw) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "status": "ok", - "backup": backup, - "warnings": nonBlocking(discordbot.ValidateChannelTemplatesRaw(raw)), - }) - } -} - -// handleValidateChannelTemplates implements POST /api/autocreate/templates/validate. -// Same body as POST .../templates but never writes — useful for "lint as -// you type" in the editor. -func handleValidateChannelTemplates() gin.HandlerFunc { - return func(c *gin.Context) { - _, raw, ok := readTemplatesBody(c) - if !ok { - return - } - errs := discordbot.ValidateChannelTemplatesRaw(raw) - if hasBlockingErrors(errs) { - c.JSON(http.StatusBadRequest, gin.H{"status": "error", "errors": errs}) - return - } - c.JSON(http.StatusOK, gin.H{"status": "ok", "warnings": nonBlocking(errs)}) - } -} - -// handleDeleteChannelTemplate implements DELETE /api/autocreate/templates/:name. -func handleDeleteChannelTemplate(cfg *config.Config) gin.HandlerFunc { - return func(c *gin.Context) { - name := c.Param("name") - if name == "" { - c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "template name is required"}) - return - } - backup, err := discordbot.DeleteChannelTemplate(cfg.BaseDir, name) - if errors.Is(err, os.ErrNotExist) { - c.JSON(http.StatusNotFound, gin.H{"status": "error", "message": "template not found"}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"status": "ok", "backup": backup}) - } -} - -// handleGetChannelTemplatesSchema implements GET /api/autocreate/templates/schema. -// Static metadata the editor uses to render dropdowns + permission flags. -func handleGetChannelTemplatesSchema() gin.HandlerFunc { - return func(c *gin.Context) { - out := channelTemplatesEnums{ - ChannelTypes: []string{"text", "voice"}, - ControlTypes: []string{"", "bot", "webhook"}, - ButtonStyles: []string{"primary", "secondary", "success", "danger"}, - PermissionFlags: discordbot.PermissionFlagsList(), - PlaceholderHelp: map[string]string{ - "interactive": "{N} indexes args[N+1] from !autocreate