diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index b722b2c23..43f3e2b94 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -26,6 +26,7 @@ "search_quests": "Search Quests", "search_lures": "Search Lures", "search_invasions": "Search Invasions", + "search_incidents": "Search Pokestop Events", "sm": "sm", "md": "md", "lg": "lg", @@ -362,6 +363,8 @@ "lure": "Lure", "quest": "Quest", "invasion": "Invasion", + "incident": "Pokestop Events", + "incident_unknown": "Pokestop Event #{{id}}", "nest": "Nest", "pokestop": "Pokestop", "team": "Team", @@ -396,8 +399,12 @@ "slider_pvp": "Ranks", "profile_no": "Profile", "template": "Template", + "default_template": "Default", "gender": "Gender", + "gender_3": "Genderless", "clean": "Clean", + "edit": "Edit", + "summary": "Summary", "all_forms": "All Forms", "pvp_entry": "PVP Entry", "no_iv": "Incl. No IV", diff --git a/packages/types/lib/config.d.ts b/packages/types/lib/config.d.ts index 15bbf422a..1a8a401d6 100644 --- a/packages/types/lib/config.d.ts +++ b/packages/types/lib/config.d.ts @@ -192,6 +192,12 @@ export interface Webhook { host: string port: number poracleSecret: string + /** + * Which Poracle API to use. `auto` (the default) uses the PoracleNG v2 API + * when the server's `/openapi.json` lists the v2 routes, and v1 otherwise + * (PoracleJS, older PoracleNG). `v1` and `v2` skip the check. + */ + apiVersion?: 'auto' | 'v1' | 'v2' addressFormat?: string /** Base URL of the geocoding backend. Used for both providers. */ nominatimUrl?: string diff --git a/packages/types/lib/poracle.d.ts b/packages/types/lib/poracle.d.ts index 42f7adc1b..2a458ce26 100644 --- a/packages/types/lib/poracle.d.ts +++ b/packages/types/lib/poracle.d.ts @@ -27,6 +27,10 @@ export interface PoracleActiveHours { day: number hours: string mins: string + /** Hours between repeats; set (> 0) on a range entry. */ + step?: number + end_hours?: number + end_mins?: number } export interface PoracleProfile { @@ -40,177 +44,37 @@ export interface PoracleProfile { name: string } -export interface PoracleGym { - uid: number - id: string - profile_no: number - ping: string - clean: boolean - distance: number - template: string - team: number - slot_changes: number - gym_id: string - description: string +/** A tracking rule as PoracleNG v2 returns it (`null` = any). */ +export type PoracleRule = { + uid?: number description?: string -} - -export interface PoracleRaid { - uid: number - id: string - profile_no: number - ping: string - exclusive: boolean - level: number - clean: boolean - distance: number - template: string - team: number - rsvp_changes: number - gym_id: string - form: number - move: number - pokemon_id: number - description: string -} - -export interface PoracleEgg { - uid: number - id: string - profile_no: number - ping: string - exclusive: boolean - level: number - clean: boolean - distance: number - template: string - team: number - rsvp_changes: number - gym_id: string - description: string -} - -export interface PoracleInvasion { - uid: number - id: string - profile_no: number - ping: string - clean: boolean - gender: number - grunt_type: string - template: string - distance: number - description: string - real_grunt_id?: number -} - -export interface PoraclePokemon { - uid: number - id: string - ping: string - clean: boolean - pokemon_id: number - distance: number - min_iv: number - max_iv: number - min_cp: number - max_cp: number - min_level: number - max_level: number - atk: number - def: number - sta: number - template: string - min_weight: number - max_weight: number - form: number - max_atk: number - max_def: number - max_sta: number - gender: number - profile_no: number - min_time: number - rarity: number - max_rarity: number - size: number - max_size: number - pvp_ranking_worst: number - pvp_ranking_best: number - pvp_ranking_min_cp: number - pvp_ranking_league: number - pvp_ranking_cap: number - description: string -} - -export interface PoracleLure { - uid: number - id: string - profile_no: number - ping: string - clean: boolean - lure_id: number - template: string - distance: number - description: string -} - -export interface PoracleQuest { - uid: number - id: string - profile_no: number - ping: string - clean: boolean - template: string - distance: number - amount: number - form: number - reward: number - reward_type: number - shiny: boolean - description: string -} - -export interface PoracleNest { - uid: number - id: string - profile_no: number - ping: string - clean: boolean - template: string - distance: number - min_spawn_avg: number - pokemon_id: number - form: number - description?: string -} - -export interface PoracleWeather { - uid: number - id: string - profile_no: number - ping: string - clean: boolean - template: string - cell: number - condition: number - description: string -} +} & Record + +export type PoracleGym = PoracleRule +export type PoracleRaid = PoracleRule +export type PoracleEgg = PoracleRule +export type PoracleInvasion = PoracleRule +export type PoracleIncident = PoracleRule +export type PoraclePokemon = PoracleRule +export type PoracleLure = PoracleRule +export type PoracleQuest = PoracleRule +export type PoracleNest = PoracleRule export interface Poracle { status: string message: string category: string human: PoracleHuman - gym: PoracleGym[] - egg: PoracleEgg[] - raid: PoracleRaid[] - pokemon: PoraclePokemon[] - invasion: PoracleInvasion[] - lure: PoracleLure[] - quest: PoracleQuest[] profile: PoracleProfile[] - nest: PoracleNest[] - weather: PoracleWeather[] + pokemon: PoracleRule[] + raid: PoracleRule[] + egg: PoracleRule[] + quest: PoracleRule[] + invasion: PoracleRule[] + incident: PoracleRule[] + lure: PoracleRule[] + nest: PoracleRule[] + gym: PoracleRule[] } export interface PoracleHumanArea { @@ -234,6 +98,7 @@ export type PoracleCategory = | 'profiles' | 'egg' | 'invasion' + | 'incident' | 'lure' | 'nest' | 'pokemon' @@ -319,7 +184,7 @@ interface APIReturnType { lure: PoracleLure[] quest: PoracleQuest[] nest: PoracleNest[] - weather: PoracleWeather[] + incident: PoracleIncident[] profile: PoracleProfile[] profiles: PoracleProfile[] gym: PoracleGym[] diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index 28f8c9b05..7c8ac9853 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -607,27 +607,6 @@ const resolvers = { category, status, ) - if (category === 'pokemon' && result.pokemon) { - result.pokemon = result.pokemon.map((x) => ({ - ...x, - allForms: !x.form, - pvpEntry: !!x.pvp_ranking_league, - xs: x.max_weight !== 9000000, - xl: x.min_weight !== 0, - })) - } - if (category === 'invasion' && result.invasion) { - result.invasion = result.invasion.map((x) => ({ - ...x, - real_grunt_id: PoracleAPI.getRealGruntId(x, Event.invasions), - })) - } - if (category === 'raid' && result.raid) { - result.raid = result.raid.map((x) => ({ - ...x, - allMoves: x.move === 9000, - })) - } return result } return {} @@ -642,13 +621,14 @@ const resolvers = { }, webhookCategories: async (_, __, { req, perms, Event }) => { if (req.user?.id && perms.webhooks.includes(req.user?.selectedWebhook)) { - const human = await Event.webhookObj[req.user?.selectedWebhook].api( + // api() wraps the human as { human }; on a failed read it has none. + const { human } = await Event.webhookObj[req.user?.selectedWebhook].api( PoracleAPI.getWebhookId(req.user), 'oneHuman', 'GET', ) return Event.webhookObj[req.user?.selectedWebhook].getAllowedCategories( - human.blocked_alerts, + human?.blocked_alerts, ) } return [] @@ -772,27 +752,6 @@ const resolvers = { status, data, ) - if (category === 'pokemon' && result.pokemon) { - result.pokemon = result.pokemon.map((x) => ({ - ...x, - allForms: !x.form, - pvpEntry: !!x.pvp_ranking_league, - xs: x.max_weight !== 9000000, - xl: x.min_weight !== 0, - })) - } - if (category === 'invasion' && result.invasion) { - result.invasion = result.invasion.map((x) => ({ - ...x, - real_grunt_id: PoracleAPI.getRealGruntId(x, Event.invasions), - })) - } - if (category === 'raid' && result.raid) { - result.raid = result.raid.map((x) => ({ - ...x, - allMoves: x.move === 9000, - })) - } return result } return {} diff --git a/server/src/graphql/typeDefs/poracle.graphql b/server/src/graphql/typeDefs/poracle.graphql index 404ac595c..8a1dab44a 100644 --- a/server/src/graphql/typeDefs/poracle.graphql +++ b/server/src/graphql/typeDefs/poracle.graphql @@ -22,6 +22,9 @@ type ActiveHour { day: Int hours: String mins: String + step: Int + end_hours: Int + end_mins: Int id: ID } @@ -36,180 +39,21 @@ type PoracleProfile { name: String } -type PoracleGym { - uid: Int - id: ID - profile_no: Int - ping: String - clean: Boolean - distance: Int - template: String - team: Int - slot_changes: Int - gym_id: String - description: String -} - -type PoracleRaid { - uid: Int - id: ID - profile_no: Int - ping: String - exclusive: Boolean - level: Int - clean: Boolean - distance: Int - template: String - team: Int - rsvp_changes: Int - gym_id: String - form: Int - move: Int - pokemon_id: Int - description: String - allMoves: Boolean -} - -type PoracleEgg { - uid: Int - id: ID - profile_no: Int - ping: String - exclusive: Boolean - level: Int - clean: Boolean - distance: Int - template: String - team: Int - rsvp_changes: Int - gym_id: String - description: String -} - -type PoracleInvasion { - uid: Int - id: ID - profile_no: Int - ping: String - clean: Boolean - gender: Int - grunt_type: String - template: String - distance: Int - description: String - real_grunt_id: Int -} - -type PoraclePokemon { - uid: Int - id: ID - ping: String - clean: Boolean - pokemon_id: Int - distance: Int - min_iv: Int - max_iv: Int - min_cp: Int - max_cp: Int - min_level: Int - max_level: Int - atk: Int - def: Int - sta: Int - template: String - min_weight: Int - max_weight: Int - form: Int - max_atk: Int - max_def: Int - max_sta: Int - gender: Int - profile_no: Int - min_time: Int - rarity: Int - max_rarity: Int - size: Int - max_size: Int - pvp_ranking_worst: Int - pvp_ranking_best: Int - pvp_ranking_min_cp: Int - pvp_ranking_league: Int - pvp_ranking_cap: Int - description: String - allForms: Boolean - pvpEntry: Boolean - xs: Boolean - xl: Boolean -} - -type PoracleLure { - uid: Int - id: ID - profile_no: Int - ping: String - clean: Boolean - lure_id: Int - template: String - distance: Int - description: String -} - -type PoracleQuest { - uid: Int - id: ID - profile_no: Int - ping: String - clean: Boolean - template: String - distance: Int - amount: Int - form: Int - reward: Int - reward_type: Int - shiny: Boolean - description: String -} - -type PoracleNest { - uid: Int - id: ID - profile_no: Int - ping: String - clean: Boolean - template: String - distance: Int - min_spawn_avg: Int - pokemon_id: Int - form: Int -} - -type PoracleWeather { - uid: Int - id: ID - profile_no: Int - ping: String - clean: Boolean - template: String - cell: Int - condition: Int - description: String -} - type Poracle { status: String message: String category: String human: PoracleHuman - gym: [PoracleGym] - egg: [PoracleEgg] - raid: [PoracleRaid] - pokemon: [PoraclePokemon] - invasion: [PoracleInvasion] - lure: [PoracleLure] - quest: [PoracleQuest] profile: [PoracleProfile] - nest: [PoracleNest] - weather: [PoracleWeather] + pokemon: [JSON] + raid: [JSON] + egg: [JSON] + quest: [JSON] + invasion: [JSON] + incident: [JSON] + lure: [JSON] + nest: [JSON] + gym: [JSON] } type PoraclePvpConfig { @@ -229,6 +73,7 @@ type PoracleConfig { leagues: [PoraclePvpConfig] pvp: String ui: JSON + capabilities: JSON } type UserPoracleConfig { diff --git a/server/src/services/Poracle.js b/server/src/services/Poracle.js index 400487743..f32ba788f 100644 --- a/server/src/services/Poracle.js +++ b/server/src/services/Poracle.js @@ -1,8 +1,75 @@ // @ts-check +const { default: fetch } = require('node-fetch') + const { log, TAGS } = require('@rm/logger') const { fetchJson } = require('../utils/fetchJson') const { setCache, getCache } = require('./cache') +const { + createPoracleHttp, + PoracleRequestError, + pathInt, + pathNumber, +} = require('./poracle/http') +const { createTrackingV1 } = require('./poracle/trackingV1') +const { createTrackingV2 } = require('./poracle/trackingV2') +const { UnsupportedOnV1 } = require('./poracle/v1Adapter') + +/** Categories served by the tracking clients. */ +const TRACKING_CATEGORIES = new Set([ + 'pokemon', + 'raid', + 'egg', + 'quest', + 'invasion', + 'incident', + 'lure', + 'nest', + 'gym', +]) + +/** Profile actions the client sends as `profiles-`. */ +const PROFILE_ACTIONS = new Set(['add', 'update', 'copy', 'byProfileNo']) + +/** @param {unknown} category */ +const unknownCategory = (category) => + new PoracleRequestError(`Unknown webhook category: ${category}`, 400) + +/** @param {unknown} value */ +const pad2 = (value) => String(value ?? 0).padStart(2, '0') + +/** + * Converts ReactMap's active hours (string hours/mins plus a client-side id) + * into v2's strict integer schedule entries. + * @param {Array> | undefined} entries + */ +function toV2ActiveHours(entries) { + return (entries || []).map( + ({ day, hours, mins, step, end_hours: endHours, end_mins: endMins }) => { + /** @type {Record} */ + const entry = { day: +day, hours: +hours, mins: +mins } + if (+step > 0) { + entry.step = +step + entry.end_hours = +endHours + entry.end_mins = +endMins + } + return entry + }, + ) +} + +/** + * Converts v2 schedule entries into ReactMap's shape, with zero-padded string + * hours and minutes as v1 stores them. + * @param {Array> | null | undefined} entries + */ +function fromV2ActiveHours(entries) { + return (entries || []).map((entry) => ({ + ...entry, + hours: pad2(entry.hours), + mins: pad2(entry.mins), + })) +} const PLATFORMS = /** @type {const} */ (['discord', 'telegram']) @@ -37,9 +104,33 @@ const APIS = /** @type {APIReference} */ ({ }`, }) -const SUBCATEGORIES = /** @type {const} */ ({ - gym: ['raid', 'egg', 'gym'], -}) +/** PoracleNG v2 routes, used when the server supports them. */ +const V2 = { + human: (userId) => `/api/v2/humans/${userId}`, + action: (userId, action) => `/api/v2/humans/${userId}/${action}`, + profiles: (userId, suffix) => + `/api/v2/humans/${userId}/profiles${suffix ? `/${suffix}` : ''}`, +} + +/** + * OpenAPI paths (relative to the spec's `/api` server) that the v2 code path + * relies on. `auto` only switches to v2 when all of them are served. + */ +const V2_REQUIRED_PATHS = [ + '/v2/humans/{id}', + '/v2/humans/{id}/areas', + '/v2/humans/{id}/enable', + '/v2/humans/{id}/disable', + '/v2/humans/{id}/location', + '/v2/humans/{id}/profile', + '/v2/humans/{id}/profiles', + '/v2/humans/{id}/profiles/{profile_no}', + '/v2/humans/{id}/profiles/{profile_no}/copy', + ...[...TRACKING_CATEGORIES].flatMap((type) => [ + `/v2/humans/{id}/tracking/${type}`, + `/v2/humans/{id}/tracking/${type}/{uid}`, + ]), +] /** * Geocoding backends ReactMap can drive. Poracle also offers `google` and @@ -76,6 +167,12 @@ function resolveGeocoderProvider({ remote, local, usingRemoteURL }) { } class PoracleAPI { + /** @type {ReturnType} */ + #tracking + + /** @type {ReturnType} */ + #http + /** @param {import("@rm/types").Config['webhooks'][number]} webhook */ constructor(webhook) { if (!webhook.name) @@ -91,6 +188,10 @@ class PoracleAPI { this.nominatimUrl = webhook.nominatimUrl this.geocoderProvider = webhook.geocoderProvider this.enabled = webhook.enabled || false + this.apiVersion = webhook.apiVersion || 'auto' + /** Whether requests go to the PoracleNG v2 API, resolved during init */ + this.v2 = this.apiVersion === 'v2' + this.#buildTracking() this.discordRoles = webhook.discordRoles || [] this.telegramGroups = webhook.telegramGroups || [] @@ -136,6 +237,7 @@ class PoracleAPI { leagues: this.leagues, pvp: this.pvp, ui: this.ui, + capabilities: { edit: this.v2, summary: this.v2 }, } } @@ -145,11 +247,18 @@ class PoracleAPI { * @returns */ getAllowedCategories(blockedAlerts = []) { - const categories = Object.keys(this.ui).filter( - (key) => - !(this.disabledHooks.includes(key) || blockedAlerts?.includes(key)), - ) - return categories + // Pokestop events come from the invasion webhook, so its switch covers + // them, and both Poracles match them as invasion alerts: an `invasion` + // block (the only one blocked_alerts carries for them) stops them too. + const disabled = (key) => + this.disabledHooks.includes(key) || + (key === 'incident' && this.disabledHooks.includes('invasion')) + // blocked_alerts uses Poracle's alert names: pokemon alerts are `monster`. + const blocked = (key) => + blockedAlerts?.includes(key) || + (key === 'incident' && blockedAlerts?.includes('invasion')) || + (key === 'pokemon' && blockedAlerts?.includes('monster')) + return Object.keys(this.ui).filter((key) => !disabled(key) && !blocked(key)) } /** @@ -179,7 +288,9 @@ class PoracleAPI { */ async getUserAreas(userId) { /** @type {{ areas: import("@rm/types").PoracleHumanArea[] }} */ - const { areas } = await this.#sendRequest(APIS.humans(userId)) + const { areas } = await this.#sendRequest( + this.v2 ? V2.action(userId, 'areas') : APIS.humans(userId), + ) const areaGroups = areas.reduce((groupMap, area) => { if (area.userSelectable) { if (!groupMap[area.group]) groupMap[area.group] = [] @@ -218,22 +329,47 @@ class PoracleAPI { } } + /** Picks the tracking client matching the resolved API version. */ + #buildTracking() { + this.#http = createPoracleHttp({ + endpoint: this.endpoint, + secret: this.secret, + }) + this.#tracking = this.v2 + ? createTrackingV2(this.#http) + : createTrackingV1(this.#http) + } + initFromCache() { const cached = getCache(`${this.name}-webhook.json`) if (!cached) { log.warn(TAGS.webhooks, `${this.name} webhook not found in cache`) return false } + // The configured apiVersion wins over a cached one; a cached `auto` + // result is only reused while the config still says `auto`. + const { apiVersion } = this Object.assign(this, cached) + this.apiVersion = apiVersion + if (apiVersion !== 'auto') this.v2 = apiVersion === 'v2' + this.#buildTracking() } async init() { try { - await Promise.all([ - this.#fetchConfig(), - this.#fetchGeojson(), - this.#fetchTemplates(), - ]) + // Settle all first: a failed webhook is still used, so none may change + // it after init returns (detection would flip v2 under live requests). + const failed = ( + await Promise.allSettled([ + this.#fetchConfig(), + this.#fetchGeojson(), + this.#fetchTemplates(), + this.#detectApiVersion(), + ]) + ).find((result) => result.status === 'rejected') + if (failed) throw failed.reason + if (!this.v2) this.#useInvasionTemplatesForIncidents() + this.#buildTracking() this.ui = this.generateUi() this.lastFetched = Date.now() log.info(TAGS.webhooks, `${this.name} webhook initialized`) @@ -299,6 +435,38 @@ class PoracleAPI { } } + /** + * Resolves `apiVersion: 'auto'` by checking the server's public OpenAPI spec + * for the v2 routes this service uses. PoracleJS has no spec, so it stays on + * v1; the probe is quiet because that miss is expected. + */ + async #detectApiVersion() { + if (this.apiVersion !== 'auto') { + this.v2 = this.apiVersion === 'v2' + this.#buildTracking() + return + } + try { + const response = await fetch(`${this.endpoint}/openapi.json`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(10_000), + }) + const spec = response.ok ? await response.json() : null + const paths = spec?.paths || {} + this.v2 = V2_REQUIRED_PATHS.every((path) => path in paths) + } catch (e) { + log.debug(TAGS.webhooks, `[${this.name}] v2 API probe failed`, e) + this.v2 = false + } + log.info( + TAGS.webhooks, + `[${this.name}] using Poracle API ${this.v2 ? 'v2' : 'v1'}`, + ) + // Right away: if another init request fails, the webhook is still used, + // and tracking must not stay on the other API version. + this.#buildTracking() + } + async #fetchGeojson() { /** @type {{ geoJSON: import("@rm/types").RMGeoJSON }} */ const { geoJSON } = await this.#sendRequest(APIS.geofence) @@ -325,6 +493,14 @@ class PoracleAPI { } } + /** PoracleJS (v1) renders pokestop events with its invasion templates. */ + #useInvasionTemplatesForIncidents() { + PLATFORMS.forEach((platform) => { + const list = this.templates[platform] + if (list?.invasion) list.incident = list.invasion + }) + } + /** * * @param {string} path @@ -345,28 +521,48 @@ class PoracleAPI { return response } + /** + * A v2 request through the Poracle transport: only sends a body when one is + * given (v2 is strict about bodies), and rejects with a PoracleRequestError + * on any failure, so an error never reads as an empty result. + * @param {string} path + * @param {Method} [method] + * @param {any} [body] + */ + async #sendV2(path, method = 'GET', body = undefined) { + return this.#http.request(path, { method, body }) + } + + /** + * v1 returns the list columns as JSON-encoded strings, v2 as arrays. + * @param {string | string[] | null | undefined} value + * @returns {string[]} + */ + static #parseList(value) { + if (!value) return [] + return typeof value === 'string' ? JSON.parse(value) : value + } + /** * * @param {string | number} userId * @returns */ async #oneHuman(userId) { - const { human } = await this.#sendRequest(APIS.oneHuman(userId)) + const { human } = this.v2 + ? await this.#sendV2(V2.human(userId)) + : await this.#sendRequest(APIS.oneHuman(userId)) return { human: { ...human, enabled: !!human.enabled, admin_disable: !!human.admin_disable, - area: human.area ? JSON.parse(human.area) : [], - area_restrictions: human.area_restrictions - ? JSON.parse(human.area_restrictions) - : [], - blocked_alerts: human.blocked_alerts - ? JSON.parse(human.blocked_alerts) - : [], - community_membership: human.community_membership - ? JSON.parse(human.community_membership) - : [], + area: PoracleAPI.#parseList(human.area), + area_restrictions: PoracleAPI.#parseList( + this.v2 ? human.area_restriction : human.area_restrictions, + ), + blocked_alerts: PoracleAPI.#parseList(human.blocked_alerts), + community_membership: PoracleAPI.#parseList(human.community_membership), }, } } @@ -396,15 +592,18 @@ class PoracleAPI { */ async #profileManagement(userId, category, method, data) { const [, action] = PoracleAPI.#split(category) + if (action && !PROFILE_ACTIONS.has(action)) throw unknownCategory(category) const first = await this.#sendRequest( action ? APIS.profileAction( userId, action, - `${action === 'copy' ? `${data.from}/${data.to}` : ''}${ - method === 'DELETE' ? `${data}` : '' - }`, + `${ + action === 'copy' + ? `${pathInt(data.from, 'profile')}/${pathInt(data.to, 'profile')}` + : '' + }${method === 'DELETE' ? pathInt(data, 'profile') : ''}`, ) : APIS.profiles(userId), method, @@ -412,11 +611,25 @@ class PoracleAPI { ? { ...data, active_hours: - data?.active_hours?.map(({ day, mins, hours }) => ({ - day, - mins: mins.padStart(2, '0'), - hours: hours.padStart(2, '0'), - })) || [], + data?.active_hours?.map( + ({ + day, + mins, + hours, + step, + end_hours: endHours, + end_mins: endMins, + }) => ({ + day, + mins: mins.padStart(2, '0'), + hours: hours.padStart(2, '0'), + // PoracleNG range entries repeat every `step` hours until + // the end time; keep them so a save does not flatten them. + ...(+step > 0 + ? { step: +step, end_hours: +endHours, end_mins: +endMins } + : {}), + }), + ) || [], } : undefined, ) @@ -435,11 +648,13 @@ class PoracleAPI { static #processProfiles(profiles) { return profiles.map((profile) => ({ ...profile, - area: profile.area ? JSON.parse(profile.area) : [], + area: PoracleAPI.#parseList(profile.area), active_hours: profile.active_hours && profile.active_hours !== '{}' ? /** @type {import("@rm/types").PoracleActiveHours[]} */ ( - JSON.parse(profile.active_hours) + typeof profile.active_hours === 'string' + ? JSON.parse(profile.active_hours) + : profile.active_hours ) .sort((a, b) => a.day === b.day @@ -460,7 +675,11 @@ class PoracleAPI { * @returns */ async #setLocation(userId, location) { - await this.#sendRequest(APIS.location(userId, location), 'POST') + if (!Array.isArray(location) || location.length !== 2) { + throw new PoracleRequestError('Invalid location', 400) + } + const coords = location.map((value) => pathNumber(value, 'location')) + await this.#sendRequest(APIS.location(userId, coords), 'POST') const second = await this.#oneHuman(userId) return second } @@ -477,33 +696,6 @@ class PoracleAPI { return { ...first, ...second } } - /** @type {PoracleAPI['api']} */ - async #tracking(userId, category, method, data) { - const [main, action] = PoracleAPI.#split(category) - const first = action - ? await this.#sendRequest( - APIS.tracking(userId, main, action), - method, - method === 'POST' ? data : undefined, - ) - : await this.#sendRequest( - APIS.tracking( - userId, - main, - method === 'DELETE' ? `byUid/${data.uid}` : '', - ), - method, - data, - ) - const second = await this.#sendRequest(APIS.tracking(userId, main)) - - const sortBy = this.ui[category]?.sortProp - if (sortBy) { - second[category].sort((a, b) => a[sortBy] - b[sortBy]) - } - return { ...first, ...second } - } - /** * * @template {APIInput} T @@ -514,15 +706,20 @@ class PoracleAPI { * @returns {Promise[0]]>} */ async api(userId, category, method = 'GET', data = null) { + if (this.v2) return this.#apiV2(userId, category, method, data) const [main, action] = PoracleAPI.#split(category) + if (TRACKING_CATEGORIES.has(main)) { + return this.#trackingApi(userId, main, action, method, data) + } switch (main) { case 'start': case 'stop': return this.#setActive(userId, category, method) case 'switchProfile': - return this.#sendRequest(APIS.switchProfile(userId, data), method).then( - () => this.#oneHuman(userId), - ) + return this.#sendRequest( + APIS.switchProfile(userId, pathInt(data, 'profile')), + method, + ).then(() => this.#oneHuman(userId)) case 'setLocation': return this.#setLocation(userId, data) case 'setAreas': @@ -541,57 +738,254 @@ class PoracleAPI { method, data, ) - case 'egg': - case 'invasion': - case 'lure': - case 'nest': - case 'pokemon': - case 'quest': - case 'raid': - case 'gym': - return this.#tracking(userId, category, method, data) case 'quickGym': return this.#quickGym(userId, data) case 'human': case 'oneHuman': return this.#oneHuman(userId) default: - return this.#tracking(userId, category, method, data) + throw unknownCategory(category) } } /** - * + * Runs one tracking operation and returns the fresh rule list. * @param {string | number} userId - * @param {string} data + * @param {string} category + * @param {Action | undefined} action + * @param {Method} method + * @param {any} data + * @returns {Promise} */ - async #quickGym(userId, data) { - await Promise.all( - SUBCATEGORIES.gym.map((subCategory) => - this.#sendRequest(APIS.tracking(userId, subCategory), 'POST', { - ...this.ui[subCategory].defaults, - ...this.#wildCards(subCategory), - gym_id: data, - }), - ), - ) - return this.#sendRequest(APIS.tracking(userId, 'gym')) + async #trackingApi(userId, category, action, method, data) { + const id = String(userId) + return this.#runTracking(id, category, async () => { + if (action === 'delete') { + await this.#tracking.remove(id, category, data || []) + } else if (method === 'DELETE') { + await this.#tracking.remove(id, category, [data.uid]) + } else if (method === 'POST') { + await this.#tracking.save( + id, + category, + Array.isArray(data) ? data : [data], + ) + } + }) } /** - * - * @param {typeof SUBCATEGORIES.gym[number]} category - * @returns + * Runs a write, then lists the category; request and adapter errors become + * a `status: 'error'` result carrying the current rules. + * @param {string} id + * @param {string} category + * @param {() => Promise} operation + */ + async #runTracking(id, category, operation) { + /** @type {string | undefined} */ + let message + try { + await operation() + } catch (e) { + if (e instanceof PoracleRequestError || e instanceof UnsupportedOnV1) { + message = e.message + } else { + throw e + } + } + // Stays null when the read fails, so the client keeps the rules it shows. + /** @type {Record[] | null} */ + let rules = null + try { + rules = await this.#tracking.list(id, category) + } catch (e) { + if (!(e instanceof PoracleRequestError)) throw e + message ||= e.message + } + const sortBy = this.ui[category]?.sortProp + if (sortBy && rules) rules.sort((a, b) => a[sortBy] - b[sortBy]) + if (message !== undefined) { + log.warn(TAGS.webhooks, `${this.name} ${category} tracking:`, message) + } + return message === undefined + ? { status: 'ok', [category]: rules } + : { status: 'error', message, [category]: rules } + } + + /** + * Tracks a gym with minimal raid, egg and gym rules. + * @param {string | number} userId + * @param {string} gymId + */ + async #quickGym(userId, gymId) { + const id = String(userId) + return this.#runTracking(id, 'gym', async () => { + await Promise.all([ + this.#tracking.save(id, 'raid', [{ gym_id: gymId }]), + this.#tracking.save(id, 'egg', [{ level: 90, gym_id: gymId }]), + this.#tracking.save(id, 'gym', [ + { + team: 'any', + slot_changes: true, + battle_changes: this.gymBattles, + gym_id: gymId, + }, + ]), + ]) + }) + } + + /** + * v2 counterpart of api(): same categories, same v1-shaped results. + * @param {string | number} userId + * @param {APIInput} category + * @param {Method} method + * @param {any} data + * @returns {Promise} + */ + async #apiV2(userId, category, method = 'GET', data = null) { + const [main, action] = PoracleAPI.#split(category) + if (TRACKING_CATEGORIES.has(main)) { + return this.#trackingApi(userId, main, action, method, data) + } + if (main === 'profile' || main === 'profiles') { + return this.#profileManagementV2(userId, action, method, data) + } + try { + return await this.#humanApiV2(userId, category, main, data) + } catch (e) { + if (!(e instanceof PoracleRequestError)) throw e + log.warn(TAGS.webhooks, `${this.name} ${category}:`, e.message) + // No human in the result: the client keeps the one it shows. + return { status: 'error', message: e.message } + } + } + + /** + * The v2 human actions of #apiV2. + * @param {string | number} userId + * @param {APIInput} category + * @param {Category} main + * @param {any} data + */ + async #humanApiV2(userId, category, main, data) { + switch (main) { + case 'start': + case 'stop': + await this.#sendV2( + V2.action(userId, main === 'start' ? 'enable' : 'disable'), + 'POST', + ) + return this.#oneHuman(userId) + case 'switchProfile': + await this.#sendV2(V2.action(userId, 'profile'), 'POST', { + profile_no: +data, + }) + return this.#oneHuman(userId) + case 'setLocation': + await this.#sendV2(V2.action(userId, 'location'), 'POST', { + latitude: +data[0], + longitude: +data[1], + }) + return this.#oneHuman(userId) + case 'setAreas': { + const first = await this.#sendV2(V2.action(userId, 'areas'), 'POST', { + areas: data, + }) + return { ...first, ...(await this.#oneHuman(userId)) } + } + case 'geojson': + return this.#sendRequest(APIS.geofence) + case 'areaSecurity': + return this.#sendRequest(APIS.areaSecurity(userId)) + case 'humans': + return this.#sendV2(V2.action(userId, 'areas')) + case 'quickGym': + return this.#quickGym(userId, data) + case 'human': + case 'oneHuman': + return this.#oneHuman(userId) + default: + throw unknownCategory(category) + } + } + + /** + * @param {string | number} userId + * @param {Action | undefined} action + * @param {Method} method + * @param {any} data */ - #wildCards(category) { - switch (category) { - case 'gym': - return { team: 4, slot_changes: true, battle_changes: this.gymBattles } - case 'egg': - case 'raid': - return { level: 90 } + async #profileManagementV2(userId, action, method, data) { + /** @type {string | undefined} */ + let message + try { + await this.#profileWriteV2(userId, action, method, data) + } catch (e) { + if (!(e instanceof PoracleRequestError)) throw e + message = e.message + } + // Stays null when the read fails, so the client keeps the profiles it shows. + /** @type {import("@rm/types").PoracleProfile[] | null} */ + let profile = null + try { + const { profiles } = await this.#sendV2(V2.profiles(userId)) + profile = PoracleAPI.#processProfiles( + (profiles || []).map((p) => ({ + ...p, + active_hours: fromV2ActiveHours(p.active_hours), + })), + ) + } catch (e) { + if (!(e instanceof PoracleRequestError)) throw e + message ||= e.message + } + if (message === undefined) return { status: 'ok', profile } + log.warn(TAGS.webhooks, `${this.name} profiles:`, message) + return { status: 'error', message, profile } + } + + /** + * The write half of a v2 profile action; a plain read writes nothing. + * @param {string | number} userId + * @param {Action | undefined} action + * @param {Method} method + * @param {any} data + */ + async #profileWriteV2(userId, action, method, data) { + switch (action) { + case 'add': + await this.#sendV2(V2.profiles(userId), 'POST', { + name: data.name, + ...(data.active_hours?.length + ? { active_hours: toV2ActiveHours(data.active_hours) } + : {}), + }) + break + case 'update': + // v1's update only ever wrote active_hours; keep it that way. + await this.#sendV2( + V2.profiles(userId, pathInt(data.profile_no, 'profile')), + 'PATCH', + { active_hours: toV2ActiveHours(data.active_hours) }, + ) + break + case 'byProfileNo': + if (method === 'DELETE') { + await this.#sendV2( + V2.profiles(userId, pathInt(data, 'profile')), + 'DELETE', + ) + } + break + case 'copy': + await this.#sendV2( + V2.profiles(userId, `${pathInt(data.to, 'profile')}/copy`), + 'POST', + { from_profile: +data.from }, + ) + break default: - return {} } } @@ -617,30 +1011,31 @@ class PoracleAPI { identifiers: ['pokemon_id', 'form'], defaults: { clean: false, + edit: false, distance: 0, template: this.defaultTemplateName.toString(), - pokemon_id: 0, - form: 0, - gender: 0, - atk: 0, - max_atk: 15, - min_cp: 0, - max_cp: 9000, - def: 0, - max_def: 15, - min_iv: -1, - max_iv: 100, - min_level: 0, - max_level: 55, - size: 1, - max_size: 5, - rarity: -1, - max_rarity: 6, - sta: 0, - max_sta: 15, - max_weight: 9000000, + pokemon_id: null, + form: null, + gender: null, + atk: null, + max_atk: null, + min_cp: null, + max_cp: null, + def: null, + max_def: null, + min_iv: null, + max_iv: null, + min_level: null, + max_level: null, + size: null, + max_size: null, + rarity: null, + max_rarity: null, + sta: null, + max_sta: null, + max_weight: null, min_time: 0, - min_weight: 0, + min_weight: null, pvp_ranking_league: 0, pvp_ranking_best: 1, pvp_ranking_worst: this.pvpFilterMaxRank, @@ -668,6 +1063,8 @@ class PoracleAPI { perm: 'iv', low: 'min_iv', high: 'max_iv', + anyLow: true, + anyHigh: true, }, { name: 'level', @@ -677,6 +1074,8 @@ class PoracleAPI { perm: 'iv', low: 'min_level', high: 'max_level', + anyLow: true, + anyHigh: true, }, ], }, @@ -690,6 +1089,8 @@ class PoracleAPI { perm: 'iv', low: 'min_cp', high: 'max_cp', + anyLow: true, + anyHigh: true, }, { name: 'atk_iv', @@ -699,6 +1100,8 @@ class PoracleAPI { perm: 'iv', low: 'atk', high: 'max_atk', + anyLow: true, + anyHigh: true, }, { name: 'def_iv', @@ -708,6 +1111,8 @@ class PoracleAPI { perm: 'iv', low: 'def', high: 'max_def', + anyLow: true, + anyHigh: true, }, { name: 'sta_iv', @@ -717,6 +1122,8 @@ class PoracleAPI { perm: 'iv', low: 'sta', high: 'max_sta', + anyLow: true, + anyHigh: true, }, { name: 'size', @@ -727,6 +1134,8 @@ class PoracleAPI { perm: 'iv', low: 'size', high: 'max_size', + anyLow: true, + anyHigh: true, marks: [1, 2, 3, 4, 5], markI18n: 'size_', }, @@ -783,6 +1192,8 @@ class PoracleAPI { perm: 'pvp', low: 'pvp_ranking_best', high: 'pvp_ranking_worst', + anyLow: false, + anyHigh: false, }, ], }, @@ -796,10 +1207,16 @@ class PoracleAPI { sm: 4, }, { name: 'template', options: [], xs: 4, sm: 4 }, - { name: 'gender', options: [0, 1, 2], xs: 4, sm: 4 }, + { + name: 'gender', + options: [null, 'male', 'female'], + xs: 4, + sm: 4, + }, ], booleans: [ { name: 'clean', xs: 6, sm: 3 }, + { name: 'edit', xs: 6, sm: 3 }, { name: 'allForms', xs: 6, sm: 3 }, { name: 'pvpEntry', xs: 6, sm: 3 }, { name: 'noIv', xs: 6, sm: 3 }, @@ -834,16 +1251,17 @@ class PoracleAPI { identifiers: ['pokemon_id', 'form', 'level'], defaults: { clean: false, + edit: false, distance: 0, template: this.defaultTemplateName.toString(), - pokemon_id: 9000, - evolution: 9000, - form: 0, - move: 9000, + pokemon_id: null, + evolution: null, + form: null, + move: null, exclusive: false, - level: 9000, - team: 4, - rsvp_changes: 0, + level: null, + team: null, + rsvp_changes: null, gym_id: null, byDistance: false, allMoves: true, @@ -864,12 +1282,23 @@ class PoracleAPI { sm: 4, }, { name: 'template', options: [], xs: 6, sm: 4 }, - { name: 'team', options: [0, 1, 2, 3, 4], xs: 6, sm: 4 }, + { + name: 'team', + options: [null, 'harmony', 'mystic', 'valor', 'instinct'], + xs: 6, + sm: 4, + }, { name: 'move', options: [], xs: 6, sm: 5 }, - { name: 'rsvp_changes', options: [0, 1, 2], xs: 6, sm: 5 }, + { + name: 'rsvp_changes', + options: [null, 'rsvp', 'rsvp_only'], + xs: 6, + sm: 5, + }, ], booleans: [ { name: 'clean', xs: 6, sm: 3 }, + { name: 'edit', xs: 6, sm: 3 }, { name: 'exclusive', xs: 6, sm: 3 }, { name: 'allForms', disabled: ['r'], xs: 6, sm: 3 }, { name: 'allMoves', disabled: ['r'], xs: 6, sm: 3 }, @@ -913,12 +1342,13 @@ class PoracleAPI { identifiers: ['level'], defaults: { clean: false, + edit: false, distance: 0, template: this.defaultTemplateName.toString(), exclusive: false, - level: 9000, - team: 4, - rsvp_changes: 0, + level: 90, + team: null, + rsvp_changes: null, gym_id: null, byDistance: false, everything_individually: @@ -937,12 +1367,23 @@ class PoracleAPI { sm: 3, }, { name: 'template', options: [], xs: 6, sm: 3 }, - { name: 'team', options: [0, 1, 2, 3, 4], xs: 6, sm: 3 }, - { name: 'rsvp_changes', options: [0, 1, 2], xs: 6, sm: 3 }, + { + name: 'team', + options: [null, 'harmony', 'mystic', 'valor', 'instinct'], + xs: 6, + sm: 3, + }, + { + name: 'rsvp_changes', + options: [null, 'rsvp', 'rsvp_only'], + xs: 6, + sm: 3, + }, ], booleans: [ - { name: 'clean', xs: 6, sm: 6 }, - { name: 'exclusive', xs: 6, sm: 6 }, + { name: 'clean', xs: 4, sm: 4 }, + { name: 'edit', xs: 4, sm: 4 }, + { name: 'exclusive', xs: 4, sm: 4 }, ], autoComplete: [ { @@ -985,7 +1426,7 @@ class PoracleAPI { clean: false, distance: 0, template: this.defaultTemplateName.toString(), - team: 4, + team: 'any', slot_changes: false, battle_changes: false, gym_id: null, @@ -1056,7 +1497,7 @@ class PoracleAPI { distance: 0, template: this.defaultTemplateName.toString(), grunt_type: null, - gender: 0, + gender: null, byDistance: false, everything_individually: this.everythingFlagPermissions === @@ -1102,10 +1543,60 @@ class PoracleAPI { }, }, }, + incident: { + identifiers: ['display_type'], + defaults: { + clean: false, + edit: false, + distance: 0, + template: this.defaultTemplateName.toString(), + display_type: null, + byDistance: false, + }, + ui: { + general: { + selects: [ + { + name: 'profile_no', + disabled: true, + options: [], + xs: 4, + sm: 4, + }, + { name: 'template', options: [], xs: 4, sm: 4 }, + ], + booleans: [ + { name: 'clean', xs: 4, sm: 4 }, + { name: 'edit', xs: 4, sm: 4 }, + ], + distanceOrArea: { + booleans: [ + { + name: 'byDistance', + max: this.maxDistance, + xs: 6, + sm: 8, + override: true, + }, + ], + texts: [ + { + name: 'distance', + type: 'number', + adornment: 'm', + xs: 6, + sm: 4, + }, + ], + }, + }, + }, + }, lure: { identifiers: ['lure_id'], defaults: { clean: false, + edit: false, distance: 0, template: this.defaultTemplateName.toString(), lure_id: 0, @@ -1123,7 +1614,10 @@ class PoracleAPI { }, { name: 'template', options: [], xs: 4, sm: 4 }, ], - booleans: [{ name: 'clean', xs: 4, sm: 4 }], + booleans: [ + { name: 'clean', xs: 4, sm: 4 }, + { name: 'edit', xs: 4, sm: 4 }, + ], distanceOrArea: { booleans: [ { @@ -1151,13 +1645,14 @@ class PoracleAPI { identifiers: ['reward', 'reward_type'], defaults: { clean: false, + summary: false, distance: 0, template: this.defaultTemplateName.toString(), reward: null, - shiny: 0, + shiny: false, reward_type: 0, amount: 0, - form: 0, + form: null, byDistance: false, allForms: false, }, @@ -1175,6 +1670,7 @@ class PoracleAPI { ], booleans: [ { name: 'clean', xs: 4, sm: 4 }, + { name: 'summary', xs: 4, sm: 4 }, { name: 'allForms', xs: 6, @@ -1220,9 +1716,9 @@ class PoracleAPI { clean: false, distance: 0, template: this.defaultTemplateName.toString(), - pokemon_id: 0, + pokemon_id: null, min_spawn_avg: 0, - form: 0, + form: null, byDistance: false, allForms: false, }, @@ -1284,23 +1780,11 @@ class PoracleAPI { // this.disabledHooks.forEach((hook) => delete poracleUiObj[hook]) return poracleUiObj } - - /** - * - * @param {import('@rm/types').PoracleInvasion} item - * @param {import('@rm/masterfile').Masterfile['invasions']} invasions - */ - static getRealGruntId(item, invasions) { - return ( - +Object.keys(invasions).find( - (key) => - invasions[key]?.type?.toLowerCase() === - item.grunt_type.toLowerCase() && - (invasions[key]?.gender === 0 || - invasions[key].gender === (item.gender || 1)), - ) || 0 - ) - } } -module.exports = { PoracleAPI, resolveGeocoderProvider } +module.exports = { + PoracleAPI, + resolveGeocoderProvider, + toV2ActiveHours, + fromV2ActiveHours, +} diff --git a/server/src/services/poracle/http.js b/server/src/services/poracle/http.js new file mode 100644 index 000000000..5534143f1 --- /dev/null +++ b/server/src/services/poracle/http.js @@ -0,0 +1,143 @@ +// @ts-check +const { default: fetch } = require('node-fetch') + +const config = require('@rm/config') +const { log, TAGS } = require('@rm/logger') + +const { setLongTimeout } = require('../../utils/setLongTimeout') + +class PoracleRequestError extends Error { + /** + * @param {string} message + * @param {number} status + */ + constructor(message, status) { + super(message) + this.name = 'PoracleRequestError' + this.status = status + } +} + +/** + * The most specific message an error body carries: huma problem `detail` plus + * its `errors[]`, the v1 `{ status, message }` body, else the problem title. + * @param {any} parsed + * @param {{ status: number, statusText: string }} response + * @returns {string} + */ +function errorMessage(parsed, response) { + const details = Array.isArray(parsed?.errors) + ? parsed.errors + .filter((e) => e && e.message) + .map((e) => (e.location ? `${e.location}: ${e.message}` : e.message)) + : [] + if (parsed?.detail) { + return details.length + ? `${parsed.detail}: ${details.join('; ')}` + : parsed.detail + } + if (details.length) return details.join('; ') + if (typeof parsed?.message === 'string' && parsed.message) { + return parsed.message + } + return parsed?.title || `${response.status} ${response.statusText}` +} + +/** + * @param {{ endpoint: string, secret: string }} options + */ +function createPoracleHttp({ endpoint, secret }) { + return { + /** + * @param {string} path + * @param {{ method?: string, body?: any }} [options] + * @returns {Promise} + */ + async request(path, { method = 'GET', body } = {}) { + const controller = new AbortController() + const clear = setLongTimeout(() => { + controller.abort() + }, config.getSafe('api.fetchTimeoutMs')) + try { + /** @type {Record} */ + const headers = { 'X-Poracle-Secret': secret } + if (body !== undefined) headers['Content-Type'] = 'application/json' + /** @type {import('node-fetch').Response} */ + let response + let text = '' + try { + response = await fetch(`${endpoint}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }) + text = await response.text() + } catch (e) { + // Transport errors carry the internal URL: log it, never return it. + const timedOut = e?.name === 'AbortError' + log.warn( + TAGS.webhooks, + `Poracle ${method} ${path} ${timedOut ? 'timed out' : 'failed'}:`, + e?.message, + ) + throw new PoracleRequestError( + timedOut + ? 'Poracle server timed out' + : 'Poracle server unreachable', + 0, + ) + } + /** @type {any} */ + let parsed = {} + try { + parsed = text ? JSON.parse(text) : {} + } catch { + parsed = {} + } + // PoracleJS also reports some failures as 200 { status: 'error' }. + if (!response.ok || parsed?.status === 'error') { + throw new PoracleRequestError( + errorMessage(parsed, response), + response.status, + ) + } + return parsed + } finally { + clear() + } + }, + } +} + +/** + * A client-supplied id (rule uid, profile number) as a URL path segment. + * Anything but digits could move the request to another path, such as + * another human's rules, so it is refused before any request is made. + * @param {unknown} value + * @param {string} name + * @returns {string} + */ +function pathInt(value, name) { + const text = String(value) + if (!/^\d+$/.test(text)) { + throw new PoracleRequestError(`Invalid ${name}`, 400) + } + return text +} + +/** + * A client-supplied coordinate as a URL path segment. + * @param {unknown} value + * @param {string} name + * @returns {string} + */ +function pathNumber(value, name) { + const number = typeof value === 'string' && value.trim() ? +value : value + if (typeof number !== 'number' || !Number.isFinite(number)) { + throw new PoracleRequestError(`Invalid ${name}`, 400) + } + return String(number) +} + +module.exports = { createPoracleHttp, PoracleRequestError, pathInt, pathNumber } diff --git a/server/src/services/poracle/trackingV1.js b/server/src/services/poracle/trackingV1.js new file mode 100644 index 000000000..ff0481087 --- /dev/null +++ b/server/src/services/poracle/trackingV1.js @@ -0,0 +1,66 @@ +// @ts-check + +const { toV1Row, fromV1Row, v1Category, isIncidentRow } = require('./v1Adapter') + +/** + * @param {{ request: (path: string, options?: { method?: string, body?: any }) => Promise }} http + */ +function createTrackingV1(http) { + /** + * @param {string} userId + * @param {string} category + */ + const base = (userId, category) => + `/api/tracking/${v1Category(category)}/${encodeURIComponent(userId)}` + + return { + /** + * @param {string} userId + * @param {string} category + * @returns {Promise[]>} + */ + async list(userId, category) { + const response = await http.request(base(userId, category)) + /** @type {Record[]} */ + const rows = response?.[v1Category(category)] ?? [] + const wantIncident = category === 'incident' + return rows + .filter((row) => !wantIncident || isIncidentRow(row)) + .filter( + (row) => + wantIncident || category !== 'invasion' || !isIncidentRow(row), + ) + .map((row) => fromV1Row(category, row)) + }, + + /** + * @param {string} userId + * @param {string} category + * @param {Record[]} rules + */ + async save(userId, category, rules) { + if (!rules.length) return + // convert everything first so an unsupported rule sends nothing + const rows = rules.map((rule) => toV1Row(category, rule)) + await http.request(base(userId, category), { + method: 'POST', + body: rows, + }) + }, + + /** + * @param {string} userId + * @param {string} category + * @param {number[]} uids + */ + async remove(userId, category, uids) { + if (!uids.length) return + await http.request(`${base(userId, category)}/delete`, { + method: 'POST', + body: uids, + }) + }, + } +} + +module.exports = { createTrackingV1 } diff --git a/server/src/services/poracle/trackingV2.js b/server/src/services/poracle/trackingV2.js new file mode 100644 index 000000000..4bfbc39c6 --- /dev/null +++ b/server/src/services/poracle/trackingV2.js @@ -0,0 +1,93 @@ +// @ts-check + +const { pathInt } = require('./http') + +const INVASION_TARGETING = ['type_id', 'grunt_id', 'everything', 'boss'] + +/** + * Strips what a v2 write must not carry. + * @param {string} category + * @param {Record} rule + * @param {boolean} isEdit + */ +function cleanRule(category, rule, isEdit) { + /** @type {Record} */ + const out = {} + Object.entries(rule).forEach(([key, value]) => { + if (key === 'uid' || key === 'description' || value === undefined) return + if (isEdit && category === 'invasion' && INVASION_TARGETING.includes(key)) + return + out[key] = value + }) + return out +} + +/** + * @param {{ request: (path: string, options?: { method?: string, body?: any }) => Promise }} http + */ +function createTrackingV2(http) { + /** + * @param {string} userId + * @param {string} category + */ + const base = (userId, category) => + `/api/v2/humans/${encodeURIComponent(userId)}/tracking/${category}` + + return { + /** + * @param {string} userId + * @param {string} category + * @returns {Promise[]>} + */ + async list(userId, category) { + const response = await http.request( + `${base(userId, category)}?include_descriptions=true`, + ) + return response?.rules ?? [] + }, + + /** + * @param {string} userId + * @param {string} category + * @param {Record[]} rules + */ + async save(userId, category, rules) { + const created = rules.filter((r) => r.uid == null) + const edited = rules.filter((r) => r.uid != null) + // validate every uid before the first request goes out + const paths = edited.map( + (r) => `${base(userId, category)}/${pathInt(r.uid, 'rule uid')}`, + ) + const calls = edited.map((r, i) => + http.request(paths[i], { + method: 'PUT', + body: cleanRule(category, r, true), + }), + ) + if (created.length) { + calls.push( + http.request(base(userId, category), { + method: 'POST', + body: created.map((r) => cleanRule(category, r, false)), + }), + ) + } + await Promise.all(calls) + }, + + /** + * @param {string} userId + * @param {string} category + * @param {number[]} uids + */ + async remove(userId, category, uids) { + if (!uids.length) return + const list = uids.map((uid) => pathInt(uid, 'rule uid')).join(',') + await http.request(`${base(userId, category)}?uid=${list}`, { + method: 'DELETE', + }) + }, + } +} + +module.exports = { createTrackingV2 } diff --git a/server/src/services/poracle/v1Adapter.js b/server/src/services/poracle/v1Adapter.js new file mode 100644 index 000000000..feede3ce2 --- /dev/null +++ b/server/src/services/poracle/v1Adapter.js @@ -0,0 +1,367 @@ +// @ts-check + +/** + * Adapter between PoracleNG v2 rules and the v1 rows older Poracle servers + * speak. A v2 rule uses `null` for "any", named enums and separate + * `clean` / `edit` / `summary` booleans; a v1 row uses integer sentinels + * (9000, -1, 4096, ...), integer enums and a packed `clean` bitmask. + * + * `toV1Row` and `fromV1Row` are inverses for every supported field, and both + * are pure. + */ + +const TEAMS = /** @type {const} */ ([ + 'harmony', + 'mystic', + 'valor', + 'instinct', + 'any', +]) +const GENDERS = /** @type {const} */ (['any', 'male', 'female', 'genderless']) +const RSVP_CHANGES = /** @type {const} */ (['none', 'rsvp', 'rsvp_only']) + +/** v2 incident `display_type` ⇄ v1 invasion `grunt_type`. */ +const EVENT_NAMES = /** @type {Record} */ ({ + 7: 'gold-stop', + 8: 'kecleon', + 9: 'showcase', +}) +const EVENT_DISPLAY_TYPES = Object.fromEntries( + Object.entries(EVENT_NAMES).map(([id, name]) => [name, +id]), +) + +const RAID_LEVEL_ANY = 90 +const ANY_ID = 9000 + +/** + * `any` is the v1 sentinel that maps to `null` in v2. A `required` field has + * no wildcard in v2, so its value is kept as is. + * @typedef {{ kind: 'int', any?: number, required?: boolean } + * | { kind: 'enum', values: readonly string[], any: number, required?: boolean } + * | { kind: 'bool' } + * | { kind: 'string', empty: string | null } + * | { kind: 'list' }} FieldSpec + */ + +/** @type {Record} */ +const COMMON_FIELDS = { + distance: { kind: 'int', any: 0 }, + template: { kind: 'string', empty: '' }, + ping: { kind: 'string', empty: '' }, + override_location_label: { kind: 'string', empty: '' }, + override_areas: { kind: 'list' }, +} + +/** @type {Record>} */ +const FIELDS = { + pokemon: { + pokemon_id: { kind: 'int', any: 0 }, + form: { kind: 'int', any: 0 }, + costume: { kind: 'int', any: ANY_ID }, + min_iv: { kind: 'int', any: -1 }, + max_iv: { kind: 'int', any: 100 }, + min_cp: { kind: 'int', any: 0 }, + max_cp: { kind: 'int', any: 9000 }, + min_level: { kind: 'int', any: 0 }, + max_level: { kind: 'int', any: 55 }, + atk: { kind: 'int', any: 0 }, + def: { kind: 'int', any: 0 }, + sta: { kind: 'int', any: 0 }, + max_atk: { kind: 'int', any: 15 }, + max_def: { kind: 'int', any: 15 }, + max_sta: { kind: 'int', any: 15 }, + gender: { kind: 'enum', values: GENDERS, any: 0 }, + rarity: { kind: 'int', any: -1 }, + max_rarity: { kind: 'int', any: 6 }, + size: { kind: 'int', any: -1 }, + max_size: { kind: 'int', any: 5 }, + min_weight: { kind: 'int', any: 0 }, + max_weight: { kind: 'int', any: 9000000 }, + min_time: { kind: 'int', any: 0 }, + pvp_ranking_league: { kind: 'int', any: 0 }, + pvp_ranking_best: { kind: 'int', any: 1 }, + pvp_ranking_worst: { kind: 'int', any: 4096 }, + pvp_ranking_min_cp: { kind: 'int', any: 0 }, + pvp_ranking_cap: { kind: 'int', any: 0 }, + pvp_ranking_evolution: { kind: 'int', any: 0 }, + }, + // raid pokemon_id and level are derived from each other; see raidToV1. + raid: { + form: { kind: 'int', any: 0 }, + costume: { kind: 'int', any: ANY_ID }, + move: { kind: 'int', any: ANY_ID }, + evolution: { kind: 'int', any: ANY_ID }, + team: { kind: 'enum', values: TEAMS, any: 4 }, + exclusive: { kind: 'bool' }, + gym_id: { kind: 'string', empty: null }, + rsvp_changes: { kind: 'enum', values: RSVP_CHANGES, any: 0 }, + }, + egg: { + level: { kind: 'int', any: RAID_LEVEL_ANY, required: true }, + team: { kind: 'enum', values: TEAMS, any: 4 }, + exclusive: { kind: 'bool' }, + gym_id: { kind: 'string', empty: null }, + rsvp_changes: { kind: 'enum', values: RSVP_CHANGES, any: 0 }, + }, + gym: { + team: { kind: 'enum', values: TEAMS, any: 4, required: true }, + slot_changes: { kind: 'bool' }, + battle_changes: { kind: 'bool' }, + gym_id: { kind: 'string', empty: null }, + }, + quest: { + reward_type: { kind: 'int', any: 0, required: true }, + reward: { kind: 'int', any: 0 }, + amount: { kind: 'int', any: 0 }, + form: { kind: 'int', any: 0 }, + shiny: { kind: 'bool' }, + }, + lure: { + lure_id: { kind: 'int', any: 0, required: true }, + }, + nest: { + pokemon_id: { kind: 'int', any: 0 }, + form: { kind: 'int', any: 0 }, + min_spawn_avg: { kind: 'int', any: 0 }, + }, + invasion: { + grunt_type: { kind: 'string', empty: 'everything' }, + gender: { kind: 'enum', values: GENDERS.slice(0, 3), any: 0 }, + }, + // v1 has no incident type; see incidentToV1. + incident: {}, +} + +/** + * Thrown when a v2 rule can't be expressed on a v1 server. + */ +class UnsupportedOnV1 extends Error { + /** + * @param {string} field + * @param {string} category + */ + constructor(field, category) { + super(`This Poracle server doesn't support ${field} on ${category} rules.`) + this.name = 'UnsupportedOnV1' + this.field = field + this.category = category + } +} + +/** + * @param {string} category + * @returns {string} + */ +const v1Category = (category) => + category === 'incident' ? 'invasion' : category + +/** + * @param {Record} row + * @returns {boolean} + */ +const isIncidentRow = (row) => + typeof row?.grunt_type === 'string' && row.grunt_type in EVENT_DISPLAY_TYPES + +/** + * @param {number} value + * @param {1 | 2 | 4} bit + */ +const hasBit = (value, bit) => Math.floor(value / bit) % 2 === 1 + +/** + * @param {unknown} value + * @returns {number | undefined} + */ +function toInt(value) { + if (value === null || value === undefined || value === '') return undefined + const n = Number(value) + return Number.isFinite(n) ? Math.trunc(n) : undefined +} + +/** + * @param {unknown} value 0/1/true/false, or a numeric string + * @returns {boolean} + */ +const toBool = (value) => value === true || (!!value && value !== '0') + +/** + * Copies `specs` fields from a v2 rule into a v1 row, replacing `null` and + * missing values with the v1 sentinel. + * @param {Record} rule + * @param {Record} specs + * @param {Record} out + */ +function copyToV1(rule, specs, out) { + Object.entries(specs).forEach(([field, spec]) => { + const value = rule[field] + switch (spec.kind) { + case 'int': + out[field] = toInt(value) ?? spec.any ?? 0 + break + case 'enum': { + const index = spec.values.indexOf(value) + out[field] = index === -1 ? spec.any : index + break + } + case 'bool': + out[field] = toBool(value) ? 1 : 0 + break + case 'string': + out[field] = value ?? spec.empty + break + case 'list': + out[field] = value ?? null + break + default: + } + }) +} + +/** + * Copies `specs` fields from a v1 row into a complete v2 rule, turning each + * sentinel back into `null`. + * @param {Record} row + * @param {Record} specs + * @param {Record} out + */ +function copyFromV1(row, specs, out) { + Object.entries(specs).forEach(([field, spec]) => { + const value = row[field] + switch (spec.kind) { + case 'int': { + const n = toInt(value) + out[field] = + n === undefined || (!spec.required && n === spec.any) ? null : n + break + } + case 'enum': { + const n = toInt(value) + out[field] = + n === undefined || (!spec.required && n === spec.any) + ? null + : (spec.values[n] ?? null) + break + } + case 'bool': + out[field] = toBool(value) + break + case 'string': + out[field] = + value === undefined || value === null || value === spec.empty + ? null + : String(value) + break + case 'list': + out[field] = Array.isArray(value) ? value : null + break + default: + } + }) +} + +/** + * A raid names either a boss (`pokemon_id`, level unused) or a tier. + * @param {Record} rule + * @param {Record} out + */ +function raidToV1(rule, out) { + const pokemonId = toInt(rule.pokemon_id) + if (pokemonId) { + out.pokemon_id = pokemonId + out.level = ANY_ID + } else { + out.pokemon_id = ANY_ID + out.level = toInt(rule.level) ?? RAID_LEVEL_ANY + } +} + +/** + * @param {Record} row + * @param {Record} out + */ +function raidFromV1(row, out) { + const pokemonId = toInt(row.pokemon_id) + const level = toInt(row.level) + const isBoss = + pokemonId !== undefined && pokemonId > 0 && pokemonId !== ANY_ID + out.pokemon_id = isBoss ? pokemonId : null + out.level = + isBoss || + level === undefined || + level === ANY_ID || + level === RAID_LEVEL_ANY + ? null + : level +} + +/** + * @param {Record} rule + * @param {Record} out + */ +function incidentToV1(rule, out) { + const grunt = EVENT_NAMES[rule.display_type] + if (!grunt) throw new UnsupportedOnV1('display_type', 'incident') + out.grunt_type = grunt + out.gender = 0 +} + +/** + * Converts a v2 rule into the v1 row to post to `/api/tracking/{category}`. + * Incident rules become invasion rows. + * @param {string} category ReactMap category + * @param {Record} rule + * @returns {Record} + */ +function toV1Row(category, rule) { + /** @type {Record} */ + const row = {} + if (rule.uid !== undefined) row.uid = rule.uid + // An edit names its profile: v1 updates a pokemon row by uid and files a + // missing profile_no under the active profile, which moves the rule if the + // profile was switched (schedule, bot command) while it was being edited. + if (Number.isInteger(rule.profile_no)) row.profile_no = rule.profile_no + if (category === 'incident') incidentToV1(rule, row) + else copyToV1(rule, FIELDS[category] || {}, row) + if (category === 'raid') raidToV1(rule, row) + copyToV1(rule, COMMON_FIELDS, row) + row.clean = + (rule.clean ? 1 : 0) + (rule.edit ? 2 : 0) + (rule.summary ? 4 : 0) + return row +} + +/** + * Converts a v1 row into a complete v2 rule: every field of the category is + * present, `null` where the row holds the sentinel. + * @param {string} category ReactMap category (`incident` for event rows) + * @param {Record} row + * @returns {Record} + */ +function fromV1Row(category, row) { + /** @type {Record} */ + const rule = {} + if (row.uid !== undefined) rule.uid = row.uid + if (row.description !== undefined) rule.description = row.description + const profileNo = toInt(row.profile_no) + if (profileNo !== null && profileNo !== undefined) rule.profile_no = profileNo + if (category === 'incident') { + rule.display_type = EVENT_DISPLAY_TYPES[row.grunt_type] ?? null + } else { + copyFromV1(row, FIELDS[category] || {}, rule) + } + if (category === 'raid') raidFromV1(row, rule) + copyFromV1(row, COMMON_FIELDS, rule) + const clean = + toInt(typeof row.clean === 'boolean' ? +row.clean : row.clean) ?? 0 + rule.clean = hasBit(clean, 1) + rule.edit = hasBit(clean, 2) + rule.summary = hasBit(clean, 4) + return rule +} + +module.exports = { + UnsupportedOnV1, + v1Category, + isIncidentRow, + toV1Row, + fromV1Row, +} diff --git a/server/test/poracleTracking.test.js b/server/test/poracleTracking.test.js new file mode 100644 index 000000000..834b427c8 --- /dev/null +++ b/server/test/poracleTracking.test.js @@ -0,0 +1,1095 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const http = require('node:http') +const path = require('node:path') +const { test } = require('node:test') + +const { + PoracleAPI, + toV2ActiveHours, + fromV2ActiveHours, +} = require('../src/services/Poracle') +const { + toV1Row, + fromV1Row, + isIncidentRow, + v1Category, + UnsupportedOnV1, +} = require('../src/services/poracle/v1Adapter') +const { + createPoracleHttp, + PoracleRequestError, +} = require('../src/services/poracle/http') +const { createTrackingV2 } = require('../src/services/poracle/trackingV2') +const { createTrackingV1 } = require('../src/services/poracle/trackingV1') + +test('v1 adapter: pokemon wildcards become sentinels', () => { + const rule = { + pokemon_id: 25, + min_iv: 90, + max_iv: null, + gender: 'female', + size: null, + clean: true, + edit: true, + summary: false, + } + const row = toV1Row('pokemon', rule) + assert.partialDeepStrictEqual(row, { + pokemon_id: 25, + min_iv: 90, + max_iv: 100, + gender: 2, + size: -1, + max_size: 5, + max_weight: 9000000, + pvp_ranking_worst: 4096, + costume: 9000, + clean: 3, + }) + const back = fromV1Row('pokemon', row) + assert.equal(back.pokemon_id, 25) + assert.equal(back.min_iv, 90) + assert.equal(back.max_iv, null) + assert.equal(back.gender, 'female') + assert.equal(back.size, null) + assert.equal(back.costume, null) + assert.equal(back.max_weight, null) + assert.equal(back.pvp_ranking_worst, null) + assert.equal(back.clean, true) + assert.equal(back.edit, true) + assert.equal(back.summary, false) + // a complete rule: re-encoding it is stable + assert.deepEqual(toV1Row('pokemon', back), row) + Object.entries(rule).forEach(([k, v]) => { + if (v !== null) assert.equal(back[k], v) + }) +}) + +test('v1 adapter: raid boss, level and everything', () => { + assert.partialDeepStrictEqual( + toV1Row('raid', { pokemon_id: 150, level: null }), + { pokemon_id: 150, level: 9000 }, + ) + assert.partialDeepStrictEqual( + toV1Row('raid', { pokemon_id: null, level: 5, team: 'mystic' }), + { pokemon_id: 9000, level: 5, team: 1 }, + ) + assert.equal(toV1Row('raid', { pokemon_id: null, level: null }).level, 90) + + const boss = fromV1Row('raid', { pokemon_id: 150, level: 9000, team: 4 }) + assert.equal(boss.pokemon_id, 150) + assert.equal(boss.level, null) + assert.equal(boss.team, null) + const tier = fromV1Row('raid', { pokemon_id: 9000, level: 5, team: 1 }) + assert.equal(tier.pokemon_id, null) + assert.equal(tier.level, 5) + assert.equal(tier.team, 'mystic') + const all = fromV1Row('raid', { pokemon_id: 9000, level: 90 }) + assert.equal(all.pokemon_id, null) + assert.equal(all.level, null) +}) + +test('v1 adapter: incident is an invasion row on v1', () => { + assert.partialDeepStrictEqual( + toV1Row('incident', { display_type: 9, clean: true }), + { grunt_type: 'showcase', gender: 0, clean: 1 }, + ) + assert.partialDeepStrictEqual( + fromV1Row('incident', { uid: 3, grunt_type: 'kecleon' }), + { uid: 3, display_type: 8 }, + ) + assert.equal(isIncidentRow({ grunt_type: 'gold-stop' }), true) + assert.equal(isIncidentRow({ grunt_type: 'water' }), false) + assert.equal(v1Category('incident'), 'invasion') + assert.equal(v1Category('raid'), 'raid') +}) + +test('v1 adapter: unknown event type is unsupported', () => { + assert.throws( + () => toV1Row('incident', { display_type: 12 }), + (e) => + e instanceof UnsupportedOnV1 && + e.field === 'display_type' && + e.category === 'incident' && + e.message === + "This Poracle server doesn't support display_type on incident rules.", + ) +}) + +test('v1 round trip keeps NG-only fields', () => { + const rule = fromV1Row('pokemon', { + uid: 1, + pokemon_id: 25, + clean: 3, + costume: 4, + override_areas: ['town'], + pvp_ranking_league: 1500, + pvp_ranking_evolution: 1, + }) + assert.partialDeepStrictEqual(rule, { + costume: 4, + override_areas: ['town'], + pvp_ranking_evolution: 1, + clean: true, + edit: true, + }) + assert.partialDeepStrictEqual(toV1Row('pokemon', rule), { + clean: 3, + costume: 4, + override_areas: ['town'], + pvp_ranking_evolution: 1, + uid: 1, + }) +}) + +test('v1 edits keep the profile the rule was read from', () => { + const rule = fromV1Row('pokemon', { uid: 1, pokemon_id: 25, profile_no: 2 }) + assert.equal(rule.profile_no, 2) + assert.equal(toV1Row('pokemon', rule).profile_no, 2) + // a new rule has none, so Poracle files it under the active profile + assert.equal('profile_no' in toV1Row('pokemon', { pokemon_id: 25 }), false) +}) + +test('v1 adapter: gym team and quest summary', () => { + assert.partialDeepStrictEqual( + toV1Row('gym', { team: 'any', slot_changes: true }), + { team: 4, slot_changes: 1 }, + ) + assert.partialDeepStrictEqual( + toV1Row('quest', { + reward_type: 7, + shiny: true, + summary: true, + clean: false, + }), + { shiny: 1, clean: 4 }, + ) +}) + +/** + * @param {{ routes?: Record }} [opts] + */ +async function stubPoracle({ + routes = {}, + spec = null, + templates = { discord: {}, telegram: {} }, + config = { version: '9' }, +} = {}) { + /** @type {{ method: string, url: string, body: any }[]} */ + const requests = [] + const server = http.createServer((req, res) => { + let raw = '' + req.on('data', (chunk) => { + raw += chunk + }) + req.on('end', () => { + const { url = '', method = 'GET' } = req + const reply = (status, body) => { + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(body)) + } + if (url === '/openapi.json') return reply(spec ? 200 : 404, spec ?? {}) + if (url === '/api/config/poracleWeb') { + return config ? reply(200, config) : reply(503, {}) + } + if (url === '/api/geofence/all/geojson') { + return reply(200, { geoJSON: { features: [] } }) + } + if (url.startsWith('/api/config/templates')) { + return reply(200, templates) + } + requests.push({ method, url, body: raw ? JSON.parse(raw) : undefined }) + const hit = routes[`${method} ${url.split('?')[0]}`] + const isCustom = hit && typeof hit.status === 'number' + res.writeHead(isCustom ? hit.status : 200, { + 'Content-Type': 'application/json', + }) + res.end(JSON.stringify(isCustom ? hit.body : (hit ?? {}))) + }) + }) + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve(undefined)) + }) + const { port } = /** @type {import('node:net').AddressInfo} */ ( + server.address() + ) + const poracleHttp = createPoracleHttp({ + endpoint: `http://127.0.0.1:${port}`, + secret: 's', + }) + return { + requests, + port, + client: createTrackingV2(poracleHttp), + v1: createTrackingV1(poracleHttp), + close: () => + new Promise((resolve) => { + server.close(resolve) + }), + } +} + +test('v2 list asks for descriptions and returns rules untouched', async () => { + const rules = [{ uid: 1, display_type: 9, description: 'Showcase' }] + const stub = await stubPoracle({ + routes: { 'GET /api/v2/humans/123/tracking/incident': { rules } }, + }) + try { + assert.deepEqual(await stub.client.list('123', 'incident'), rules) + assert.equal( + stub.requests[0].url, + '/api/v2/humans/123/tracking/incident?include_descriptions=true', + ) + } finally { + await stub.close() + } +}) + +test('v2 save posts new rules and PUTs edits without uid or description', async () => { + const stub = await stubPoracle() + try { + await stub.client.save('123', 'pokemon', [ + { pokemon_id: 1 }, + { + uid: 7, + description: 'x', + pokemon_id: 25, + costume: 4, + override_areas: ['town'], + }, + ]) + const calls = stub.requests.map((r) => [r.method, r.url, r.body]).sort() + assert.deepEqual(calls, [ + ['POST', '/api/v2/humans/123/tracking/pokemon', [{ pokemon_id: 1 }]], + [ + 'PUT', + '/api/v2/humans/123/tracking/pokemon/7', + { pokemon_id: 25, costume: 4, override_areas: ['town'] }, + ], + ]) + } finally { + await stub.close() + } +}) + +test('v2 invasion edit keeps only grunt_type', async () => { + const stub = await stubPoracle() + try { + await stub.client.save('123', 'invasion', [ + { + uid: 2, + grunt_type: 'water', + type_id: 11, + grunt_id: null, + everything: null, + boss: null, + gender: 'male', + }, + ]) + assert.deepEqual(stub.requests[0].body, { + grunt_type: 'water', + gender: 'male', + }) + } finally { + await stub.close() + } +}) + +test('v2 remove is one bulk delete without a body', async () => { + const stub = await stubPoracle() + try { + await stub.client.remove('123', 'raid', []) + assert.equal(stub.requests.length, 0) + await stub.client.remove('123', 'raid', [4, 5]) + assert.deepEqual(stub.requests, [ + { + method: 'DELETE', + url: '/api/v2/humans/123/tracking/raid?uid=4,5', + body: undefined, + }, + ]) + } finally { + await stub.close() + } +}) + +test('save surfaces problem detail', async () => { + const stub = await stubPoracle({ + routes: { + 'PUT /api/v2/humans/123/tracking/pokemon/4': { + status: 409, + body: { title: 'Conflict', detail: 'would duplicate rule 4' }, + }, + }, + }) + try { + await assert.rejects( + stub.client.save('123', 'pokemon', [{ uid: 4, pokemon_id: 1 }]), + (e) => + e instanceof PoracleRequestError && + e.status === 409 && + e.message === 'would duplicate rule 4', + ) + } finally { + await stub.close() + } +}) + +test('save surfaces huma validation errors', async () => { + const stub = await stubPoracle({ + routes: { + 'POST /api/v2/humans/123/tracking/egg': { + status: 422, + body: { + title: 'Unprocessable Entity', + detail: 'validation failed', + errors: [ + { message: 'expected number >= 1', location: 'body[0].level' }, + { message: 'expected required property team', location: 'body' }, + ], + }, + }, + }, + }) + try { + await assert.rejects( + stub.client.save('123', 'egg', [{ level: 0 }]), + (e) => + e instanceof PoracleRequestError && + e.status === 422 && + e.message === + 'validation failed: body[0].level: expected number >= 1; body: expected required property team', + ) + } finally { + await stub.close() + } +}) + +test('save surfaces the v1 error message', async () => { + const stub = await stubPoracle({ + routes: { + 'POST /api/tracking/pokemon/123': { + status: 400, + body: { status: 'error', message: 'Invalid profile' }, + }, + }, + }) + try { + await assert.rejects( + stub.v1.save('123', 'pokemon', [{ pokemon_id: 25 }]), + (e) => + e instanceof PoracleRequestError && + e.status === 400 && + e.message === 'Invalid profile', + ) + } finally { + await stub.close() + } +}) + +test('v1 list surfaces a 200 status error', async () => { + const stub = await stubPoracle({ + routes: { + 'GET /api/tracking/invasion/123': { + status: 'error', + message: 'User not found', + }, + }, + }) + try { + await assert.rejects( + stub.v1.list('123', 'invasion'), + (e) => + e instanceof PoracleRequestError && + e.status === 200 && + e.message === 'User not found', + ) + } finally { + await stub.close() + } +}) + +test('v1 list splits incidents from invasions', async () => { + const stub = await stubPoracle({ + routes: { + 'GET /api/tracking/invasion/123': { + invasion: [ + { uid: 1, grunt_type: 'water', gender: 1 }, + { uid: 2, grunt_type: 'showcase', gender: 0 }, + ], + }, + }, + }) + try { + const invasions = await stub.v1.list('123', 'invasion') + assert.equal(invasions.length, 1) + assert.partialDeepStrictEqual(invasions[0], { + uid: 1, + grunt_type: 'water', + gender: 'male', + }) + const incidents = await stub.v1.list('123', 'incident') + assert.equal(incidents.length, 1) + assert.partialDeepStrictEqual(incidents[0], { uid: 2, display_type: 9 }) + } finally { + await stub.close() + } +}) + +test('v1 save converts and posts once', async () => { + const stub = await stubPoracle() + try { + await stub.v1.save('123', 'pokemon', [ + { uid: 7, pokemon_id: 25, min_iv: null, clean: true }, + ]) + assert.equal(stub.requests.length, 1) + assert.equal(stub.requests[0].method, 'POST') + assert.equal(stub.requests[0].url, '/api/tracking/pokemon/123') + assert.equal(stub.requests[0].body.length, 1) + assert.partialDeepStrictEqual(stub.requests[0].body[0], { + uid: 7, + min_iv: -1, + clean: 1, + }) + } finally { + await stub.close() + } +}) + +test('v1 incident save goes to invasion', async () => { + const stub = await stubPoracle() + try { + await stub.v1.save('123', 'incident', [{ display_type: 7 }]) + assert.equal(stub.requests[0].url, '/api/tracking/invasion/123') + assert.equal(stub.requests[0].body[0].grunt_type, 'gold-stop') + } finally { + await stub.close() + } +}) + +test('v1 save rejects unsupported before sending', async () => { + const stub = await stubPoracle() + try { + await assert.rejects( + stub.v1.save('123', 'incident', [{ display_type: 12 }]), + UnsupportedOnV1, + ) + assert.equal(stub.requests.filter((r) => r.method === 'POST').length, 0) + } finally { + await stub.close() + } +}) + +test('v1 remove', async () => { + const stub = await stubPoracle() + try { + await stub.v1.remove('123', 'incident', [2]) + assert.equal(stub.requests[0].method, 'POST') + assert.equal(stub.requests[0].url, '/api/tracking/invasion/123/delete') + assert.deepEqual(stub.requests[0].body, [2]) + } finally { + await stub.close() + } +}) + +const v2Spec = { + paths: Object.fromEntries( + [ + '/v2/humans/{id}', + '/v2/humans/{id}/areas', + '/v2/humans/{id}/enable', + '/v2/humans/{id}/disable', + '/v2/humans/{id}/location', + '/v2/humans/{id}/profile', + '/v2/humans/{id}/profiles', + '/v2/humans/{id}/profiles/{profile_no}', + '/v2/humans/{id}/profiles/{profile_no}/copy', + ...[ + 'pokemon', + 'raid', + 'egg', + 'quest', + 'invasion', + 'incident', + 'lure', + 'nest', + 'gym', + ].flatMap((type) => [ + `/v2/humans/{id}/tracking/${type}`, + `/v2/humans/{id}/tracking/${type}/{uid}`, + ]), + ].map((p) => [p, {}]), + ), +} + +/** @param {number} port @param {string} name */ +async function initApi(port, name, apiVersion = undefined) { + const api = new PoracleAPI({ + name, + host: 'http://127.0.0.1', + port, + enabled: true, + apiVersion, + }) + await api.init() + fs.rmSync(path.join(__dirname, '../.cache', `${name}-webhook.json`), { + force: true, + }) + return api +} + +test('api routes tracking through the v2 client', async () => { + const stub = await stubPoracle({ + spec: v2Spec, + routes: { + 'GET /api/v2/humans/123/tracking/incident': { + rules: [{ uid: 3, display_type: 9 }], + }, + }, + }) + try { + const api = await initApi(stub.port, 'trk-v2') + const result = await api.api('123', 'incident', 'POST', [ + { display_type: 9 }, + ]) + assert.equal(stub.requests[0].method, 'POST') + assert.equal(stub.requests[0].url, '/api/v2/humans/123/tracking/incident') + assert.deepEqual(result, { + status: 'ok', + incident: [{ uid: 3, display_type: 9 }], + }) + } finally { + await stub.close() + } +}) + +test('api returns status error with the current rules', async () => { + const stub = await stubPoracle({ + spec: v2Spec, + routes: { + 'PUT /api/v2/humans/123/tracking/pokemon/7': { + status: 409, + body: { title: 'Conflict', detail: 'would duplicate rule 4' }, + }, + 'GET /api/v2/humans/123/tracking/pokemon': { + rules: [{ uid: 7, pokemon_id: 25 }], + }, + }, + }) + try { + const api = await initApi(stub.port, 'trk-err') + const result = await api.api('123', 'pokemon', 'POST', [ + { uid: 7, pokemon_id: 25 }, + ]) + assert.equal(result.status, 'error') + assert.equal(result.message, 'would duplicate rule 4') + assert.deepEqual(result.pokemon, [{ uid: 7, pokemon_id: 25 }]) + } finally { + await stub.close() + } +}) + +test('api returns the v1 adapter error', async () => { + const stub = await stubPoracle() + try { + const api = await initApi(stub.port, 'trk-v1err') + assert.equal(api.v2, false) + const result = await api.api('123', 'incident', 'POST', [ + { display_type: 12 }, + ]) + assert.equal(result.status, 'error') + assert.ok(result.message) + assert.ok(Array.isArray(result.incident)) + } finally { + await stub.close() + } +}) + +test('an unreachable server is a generic error without its URL', async () => { + const stub = await stubPoracle({ spec: v2Spec }) + const api = await initApi(stub.port, 'trk-down') + await stub.close() + const result = await api.api('123', 'pokemon', 'POST', [{ pokemon_id: 25 }]) + assert.equal(result.status, 'error') + assert.equal(result.message, 'Poracle server unreachable') + assert.ok(!result.message.includes('127.0.0.1')) + // no list: the client keeps the rules it shows instead of clearing them + assert.equal(result.pokemon, null) +}) + +test('a failed tracking read returns no list', async () => { + const stub = await stubPoracle({ + spec: v2Spec, + routes: { + 'GET /api/v2/humans/123/tracking/raid': { + status: 500, + body: { title: 'Internal Server Error', detail: 'db down' }, + }, + }, + }) + try { + const api = await initApi(stub.port, 'trk-readerr') + const result = await api.api('123', 'raid', 'GET') + assert.equal(result.status, 'error') + assert.equal(result.message, 'db down') + assert.equal(result.raid, null) + } finally { + await stub.close() + } +}) + +test('quick gym on v2 posts raid, egg and gym rules', async () => { + const gym = [{ uid: 9, team: 'any', gym_id: 'g1' }] + const stub = await stubPoracle({ + spec: v2Spec, + routes: { 'GET /api/v2/humans/123/tracking/gym': { rules: gym } }, + }) + try { + const api = await initApi(stub.port, 'trk-qg2') + const result = await api.api('123', 'quickGym', 'POST', 'g1') + const posts = Object.fromEntries( + stub.requests + .filter((r) => r.method === 'POST') + .map((r) => [r.url, r.body]), + ) + assert.deepEqual(posts, { + '/api/v2/humans/123/tracking/raid': [{ gym_id: 'g1' }], + '/api/v2/humans/123/tracking/egg': [{ level: 90, gym_id: 'g1' }], + '/api/v2/humans/123/tracking/gym': [ + { + team: 'any', + slot_changes: true, + battle_changes: api.gymBattles, + gym_id: 'g1', + }, + ], + }) + assert.deepEqual(result, { status: 'ok', gym }) + } finally { + await stub.close() + } +}) + +test('quick gym on v1 posts converted raid, egg and gym rows', async () => { + const stub = await stubPoracle({ + routes: { + 'GET /api/tracking/gym/123': { + gym: [{ uid: 9, team: 4, gym_id: 'g1', slot_changes: 1 }], + }, + }, + }) + try { + const api = await initApi(stub.port, 'trk-qg1') + assert.equal(api.v2, false) + const result = await api.api('123', 'quickGym', 'POST', 'g1') + const posts = Object.fromEntries( + stub.requests + .filter((r) => r.method === 'POST') + .map((r) => [r.url, r.body]), + ) + assert.deepEqual(Object.keys(posts).sort(), [ + '/api/tracking/egg/123', + '/api/tracking/gym/123', + '/api/tracking/raid/123', + ]) + assert.partialDeepStrictEqual(posts['/api/tracking/raid/123'], [ + { gym_id: 'g1', pokemon_id: 9000, level: 90 }, + ]) + assert.partialDeepStrictEqual(posts['/api/tracking/egg/123'], [ + { gym_id: 'g1', level: 90 }, + ]) + assert.partialDeepStrictEqual(posts['/api/tracking/gym/123'], [ + { gym_id: 'g1', team: 4, slot_changes: 1 }, + ]) + assert.equal(result.status, 'ok') + assert.partialDeepStrictEqual(result.gym, [ + { uid: 9, team: 'any', gym_id: 'g1' }, + ]) + } finally { + await stub.close() + } +}) + +test('capabilities follow the API version', async () => { + const ng = await stubPoracle({ spec: v2Spec }) + const legacy = await stubPoracle() + try { + const v2 = await initApi(ng.port, 'cap-v2') + const v1 = await initApi(legacy.port, 'cap-v1') + assert.deepEqual(v2.getClientContext('discord').capabilities, { + edit: true, + summary: true, + }) + assert.deepEqual(v1.getClientContext('discord').capabilities, { + edit: false, + summary: false, + }) + } finally { + await Promise.all([ng.close(), legacy.close()]) + } +}) + +test('stepped active hours survive the v2 round trip', () => { + const stepped = { + day: 1, + hours: 8, + mins: 0, + step: 2, + end_hours: 20, + end_mins: 0, + } + const [entry] = fromV2ActiveHours([stepped]) + assert.deepEqual(toV2ActiveHours([{ ...entry, id: '1_0' }]), [stepped]) + // GraphQL sends null for the range fields of a single entry. + assert.deepEqual( + toV2ActiveHours([ + { day: 2, hours: '09', mins: '30', step: null, end_hours: null }, + ]), + [{ day: 2, hours: 9, mins: 30 }], + ) +}) + +test('active hours convert to v2 integers and back to padded strings', () => { + assert.deepEqual( + toV2ActiveHours([{ day: 1, hours: '09', mins: '05', id: '1_0' }]), + [{ day: 1, hours: 9, mins: 5 }], + ) + assert.deepEqual(fromV2ActiveHours([{ day: 7, hours: 9, mins: 5 }]), [ + { day: 7, hours: '09', mins: '05' }, + ]) +}) + +test('auto detects v2 from the OpenAPI spec, v1 without one', async () => { + const ng = await stubPoracle({ spec: v2Spec }) + const legacy = await stubPoracle() + const partial = await stubPoracle({ + spec: { paths: { '/v2/humans/{id}': {} } }, + }) + try { + assert.equal((await initApi(ng.port, 'det-ng')).v2, true) + assert.equal((await initApi(legacy.port, 'det-legacy')).v2, false) + assert.equal((await initApi(partial.port, 'det-partial')).v2, false) + // An explicit version skips the probe. + assert.equal((await initApi(ng.port, 'det-forced', 'v1')).v2, false) + } finally { + await Promise.all([ng.close(), legacy.close(), partial.close()]) + } +}) + +test('v2 profile update sends only typed active hours', async () => { + const stub = await stubPoracle({ + spec: v2Spec, + routes: { + 'GET /api/v2/humans/123/profiles': { + profiles: [ + { + uid: 9, + profile_no: 1, + name: 'home', + area: ['town'], + active_hours: [{ day: 2, hours: 8, mins: 0 }], + }, + ], + }, + }, + }) + try { + const api = await initApi(stub.port, 'trk-profile') + const { profile } = await api.api('123', 'profiles-update', 'POST', { + uid: 9, + profile_no: 1, + name: 'home', + area: ['town'], + active_hours: [{ day: 2, hours: '08', mins: '00', id: '9_0' }], + }) + const patch = stub.requests.find((r) => r.method === 'PATCH') + assert.equal(patch.url, '/api/v2/humans/123/profiles/1') + assert.deepEqual(patch.body, { + active_hours: [{ day: 2, hours: 8, mins: 0 }], + }) + assert.deepEqual(profile[0].area, ['town']) + assert.deepEqual(profile[0].active_hours, [ + { day: 2, hours: '08', mins: '00', id: '9_0' }, + ]) + } finally { + await stub.close() + } +}) + +test('a failed v2 profile read is an error, not an empty list', async () => { + const stub = await stubPoracle({ + spec: v2Spec, + routes: { + 'GET /api/v2/humans/123/profiles': { + status: 503, + body: { title: 'Service Unavailable', detail: 'db down' }, + }, + }, + }) + try { + const api = await initApi(stub.port, 'trk-profile-503') + const result = await api.api('123', 'profiles', 'GET') + assert.equal(result.status, 'error') + assert.equal(result.message, 'db down') + // no list: the client keeps the profiles it shows + assert.equal(result.profile, null) + } finally { + await stub.close() + } +}) + +test('a failed v2 profile write still returns the current profiles', async () => { + const stub = await stubPoracle({ + spec: v2Spec, + routes: { + 'DELETE /api/v2/humans/123/profiles/2': { + status: 409, + body: { title: 'Conflict', detail: 'active profile' }, + }, + 'GET /api/v2/humans/123/profiles': { + profiles: [{ uid: 9, profile_no: 2, name: 'home', area: [] }], + }, + }, + }) + try { + const api = await initApi(stub.port, 'trk-profile-409') + const result = await api.api('123', 'profiles-byProfileNo', 'DELETE', 2) + assert.equal(result.status, 'error') + assert.equal(result.message, 'active profile') + assert.deepEqual( + result.profile.map((p) => p.profile_no), + [2], + ) + } finally { + await stub.close() + } +}) + +test('a failed v2 human request is an error without a human', async () => { + const stub = await stubPoracle({ + spec: v2Spec, + routes: { + 'POST /api/v2/humans/123/enable': { + status: 503, + body: { title: 'Service Unavailable', detail: 'db down' }, + }, + 'GET /api/v2/humans/123': { + status: 503, + body: { title: 'Service Unavailable', detail: 'db down' }, + }, + }, + }) + try { + const api = await initApi(stub.port, 'trk-human-503') + const results = await Promise.all([ + api.api('123', 'start', 'POST'), + api.api('123', 'oneHuman', 'GET'), + ]) + results.forEach((result) => { + assert.equal(result.status, 'error') + assert.equal(result.message, 'db down') + assert.equal(result.human, undefined) + }) + } finally { + await stub.close() + } +}) + +test('a cached v2 result does not override a configured apiVersion', () => { + const name = 'v2-cache-override' + const file = path.join(__dirname, '../.cache', `${name}-webhook.json`) + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, JSON.stringify({ apiVersion: 'auto', v2: true })) + try { + const forced = new PoracleAPI({ + name, + host: 'http://x', + port: 1, + apiVersion: 'v1', + }) + forced.initFromCache() + assert.equal(forced.apiVersion, 'v1') + assert.equal(forced.v2, false) + + const auto = new PoracleAPI({ name, host: 'http://x', port: 1 }) + auto.initFromCache() + assert.equal(auto.v2, true) + } finally { + fs.rmSync(file, { force: true }) + } +}) + +test('v1 profile update keeps stepped active hours', async () => { + const stub = await stubPoracle({ + routes: { 'GET /api/profiles/123': { profile: [] } }, + }) + try { + const api = await initApi(stub.port, 'trk-steps') + await api.api('123', 'profiles-update', 'POST', { + uid: 9, + profile_no: 1, + active_hours: [ + { day: 1, hours: '8', mins: '0', step: 2, end_hours: 20, end_mins: 0 }, + { day: 2, hours: '9', mins: '30', step: null, end_hours: null }, + ], + }) + const post = stub.requests.find((r) => r.method === 'POST') + assert.deepEqual(post.body.active_hours, [ + { day: 1, hours: '08', mins: '00', step: 2, end_hours: 20, end_mins: 0 }, + { day: 2, hours: '09', mins: '30' }, + ]) + } finally { + await stub.close() + } +}) + +test('generateUi is v2-shaped', () => { + const api = new PoracleAPI({ name: 'ui', host: 'http://x', port: 1 }) + const ui = api.generateUi() + assert.equal(ui.pokemon.defaults.min_iv, null) + assert.equal(ui.pokemon.defaults.gender, null) + assert.equal(ui.pokemon.defaults.clean, false) + assert.equal(ui.pokemon.defaults.edit, false) + assert.equal(ui.gym.defaults.team, 'any') + assert.equal(ui.raid.defaults.team, null) + assert.equal(ui.raid.defaults.level, null) + assert.equal(ui.raid.defaults.pokemon_id, null) + assert.equal(ui.egg.defaults.level, 90) + assert.equal(ui.incident.defaults.display_type, null) + assert.deepEqual(ui.incident.identifiers, ['display_type']) + const iv = ui.pokemon.ui.primary.sliders.find((s) => s.name === 'iv') + assert.equal(iv.anyLow, true) + assert.equal(iv.anyHigh, true) + const pvp = ui.pokemon.ui.pvp.sliders.find((s) => s.name === 'pvp') + assert.equal(pvp.anyLow, false) + assert.equal(pvp.anyHigh, false) + const names = (cat) => ui[cat].ui.general.booleans.map((b) => b.name) + assert.ok(names('quest').includes('summary')) + assert.ok(names('raid').includes('edit')) + assert.deepEqual( + ui.pokemon.ui.general.selects.find((s) => s.name === 'gender').options, + [null, 'male', 'female'], + ) +}) + +test('incident uses invasion templates on v1 only', async () => { + const templates = { + discord: { invasion: { '%': ['1', 'gold'] }, incident: { '%': ['1'] } }, + telegram: { invasion: { '%': ['1'] } }, + } + const ng = await stubPoracle({ spec: v2Spec, templates }) + const legacy = await stubPoracle({ templates }) + try { + const v2 = await initApi(ng.port, 'tpl-v2') + const v1 = await initApi(legacy.port, 'tpl-v1') + assert.deepEqual(v2.templates.discord.incident, { '%': ['1'] }) + assert.equal(v2.templates.telegram.incident, undefined) + assert.deepEqual(v1.templates.discord.incident, { '%': ['1', 'gold'] }) + assert.deepEqual(v1.templates.telegram.incident, { '%': ['1'] }) + } finally { + await Promise.all([ng.close(), legacy.close()]) + } +}) + +test('the invasion switches also hide incidents', () => { + const api = new PoracleAPI({ name: 'hooks', host: 'http://x', port: 1 }) + api.ui = api.generateUi() + api.disabledHooks = ['invasion'] + assert.ok(!api.getAllowedCategories().includes('incident')) + api.disabledHooks = [] + // both Poracles match incidents as invasion alerts + ;[true, false].forEach((v2) => { + api.v2 = v2 + const allowed = api.getAllowedCategories(['invasion', 'monster']) + assert.ok(!allowed.includes('incident')) + assert.ok(!allowed.includes('invasion')) + assert.ok(!allowed.includes('pokemon')) + assert.ok(allowed.includes('raid')) + }) +}) + +test('a half-failed init keeps tracking on the detected API version', async () => { + const stub = await stubPoracle({ spec: v2Spec, config: null }) + try { + const api = await initApi(stub.port, 'half-init') + assert.equal(api.v2, true) + await api.api('123', 'pokemon', 'GET') + assert.ok( + stub.requests.some((r) => + r.url.startsWith('/api/v2/humans/123/tracking/pokemon'), + ), + JSON.stringify(stub.requests), + ) + } finally { + await stub.close() + } +}) + +/** @param {(() => Promise)[]} attempts */ +const assertRefused = (attempts) => + Promise.all( + attempts.map(async (attempt) => { + const result = await attempt().catch((e) => ({ thrown: e })) + assert.ok( + result.thrown instanceof PoracleRequestError || + result.status === 'error', + JSON.stringify(result), + ) + }), + ) + +test('client values never reach a request path unchecked', async () => { + const bad = '../../../victim/tracking/pokemon/7' + const stub = await stubPoracle({ spec: v2Spec }) + const legacy = await stubPoracle() + try { + await assert.rejects( + stub.client.save('123', 'pokemon', [{ uid: bad, pokemon_id: 1 }]), + (e) => e instanceof PoracleRequestError && e.status === 400, + ) + await assert.rejects( + stub.client.remove('123', 'pokemon', [1, '2&uid=3']), + (e) => e instanceof PoracleRequestError, + ) + await stub.client.save('123', 'pokemon', [{ uid: '7', pokemon_id: 1 }]) + + const v2 = await initApi(stub.port, 'path-v2') + const v1 = await initApi(legacy.port, 'path-v1') + const before = stub.requests.length + const attempts = [ + () => v2.api('123', '../../victim/tracking/pokemon', 'POST', [{}]), + () => v2.api('123', 'pokemon', 'POST', [{ uid: bad }]), + () => v2.api('123', 'pokemon', 'DELETE', { uid: bad }), + () => v2.api('123', 'profiles-update', 'POST', { profile_no: bad }), + () => v2.api('123', 'profiles-byProfileNo', 'DELETE', bad), + () => v2.api('123', 'profiles-copy', 'POST', { from: 1, to: bad }), + ] + await assertRefused(attempts) + // only the rule list re-reads after a refused write reach the server + assert.deepEqual( + stub.requests + .slice(before) + .filter((r) => r.method !== 'GET') + .map((r) => r.url), + [], + ) + + const legacyBefore = legacy.requests.length + await assertRefused([ + () => v1.api('123', 'switchProfile', 'POST', bad), + () => v1.api('123', 'setLocation', 'POST', ['1', bad]), + () => v1.api('123', 'profiles-copy', 'POST', { from: bad, to: 2 }), + () => v1.api('123', 'profiles-byProfileNo', 'DELETE', bad), + ]) + assert.deepEqual( + legacy.requests + .slice(legacyBefore) + .filter((r) => r.method !== 'GET') + .map((r) => r.url), + [], + ) + } finally { + await Promise.all([stub.close(), legacy.close()]) + } +}) diff --git a/server/test/ruleForm.test.js b/server/test/ruleForm.test.js new file mode 100644 index 000000000..e611ba1ef --- /dev/null +++ b/server/test/ruleForm.test.js @@ -0,0 +1,428 @@ +const assert = require('node:assert/strict') +const { test, before } = require('node:test') + +const { PoracleAPI } = require('../src/services/Poracle') + +/** @type {typeof import('../../src/features/webhooks/services/ruleForm.js')} */ +let mod +let uis + +before(async () => { + // eslint-disable-next-line import/extensions -- native ESM needs the extension + mod = await import('../../src/features/webhooks/services/ruleForm.js') + uis = new PoracleAPI({ + name: 'test', + host: 'http://x', + port: 1, + }).generateUi() +}) + +test('slider bounds map to null', () => { + const ui = uis.pokemon + const form = mod.ruleToForm( + 'pokemon', + { pokemon_id: 25, min_iv: null, max_iv: 90 }, + ui, + ) + assert.equal(form.min_iv, -1) + assert.equal(form.max_iv, 90) + const rule = mod.formToRule( + 'pokemon', + { ...form, min_iv: -1, max_iv: 100 }, + ui, + ) + assert.equal(rule.min_iv, null) + assert.equal(rule.max_iv, null) +}) + +test('pvp slider stays concrete', () => { + const ui = uis.pokemon + const form = { + ...mod.ruleToForm('pokemon', { pokemon_id: 25 }, ui), + pvpEntry: true, + pvp_ranking_league: 1500, + pvp_ranking_best: 1, + pvp_ranking_worst: 100, + } + const rule = mod.formToRule('pokemon', form, ui) + assert.equal(rule.pvp_ranking_worst, 100) + assert.equal(rule.pvp_ranking_best, 1) + assert.equal(rule.pvp_ranking_league, 1500) + assert.equal(rule.min_iv, null) +}) + +test('pvp off clears pvp fields', () => { + const ui = uis.pokemon + const original = { pokemon_id: 25, pvp_ranking_evolution: true } + const rule = mod.formToRule( + 'pokemon', + mod.ruleToForm('pokemon', original, ui), + ui, + original, + ) + assert.equal(rule.pvp_ranking_league, 0) + assert.equal(rule.pvp_ranking_best, null) + assert.equal(rule.pvp_ranking_evolution, null) +}) + +test('size at its lowest bound is any size', () => { + const rule = mod.formToRule( + 'pokemon', + { ...mod.ruleToForm('pokemon', {}, uis.pokemon), size: 1, max_size: 5 }, + uis.pokemon, + ) + assert.equal(rule.size, null) + assert.equal(rule.max_size, null) +}) + +test('derived form-only fields', () => { + const form = mod.ruleToForm( + 'pokemon', + { pokemon_id: 25, form: 3, distance: 500, max_weight: 5, min_weight: null }, + uis.pokemon, + ) + assert.equal(form.byDistance, true) + assert.equal(form.allForms, false) + assert.equal(form.pvpEntry, false) + assert.equal(form.xs, true) + assert.equal(form.xl, false) +}) + +test('formToRule keeps unknown fields', () => { + const ui = uis.pokemon + const original = { + uid: 7, + description: 'd', + pokemon_id: 25, + costume: 4, + override_areas: ['town'], + edit: true, + } + const rule = mod.formToRule( + 'pokemon', + mod.ruleToForm('pokemon', original, ui), + ui, + original, + ) + assert.partialDeepStrictEqual(rule, { + uid: 7, + pokemon_id: 25, + costume: 4, + override_areas: ['town'], + edit: true, + }) + assert.equal('description' in rule, false) +}) + +test('toForm keeps form-only choices and fills missing defaults', () => { + const ui = uis.pokemon + const form = mod.toForm( + 'pokemon', + { pokemon_id: 25, form: 0, allForms: false, pvpEntry: true, enabled: 1 }, + ui, + ) + assert.equal(form.allForms, false) + assert.equal(form.pvpEntry, true) + assert.equal(form.max_iv, 100) + assert.equal(form.template, ui.defaults.template) + const fromRule = mod.toForm('pokemon', { pokemon_id: 25, form: 0 }, ui) + assert.equal(fromRule.allForms, true) + assert.equal('enabled' in mod.formToRule('pokemon', form, ui), false) + assert.equal(mod.toForm('raid', undefined, uis.raid).allMoves, true) +}) + +test('form-only fields never reach the rule', () => { + Object.keys(uis) + .filter((k) => k !== 'human') + .forEach((category) => { + const ui = uis[category] + const rule = mod.formToRule( + category, + { + ...mod.ruleToForm(category, {}, ui), + xs: true, + xl: true, + all: false, + }, + ui, + ) + mod.FORM_ONLY_FIELDS.forEach((key) => { + assert.equal(key in rule, false, `${category}.${key}`) + }) + }) +}) + +test('catch-all rules round trip', () => { + const p = mod.formToRule( + 'pokemon', + mod.ruleToForm('pokemon', { pokemon_id: 0 }, uis.pokemon), + uis.pokemon, + ) + assert.equal(p.pokemon_id, 0) + const raidRule = { pokemon_id: null, level: null } + const r = mod.formToRule( + 'raid', + mod.ruleToForm('raid', raidRule, uis.raid), + uis.raid, + ) + assert.equal(r.pokemon_id, null) + assert.equal(r.level, null) +}) + +test('distance, form and move follow their toggles', () => { + const form = mod.ruleToForm( + 'raid', + { pokemon_id: 1, form: 5, move: 3 }, + uis.raid, + ) + assert.equal(form.allMoves, false) + const kept = mod.formToRule( + 'raid', + { ...form, byDistance: false, distance: 99 }, + uis.raid, + ) + assert.equal(kept.distance, 0) + assert.equal(kept.form, 5) + assert.equal(kept.move, 3) + const any = mod.formToRule( + 'raid', + { ...form, allForms: true, allMoves: true }, + uis.raid, + ) + assert.equal(any.form, null) + assert.equal(any.move, null) +}) + +test('edit is dropped where v2 rejects it', () => { + ;['quest', 'gym', 'invasion', 'nest'].forEach((category) => { + const ui = uis[category] + const rule = mod.formToRule( + category, + { ...mod.ruleToForm(category, {}, ui), edit: true }, + ui, + ) + assert.equal('edit' in rule, false, category) + }) + const raid = mod.formToRule( + 'raid', + { ...mod.ruleToForm('raid', {}, uis.raid), edit: true, summary: true }, + uis.raid, + ) + assert.equal(raid.edit, true) + assert.equal('summary' in raid, false) + const quest = mod.formToRule( + 'quest', + { ...mod.ruleToForm('quest', {}, uis.quest), summary: true }, + uis.quest, + ) + assert.equal(quest.summary, true) +}) + +test('rules only carry fields the category owns', () => { + Object.keys(uis) + .filter((k) => k !== 'human') + .forEach((category) => { + const ui = uis[category] + const allowed = new Set( + Object.keys(ui.defaults).filter( + (k) => !mod.FORM_ONLY_FIELDS.includes(k), + ), + ) + Object.keys( + mod.formToRule(category, mod.ruleToForm(category, ui.defaults, ui), ui), + ).forEach((key) => { + assert.ok(allowed.has(key), `${category}.${key}`) + }) + const original = { uid: 1, description: 'd', costume: 2 } + Object.keys( + mod.formToRule( + category, + mod.ruleToForm(category, original, ui), + ui, + original, + ), + ).forEach((key) => { + assert.ok(allowed.has(key) || key in original, `${category}.${key}`) + }) + }) +}) + +test('bot-created v2 rules have no null where the form expects a number', () => { + const rules = { + pokemon: { + pokemon_id: 25, + template: null, + min_time: null, + pvp_ranking_league: 1500, + pvp_ranking_min_cp: null, + pvp_ranking_cap: null, + }, + quest: { reward_type: 3, reward: 0, amount: null, template: null }, + nest: { pokemon_id: 25, min_spawn_avg: null, template: null }, + } + Object.entries(rules).forEach(([category, rule]) => { + const ui = uis[category] + const form = mod.ruleToForm(category, rule, ui) + Object.entries(ui.defaults).forEach(([key, value]) => { + if (typeof value === 'number') { + assert.notEqual(form[key], null, `${category}.${key}`) + assert.notEqual(form[key], undefined, `${category}.${key}`) + } + }) + // template null means the server default and stays null + assert.equal(form.template, null) + }) + const pokemon = mod.ruleToForm('pokemon', rules.pokemon, uis.pokemon) + assert.equal(pokemon.pvp_ranking_cap, 0) + assert.equal(pokemon.pvp_ranking_min_cp, 0) + assert.equal(pokemon.min_time, 0) +}) + +test('getId for catch-alls', () => { + assert.equal(mod.ruleId('pokemon', { pokemon_id: 0 }), '0-0') + assert.equal(mod.ruleId('pokemon', { pokemon_id: null }), '0-0') + assert.equal(mod.ruleId('raid', { pokemon_id: null, level: null }), 'r90') + assert.equal(mod.ruleId('raid', { pokemon_id: 150, form: null }), '150-0') + assert.equal(mod.ruleId('incident', { display_type: 9 }), 'showcase') + assert.equal(mod.ruleId('incident', { display_type: 12 }), 'incident-12') + assert.equal(mod.ruleId('gym', { team: 'any' }), 't4-0') + assert.equal(mod.ruleId('egg', { level: 5 }), 'e5') + assert.equal(mod.ruleId('lure', { lure_id: 501 }), 'l501') + assert.equal(mod.ruleId('quest', { reward_type: 3, amount: null }), 'd0') + assert.equal(mod.ruleId('quest', { reward_type: 3, amount: 500 }), 'd500') + assert.equal(mod.ruleId('invasion', { grunt_type: 'everything' }), 'i0') + assert.equal(mod.ruleId('invasion', { grunt_type: null }), 'i0') + assert.equal( + mod.ruleId('quest', { reward_type: 7, reward: 25, form: null }), + '25-0', + ) + const invasions = { 4: { type: 'Water', gender: 1 } } + assert.equal( + mod.ruleId('invasion', { grunt_type: 'water', gender: 'male' }, invasions), + 'i4', + ) +}) + +test('an untouched wildcard pvp ceiling stays a wildcard', () => { + const base = uis.pokemon + const ui = { ...base, defaults: { ...base.defaults, pvp_ranking_worst: 100 } } + const original = { + pokemon_id: 25, + pvp_ranking_league: 1500, + pvp_ranking_best: 1, + pvp_ranking_worst: null, + } + const form = mod.ruleToForm('pokemon', original, ui) + assert.equal(form.pvp_ranking_worst, 100) + const kept = mod.formToRule( + 'pokemon', + { ...form, template: 'x' }, + ui, + original, + ) + assert.equal(kept.pvp_ranking_worst, null) + const moved = mod.formToRule( + 'pokemon', + { ...form, pvp_ranking_worst: 50 }, + ui, + original, + ) + assert.equal(moved.pvp_ranking_worst, 50) +}) + +test('switching distance mode clears the conflicting override', () => { + const ui = uis.raid + const labelled = { + level: 5, + distance: 500, + override_location_label: 'home', + override_areas: null, + } + const toArea = mod.formToRule( + 'raid', + { ...mod.ruleToForm('raid', labelled, ui), byDistance: false }, + ui, + labelled, + ) + assert.equal(toArea.distance, 0) + assert.equal(toArea.override_location_label, null) + + const kept = mod.formToRule( + 'raid', + { ...mod.ruleToForm('raid', labelled, ui), template: 'x' }, + ui, + labelled, + ) + assert.equal(kept.override_location_label, 'home') + + const areas = { level: 5, distance: 0, override_areas: ['north'] } + const toDistance = mod.formToRule( + 'raid', + { ...mod.ruleToForm('raid', areas, ui), byDistance: true, distance: 300 }, + ui, + areas, + ) + assert.equal(toDistance.distance, 300) + assert.equal(toDistance.override_areas, null) + + const keptAreas = mod.formToRule( + 'raid', + mod.ruleToForm('raid', areas, ui), + ui, + areas, + ) + assert.deepEqual(keptAreas.override_areas, ['north']) +}) + +test('turning pvp on saves the ceiling the slider shows', () => { + const base = uis.pokemon + const ui = { ...base, defaults: { ...base.defaults, pvp_ranking_worst: 100 } } + const original = { + pokemon_id: 25, + pvp_ranking_league: 0, + pvp_ranking_worst: null, + } + const rule = mod.formToRule( + 'pokemon', + { + ...mod.ruleToForm('pokemon', original, ui), + pvpEntry: true, + pvp_ranking_league: 1500, + }, + ui, + original, + ) + assert.equal(rule.pvp_ranking_worst, 100) +}) + +test('the apply-to-all preset starts without All Forms', () => { + ;['pokemon', 'raid', 'quest', 'nest'].forEach((category) => { + const preset = mod.globalPreset(category, uis[category], { profile_no: 1 }) + assert.equal(preset.allForms, false, category) + assert.equal('form' in preset, false, category) + assert.equal(preset.profile_no, 1, category) + }) +}) + +test('an edit keeps the profile of the rule it edits', () => { + const ui = uis.pokemon + const original = { uid: 7, pokemon_id: 25, profile_no: 2 } + const form = { ...mod.ruleToForm('pokemon', original, ui), profile_no: 3 } + const rule = mod.formToRule('pokemon', form, ui, original) + assert.equal(rule.profile_no, 2) + assert.equal('profile_no' in mod.formToRule('pokemon', form, ui), false) +}) + +test('the fallback size label treats null bounds as any size', () => { + const t = (key) => key + assert.equal(mod.sizeSuffix({ size: null, max_size: null }, t), '') + assert.equal(mod.sizeSuffix({ size: -1, max_size: 5 }, t), '') + assert.equal( + mod.sizeSuffix({ size: 4, max_size: null }, t), + ' | size:size:size_4-size_5', + ) + assert.equal( + mod.sizeSuffix({ size: null, max_size: 1 }, t), + ' | size:size:size_1', + ) +}) diff --git a/server/test/webhookQueries.test.js b/server/test/webhookQueries.test.js new file mode 100644 index 000000000..ede4b64d1 --- /dev/null +++ b/server/test/webhookQueries.test.js @@ -0,0 +1,64 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const { test } = require('node:test') +const { + buildSchema, + parse, + validate, + specifiedRules, + NoUnusedFragmentsRule, +} = require('graphql') + +// PROFILE is exported as a bare fragment, so unused fragments are expected +const rules = specifiedRules.filter((r) => r !== NoUnusedFragmentsRule) + +const typeDirs = path.join(__dirname, '../src/graphql/typeDefs') +const schema = buildSchema( + fs + .readdirSync(typeDirs) + .filter((f) => f.endsWith('.graphql')) + .map((f) => fs.readFileSync(path.join(typeDirs, f), 'utf8')) + .join('\n'), +) + +test('client webhook documents validate against the schema', async () => { + // The client module is ESM importing @apollo/client; evaluate its source + // with a minimal `gql` so the documents can be validated server-side. + const src = fs + .readFileSync( + path.join(__dirname, '../../src/services/queries/webhook.js'), + 'utf8', + ) + .replace(/^import .*$/m, '') + .replace(/^export const (\w+) =/gm, 'const $1 = exports_.$1 =') + const exports_ = {} + const gql = (strings, ...values) => { + const body = strings.reduce( + (acc, str, i) => + acc + str + (values[i]?.loc ? values[i].loc.source.body : ''), + '', + ) + return parse(body) + } + // eslint-disable-next-line no-new-func + new Function('gql', 'exports_', src)(gql, exports_) + const mod = exports_ + const docs = Object.entries(mod).filter(([, v]) => v?.kind === 'Document') + assert.ok(docs.length >= 15) + assert.ok(mod.INCIDENT) + docs.forEach(([name, doc]) => { + const errors = validate(schema, doc, rules) + assert.deepEqual( + errors.map((e) => e.message), + [], + name, + ) + }) + // a failed read must reach useGetWebhookData's error branch + const fields = mod.ALL_PROFILES.definitions + .find((d) => d.kind === 'OperationDefinition') + .selectionSet.selections[0].selectionSet.selections.map((s) => s.name.value) + assert.ok(fields.includes('status'), 'ALL_PROFILES selects status') + assert.ok(fields.includes('message'), 'ALL_PROFILES selects message') +}) diff --git a/src/features/gym/useWebhook.js b/src/features/gym/useWebhook.js index 5da591fa2..d10782b15 100644 --- a/src/features/gym/useWebhook.js +++ b/src/features/gym/useWebhook.js @@ -23,15 +23,16 @@ export function useWebhook({ category }) { useEffect(() => { if (data?.webhook || error) { + const failed = error || data?.webhook?.status === 'error' const message = error ? error.message - : data?.webhook - ? t(`webhook_success_${category.replace('quick', '').toLowerCase()}`) - : t('success') + : failed + ? data.webhook.message + : t(`webhook_success_${category.replace('quick', '').toLowerCase()}`) useWebhookStore.setState({ alert: { open: true, - severity: error ? 'error' : 'success', + severity: failed ? 'error' : 'success', message, }, }) diff --git a/src/features/webhooks/Manage.jsx b/src/features/webhooks/Manage.jsx index b7147a7df..4219e3bdc 100644 --- a/src/features/webhooks/Manage.jsx +++ b/src/features/webhooks/Manage.jsx @@ -18,7 +18,13 @@ import { apolloClient } from '@services/apollo' import { Query } from '@services/queries' import { ALL_PROFILES } from '@services/queries/webhook' import { Menu } from '@components/Menu' -import { setMode, setSelected, useWebhookStore } from '@store/useWebhookStore' +import { + applyWebhookResult, + setMode, + setSelected, + showWebhookError, + useWebhookStore, +} from '@store/useWebhookStore' import { analytics } from '@utils/analytics' import { Human } from './human' @@ -27,6 +33,7 @@ import { useGenFilters } from './hooks/useGenFilters' import { useGetHookContext } from './hooks/useGetHookContext' import { ProfileEditing } from './human/profile' import { Poracle } from './services/Poracle' +import { formToRule, toForm } from './services/ruleForm' import { WebhookItem } from './tiles/WebhookItem' export function Manage() { @@ -90,27 +97,44 @@ export function Manage() { }, [category]) React.useEffect(() => { + if (addNew.open && category !== 'human') { + // Start Add New clean: editing a tracked rule leaves it in tempFilters + useWebhookStore.setState({ tempFilters: { ...filters[category] } }) + } if (!addNew.open && addNew.save && category !== 'human') { - const { tempFilters } = useWebhookStore.getState() - const values = Poracle.processor( - category, - Object.values(tempFilters || {}).filter((x) => x && x.enabled), - useWebhookStore.getState().context.ui[category].defaults, - ) - apolloClient.mutate({ - // @ts-ignore - mutation: Query.webhook(category.toUpperCase()), - variables: { - category, - data: values, - status: 'POST', - }, - refetchQueries: [ALL_PROFILES], - }) - useWebhookStore.setState((prev) => ({ - [category]: [...prev[category], ...values], - tempFilters: { ...filters[category] }, - })) + const { tempFilters, context } = useWebhookStore.getState() + const info = context.ui[category] + // allForms collapses several form tiles into one rule: send it once + const seen = new Set() + const values = Object.values(tempFilters || {}) + .filter((x) => x && x.enabled) + // new rules only: an edited tracked rule may have left its uid here + .map((x) => { + const form = toForm(category, x, info) + delete form.uid + return formToRule(category, form, info) + }) + .filter((rule) => { + const key = JSON.stringify(rule) + if (seen.has(key)) return false + seen.add(key) + return true + }) + useWebhookStore.setState({ tempFilters: { ...filters[category] } }) + if (!values.length) return + apolloClient + .mutate({ + // @ts-ignore + mutation: Query.webhook(category.toUpperCase()), + variables: { + category, + data: values, + status: 'POST', + }, + refetchQueries: [ALL_PROFILES], + }) + .then(({ data }) => applyWebhookResult(category, data?.webhook)) + .catch(showWebhookError) } }, [addNew]) diff --git a/src/features/webhooks/Selecting.jsx b/src/features/webhooks/Selecting.jsx index 22217b62d..7f9f78676 100644 --- a/src/features/webhooks/Selecting.jsx +++ b/src/features/webhooks/Selecting.jsx @@ -5,17 +5,18 @@ import Fab from '@mui/material/Fab' import Slide from '@mui/material/Slide' import Typography from '@mui/material/Typography' import { useTranslation } from 'react-i18next' -import { useMutation } from '@apollo/client' -import { SET_PROFILE, ALL_PROFILES } from '@services/queries/webhook' -import { useWebhookStore, setSelected } from '@store/useWebhookStore' +import { apolloClient } from '@services/apollo' +import * as webhookNodes from '@services/queries/webhook' +import { + useWebhookStore, + setSelected, + applyWebhookResult, + showWebhookError, +} from '@store/useWebhookStore' export function Selecting() { const { t } = useTranslation() - const [save] = useMutation(SET_PROFILE, { - refetchQueries: [ALL_PROFILES], - }) - const selected = useWebhookStore((s) => s.selected) const handleAll = () => { @@ -30,13 +31,18 @@ export function Selecting() { const deleteAll = () => { const { category } = useWebhookStore.getState() - save({ - variables: { - category: `${category}-delete`, - data: Object.keys(selected).filter((x) => selected[x]), - status: 'POST', - }, - }) + apolloClient + .mutate({ + mutation: webhookNodes[category.toUpperCase()], + variables: { + category: `${category}-delete`, + data: Object.keys(selected).filter((x) => selected[x]), + status: 'POST', + }, + refetchQueries: [webhookNodes.ALL_PROFILES], + }) + .then(({ data }) => applyWebhookResult(category, data?.webhook)) + .catch(showWebhookError) setSelected()() } diff --git a/src/features/webhooks/WebhookAdv.jsx b/src/features/webhooks/WebhookAdv.jsx index 381d65ed4..25eb3ab73 100644 --- a/src/features/webhooks/WebhookAdv.jsx +++ b/src/features/webhooks/WebhookAdv.jsx @@ -34,44 +34,34 @@ import { camelToSnake } from '@utils/strings' import { FCSelect } from '@components/inputs/FCSelect' import { Poracle } from './services/Poracle' +import { FORM_ONLY_FIELDS, GLOBAL_IDENTITY, toForm } from './services/ruleForm' const skipFields = new Set([ - 'profile_no', - 'allForms', - 'pvpEntry', - 'noIv', - 'byDistance', + ...FORM_ONLY_FIELDS, 'distance', - 'xs', - 'xl', - 'clean', 'gender', - 'description', - 'uid', 'max_size', - 'id', 'ping', 'pokemon_id', 'form', - '__typename', - 'allMoves', - 'enabled', 'level', - 'exclusive', 'lure_id', 'reward', 'reward_type', 'grunt_type', - 'grunt_id', + 'display_type', 'gym_id', + 'shiny', + 'clean', + 'edit', + 'summary', + 'exclusive', 'slot_changes', + 'battle_changes', 'team', 'rsvp_changes', - 'battle_changes', - 'shiny', - 'everything_individually', - 'all', - 'real_grunt_id', + 'uid', + 'description', ]) const wildCards = { @@ -79,18 +69,62 @@ const wildCards = { egg: ['e90'], gym: ['t4'], invasion: ['i0'], + // v2 has no "any event" incident: apply to every event tile instead + incident: ['gold-stop', 'kecleon', 'showcase'], +} + +/** + * Derived from identity fields, so they stay per item too (an explicit + * All Forms is applied separately in handleClose). + */ +const GLOBAL_DERIVED = ['allForms', 'xs', 'xl', 'min_weight', 'max_weight'] + +/** Switches shown only when the Poracle server supports them. */ +const CAPABILITY_GATED = { edit: true, summary: true } + +/** MUI selects cannot hold `null`; this stands in for "any" in the menu. */ +const ANY = '__any__' + +/** Named values map back to the numeric locale keys (`team_1`, ...). */ +const LABEL_INDEX = { + team: { harmony: 0, mystic: 1, valor: 2, instinct: 3, any: 4 }, + gender: { male: 1, female: 2, genderless: 3 }, + rsvp_changes: { none: 0, rsvp: 1, rsvp_only: 2 }, +} +const LABEL_PREFIX = { team: 'team', gender: 'gender', rsvp_changes: 'rsvp' } + +/** + * @param {(key: string) => string} t + * @param {'team' | 'gender' | 'rsvp_changes'} field + * @param {string | null} value + */ +const namedLabel = (t, field, value) => { + // rsvp_changes null means "none" (no RSVP updates), not "any" + if (field === 'rsvp_changes' && (value === null || value === undefined)) + return t('rsvp_0') + return value === null || value === undefined || value === 'any' + ? t('any') + : t(`${LABEL_PREFIX[field]}_${LABEL_INDEX[field][value] ?? value}`) } export function WebhookAdvanced() { - const { id, category, open, selectedIds, onClose } = useWebhookStore( + const { id, uid, category, open, selectedIds, onClose } = useWebhookStore( (s) => s.advanced, ) const idObj = Poracle.getIdObj(id) const { t } = useTranslation() const location = useStorage((s) => s.location) const webhookAdv = useStorage((s) => s.webhookAdv) - const { templates, prefix, leagues, pvp, hasNominatim, locale, everything } = - useWebhookStore((s) => s.context) + const { + templates, + prefix, + leagues, + pvp, + hasNominatim, + locale, + everything, + capabilities, + } = useWebhookStore((s) => s.context) const info = useWebhookStore((s) => s.context.ui?.[category]) const human = useWebhookStore((s) => s.human) const profile = useWebhookStore((s) => s.profile) @@ -98,18 +132,18 @@ export function WebhookAdvanced() { const { pokemon, moves, types } = useMemory((s) => s.masterfile) const isMobile = useMemory((s) => s.isMobile) - const [filterValues, setFilterValues] = React.useState( - tempFilters?.template - ? Poracle.reactMapFriendly(tempFilters) - : { - ...Poracle.reactMapFriendly(info?.defaults), + /** @returns {Record} */ + const initialForm = () => + info + ? { profile_no: human.current_profile_no, - }, - ) - const [poracleValues, setPoracleValues] = React.useState( - tempFilters?.template - ? tempFilters - : { ...info?.defaults, profile_no: human.current_profile_no }, + ...toForm(category, tempFilters ?? info.defaults, info), + } + : {} + const [poracleValues, setPoracleValues] = React.useState(initialForm) + const defaultForm = React.useMemo( + () => (info ? toForm(category, info.defaults, info) : {}), + [category, info], ) useAnalytics(`/poracle/${category}`) @@ -145,20 +179,8 @@ export function WebhookAdvanced() { const fetchedData = data || previousData React.useEffect(() => { - setPoracleValues( - tempFilters?.template - ? { ...tempFilters } - : { ...info?.defaults, profile_no: human.current_profile_no }, - ) - setFilterValues( - tempFilters?.template - ? Poracle.reactMapFriendly(tempFilters) - : { - ...Poracle.reactMapFriendly(info?.defaults), - profile_no: human.current_profile_no, - }, - ) - }, [tempFilters, id, human.current_profile_no, info?.defaults]) + setPoracleValues(initialForm()) + }, [tempFilters, id, human.current_profile_no, info]) React.useEffect(() => { setPoracleValues((prev) => ({ @@ -169,7 +191,6 @@ export function WebhookAdvanced() { const handleSlider = React.useCallback( (low, high) => (name, values) => { - setFilterValues((prev) => ({ ...prev, [name]: values })) setPoracleValues((prev) => ({ ...prev, [low]: values[0], @@ -188,8 +209,8 @@ export function WebhookAdvanced() { ...poracleValues, min_weight: checked ? Math.ceil(pokemon[idObj.id].weight * 1.313) - : info?.defaults.min_weight, - max_weight: info?.defaults.max_weight, + : null, + max_weight: null, [name]: checked, xs: false, }) @@ -197,10 +218,10 @@ export function WebhookAdvanced() { case 'xs': setPoracleValues({ ...poracleValues, - min_weight: info?.defaults.min_weight, + min_weight: null, max_weight: checked ? Math.floor(pokemon[idObj.id].weight / 1.6431924) - : info?.defaults.max_weight, + : null, [name]: checked, xl: false, }) @@ -223,7 +244,7 @@ export function WebhookAdvanced() { setPoracleValues({ ...poracleValues, [name]: checked, - move: 9000, + move: null, }) break case 'byDistance': @@ -239,23 +260,35 @@ export function WebhookAdvanced() { } const handleSelect = (event) => { - const { name, value } = event.target + const { name } = event.target + const value = event.target.value === ANY ? null : event.target.value const newObj = { [name]: value } if (name === 'pvp_ranking_league') { - newObj.pvp_ranking_min_cp = pvp === 'ohbem' ? 0 : value - 50 + newObj.pvp_ranking_min_cp = pvp === 'ohbem' || !value ? 0 : value - 50 } if (name.startsWith('pvp')) { - newObj.pvpEntry = true + newObj.pvpEntry = name === 'pvp_ranking_league' ? value > 0 : true } - if (name === 'move' && value !== 9000) { - newObj.allMoves = false + if (name === 'move') { + newObj.move = value === null ? null : +value + newObj.allMoves = value === null } if (name === 'template') { - newObj[name] = value?.toString() || '' + newObj[name] = value === null ? null : value.toString() } setPoracleValues({ ...poracleValues, ...newObj }) } + /** Number inputs hand back strings; rules need numbers. */ + const handleText = (type) => (event) => { + const { name, value } = event.target + setPoracleValues({ + ...poracleValues, + [name]: type === 'number' ? Number(value) || 0 : value, + ...(name.startsWith('pvp') ? { pvpEntry: true } : {}), + }) + } + const handleChange = (panel) => (_, isExpanded) => { useStorage.setState((prev) => ({ webhookAdv: { ...prev.webhookAdv, [panel]: isExpanded }, @@ -266,6 +299,12 @@ export function WebhookAdvanced() { const menuItems = [] switch (option.name) { case 'template': + // null = the Poracle server's default template + menuItems.push( + + {t('default_template')} + , + ) templates[poracleValues.noIv ? `${category}NoIv` : category]?.[ human.language || locale ]?.forEach((item) => @@ -312,35 +351,19 @@ export function WebhookAdvanced() { ) break case 'gender': - option.options.forEach((gender) => - menuItems.push( - - {t(`gender_${gender}`)} - , - ), - ) - break case 'team': - option.options.forEach((team) => - menuItems.push( - - {t(`team_${team}`, t('any'))} - , - ), - ) - break case 'rsvp_changes': - option.options.forEach((rsvp_changes) => + option.options.forEach((named) => menuItems.push( - - {t(`rsvp_${rsvp_changes}`)} + + {namedLabel(t, option.name, named)} , ), ) break case 'move': menuItems.push( - + {t('any')} , ) @@ -412,27 +435,46 @@ export function WebhookAdvanced() { if (field === 'exclusive' && poracleValues.exclusive) return ` ${t('exclusive')} ` if (field === 'clean' && poracleValues.clean) return ` ${t('clean')} ` + if (field === 'edit' && poracleValues.edit && capabilities?.edit) + return ` ${t('edit')} ` + if (field === 'summary' && poracleValues.summary && capabilities?.summary) + return ` ${t('summary')} ` if (field === 'min_spawn_avg' && poracleValues.min_spawn_avg > 0) return ` ${t('minspawn')}${poracleValues.min_spawn_avg} ` if (field === 'slot_changes' && poracleValues.slot_changes) return ` ${t('slot_changes_poracle')} ` if (field === 'battle_changes' && poracleValues.battle_changes) return ` ${t('battle_changes_poracle')} ` - if (field === 'team' && poracleValues.team !== 4) - return t(`team_${poracleValues.team}`) - if (field === 'rsvp_changes' && poracleValues.rsvp_changes !== 0) - return t(`rsvp_${poracleValues.rsvp_changes}`) + if ( + field === 'team' && + poracleValues.team !== null && + poracleValues.team !== undefined && + poracleValues.team !== 'any' + ) + return namedLabel(t, 'team', poracleValues.team) + if ( + field === 'rsvp_changes' && + poracleValues.rsvp_changes !== null && + poracleValues.rsvp_changes !== undefined && + poracleValues.rsvp_changes !== 'none' + ) + return namedLabel(t, 'rsvp_changes', poracleValues.rsvp_changes) if ( field === 'everything_individually' && poracleValues.everything_individually ) return ` ${t('individually')} ` - if (skipFields.has(field)) return '' + if ( + field === 'template' && + (poracleValues.template === null || poracleValues.template === '') + ) + return '' + if (skipFields.has(field) || !(field in defaultForm)) return '' if (field.startsWith('pvp')) { if ( poracleValues.pvpEntry && poracleValues.pvp_ranking_league && - poracleValues[field] !== info?.defaults[field] + poracleValues[field] !== defaultForm[field] ) { const league = leagues.find((x) => x.cp === poracleValues.pvp_ranking_league) || {} @@ -456,7 +498,7 @@ export function WebhookAdvanced() { return '' } if (!poracleValues.pvpEntry) { - return poracleValues[field] === info?.defaults[field] + return poracleValues[field] === defaultForm[field] ? '' : `${field.replace(/_/g, '').replace('min', '')}${poracleValues[field]}` } @@ -464,20 +506,21 @@ export function WebhookAdvanced() { const getPoracleString = () => { if (!id) return '' - if (id === 'gold-stop') - return `${prefix}${t('invasion')} gold-stop ${Object.keys(poracleValues) - .map(checkDefaults) - .join(' ')}` - if (id === 'kecleon') - return `${prefix}${t('invasion')} ${t('poke_352')} ${Object.keys( - poracleValues, - ) - .map(checkDefaults) - .join(' ')}` - if (id === 'showcase') - return `${prefix}${t('showcase')} ${Object.keys(poracleValues) - .map(checkDefaults) - .join(' ')}` + if (category === 'incident') { + const settings = Object.keys(poracleValues).map(checkDefaults).join(' ') + switch (id) { + case 'gold-stop': + return `${prefix}${t('invasion')} gold-stop ${settings}` + case 'kecleon': + return `${prefix}${t('invasion')} ${t('poke_352')} ${settings}` + case 'showcase': + return `${prefix}${t('showcase')} ${settings}` + default: + return `${prefix}${t('invasion')} ${t('incident_unknown', { + id: idObj.id, + })} ${settings}` + } + } switch (id.charAt(0)) { case 't': return `${prefix}${t('gym')} @@ -539,7 +582,11 @@ export function WebhookAdvanced() { }` : Object.keys(poracleValues).map(checkDefaults).join(' ') } - ${poracleValues.gender ? ` ${t(`gender_${poracleValues.gender}`)}` : ''}` + ${ + poracleValues.gender && poracleValues.gender !== 'any' + ? ` ${namedLabel(t, 'gender', poracleValues.gender)}` + : '' + }` } } @@ -570,7 +617,7 @@ export function WebhookAdvanced() { switch (type) { case 'sliders': return options - .filter((option) => filterValues[option.name] !== undefined) + .filter((option) => poracleValues[option.low] !== undefined) .map((option, i) => ( )) @@ -597,7 +644,7 @@ export function WebhookAdvanced() { > )) case 'booleans': - return options.map((option) => ( - - - - {t(camelToSnake(option.name))} - - - - + return options + .filter( + (option) => + !(option.name in CAPABILITY_GATED) || capabilities?.[option.name], + ) + .map((option) => ( + + + + {t(camelToSnake(option.name))} + + + + + - - )) + )) case 'texts': return options.map((option) => ( { const realSave = typeof save === 'boolean' && save - if (realSave) { + if (uid) { + // A tracked rule saves through its own onClose (TrackedTile); writing it + // to tempFilters would re-POST it from Add New later. + } else if (realSave) { useWebhookStore.setState((prev) => { if (filterId === 'global' && filterToSave) { const newFilters = {} const wc = wildCards[category] || ['0-0'] - if (filterToSave.everything_individually !== false) { - selectedIds.forEach((item) => { - newFilters[item] = { - ...prev.tempFilters[item], - ...filterToSave, - enabled: true, - } - }) - } else { - wc.forEach((item) => { - newFilters[item] = { - ...prev.tempFilters[item], - ...filterToSave, - enabled: true, - } - }) - } + const shared = { ...filterToSave } + ;[ + ...(info?.identifiers || []), + ...(GLOBAL_IDENTITY[category] || []), + ...GLOBAL_DERIVED, + ].forEach((key) => delete shared[key]) + const targets = + filterToSave.everything_individually !== false ? selectedIds : wc + targets.forEach((item) => { + newFilters[item] = { + ...prev.tempFilters[item], + ...shared, + // An explicit All Forms applies everywhere; unticked, each + // tile keeps the form it stands for. + ...(filterToSave.allForms && { allForms: true }), + enabled: true, + } + }) return { tempFilters: { ...prev.tempFilters, diff --git a/src/features/webhooks/hooks/useGenFilters.js b/src/features/webhooks/hooks/useGenFilters.js index 3811dec89..d5cf1431a 100644 --- a/src/features/webhooks/hooks/useGenFilters.js +++ b/src/features/webhooks/hooks/useGenFilters.js @@ -4,6 +4,8 @@ import { useMemory } from '@store/useMemory' import { useWebhookStore } from '@store/useWebhookStore' +import { globalPreset, ruleToForm } from '../services/ruleForm' + export function useGenFilters() { const profile_no = useWebhookStore((s) => s.human.current_profile_no) const ui = useWebhookStore((s) => s.context.ui) @@ -14,239 +16,157 @@ export function useGenFilters() { masterfile: { invasions }, } = useMemory.getState() if (!ui) return {} - const { pokemon, egg, gym, invasion, lure, quest, nest, raid } = ui + const { pokemon, egg, gym, invasion, incident, lure, quest, nest, raid } = + ui + /** + * @param {string} category + * @param {Record} [rule] + */ + const form = (category, rule = {}) => + ruleToForm(category, { ...rule, profile_no }, ui[category]) + /** @param {string} category */ + const globalForm = (category) => + globalPreset(category, ui[category], { profile_no }) const filters = { pokemon: pokemon - ? { - global: { ...pokemon.defaults, profile_no }, - '0-0': { ...pokemon.defaults, profile_no }, - } + ? { global: globalForm('pokemon'), '0-0': form('pokemon') } : {}, raid: raid ? { - global: { ...raid.defaults, profile_no }, - r90: { - ...raid.defaults, - level: 90, - profile_no, - }, + global: globalForm('raid'), + r90: form('raid', { level: 90 }), } : {}, egg: egg ? { - global: { ...egg.defaults, profile_no }, - e90: { - ...egg.defaults, - level: 90, - profile_no, - }, + global: globalForm('egg'), + e90: form('egg', { level: 90 }), } : {}, gym: gym ? { - global: { ...gym.defaults, profile_no }, - t4: { ...gym.defaults, profile_no }, + global: globalForm('gym'), + t4: form('gym', { team: 'any' }), } : {}, invasion: invasion ? { - global: { - ...invasion.defaults, - profile_no, - }, - i0: { - ...invasion.defaults, - grunt_type: 'everything', - profile_no, - }, - 'gold-stop': { - ...invasion.defaults, - grunt_type: 'gold-stop', - profile_no, - }, - kecleon: { - ...invasion.defaults, - grunt_type: 'kecleon', - profile_no, - }, - showcase: { - ...invasion.defaults, - grunt_type: 'showcase', - profile_no, - }, + global: globalForm('invasion'), + i0: form('invasion', { grunt_type: 'everything' }), + } + : {}, + incident: incident + ? { + global: globalForm('incident'), + 'gold-stop': form('incident', { display_type: 7 }), + kecleon: form('incident', { display_type: 8 }), + showcase: form('incident', { display_type: 9 }), } : {}, - lure: { - global: lure ? { ...lure.defaults, profile_no } : {}, - }, - nest: { - global: nest ? { ...nest.defaults, profile_no } : {}, - }, - quest: { - global: quest ? { ...quest.defaults, profile_no } : {}, - }, + lure: { global: lure ? globalForm('lure') : {} }, + nest: { global: nest ? globalForm('nest') : {} }, + quest: { global: quest ? globalForm('quest') : {} }, } + const TEAMS = ['harmony', 'mystic', 'valor', 'instinct', 'any'] + const GENDERS = ['any', 'male', 'female'] + const off = { enabled: false } + Object.keys(rmFilters.pokemon?.filter || {}).forEach((key) => { - if (pokemon) - filters.pokemon[key] = { - ...pokemon.defaults, - pokemon_id: +key.split('-')[0], - form: +key.split('-')[1], - profile_no, - enabled: false, - } - if (raid) - filters.raid[key] = { - ...raid.defaults, - pokemon_id: +key.split('-')[0], - form: +key.split('-')[1], - profile_no, - enabled: false, - } - if (nest) - filters.nest[key] = { - ...nest.defaults, - pokemon_id: +key.split('-')[0], - form: +key.split('-')[1], - profile_no, - enabled: false, - } - if (quest) - filters.quest[key] = { - ...quest.defaults, - reward: +key.split('-')[0], - form: +key.split('-')[1], - profile_no, + if (key === 'global') return + const [id, formId] = key.split('-').map(Number) + if (pokemon) { + filters.pokemon[key] = form('pokemon', { + pokemon_id: id, + form: formId, + ...off, + }) + } + if (raid) { + filters.raid[key] = form('raid', { + pokemon_id: id, + form: formId, + ...off, + }) + } + if (nest) { + filters.nest[key] = form('nest', { + pokemon_id: id, + form: formId, + ...off, + }) + } + if (quest) { + filters.quest[key] = form('quest', { + reward: id, + form: formId, reward_type: 7, - enabled: false, - } - if (key === 'global') { - if (pokemon) { - delete filters.pokemon[key].pokemon_id - delete filters.pokemon[key].form - } - if (raid) { - delete filters.raid[key].pokemon_id - delete filters.raid[key].form - } - if (nest) { - delete filters.nest[key].pokemon_id - delete filters.nest[key].form - } - if (quest) { - delete filters.quest[key].reward - delete filters.quest[key].form - } + ...off, + }) } }) Object.keys(rmFilters.pokestops?.filter || {}).forEach((key) => { - if (key.startsWith('i') && invasion) { - filters.invasion[key] = { - ...invasion.defaults, - grunt_type: invasions[key.slice(1)].type.toLowerCase(), - gender: invasions[key.slice(1)].gender, - profile_no, - enabled: false, - } + if (key === 'global') return + const num = key.slice(1) + if (key.startsWith('i') && invasion && invasions[num]) { + filters.invasion[key] = form('invasion', { + grunt_type: invasions[num].type.toLowerCase(), + gender: GENDERS[invasions[num].gender] ?? 'any', + ...off, + }) } if (key.startsWith('l') && lure) { - filters.lure[key] = { - ...lure.defaults, - lure_id: key.slice(1), - profile_no, - enabled: false, - } + filters.lure[key] = form('lure', { lure_id: +num, ...off }) } if (key.startsWith('q') && quest) { - filters.quest[key] = { - ...quest.defaults, - reward: +key.slice(1), - profile_no, + filters.quest[key] = form('quest', { + reward: +num, reward_type: 2, - enabled: false, - } + ...off, + }) } if (key.startsWith('c') && quest) { - filters.quest[key] = { - ...quest.defaults, - reward: +key.slice(1), - profile_no, + filters.quest[key] = form('quest', { + reward: +num, reward_type: 4, - enabled: false, - } + ...off, + }) } if (key.startsWith('d') && quest) { - filters.quest[key] = { - ...quest.defaults, - profile_no, + filters.quest[key] = form('quest', { reward_type: 3, - amount: +key.slice(1), - enabled: false, - } + amount: +num, + ...off, + }) } if (key.startsWith('m') && quest) { - filters.quest[key] = { - ...quest.defaults, - profile_no, + filters.quest[key] = form('quest', { reward_type: 12, reward: +key.split('-')[0].slice(1), amount: +key.split('-')[1], - enabled: false, - } + ...off, + }) } if (key.startsWith('x') && quest) { - filters.quest[key] = { - ...quest.defaults, - reward: +key.slice(1), - profile_no, + filters.quest[key] = form('quest', { + reward: +num, reward_type: 9, - enabled: false, - } - } - if (key === 'global') { - if (invasion) { - delete filters.invasion[key].grunt_type - delete filters.invasion[key].gender - } - if (lure) { - delete filters.lure[key].lure_id - } - if (quest) { - delete filters.quest[key].reward - delete filters.quest[key].reward_type - delete filters.quest[key].amount - } + ...off, + }) } }) Object.keys(rmFilters.gyms?.filter || {}).forEach((key) => { + if (key === 'global') return if (key.startsWith('r') && raid) { - filters.raid[key] = { - ...raid.defaults, - level: +key.slice(1), - profile_no, - enabled: false, - } + filters.raid[key] = form('raid', { level: +key.slice(1), ...off }) } if (key.startsWith('e') && egg) { - filters.egg[key] = { - ...egg.defaults, - level: +key.slice(1), - profile_no, - enabled: false, - } + filters.egg[key] = form('egg', { level: +key.slice(1), ...off }) } if (key.startsWith('t') && gym) { - filters.gym[key] = { - ...gym.defaults, - team: +key.slice(1).split('-')[0], - profile_no, - enabled: false, - } - } - if (key === 'global') { - if (raid) delete filters.raid[key].level - if (egg) delete filters.egg[key].level - if (gym) delete filters.gym[key].team + filters.gym[key] = form('gym', { + team: TEAMS[+key.slice(1).split('-')[0]] ?? 'any', + ...off, + }) } }) return filters diff --git a/src/features/webhooks/hooks/useGetWebhookData.js b/src/features/webhooks/hooks/useGetWebhookData.js index 432e9d094..284465e34 100644 --- a/src/features/webhooks/hooks/useGetWebhookData.js +++ b/src/features/webhooks/hooks/useGetWebhookData.js @@ -47,10 +47,11 @@ export function useGetWebhookData(category) { }, [category, realCategory, refetch, timeout.current]) const filtererData = useMemo(() => { - const source = data ?? previousData + // A failed read has no list: keep showing the last one. + const list = data?.webhook?.[category] ?? previousData?.webhook?.[category] return category === 'human' || category === 'profile' - ? source?.webhook?.[category] - : (source?.webhook?.[category] || []).filter( + ? list + : (list || []).filter( (x) => !search || (x.description @@ -61,23 +62,28 @@ export function useGetWebhookData(category) { ) || [] }, [data, previousData, search]) + // The list and its tiles read the same filtered rows, also while a failed + // read keeps the last list on screen. useEffect(() => { - if (!loading && data?.webhook) { - if (data.webhook.status === 'error') { - const { context } = useWebhookStore.getState() - useWebhookStore.setState({ - alert: { - open: true, - severity: data.webhook.status, - message: t(data.webhook.message, { name: context.name || '' }), - }, - }) - } else { - useWebhookStore.setState({ [category]: filtererData }) - } + if (!loading && data?.webhook && filtererData !== undefined) { + useWebhookStore.setState({ [category]: filtererData }) } }, [data, filtererData, search]) + // Once per read result, not again on every search change. + useEffect(() => { + if (!loading && data?.webhook?.status === 'error') { + const { context } = useWebhookStore.getState() + useWebhookStore.setState({ + alert: { + open: true, + severity: data.webhook.status, + message: t(data.webhook.message, { name: context.name || '' }), + }, + }) + } + }, [data, loading]) + useEffect(() => { if (category === 'human') { useWebhookStore.setState({ diff --git a/src/features/webhooks/human/Location.jsx b/src/features/webhooks/human/Location.jsx index 6dbf3d35c..8960f9760 100644 --- a/src/features/webhooks/human/Location.jsx +++ b/src/features/webhooks/human/Location.jsx @@ -18,7 +18,11 @@ import { WEBHOOK_NOMINATIM } from '@services/queries/geocoder' import { useLocation } from '@hooks/useLocation' import { Loading } from '@components/Loading' import { basicEqualFn } from '@store/useMemory' -import { setModeBtn, useWebhookStore } from '@store/useWebhookStore' +import { + applyHumanResult, + setModeBtn, + useWebhookStore, +} from '@store/useWebhookStore' import { useSyncData } from '../hooks/useSyncData' @@ -76,11 +80,7 @@ const Location = () => { status: 'POST', }, }) - .then(({ data: newData }) => { - if (newData?.webhook) { - useWebhookStore.setState({ human: newData.webhook.human }) - } - }) + .then(({ data: newData }) => applyHumanResult(newData?.webhook)) .finally(() => { // Only clear if a newer save has not already claimed the ref, so an // in-flight save can't wipe the marker for the location after it. diff --git a/src/features/webhooks/human/profile/ActiveHourChip.jsx b/src/features/webhooks/human/profile/ActiveHourChip.jsx index 005dfcc00..1fb850f5a 100644 --- a/src/features/webhooks/human/profile/ActiveHourChip.jsx +++ b/src/features/webhooks/human/profile/ActiveHourChip.jsx @@ -15,8 +15,20 @@ const StyledChip = styled(Chip)(({ theme }) => ({ margin: theme.spacing(0.5), })) +/** @param {unknown} value */ +const pad = (value) => String(value).padStart(2, '0') + /** @param {import("@rm/types").PoracleActiveHours & { uid: number }} props */ -export const ActiveHourChip = ({ day, hours, mins, uid, id }) => { +export const ActiveHourChip = ({ + day, + hours, + mins, + step, + end_hours: endHours, + end_mins: endMins, + uid, + id, +}) => { const { t } = useTranslation() const [save] = useMutation(SET_PROFILE, { refetchQueries: [ALL_PROFILES], @@ -47,7 +59,9 @@ export const ActiveHourChip = ({ day, hours, mins, uid, id }) => { clickable color="secondary" deleteIcon={} - label={`${t(`day_${day}`)} ${hours}:${String(mins).padStart(2, '0')}`} + label={`${t(`day_${day}`)} ${hours}:${pad(mins)}${ + step > 0 ? `-${pad(endHours)}:${pad(endMins)} /${step}h` : '' + }`} onDelete={handleRemoveSchedule} size="small" /> diff --git a/src/features/webhooks/human/profile/NewProfile.jsx b/src/features/webhooks/human/profile/NewProfile.jsx index 196be7fb6..cdaf3ad26 100644 --- a/src/features/webhooks/human/profile/NewProfile.jsx +++ b/src/features/webhooks/human/profile/NewProfile.jsx @@ -10,6 +10,8 @@ import { useMutation } from '@apollo/client' import { ALL_PROFILES, SET_PROFILE } from '@services/queries/webhook' import { useWebhookStore } from '@store/useWebhookStore' +import { handleUpdate } from './handleUpdate' + export const NewProfile = () => { const { t } = useTranslation() const [save] = useMutation(SET_PROFILE, { @@ -29,7 +31,7 @@ export const NewProfile = () => { data: { name: newProfile }, status: 'POST', }, - }) + }).then(handleUpdate) }, [newProfile, save]) /** @type {React.FormEventHandler} */ diff --git a/src/features/webhooks/human/profile/handleUpdate.js b/src/features/webhooks/human/profile/handleUpdate.js index 9f6076aa2..505f42d41 100644 --- a/src/features/webhooks/human/profile/handleUpdate.js +++ b/src/features/webhooks/human/profile/handleUpdate.js @@ -1,12 +1,16 @@ // @ts-check -import { useWebhookStore } from '@store/useWebhookStore' +import { showWebhookError, useWebhookStore } from '@store/useWebhookStore' /** - * @param {{ data: { webhook: { profile: import("@rm/types").PoracleProfile[]} } }} params + * Applies a profile mutation result. A failed read has no list, so the + * profiles on screen stay; an error is shown either way. + * @param {{ data?: { webhook?: { status?: string, message?: string, profile?: import("@rm/types").PoracleProfile[] | null } } }} params */ export function handleUpdate({ data }) { + const result = data?.webhook useWebhookStore.setState({ profileLoading: null, - profile: data.webhook.profile, + ...(Array.isArray(result?.profile) && { profile: result.profile }), }) + if (result?.status === 'error') showWebhookError(result) } diff --git a/src/features/webhooks/human/status/EnableSwitch.jsx b/src/features/webhooks/human/status/EnableSwitch.jsx index 765aeb39d..997a39098 100644 --- a/src/features/webhooks/human/status/EnableSwitch.jsx +++ b/src/features/webhooks/human/status/EnableSwitch.jsx @@ -6,7 +6,7 @@ import { useMutation } from '@apollo/client' import { useTranslation } from 'react-i18next' import { SET_HUMAN } from '@services/queries/webhook' -import { useWebhookStore } from '@store/useWebhookStore' +import { applyHumanResult, useWebhookStore } from '@store/useWebhookStore' export function EnableSwitch() { const { t } = useTranslation() @@ -26,9 +26,7 @@ export function EnableSwitch() { category: enabled ? 'stop' : 'start', status: 'POST', }, - }).then(({ data }) => - useWebhookStore.setState({ human: data.webhook.human }), - ) + }).then(({ data }) => applyHumanResult(data?.webhook)) }} /> } diff --git a/src/features/webhooks/human/status/HookSelection.jsx b/src/features/webhooks/human/status/HookSelection.jsx index 322489d78..a2346d129 100644 --- a/src/features/webhooks/human/status/HookSelection.jsx +++ b/src/features/webhooks/human/status/HookSelection.jsx @@ -13,7 +13,7 @@ import { WEBHOOK_USER, ALL_PROFILES, } from '@services/queries/webhook' -import { useWebhookStore } from '@store/useWebhookStore' +import { applyHumanResult, useWebhookStore } from '@store/useWebhookStore' import { FCSelect } from '@components/inputs/FCSelect' import { useGetWebhookData } from '../../hooks/useGetWebhookData' @@ -47,10 +47,8 @@ export function HookSelection() { label={t('select_webhook')} value={selected} onChange={(e) => { - save({ variables: { webhook: e.target.value } }).then( - ({ data }) => - data?.webhook?.human && - useWebhookStore.setState({ human: data.webhook.human }), + save({ variables: { webhook: e.target.value } }).then(({ data }) => + applyHumanResult(data?.webhookChange), ) }} fcSx={{ m: 1, width: '90%' }} diff --git a/src/features/webhooks/human/status/ProfileSelect.jsx b/src/features/webhooks/human/status/ProfileSelect.jsx index 56e088545..c62a0057b 100644 --- a/src/features/webhooks/human/status/ProfileSelect.jsx +++ b/src/features/webhooks/human/status/ProfileSelect.jsx @@ -6,7 +6,7 @@ import { useMutation, useQuery } from '@apollo/client' import { useTranslation } from 'react-i18next' import { ALL_PROFILES, SET_HUMAN } from '@services/queries/webhook' -import { useWebhookStore } from '@store/useWebhookStore' +import { applyHumanResult, useWebhookStore } from '@store/useWebhookStore' import { FCSelect } from '@components/inputs/FCSelect' /** @type {React.CSSProperties} */ @@ -45,11 +45,7 @@ export function ProfileSelect() { status: 'POST', data: +event.target.value || currentProfile, }, - }).then(({ data }) => { - if (data.webhook.human) { - useWebhookStore.setState({ human: data.webhook.human }) - } - }) + }).then(({ data }) => applyHumanResult(data?.webhook)) }, [currentProfile], ) diff --git a/src/features/webhooks/services/Poracle.js b/src/features/webhooks/services/Poracle.js index bb963909e..e98650a34 100644 --- a/src/features/webhooks/services/Poracle.js +++ b/src/features/webhooks/services/Poracle.js @@ -1,8 +1,11 @@ // @ts-check import { t } from 'i18next' +import { useMemory } from '@store/useMemory' import { useWebhookStore } from '@store/useWebhookStore' +import { ruleId, sizeSuffix } from './ruleForm' + export class Poracle { static getMapCategory(poracleCategory) { switch (poracleCategory) { @@ -11,6 +14,7 @@ export class Poracle { case 'raid': return 'gyms' case 'invasion': + case 'incident': case 'lure': case 'quest': return 'pokestops' @@ -26,6 +30,7 @@ export class Poracle { case 'egg': return ['eggs'] case 'invasion': + case 'incident': return ['invasions'] case 'gym': return ['teams'] @@ -51,53 +56,17 @@ export class Poracle { static getId(item) { if (!item) return '' const { category } = useWebhookStore.getState() - - switch (category) { - case 'egg': - return `e${item.level}` - case 'invasion': - return item.grunt_type === 'gold-stop' - ? 'gold-stop' - : item.grunt_type === 'kecleon' - ? 'kecleon' - : item.grunt_type === 'showcase' - ? 'showcase' - : `i${item.real_grunt_id}` - case 'lure': - return `l${item.lure_id}` - case 'gym': - return `t${item.team}-0` - case 'raid': - return item.pokemon_id === 9000 - ? `r${item.level}` - : `${item.pokemon_id}-${item.form}` - case 'quest': - return (() => { - switch (item.reward_type) { - case 2: - return `q${item.reward}` - case 3: - return `d${item.amount}` - case 4: - return `c${item.reward}` - case 9: - return `x${item.reward}` - case 12: - return `m${item.reward}-${item.amount}` - default: - return `${item.reward}-${item.form}` - } - })() - default: - return `${item.pokemon_id}-${item.form}` - } + return ruleId(category, item, useMemory.getState().masterfile.invasions) } static getIdObj(id) { if (!id) return {} - if (id === 'gold-stop') return { id: 'gold-stop', type: 'invasion' } - if (id === 'kecleon') return { id: 'kecleon', type: 'invasion' } - if (id === 'showcase') return { id: 'showcase', type: 'invasion' } + if (id === 'gold-stop' || id === 'kecleon' || id === 'showcase') { + return { id, type: 'incident' } + } + if (id.startsWith('incident-')) { + return { id: id.replace('incident-', ''), type: 'incident' } + } switch (id.charAt(0)) { case 'e': return { id: id.replace('e', ''), type: 'egg' } @@ -128,10 +97,13 @@ export class Poracle { switch (idObj.type) { case 'egg': return idObj.id === '90' ? ['poke_global'] : [`egg_${idObj.id}_plural`] - case 'invasion': + case 'incident': if (idObj.id === 'gold-stop') return ['gold_stop'] if (idObj.id === 'kecleon') return ['poke_352'] if (idObj.id === 'showcase') return ['showcase'] + // unknown display type: no locale key exists, so give a readable title + return [t('incident_unknown', { id: idObj.id })] + case 'invasion': return idObj.id === '0' ? ['poke_global'] : [`grunt_a_${idObj.id}`] case 'lure': return [`lure_${idObj.id}`] @@ -148,158 +120,6 @@ export class Poracle { } } - static reactMapFriendly(values) { - const reactMapFriendly = {} - if (!values) return reactMapFriendly - Object.keys(values).forEach((key) => { - if (key === 'min_time') { - reactMapFriendly[key] = values[key] - } else if (key.startsWith('min')) { - const trim = key.replace('min_', '') - reactMapFriendly[trim] = [values[`min_${trim}`], values[`max_${trim}`]] - } else if (key.startsWith('max')) { - // do nothing, handled above - } else if (key.startsWith('pvp')) { - reactMapFriendly.pvp = [ - values.pvp_ranking_best, - values.pvp_ranking_worst, - ] - } else if (key === 'atk' || key === 'def' || key === 'sta') { - reactMapFriendly[`${key}_iv`] = [values[key], values[`max_${key}`]] - } else if (key.startsWith('rarity')) { - reactMapFriendly.rarity = [values[key], values[`max_${key}`]] - } else if (key.startsWith('size')) { - reactMapFriendly.size = [values[key], values[`max_${key}`]] - } else { - reactMapFriendly[key] = values[key] - } - }) - return reactMapFriendly - } - - static processor(category, entries, defaults) { - const pvpFields = [ - 'pvp_ranking_league', - 'pvp_ranking_best', - 'pvp_ranking_worst', - 'pvp_ranking_min_cp', - 'pvp_ranking_cap', - ] - const ignoredFields = [ - 'noIv', - 'byDistance', - 'xs', - 'xl', - 'allForms', - 'pvpEntry', - ] - const dupes = {} - switch (category) { - case 'egg': - return entries.map((egg) => ({ - ...defaults, - ...egg, - byDistance: undefined, - })) - case 'invasion': - return entries.map((invasion) => ({ - ...defaults, - ...invasion, - byDistance: undefined, - })) - case 'lure': - return entries.map((lure) => ({ - ...defaults, - ...lure, - lure_id: +lure.lure_id, - byDistance: undefined, - })) - case 'gym': - return entries.map((gym) => ({ - ...defaults, - ...gym, - byDistance: undefined, - })) - case 'raid': - return entries - .map((boss) => { - if (boss.allForms) { - boss.form = defaults.form - if (dupes[boss.pokemon_id]) { - return null - } - dupes[boss.pokemon_id] = true - } - if (boss.byDistance === false) { - boss.distance = 0 - } - return { ...defaults, ...boss } - }) - .filter((boss) => boss) - case 'quest': - return entries - .map((quest) => { - if (quest.allForms) { - quest.form = defaults.form - if (dupes[quest.reward]) { - return null - } - dupes[quest.reward] = true - } - return { - ...defaults, - ...quest, - byDistance: undefined, - allForms: undefined, - } - }) - .filter((quest) => quest) - default: - return entries - .map((pkmn) => { - const fields = [ - 'pokemon_id', - 'form', - 'clean', - 'distance', - 'min_time', - 'template', - 'profile_no', - 'gender', - 'rarity', - 'max_rarity', - 'size', - 'max_size', - ] - const newPokemon = {} - if (pkmn.allForms) { - pkmn.form = 0 - if (dupes[pkmn.pokemon_id]) { - return null - } - dupes[pkmn.pokemon_id] = true - } - if (pkmn.pvpEntry) { - fields.push(...pvpFields) - } else { - fields.push( - ...Object.keys(pkmn).filter( - (key) => - !pvpFields.includes(key) && !ignoredFields.includes(key), - ), - ) - } - new Set(fields).forEach( - (field) => - (newPokemon[field] = - pkmn[field] === undefined ? defaults[field] : pkmn[field]), - ) - return newPokemon - }) - .filter((pokemon) => pokemon) - } - } - /** * * @param {object} item @@ -307,21 +127,37 @@ export class Poracle { * @returns {string} */ static generateDescription(item, category) { + if (typeof item.description === 'string' && item.description) { + return item.description + } const { leagues } = useWebhookStore.getState().context switch (category) { case 'invasion': { + const gruntId = ruleId( + 'invasion', + item, + useMemory.getState().masterfile.invasions, + ) let name = t( - item.grunt_type === 'gold-stop' - ? 'gold_stop' - : item.grunt_type === 'kecleon' - ? 'poke_352' - : item.grunt_type === 'showcase' - ? 'showcase' - : item.real_grunt_id - ? `grunt_${item.real_grunt_id}` - : 'poke_global', + gruntId === 'i0' ? 'poke_global' : `grunt_${gruntId.slice(1)}`, ) - if (!item.gender) name = name.replace(/\(.+?\)/g, `(${t('all')})`) + if (!item.gender || item.gender === 'any') { + name = name.replace(/\(.+?\)/g, `(${t('all')})`) + } + return `${name}${item.clean ? ` | ${t('clean')} ` : ''}${ + item.distance ? ` | d${item.distance}` : '' + }` + } + case 'incident': { + const id = ruleId('incident', item) + const key = { + 'gold-stop': 'gold_stop', + kecleon: 'poke_352', + showcase: 'showcase', + }[id] + const name = key + ? t(key) + : t('incident_unknown', { id: item.display_type }) return `${name}${item.clean ? ` | ${t('clean')} ` : ''}${ item.distance ? ` | d${item.distance}` : '' }` @@ -387,25 +223,15 @@ export class Poracle { }${item.clean ? ` | ${t('clean')} ` : ''}${ item.distance ? ` | d${item.distance}` : '' }` - : `${item.min_iv}-${item.max_iv}% | L${item.min_level}-${ - item.max_level - } - A${item.atk}-${item.max_atk} | D${item.def}-${item.max_def} | S${ - item.sta - }-${item.max_sta}${item.clean ? ` | ${t('clean')} ` : ''}${ + : `${item.min_iv ?? 0}-${item.max_iv ?? 100}% | L${ + item.min_level ?? 1 + }-${item.max_level ?? 55} + A${item.atk ?? 0}-${item.max_atk ?? 15} | D${item.def ?? 0}-${ + item.max_def ?? 15 + } | S${item.sta ?? 0}-${item.max_sta ?? 15}${item.clean ? ` | ${t('clean')} ` : ''}${ item.distance ? ` | d${item.distance}` : '' }` - }${ - item.size > 1 || item.max_size < 5 - ? ` | ${t('size', 'Size')}:${ - item.size === item.max_size - ? `size:${t(`size_${item.size}`)}` - : `size:${t(`size_${item.size}`)}-${t( - `size_${item.max_size}`, - )}` - }` - : '' - }` + }${sizeSuffix(item, t)}` default: return item.description || '' } @@ -416,9 +242,9 @@ export class Poracle { switch (id.charAt(0)) { case 'e': case 'r': - return { level: id.slice(1) } + return { level: +id.slice(1) } default: - return { pokemon_id: id.split('-')[0], form: id.split('-')[1] } + return { pokemon_id: +id.split('-')[0], form: +id.split('-')[1] || 0 } } } } diff --git a/src/features/webhooks/services/ruleForm.js b/src/features/webhooks/services/ruleForm.js new file mode 100644 index 000000000..7c4a32861 --- /dev/null +++ b/src/features/webhooks/services/ruleForm.js @@ -0,0 +1,292 @@ +/** + * Converts between a PoracleNG v2 rule (`null` = any) and the flat form object + * the webhook editor works on. No imports: the server tests load this file. + */ + +/** Fields that exist only in the editor and never reach a rule. */ +export const FORM_ONLY_FIELDS = [ + 'byDistance', + 'pvpEntry', + 'allForms', + 'allMoves', + 'noIv', + 'xs', + 'xl', + 'everything_individually', + 'gymName', + 'profile_no', + 'enabled', + 'all', +] + +/** Categories for which v2 rejects `edit: true`. */ +const NO_EDIT = new Set(['gym', 'invasion', 'nest', 'quest', 'maxbattle']) +/** Categories that support `summary`. */ +const SUMMARY = new Set(['quest']) + +/** Fields that mean nothing when the rule is a PVP entry. */ +const IV_FAMILY = [ + 'min_iv', + 'max_iv', + 'min_cp', + 'max_cp', + 'min_level', + 'max_level', + 'atk', + 'max_atk', + 'def', + 'max_def', + 'sta', + 'max_sta', +] + +/** + * Plain numeric fields whose v2 wildcard `null` the editor shows as 0 + * (0 is also what v2 stores for "any" in these fields). + */ +const NULL_AS_ZERO = [ + 'min_time', + 'amount', + 'min_spawn_avg', + 'pvp_ranking_min_cp', + 'pvp_ranking_cap', +] + +const PVP_FIELDS = [ + 'pvp_ranking_best', + 'pvp_ranking_worst', + 'pvp_ranking_min_cp', + 'pvp_ranking_cap', + 'pvp_ranking_evolution', +] + +/** + * Collects every slider definition, wherever it sits in `ui.ui`. + * @param {any} node + * @param {any[]} [found] + * @returns {any[]} + */ +const collectSliders = (node, found = []) => { + if (node && typeof node === 'object') { + if (Array.isArray(node.sliders)) found.push(...node.sliders) + Object.values(node).forEach((child) => { + if (child && typeof child === 'object' && !Array.isArray(child)) { + collectSliders(child, found) + } + }) + } + return found +} + +/** + * @param {string} category + * @param {Record} rule + * @param {any} ui + * @returns {Record} + */ +export function ruleToForm(category, rule, ui) { + const form = { ...ui.defaults, ...rule } + collectSliders(ui.ui).forEach(({ low, high, min, max, anyLow, anyHigh }) => { + if (form[low] === null || form[low] === undefined) { + form[low] = anyLow ? min : (ui.defaults[low] ?? min) + } + if (form[high] === null || form[high] === undefined) { + form[high] = anyHigh ? max : (ui.defaults[high] ?? max) + } + }) + NULL_AS_ZERO.forEach((key) => { + if (key in ui.defaults && form[key] === null) form[key] = 0 + }) + form.byDistance = form.distance > 0 + form.pvpEntry = form.pvp_ranking_league > 0 + if ('form' in ui.defaults) form.allForms = !form.form + if ('move' in ui.defaults) { + form.allMoves = form.move === null || form.move === undefined + } + if (category === 'pokemon') { + form.xs = rule.max_weight !== null && rule.max_weight !== undefined + form.xl = rule.min_weight !== null && rule.min_weight !== undefined + } + return form +} + +/** Identity fields that stay per item in an apply-to-all edit. */ +export const GLOBAL_IDENTITY = { + pokemon: ['form'], + raid: ['form', 'level'], + nest: ['form'], + quest: ['form', 'amount'], + invasion: ['gender'], +} + +/** + * The starting form of the apply-to-all editor: defaults without the fields + * that identify an item, and All Forms off until the user ticks it (the + * absent form would otherwise read as "all forms"). + * @param {string} category + * @param {any} ui + * @param {Record} [base] + * @returns {Record} + */ +export function globalPreset(category, ui, base = {}) { + const preset = ruleToForm(category, base, ui) + ;[...(ui.identifiers || []), ...(GLOBAL_IDENTITY[category] || [])].forEach( + (key) => delete preset[key], + ) + if ('allForms' in preset) preset.allForms = !!ui.defaults.allForms + return preset +} + +/** + * Like `ruleToForm`, but keeps the form-only fields `value` already carries, + * so it accepts either a stored rule or a form the editor produced earlier. + * @param {string} category + * @param {Record} [value] + * @param {any} ui + * @returns {Record} + */ +export function toForm(category, value, ui) { + const source = value || {} + const form = ruleToForm(category, source, ui) + FORM_ONLY_FIELDS.forEach((key) => { + if (key in source) form[key] = source[key] + }) + return form +} + +/** + * @param {string} category + * @param {Record} form + * @param {any} ui + * @param {Record} [original] + * @returns {Record} + */ +export function formToRule(category, form, ui, original) { + const converted = { ...form } + // A rule just switched to PVP has no ceiling of its own to keep. + const newPvp = + category === 'pokemon' && + form.pvpEntry && + !(original?.pvp_ranking_league > 0) + /** @param {string} key */ + const storedWildcard = (key) => + !newPvp && original?.[key] === null && key in ui.defaults + collectSliders(ui.ui).forEach(({ low, high, min, max, anyLow, anyHigh }) => { + if (anyLow && converted[low] === min) converted[low] = null + if (anyHigh && converted[high] === max) converted[high] = null + // A slider without an "any" end shows a stored wildcard as its default; + // left there, the wildcard is kept rather than narrowed to that default. + if (!anyLow && storedWildcard(low)) { + if (converted[low] === (ui.defaults[low] ?? min)) converted[low] = null + } + if (!anyHigh && storedWildcard(high)) { + if (converted[high] === (ui.defaults[high] ?? max)) converted[high] = null + } + }) + if (!form.byDistance) converted.distance = 0 + // v2 refuses a location label without distance, and areas with distance. + if (form.byDistance) { + if (original?.override_areas?.length) converted.override_areas = null + } else if (original?.override_location_label) { + converted.override_location_label = null + } + if ('form' in ui.defaults && form.allForms) converted.form = null + if ('move' in ui.defaults && form.allMoves) converted.move = null + if (category === 'pokemon') { + if (form.pvpEntry) { + IV_FAMILY.forEach((key) => { + converted[key] = null + }) + } else { + converted.pvp_ranking_league = 0 + PVP_FIELDS.forEach((key) => { + // pvp_ranking_evolution is not a default; only clear it if present + if (key in ui.defaults || key in (original || {})) converted[key] = null + }) + } + } + if (NO_EDIT.has(category)) delete converted.edit + if (!SUMMARY.has(category)) delete converted.summary + FORM_ONLY_FIELDS.forEach((key) => delete converted[key]) + + const rule = { ...original, ...converted } + // Not a form field: survives from the original while pvp is on. + if (category === 'pokemon' && form.pvpEntry && original) { + rule.pvp_ranking_evolution = original.pvp_ranking_evolution ?? null + } + delete rule.description + return rule +} + +const TEAM_INDEX = { harmony: 0, mystic: 1, valor: 2, instinct: 3, any: 4 } +const INCIDENT_IDS = { 7: 'gold-stop', 8: 'kecleon', 9: 'showcase' } +const GENDER_NUMBER = { male: 1, female: 2 } + +/** + * The key a rule is filed under in the editor's filter map (also drives icons). + * @param {string} category + * @param {Record} rule + * @param {Record} [invasions] masterfile invasions + * @returns {string} + */ +export function ruleId(category, rule, invasions = {}) { + switch (category) { + case 'egg': + return `e${rule.level}` + case 'raid': + return rule.pokemon_id === null || rule.pokemon_id === undefined + ? `r${rule.level ?? 90}` + : `${rule.pokemon_id}-${rule.form ?? 0}` + case 'gym': + return `t${TEAM_INDEX[rule.team] ?? rule.team ?? 4}-0` + case 'incident': + return INCIDENT_IDS[rule.display_type] ?? `incident-${rule.display_type}` + case 'invasion': { + const type = rule.grunt_type + if (!type || type === 'everything') return 'i0' + const gender = GENDER_NUMBER[rule.gender] || 1 + const found = Object.keys(invasions || {}).find( + (key) => + invasions[key]?.type?.toLowerCase() === type.toLowerCase() && + (invasions[key]?.gender === 0 || invasions[key]?.gender === gender), + ) + return `i${+found || 0}` + } + case 'lure': + return `l${rule.lure_id}` + case 'quest': + switch (rule.reward_type) { + case 2: + return `q${rule.reward}` + case 3: + return `d${rule.amount ?? 0}` + case 4: + return `c${rule.reward}` + case 9: + return `x${rule.reward}` + case 12: + return `m${rule.reward}-${rule.amount ?? 0}` + default: + return `${rule.reward}-${rule.form ?? 0}` + } + default: + return `${rule.pokemon_id ?? 0}-${rule.form ?? 0}` + } +} + +/** + * The size part of a pokemon rule's fallback description. A null bound is the + * wildcard (any size), as are the stored sentinels -1 and 5. + * @param {{ size?: number | null, max_size?: number | null }} item + * @param {(key: string, fallback?: string) => string} t + */ +export function sizeSuffix(item, t) { + const min = item.size > 0 ? item.size : 1 + const max = item.max_size ?? 5 + if (min <= 1 && max >= 5) return '' + return ` | ${t('size', 'Size')}:${ + min === max + ? `size:${t(`size_${min}`)}` + : `size:${t(`size_${min}`)}-${t(`size_${max}`)}` + }` +} diff --git a/src/features/webhooks/tiles/TrackedTile.jsx b/src/features/webhooks/tiles/TrackedTile.jsx index 6ade3b2b4..cc6eb8770 100644 --- a/src/features/webhooks/tiles/TrackedTile.jsx +++ b/src/features/webhooks/tiles/TrackedTile.jsx @@ -10,12 +10,18 @@ import Checkbox from '@mui/material/Checkbox' import Box from '@mui/material/Box' import { useMemory } from '@store/useMemory' -import { apolloClient, apolloCache } from '@services/apollo' +import { apolloClient } from '@services/apollo' import * as webhookNodes from '@services/queries/webhook' import { getTileBackground } from '@utils/getTileBackground' -import { useWebhookStore, setSelected } from '@store/useWebhookStore' +import { + useWebhookStore, + setSelected, + applyWebhookResult, + showWebhookError, +} from '@store/useWebhookStore' import { Poracle } from '../services/Poracle' +import { formToRule } from '../services/ruleForm' export function TrackedTile({ index }) { const category = useWebhookStore((s) => s.category) @@ -23,8 +29,8 @@ export function TrackedTile({ index }) { const id = Poracle.getId(item) const advOpen = useWebhookStore((s) => s.advanced) const selected = useWebhookStore((s) => (item ? s.selected[item.uid] : false)) - const defaults = useWebhookStore((s) => - category === 'human' ? null : s.context.ui[category].defaults, + const info = useWebhookStore((s) => + category === 'human' ? null : s.context.ui[category], ) React.useEffect(() => { @@ -32,7 +38,7 @@ export function TrackedTile({ index }) { useWebhookStore.setState((prev) => ({ tempFilters: { ...prev.tempFilters, - [id]: { ...item, byDistance: !!item.distance }, + [id]: { ...item }, }, })) } @@ -40,18 +46,21 @@ export function TrackedTile({ index }) { const onClose = React.useCallback( (newFilter, save) => { - if (save) { - apolloClient.mutate({ - mutation: webhookNodes[category.toUpperCase()], - variables: { - data: Poracle.processor(category, [newFilter], defaults), - status: 'POST', - category, - }, - }) + if (save && info) { + apolloClient + .mutate({ + mutation: webhookNodes[category.toUpperCase()], + variables: { + data: [formToRule(category, newFilter, info, item)], + status: 'POST', + category, + }, + }) + .then(({ data }) => applyWebhookResult(category, data?.webhook)) + .catch(showWebhookError) } }, - [category, defaults], + [category, info, item], ) if (!item || category === 'human') return   @@ -100,25 +109,17 @@ export function TrackedTile({ index }) { size="small" disabled={!item.uid} onClick={() => { - useWebhookStore.setState((prev) => ({ - [category]: prev[category].filter((x) => x.uid !== item.uid), - })) - apolloClient.mutate({ - mutation: webhookNodes.SET_PROFILE, - variables: { - category, - data: { uid: item.uid }, - status: 'DELETE', - }, - }) - apolloClient.cache.modify({ - id: apolloCache.identify(item), - fields: { - uid() { - // deletes...? + apolloClient + .mutate({ + mutation: webhookNodes[category.toUpperCase()], + variables: { + category, + data: { uid: item.uid }, + status: 'DELETE', }, - }, - }) + }) + .then(({ data }) => applyWebhookResult(category, data?.webhook)) + .catch(showWebhookError) }} > diff --git a/src/services/Assets.js b/src/services/Assets.js index 7c7e2a42b..fe0a11c2a 100644 --- a/src/services/Assets.js +++ b/src/services/Assets.js @@ -228,6 +228,9 @@ export class UAssets { id = 'b7' } else if (id === 'showcase') { id = 'b9' + } else if (id.startsWith('incident-')) { + // Poracle incident without a named id: the event stop by display type + id = `b${id.slice('incident-'.length)}` } switch (id.charAt(0)) { case 'a': { diff --git a/src/services/apollo/index.js b/src/services/apollo/index.js index 56a18e479..8c07fbd6c 100644 --- a/src/services/apollo/index.js +++ b/src/services/apollo/index.js @@ -15,33 +15,6 @@ export const apolloCache = new InMemoryCache({ PoracleProfile: { keyFields: ['uid'], }, - PoraclePokemon: { - keyFields: ['uid'], - }, - PoracleGym: { - keyFields: ['uid'], - }, - PoracleRaid: { - keyFields: ['uid'], - }, - PoracleEgg: { - keyFields: ['uid'], - }, - PoracleInvasion: { - keyFields: ['uid'], - }, - PoracleLure: { - keyFields: ['uid'], - }, - PoracleQuest: { - keyFields: ['uid'], - }, - PoracleNest: { - keyFields: ['uid'], - }, - PoracleWeather: { - keyFields: ['uid'], - }, }, }) diff --git a/src/services/queries/webhook.js b/src/services/queries/webhook.js index c8bcf7086..8c37b510b 100644 --- a/src/services/queries/webhook.js +++ b/src/services/queries/webhook.js @@ -12,21 +12,7 @@ const base = gql` const Egg = gql` fragment PoracleEgg on Poracle { - egg { - distance - clean - exclusive - gym_id - ping - level - id - profile_no - team - rsvp_changes - template - uid - description - } + egg } ` @@ -56,117 +42,31 @@ const Human = gql` const Gym = gql` fragment PoracleGym on Poracle { - gym { - clean - gym_id - distance - id - ping - profile_no - slot_changes - team - template - uid - description - } + gym } ` const Invasion = gql` fragment PoracleInvasion on Poracle { - invasion { - clean - gender - distance - grunt_type - id - ping - profile_no - template - uid - description - real_grunt_id - } + invasion } ` const Lure = gql` fragment PoracleLure on Poracle { - lure { - distance - clean - id - lure_id - ping - template - uid - profile_no - description - } + lure } ` const Nest = gql` fragment PoracleNest on Poracle { - nest { - clean - distance - id - min_spawn_avg - ping - pokemon_id - form - profile_no - template - uid - } + nest } ` const Pokemon = gql` fragment PoraclePokemon on Poracle { - pokemon { - atk - clean - def - description - distance - form - gender - id - max_atk - max_cp - max_def - max_iv - max_level - max_rarity - max_sta - max_weight - min_cp - min_iv - min_level - min_time - min_weight - ping - pokemon_id - profile_no - pvp_ranking_best - pvp_ranking_league - pvp_ranking_min_cp - pvp_ranking_worst - pvp_ranking_cap - rarity - size - max_size - sta - template - uid - description - allForms - pvpEntry - xs - xl - } + pokemon } ` @@ -177,6 +77,9 @@ export const PROFILE = gql` day hours mins + step + end_hours + end_mins id } area @@ -192,61 +95,19 @@ export const PROFILE = gql` const Quest = gql` fragment PoracleQuest on Poracle { - quest { - amount - clean - form - distance - id - ping - profile_no - reward - reward_type - shiny - template - uid - description - } + quest } ` const Raid = gql` fragment PoracleRaid on Poracle { - raid { - exclusive - distance - clean - form - gym_id - level - move - id - ping - pokemon_id - profile_no - team - rsvp_changes - template - uid - description - allMoves - } + raid } ` -const Weather = gql` - fragment PoracleWeather on Poracle { - weather { - cell - clean - condition - id - ping - profile_no - template - uid - description - } +const Incident = gql` + fragment PoracleIncident on Poracle { + incident } ` @@ -254,6 +115,7 @@ export const ALL_PROFILES = gql` ${Human} ${Egg} ${Gym} + ${Incident} ${Invasion} ${Lure} ${Nest} @@ -261,12 +123,14 @@ export const ALL_PROFILES = gql` ${PROFILE} ${Quest} ${Raid} - ${Weather} query Webhook($category: String!, $status: String!) { webhook(category: $category, status: $status) { + status + message ...PoracleHuman ...PoracleEgg ...PoracleGym + ...PoracleIncident ...PoracleInvasion ...PoracleLure ...PoracleNest @@ -274,7 +138,6 @@ export const ALL_PROFILES = gql` ...PoracleProfile ...PoracleQuest ...PoracleRaid - ...PoracleWeather } } ` @@ -292,6 +155,7 @@ export const QUICK_ADD = gql` ${Human} ${Egg} ${Gym} + ${Incident} ${Invasion} ${Lure} ${Nest} @@ -299,12 +163,12 @@ export const QUICK_ADD = gql` ${PROFILE} ${Quest} ${Raid} - ${Weather} mutation Webhook($data: JSON, $category: String!, $status: String!) { webhook(data: $data, category: $category, status: $status) { ...PoracleHuman ...PoracleEgg ...PoracleGym + ...PoracleIncident ...PoracleInvasion ...PoracleLure ...PoracleNest @@ -312,7 +176,6 @@ export const QUICK_ADD = gql` ...PoracleProfile ...PoracleQuest ...PoracleRaid - ...PoracleWeather status message } @@ -396,6 +259,17 @@ export const INVASION = gql` } ` +export const INCIDENT = gql` + ${base} + ${Incident} + mutation Webhook($data: JSON, $category: String!, $status: String!) { + webhook(data: $data, category: $category, status: $status) { + ...Base + ...PoracleIncident + } + } +` + export const LURE = gql` ${base} ${Lure} @@ -446,6 +320,7 @@ export const WEBHOOK_CONTEXT = gql` } pvp ui + capabilities } } ` diff --git a/src/store/useWebhookStore.js b/src/store/useWebhookStore.js index 3b742aed1..8888e3474 100644 --- a/src/store/useWebhookStore.js +++ b/src/store/useWebhookStore.js @@ -1,6 +1,10 @@ // @ts-check import { apolloClient } from '@services/apollo' -import { SET_HUMAN, WEBHOOK_AREAS } from '@services/queries/webhook' +import { + ALL_PROFILES, + SET_HUMAN, + WEBHOOK_AREAS, +} from '@services/queries/webhook' import { create } from 'zustand' /** @@ -21,6 +25,7 @@ import { create } from 'zustand' * raid: import("@rm/types").PoracleRaid[] * egg: import("@rm/types").PoracleEgg[] * invasion: import("@rm/types").PoracleInvasion[] + * incident: import("@rm/types").PoracleIncident[] * lure: import("@rm/types").PoracleLure[] * nest: import("@rm/types").PoracleNest[] * quest: import("@rm/types").PoracleQuest[] @@ -196,10 +201,49 @@ export const handleClick = status: 'POST', }, }) - .then(({ data }) => { - if (data?.webhook?.human) { - useWebhookStore.setState({ human: data.webhook.human }) - } - }) + .then(({ data }) => applyHumanResult(data?.webhook)) return newAreas } + +/** @param {{ message?: string }} error */ +export const showWebhookError = (error) => + useWebhookStore.setState({ + alert: { open: true, severity: 'error', message: error?.message }, + }) + +/** + * Shows the human a webhook mutation returned and any error it reported; a + * failed request has no human, so the one on screen stays. + * @param {Record | undefined} result + */ +export const applyHumanResult = (result) => { + if (result?.human) useWebhookStore.setState({ human: result.human }) + if (result?.status === 'error') showWebhookError(result) +} + +/** + * Shows the category list a webhook mutation returned and any error it reported. + * The list goes into the tracked query's cache, so `useGetWebhookData` applies + * the active search and the list and its tiles stay on the same rules. + * @param {string} category + * @param {Record | undefined} result + */ +export const applyWebhookResult = (category, result) => { + if (Array.isArray(result?.[category])) { + apolloClient.cache.updateQuery( + { query: ALL_PROFILES, variables: { category, status: 'GET' } }, + (data) => + data && { + ...data, + // the list was read fine, whatever happened to the write + webhook: { + ...data.webhook, + status: 'ok', + message: null, + [category]: result[category], + }, + }, + ) + } + if (result?.status === 'error') showWebhookError(result) +}