diff --git a/config/default.json b/config/default.json index ed36c09e9..6d21db925 100644 --- a/config/default.json +++ b/config/default.json @@ -539,6 +539,7 @@ "candy": true, "xlCandy": true, "pokemon": true, + "tasks": false, "invasions": false, "allInvasions": true, "invasionPokemon": true, diff --git a/docs/rocket-reward-filter-bug.md b/docs/rocket-reward-filter-bug.md new file mode 100644 index 000000000..3b5d19d5c --- /dev/null +++ b/docs/rocket-reward-filter-bug.md @@ -0,0 +1,168 @@ +# Duplicate Rocket Pokemon filters in ReactMap main + +## Scope + +This document describes the original bug in ReactMap `main` at commit +`50cb6cfd` (`v1.49.1`): some Rocket reward Pokemon appeared twice in the +filter menu. Taillow is the known example used while fixing it. + +This is separate from later issues encountered while developing the fix. + +## Symptom + +The Rocket Pokemon tab could contain two tiles both labelled **Taillow**. +Internally, however, they were different filters: + +```text +a276-0 +a276-3163 +``` + +The key format is: + +```text +a- +``` + +Both keys identify Pokemon 276, but they contain different form IDs. + +## Why main creates two entries + +ReactMap builds its available Rocket reward filters from two sources. + +### Confirmed scanner data + +The `rocketPokemon` query reads the Pokemon and form stored in a confirmed +incident lineup. For Taillow, the scanner can contribute: + +```text +a276-0 +``` + +### Event masterfile fallback + +`applyRocketPokemonFallback` also adds every configured possible reward from +`state.event.invasions`. For the same Taillow reward, the masterfile +contributes: + +```text +a276-3163 +``` + +Both values are added to a JavaScript `Set`. A `Set` removes only identical +strings; it does not know that both strings represent the same Pokemon. +Therefore `a276-0` and `a276-3163` both remain in the available list. + +The client then creates the label using only the Pokemon portion of each key: + +```js +const name = t(`poke_${id.slice(1).split('-')[0]}`) +``` + +For both keys, that expression extracts `276`. Consequently, two different +internal keys are rendered with the same visible name: **Taillow**. + +## Why only one duplicate may work + +On main, the database query first strips the form and searches by Pokemon ID: + +```js +rocketPokemon.push(pokestop.slice(1).split('-')[0]) +``` + +Both Taillow keys therefore retrieve candidate grunts for Pokemon 276. + +ReactMap then runs `invasionMatchesFilters` as a secondary filter. Its original +matcher requires the complete form-specific key: + +```js +filters[`a${pokemonId}-${formId ?? 0}`] || filters[`a${pokemonId}`] +``` + +This creates inconsistent behaviour: + +- A confirmed scanner record using form `0` matches `a276-0`. +- An unconfirmed grunt checked against the masterfile form matches + `a276-3163`. +- Both tiles look like Taillow, but each can affect a different data path. + +The SQL stage treats Rocket rewards as a species, while the secondary filter +treats them as a species-and-form combination. + +## Direct fix + +Commit `ce0f73f0` fixes the original duplicate bug with two changes. + +### 1. Match Rocket rewards by Pokemon ID + +`hasRocketPokemonFilter` now checks whether any selected Rocket key belongs to +the requested Pokemon ID, regardless of its form suffix. + +It accepts both supported shapes: + +```text +a276 +a276-3163 +``` + +The comparison includes a key boundary, so Pokemon 1 cannot accidentally match +Pokemon 12. + +This makes the secondary filter agree with the existing SQL query: Rocket +rewards are filtered by species ID, not by scanner/masterfile form ID. + +### 2. Deduplicate available keys by species + +`dedupeRocketPokemonKeys` groups every `a` key by Pokemon ID and keeps only one +key per species. For the original Taillow pair: + +```text +Input: a276-0, a276-3163 +Output: a276-3163 +``` + +The non-zero masterfile form is retained for compatibility with existing icon +and saved-filter handling. Because matching now uses Pokemon ID, that surviving +key works for scanner form `0` and masterfile form `3163` alike. + +Deduplication is applied to both ways ReactMap obtains available Pokestop data: + +- Golbat `/api/fort/available` +- The SQL `getAvailable` path + +## Files changed by the direct fix + +```text +server/src/filters/pokestop/rocketPokemonKeys.js +server/src/filters/pokestop/rocketPokemonKeys.test.js +server/src/models/Pokestop.js +``` + +## Later hardening in the final branch + +The direct fix removes the duplicate entries found on main. Two follow-up +changes make that species-level approach reliable over time: + +- `425077ba` derives the displayed key from the masterfile consistently, so a + later poll cannot rename the same species because its scanner form changed. +- `a2fe5081` removes hidden all-form Rocket filters from the default filter + builder, ensuring the one visible species filter is authoritative. + +Those follow-ups support the completed implementation, but they are not the +reason main originally displayed two Taillow tiles. Changing Rocket filters to +default off was only a temporary workaround and is not part of the fix. + +## Verification + +After deploying and restarting ReactMap: + +1. Open the Rocket Pokemon filters while Taillow is available. +2. Confirm that exactly one Taillow tile is shown. +3. Confirm that the same tile matches confirmed and unconfirmed Taillow grunts. +4. Turn off both Taillow and the relevant grunt-type filter; the grunt should + disappear. +5. Turn on either Taillow or the grunt type; the grunt should appear through + the intended OR filter behaviour. + +Automated tests cover duplicate removal, form-independent matching, legacy +form-less keys, and Pokemon IDs with common numeric prefixes. diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index dc71e0cd6..82d0730a7 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -63,6 +63,7 @@ "nests": "Nests", "pokestops": "PokéStops", "pokemon": "Pokémon", + "tasks": "Tasks", "wayfarer": "Wayfarer", "scan_areas": "Scan Areas", "jump_to_areas_attribution": "Search powered by OpenStreetMap", @@ -596,6 +597,7 @@ "cell_blocked": "Cell Blocked", "poi_color": "POI Color", "quest_condition": "Quest Condition", + "task_reward": "Reward", "always_show_labels": "Always Show Labels", "scan_areas_options": "Scan Areas Options", "historic_rarity": "Historic Rarity", @@ -755,6 +757,7 @@ "developer": "Developer", "raid_override": "Raid Override", "search_rocket_pokemon": "Search Rocket Pokémon", + "search_tasks": "Search Tasks", "main": "Main", "extra": "Extra", "select": "Select", diff --git a/server/src/filters/builder/base.js b/server/src/filters/builder/base.js index 46b456a1c..3d45ab418 100644 --- a/server/src/filters/builder/base.js +++ b/server/src/filters/builder/base.js @@ -115,7 +115,6 @@ function buildDefaultFilters(perms) { hasDualQuestLayer && perms.pokestops ? false : undefined, standard: new BaseFilter(), filter: { - ...pokemon.rocket, ...buildPokestops(perms, defaultFilters.pokestops), ...pokemon.quests, }, diff --git a/server/src/filters/builder/pokemon.js b/server/src/filters/builder/pokemon.js index 6e8518859..20e9e5f61 100644 --- a/server/src/filters/builder/pokemon.js +++ b/server/src/filters/builder/pokemon.js @@ -13,7 +13,6 @@ const { getWildFilterKey } = require('../pokemon/getWildFilterKey') * raids: { [key: string]: BaseFilter }, * quests: { [key: string]: BaseFilter }, * nests: { [key: string]: BaseFilter }, - * rocket: { [key: string]: BaseFilter }, * stations: { [key: string]: BaseFilter }, * }} */ @@ -24,7 +23,6 @@ function buildPokemon(defaults, base, custom) { stations: { global: new BaseFilter() }, quests: { global: new BaseFilter() }, nests: { global: new BaseFilter() }, - rocket: { global: new BaseFilter() }, } const energyAmounts = new Set([ ...defaults.pokestops.baseMegaEnergyAmounts, @@ -43,11 +41,6 @@ function buildPokemon(defaults, base, custom) { pokemon.raids[rawKey] = new BaseFilter(defaults.gyms.pokemon) pokemon.stations[rawKey] = new BaseFilter(defaults.stations.pokemon) pokemon.quests[rawKey] = new BaseFilter(defaults.pokestops.pokemon) - if (state.db.filterContext.Pokestop.hasConfirmedInvasions) { - pokemon.rocket[`a${rawKey}`] = new BaseFilter( - defaults.pokestops.invasionPokemon, - ) - } pokemon.nests[rawKey] = new BaseFilter(defaults.nests.allPokemon) }) if ('family' in pkmn) { diff --git a/server/src/filters/builder/pokestop.js b/server/src/filters/builder/pokestop.js index 3f77bf948..1c0496563 100644 --- a/server/src/filters/builder/pokestop.js +++ b/server/src/filters/builder/pokestop.js @@ -63,6 +63,11 @@ function buildPokestops(perms, defaults) { quests[avail] = new BaseFilter(defaults.xlCandy) } else if (avail.startsWith('m')) { quests[avail] = new BaseFilter(defaults.megaEnergy) + } else if (avail.startsWith('k')) { + // Task-primary filter: the reverse of the per-reward `.adv` narrowing + // above. Same reward-type default (`tasks`) since a task is just + // another way of selecting the same underlying quest rewards. + quests[avail] = new BaseFilter(defaults.tasks) } else if ( !avail.startsWith('i') && !avail.startsWith('l') && @@ -70,6 +75,7 @@ function buildPokestops(perms, defaults) { !avail.startsWith('b') && !avail.startsWith('f') && !avail.startsWith('h') && + !avail.startsWith('k') && !Number.isInteger(+avail.charAt(0)) ) { log.warn( @@ -83,6 +89,10 @@ function buildPokestops(perms, defaults) { if (avail.startsWith('i')) { quests[avail] = new BaseFilter(defaults.allInvasions) } + // Reward filters come only from the canonical available list. Building + // an `a` key for every masterfile form creates hidden siblings that the + // menu cannot switch off, while reward matching intentionally ignores + // form differences between scanner and masterfile data. if ( avail.startsWith('a') && state.db.filterContext.Pokestop.hasConfirmedInvasions diff --git a/server/src/filters/fort/pokestop.js b/server/src/filters/fort/pokestop.js index ec42e8130..c9722e527 100644 --- a/server/src/filters/fort/pokestop.js +++ b/server/src/filters/fort/pokestop.js @@ -7,6 +7,43 @@ const { parseIdFormPair } = require('./parseIdForm') // display_type 1-4 = rocket, 7 goldstop, 8 kecleon, 9 showcase. const ROCKET_INCIDENT_DISPLAY_TYPES = [1, 2, 3, 4] +/** + * Expands enabled task-primary filters (`k-<target>`) into their + * reward-primary equivalents, so the switch below - which only understands + * reward keys - picks them up automatically without needing its own clause + * type. A task narrowed via `.adv` to specific rewards (the reverse Advanced + * dialog) expands to only those; an unnarrowed task expands to every reward + * `taskConditions` has ever seen it grant. + * + * Presence of a key in `filters` already means enabled - `trimFilters` + * strips disabled entries and the `enabled` field itself before the client + * ever sends this - so no `.enabled` check is needed here, matching every + * other key in this file. + * @param {Record<string, any>} filters + * @param {Record<string, {rewards?: string[]}>} [taskConditions] + * @returns {Record<string, any>} + */ +function expandTaskFilters(filters, taskConditions) { + if (!taskConditions) return filters + const taskKeys = Object.keys(filters).filter((key) => key.startsWith('k')) + if (!taskKeys.length) return filters + const expanded = { ...filters } + taskKeys.forEach((taskKey) => { + const filter = filters[taskKey] + const rewards = + filter?.adv && !filter.all + ? Array.isArray(filter.adv) + ? filter.adv + : filter.adv.split(',') + : taskConditions[taskKey]?.rewards + if (!rewards) return + rewards.forEach((rewardKey) => { + if (!expanded[rewardKey]) expanded[rewardKey] = { all: false, adv: '' } + }) + }) + return expanded +} + /** * Translate a pokestop's `args.filters` into ApiFortDnfFilter[] clauses. * @@ -38,12 +75,14 @@ const ROCKET_INCIDENT_DISPLAY_TYPES = [1, 2, 3, 4] * from an optional invasion check that no Golbat clause can safely track); * secondaryFilter confirms the specific reward. * - * @param {Record<string, any>} filters args.filters + * @param {Record<string, any>} rawFilters args.filters * @param {Record<string, any>} [eventInvasions] state.event.invasions (grunt→reward map, used for grunt-class exclusion) + * @param {Record<string, {rewards?: string[]}>} [taskConditions] state.db.taskConditions, used to expand task-primary keys into reward keys * @returns {object[]} */ -function buildPokestopDnfFilters(filters, eventInvasions) { - if (!filters || typeof filters !== 'object') return [] +function buildPokestopDnfFilters(rawFilters, eventInvasions, taskConditions) { + if (!rawFilters || typeof rawFilters !== 'object') return [] + const filters = expandTaskFilters(rawFilters, taskConditions) const { onlyAllPokestops, onlyArEligible, diff --git a/server/src/filters/fort/pokestop.test.js b/server/src/filters/fort/pokestop.test.js new file mode 100644 index 000000000..e4e8b9607 --- /dev/null +++ b/server/src/filters/fort/pokestop.test.js @@ -0,0 +1,118 @@ +const test = require('node:test') +const assert = require('node:assert/strict') + +const { buildPokestopDnfFilters } = require('./pokestop') + +const TASK_CONDITIONS = { + 'kcatch_pokemon-10': { + title: 'catch_pokemon', + target: 10, + rewards: ['7', 'q1', 'a633-2291'], + }, +} + +test('a task-only filter (no taskConditions passed) produces no quest clauses', () => { + // Without the third argument, expandTaskFilters is a no-op - the task key + // is dropped by the switch's default case, same as the original bug. + const filters = { + onlyQuests: true, + 'kcatch_pokemon-10': { all: false, adv: '' }, + } + const clauses = buildPokestopDnfFilters(filters, {}) + assert.deepEqual(clauses, []) +}) + +test('an unnarrowed enabled task expands to every reward it can grant', () => { + const filters = { + onlyQuests: true, + 'kcatch_pokemon-10': { all: false, adv: '' }, + } + const clauses = buildPokestopDnfFilters(filters, {}, TASK_CONDITIONS) + // '7-0' -> encounter (type 7), 'q1' -> item (type 2). 'a633-2291' is a + // rocket-reward key, which only ever produces clauses under onlyInvasions, + // not onlyQuests - so it correctly contributes nothing here. + assert.deepEqual( + clauses.sort((a, b) => a.quest_reward_type[0] - b.quest_reward_type[0]), + [ + { quest_reward_type: [2], quest_reward_item_id: [1] }, + { + quest_reward_type: [7], + quest_reward_pokemon: [{ pokemon_id: 7, form: 0 }], + }, + ], + ) +}) + +test('a task narrowed via .adv expands to only the selected rewards', () => { + const filters = { + onlyQuests: true, + 'kcatch_pokemon-10': { all: false, adv: 'q1' }, + } + const clauses = buildPokestopDnfFilters(filters, {}, TASK_CONDITIONS) + assert.deepEqual(clauses, [ + { quest_reward_type: [2], quest_reward_item_id: [1] }, + ]) +}) + +test('.all on a task bypasses narrowing, same as reward filters', () => { + const filters = { + onlyQuests: true, + 'kcatch_pokemon-10': { all: true, adv: 'q1' }, + } + const clauses = buildPokestopDnfFilters(filters, {}, TASK_CONDITIONS) + assert.deepEqual( + clauses.sort((a, b) => a.quest_reward_type[0] - b.quest_reward_type[0]), + [ + { quest_reward_type: [2], quest_reward_item_id: [1] }, + { + quest_reward_type: [7], + quest_reward_pokemon: [{ pokemon_id: 7, form: 0 }], + }, + ], + ) +}) + +test('an explicit reward filter already present is not overridden by expansion', () => { + const filters = { + onlyQuests: true, + 'kcatch_pokemon-10': { all: false, adv: '' }, + // User separately narrowed the reward filter itself to a specific task - + // expansion must not clobber that with a blank synthetic entry. + q1: { all: false, adv: 'other_task__5' }, + } + const clauses = buildPokestopDnfFilters(filters, {}, TASK_CONDITIONS) + // Both q1 (explicit) and 7-0 (synthesized) still produce clauses - the + // point is q1's *filter object* wasn't overwritten, which this test can't + // directly observe from clauses alone, but the item clause still appearing + // (rather than vanishing) confirms expansion didn't break the existing key. + const itemClause = clauses.find((c) => c.quest_reward_type?.[0] === 2) + assert.deepEqual(itemClause, { + quest_reward_type: [2], + quest_reward_item_id: [1], + }) +}) + +test('an unknown task key with no taskConditions entry expands to nothing, quietly', () => { + const filters = { + onlyQuests: true, + 'kmystery_task-1': { all: false, adv: '' }, + } + const clauses = buildPokestopDnfFilters(filters, {}, TASK_CONDITIONS) + assert.deepEqual(clauses, []) +}) + +test('a disabled task key (absent from filters) contributes nothing', () => { + // Matches the wire contract: disabled filters are never sent at all. + const filters = { onlyQuests: true } + const clauses = buildPokestopDnfFilters(filters, {}, TASK_CONDITIONS) + assert.deepEqual(clauses, []) +}) + +test('task expansion respects onlyQuests being off, same as any reward key', () => { + const filters = { + onlyQuests: false, + 'kcatch_pokemon-10': { all: false, adv: '' }, + } + const clauses = buildPokestopDnfFilters(filters, {}, TASK_CONDITIONS) + assert.deepEqual(clauses, []) +}) diff --git a/server/src/filters/pokestop/questTaskMatch.js b/server/src/filters/pokestop/questTaskMatch.js new file mode 100644 index 000000000..a0662a38d --- /dev/null +++ b/server/src/filters/pokestop/questTaskMatch.js @@ -0,0 +1,47 @@ +// @ts-check + +/** + * Shared core of the reward-primary and task-primary quest filter checks. + * + * A filter is enabled on its own (no `.adv` narrowing) matches unconditionally + * - that's the normal "I want this reward" / "I want this task" case. If + * `.adv` is set, the filter has been narrowed to a specific set of values on + * the OTHER axis (a reward filter narrowed to specific task conditions, or a + * task filter narrowed to specific reward keys) - only match if `matchValue` + * is in that set. `.all` bypasses narrowing entirely, matching the "Set All" + * bulk-enable semantics used elsewhere. + * @param {{ adv?: string | string[], all?: boolean } | undefined} filter + * @param {string} matchValue + */ +const matchesAdvancedFilter = (filter, matchValue) => { + if (!filter || !filter.adv || filter.all) return !!filter + const selected = Array.isArray(filter.adv) + ? filter.adv + : filter.adv.split(',') + return !selected.length || selected.includes(matchValue) +} + +/** + * Accumulates one reward key onto its task's entry, mutating `taskConditions` + * in place. Mirrors the reward-primary `conditions[rewardKey][conditionKey]` + * map in the opposite direction: one entry per distinct (title, target) pair + * - unlike a reward, which can come from many tasks, a task key IS one task, + * so `title`/`target` are stored once and `rewards` accumulates every reward + * key seen for it across however many quest rows share that task. + * @param {Record<string, {title: string, target: number, rewards: Record<string, boolean>}>} taskConditions + * @param {string} key reward key, e.g. `7-0`, `q123`, `a633-2291` + * @param {string} title + * @param {number} target + * @returns {string} the task key that was added/updated, e.g. `kcatch_pokemon-10` + */ +const addTaskCondition = (taskConditions, key, title, target) => { + const taskKey = `k${title}-${target}` + if (taskKey in taskConditions) { + taskConditions[taskKey].rewards[key] = true + } else { + taskConditions[taskKey] = { title, target, rewards: { [key]: true } } + } + return taskKey +} + +module.exports = { addTaskCondition, matchesAdvancedFilter } diff --git a/server/src/filters/pokestop/questTaskMatch.test.js b/server/src/filters/pokestop/questTaskMatch.test.js new file mode 100644 index 000000000..ae390aaa1 --- /dev/null +++ b/server/src/filters/pokestop/questTaskMatch.test.js @@ -0,0 +1,93 @@ +const test = require('node:test') +const assert = require('node:assert/strict') + +const { addTaskCondition, matchesAdvancedFilter } = require('./questTaskMatch') + +// --- matchesAdvancedFilter --- + +test('no filter never matches', () => { + assert.equal(matchesAdvancedFilter(undefined, 'anything'), false) +}) + +test('enabled filter with no narrowing matches unconditionally', () => { + assert.equal(matchesAdvancedFilter({}, 'anything'), true) + assert.equal(matchesAdvancedFilter({ enabled: true }, 'anything'), true) +}) + +test('.all bypasses narrowing entirely', () => { + assert.equal(matchesAdvancedFilter({ adv: 'x,y', all: true }, 'z'), true) +}) + +test('.adv as a comma string narrows to the listed values', () => { + const filter = { adv: 'a,b,c' } + assert.equal(matchesAdvancedFilter(filter, 'b'), true) + assert.equal(matchesAdvancedFilter(filter, 'z'), false) +}) + +test('.adv as an array narrows the same way as a comma string', () => { + const filter = { adv: ['a', 'b', 'c'] } + assert.equal(matchesAdvancedFilter(filter, 'b'), true) + assert.equal(matchesAdvancedFilter(filter, 'z'), false) +}) + +test('empty .adv (empty string) matches unconditionally', () => { + // split(',') on '' yields [''], and the falsy first element is filtered + // out by the caller before .adv is ever set to '' - but guard it anyway. + assert.equal(matchesAdvancedFilter({ adv: '' }, 'anything'), true) +}) + +// --- addTaskCondition --- + +test('creates a new task entry on first sight', () => { + const taskConditions = {} + const key = addTaskCondition(taskConditions, '7-0', 'catch_pokemon', 10) + assert.equal(key, 'kcatch_pokemon-10') + assert.deepEqual(taskConditions, { + 'kcatch_pokemon-10': { + title: 'catch_pokemon', + target: 10, + rewards: { '7-0': true }, + }, + }) +}) + +test('accumulates multiple reward keys onto the same task', () => { + const taskConditions = {} + addTaskCondition(taskConditions, '7-0', 'catch_pokemon', 10) + addTaskCondition(taskConditions, 'q1', 'catch_pokemon', 10) + addTaskCondition(taskConditions, 'a633-2291', 'catch_pokemon', 10) + assert.deepEqual( + Object.keys(taskConditions['kcatch_pokemon-10'].rewards).sort(), + ['7-0', 'a633-2291', 'q1'].sort(), + ) +}) + +test('the same reward seen twice for one task only appears once', () => { + const taskConditions = {} + addTaskCondition(taskConditions, '7-0', 'catch_pokemon', 10) + addTaskCondition(taskConditions, '7-0', 'catch_pokemon', 10) + assert.deepEqual(Object.keys(taskConditions['kcatch_pokemon-10'].rewards), [ + '7-0', + ]) +}) + +test('distinct (title, target) pairs stay in separate entries', () => { + const taskConditions = {} + addTaskCondition(taskConditions, '7-0', 'catch_pokemon', 10) + addTaskCondition(taskConditions, '7-0', 'catch_pokemon', 5) + addTaskCondition(taskConditions, '7-0', 'catch_water_pokemon', 10) + assert.deepEqual(Object.keys(taskConditions).sort(), [ + 'kcatch_pokemon-10', + 'kcatch_pokemon-5', + 'kcatch_water_pokemon-10', + ]) +}) + +test('round trip: a reward key added via addTaskCondition matches via matchesAdvancedFilter', () => { + const taskConditions = {} + const taskKey = addTaskCondition(taskConditions, '7-0', 'catch_pokemon', 10) + // Simulate a user narrowing the task filter to just this one reward. + const filters = { [taskKey]: { adv: '7-0' } } + assert.equal(matchesAdvancedFilter(filters[taskKey], '7-0'), true) + assert.equal(matchesAdvancedFilter(filters[taskKey], 'q1'), false) +}) diff --git a/server/src/filters/pokestop/rocketPokemonKeys.js b/server/src/filters/pokestop/rocketPokemonKeys.js new file mode 100644 index 000000000..3365ff2d5 --- /dev/null +++ b/server/src/filters/pokestop/rocketPokemonKeys.js @@ -0,0 +1,96 @@ +// @ts-check + +/** Matches `a<pokemonId>` and the legacy `a<pokemonId>-<formId>` shape. */ +const ROCKET_KEY = /^a(\d+)(?:-(\d+))?$/ + +/** + * True when any enabled filter selects `pokemonId`, whatever form it carries. + * + * The form cannot be compared. The scanner's `incident` table commonly records + * a reward's form as `0`, while the masterfile encounter pool gives it a real + * form (Deino is `2291`). An exact comparison drops whichever of the two the + * ticked filter did not come from, even though the SQL stage already selected + * the stop by Pokemon ID alone. Grunt rewards do not meaningfully vary by + * form, so matching on the ID makes every source — confirmed, unconfirmed, + * scanner, masterfile — agree at once. + * @param {Record<string, any>} filters + * @param {number | string} pokemonId + */ +const hasRocketPokemonFilter = (filters, pokemonId) => { + if (!pokemonId) return false + const prefix = `a${pokemonId}` + return Object.keys(filters).some( + (key) => key === prefix || key.startsWith(`${prefix}-`), + ) +} + +/** + * Collapses Rocket reward keys so a species contributes exactly one filter. + * + * The available list is fed from the two sources described above, so the same + * species can arrive as both `a276-0` and `a276-3163`. The menu renders those + * as two identical-looking entries, and only whichever one matches the grunt + * in hand actually does anything. + * + * The non-zero form wins: that is the masterfile shape, which is what existing + * saved filters already hold, and it is what the icon renderer expects. Since + * `hasRocketPokemonFilter` matches on the ID, the surviving key works for + * confirmed and unconfirmed grunts alike. Non-Rocket keys are left untouched. + * @param {Set<string>} availableSet mutated in place + */ +const dedupeRocketPokemonKeys = (availableSet) => { + /** @type {Map<string, { key: string, form: number }>} */ + const bySpecies = new Map() + /** @type {string[]} */ + const rocketKeys = [] + + availableSet.forEach((key) => { + const match = ROCKET_KEY.exec(key) + if (!match) return + rocketKeys.push(key) + const [, species, rawForm] = match + const form = Number(rawForm ?? 0) + const current = bySpecies.get(species) + if (!current || (current.form === 0 && form !== 0)) { + bySpecies.set(species, { key, form }) + } + }) + + const keep = new Set([...bySpecies.values()].map((entry) => entry.key)) + rocketKeys.forEach((key) => { + if (!keep.has(key)) availableSet.delete(key) + }) +} + +/** + * Resolves the form to use when building a Rocket reward's available key. + * + * A confirmed grunt's scanner-reported form and the masterfile encounter + * pool's form for the same species routinely disagree (Wobbuffet has shown + * up as both `602` and `2328` for the same grunt type). Building the key + * straight from whichever form the current poll happened to report means the + * SAME species can mint a NEW, different key on a later poll — a menu entry + * a user already switched off keeps a live twin they never see appear, and + * `dedupeRocketPokemonKeys` only reconciles keys within a single poll, not + * across separate ones over time. + * + * Always deriving the form from the masterfile encounter pool - regardless of + * whether this reward came from a confirmed scanner slot or the unconfirmed + * fallback - makes every poll agree on the same key for a given species, so + * there is nothing left to reconcile after the fact. + * @param {{id: number, form: number}[] | undefined} encounters the grunt's + * masterfile encounter pool for this slot (e.g. `fullGrunt.encounters.first`) + * @param {number} pokemonId + * @param {number} fallbackForm used only if `pokemonId` isn't in `encounters` + * (e.g. a masterfile/scanner mismatch on the species itself, not just form) + */ +const getCanonicalRewardForm = (encounters, pokemonId, fallbackForm) => { + const match = encounters?.find((poke) => poke.id === pokemonId) + return match ? match.form : (fallbackForm ?? 0) +} + +module.exports = { + dedupeRocketPokemonKeys, + getCanonicalRewardForm, + hasRocketPokemonFilter, +} diff --git a/server/src/filters/pokestop/rocketPokemonKeys.test.js b/server/src/filters/pokestop/rocketPokemonKeys.test.js new file mode 100644 index 000000000..bbaab7601 --- /dev/null +++ b/server/src/filters/pokestop/rocketPokemonKeys.test.js @@ -0,0 +1,122 @@ +const test = require('node:test') +const assert = require('node:assert/strict') + +const { + dedupeRocketPokemonKeys, + getCanonicalRewardForm, + hasRocketPokemonFilter, +} = require('./rocketPokemonKeys') + +test('matches a reward whichever form the ticked filter carries', () => { + // Deino: masterfile says form 2291, the scanner records 0. Both must match. + assert.equal(hasRocketPokemonFilter({ 'a633-2291': {} }, 633), true) + assert.equal(hasRocketPokemonFilter({ 'a633-0': {} }, 633), true) + assert.equal(hasRocketPokemonFilter({ a633: {} }, 633), true) +}) + +test('does not match a different species', () => { + assert.equal(hasRocketPokemonFilter({ 'a714-3070': {} }, 633), false) + assert.equal(hasRocketPokemonFilter({}, 633), false) +}) + +test('does not confuse species whose ids share a prefix', () => { + // `a1` must not match Pokemon 12, nor `a12` match Pokemon 1. + assert.equal(hasRocketPokemonFilter({ a12: {} }, 1), false) + assert.equal(hasRocketPokemonFilter({ 'a12-0': {} }, 1), false) + assert.equal(hasRocketPokemonFilter({ a1: {} }, 12), false) +}) + +test('a falsy pokemon id never matches', () => { + assert.equal(hasRocketPokemonFilter({ a0: {} }, 0), false) + assert.equal(hasRocketPokemonFilter({ a633: {} }, undefined), false) +}) + +test('collapses the Taillow duplicate to the masterfile form', () => { + // The scanner contributes `a276-0`, the masterfile fallback `a276-3163`. + const available = new Set(['a276-0', 'a276-3163']) + dedupeRocketPokemonKeys(available) + assert.deepEqual([...available], ['a276-3163']) +}) + +test('keeps the form-less key when it is the only one', () => { + const available = new Set(['a276']) + dedupeRocketPokemonKeys(available) + assert.deepEqual([...available], ['a276']) +}) + +test('prefers a real form over a zero form regardless of order', () => { + const forwards = new Set(['a633-0', 'a633-2291']) + dedupeRocketPokemonKeys(forwards) + assert.deepEqual([...forwards], ['a633-2291']) + + const backwards = new Set(['a633-2291', 'a633-0']) + dedupeRocketPokemonKeys(backwards) + assert.deepEqual([...backwards], ['a633-2291']) +}) + +test('collapses a form-less key against a form-carrying one', () => { + const available = new Set(['a633', 'a633-2291']) + dedupeRocketPokemonKeys(available) + assert.deepEqual([...available], ['a633-2291']) +}) + +test('leaves distinct species and non-Rocket keys untouched', () => { + const available = new Set([ + 'a276-0', + 'a276-3163', + 'a633-2291', + 'i12', // grunt type + 'l501', // lure + 'q1', // item + 'f25-0', // showcase + ]) + dedupeRocketPokemonKeys(available) + assert.deepEqual( + [...available].sort(), + ['a276-3163', 'a633-2291', 'f25-0', 'i12', 'l501', 'q1'].sort(), + ) +}) + +test('the survivor still matches, so the menu entry works', () => { + const available = new Set(['a276-0', 'a276-3163']) + dedupeRocketPokemonKeys(available) + const filters = Object.fromEntries([...available].map((k) => [k, {}])) + // Confirmed grunt reports form 0, unconfirmed reports 3163 - both match. + assert.equal(hasRocketPokemonFilter(filters, 276), true) +}) + +test('getCanonicalRewardForm prefers the masterfile encounter form', () => { + // Wobbuffet (202): scanner reports 602 for this grunt, masterfile says 2328. + const encounters = [ + { id: 202, form: 2328 }, + { id: 359, form: 830 }, + ] + assert.equal(getCanonicalRewardForm(encounters, 202, 602), 2328) +}) + +test('getCanonicalRewardForm falls back when the species is not in the pool', () => { + const encounters = [{ id: 359, form: 830 }] + assert.equal(getCanonicalRewardForm(encounters, 202, 602), 602) +}) + +test('getCanonicalRewardForm falls back to 0 with no encounters and no fallback', () => { + assert.equal(getCanonicalRewardForm(undefined, 202, undefined), 0) +}) + +test('canonical form keeps repeated polls from minting a second key', () => { + // Poll 1: confirmed slot reports scanner form 602 for Wobbuffet. + const poll1 = new Set() + const form1 = getCanonicalRewardForm([{ id: 202, form: 2328 }], 202, 602) + poll1.add(`a202-${form1}`) + dedupeRocketPokemonKeys(poll1) + + // Poll 2: a different grunt occurrence, scanner form is 0 this time. + const poll2 = new Set() + const form2 = getCanonicalRewardForm([{ id: 202, form: 2328 }], 202, 0) + poll2.add(`a202-${form2}`) + dedupeRocketPokemonKeys(poll2) + + // Both polls must agree on the exact same key - nothing left to reconcile. + assert.deepEqual([...poll1], [...poll2]) + assert.deepEqual([...poll1], ['a202-2328']) +}) diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index 83e0cf17e..6f668f6e1 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -34,6 +34,7 @@ const resolvers = { const data = { questConditions: perms.quests ? Db.questConditions : {}, + taskConditions: perms.quests ? Db.taskConditions : {}, masterfile: { ...Event.masterfile, invasions: Event.invasions }, filters: buildDefaultFilters(perms), audio: { diff --git a/server/src/graphql/typeDefs/map.graphql b/server/src/graphql/typeDefs/map.graphql index 5ad74cfa3..65e503fab 100644 --- a/server/src/graphql/typeDefs/map.graphql +++ b/server/src/graphql/typeDefs/map.graphql @@ -2,6 +2,7 @@ type MapData { masterfile: JSON filters: JSON questConditions: JSON + taskConditions: JSON icons: JSON audio: JSON supportsShinyStats: Boolean diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 5e6e8e28d..899b41f82 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -29,6 +29,15 @@ const { resolveQuestLayerSelection, } = require('../utils/questLayerMode') const { mapAvailablePokestops } = require('./pokestopAvailableMapper') +const { + dedupeRocketPokemonKeys, + getCanonicalRewardForm, + hasRocketPokemonFilter, +} = require('../filters/pokestop/rocketPokemonKeys') +const { + addTaskCondition, + matchesAdvancedFilter, +} = require('../filters/pokestop/questTaskMatch') const MEGA_RESOURCE_REWARD_TYPE = 12 const TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE = 20 @@ -867,7 +876,11 @@ class Pokestop extends Model { // endpoint rows (else a no-area user under strict mode sees everything). if (areaRestrictionsDenyAll(areaRestrictions, onlyAreas)) return [] try { - const dnf = buildPokestopDnfFilters(args.filters, state.event.invasions) + const dnf = buildPokestopDnfFilters( + args.filters, + state.event.invasions, + state.db.taskConditions, + ) // Endpoint rows always carry BOTH quest layers, so resolve the layer // selection as dual-capable (mirrors getAvailable's override). The SQL // ctx flags are undefined for a pure-endpoint source, which would make @@ -1044,11 +1057,22 @@ class Pokestop extends Model { } } - static hasRocketPokemonFilter(filters, pokemonId, formId) { - if (!pokemonId) return false - return !!( - filters[`a${pokemonId}-${formId ?? 0}`] || filters[`a${pokemonId}`] - ) + /** + * Matches a Rocket reward by Pokemon ID, ignoring the form id. + * + * The form cannot be compared: the scanner's `incident` table commonly + * records a reward's form as `0`, while the masterfile encounter pool gives + * it a real form (Deino is `2291`). An exact comparison therefore drops + * whichever of the two the ticked filter did not come from, even though the + * SQL stage already selected the stop by Pokemon ID alone. Grunt rewards do + * not meaningfully vary by form, so matching on the ID makes every source + * (confirmed, unconfirmed, scanner, masterfile) agree at once. + * + * Accepts both the `a<id>` and the legacy `a<id>-<form>` key shapes so + * existing saved filters keep resolving. + */ + static hasRocketPokemonFilter(filters, pokemonId) { + return hasRocketPokemonFilter(filters, pokemonId) } static invasionMatchesFilters( @@ -1090,13 +1114,9 @@ class Pokestop extends Model { if ( info.firstReward && (hasConfirmed && invasion.confirmed - ? this.hasRocketPokemonFilter( - filters, - invasion.slot_1_pokemon_id, - invasion.slot_1_form, - ) + ? this.hasRocketPokemonFilter(filters, invasion.slot_1_pokemon_id) : info.encounters.first.some((poke) => - this.hasRocketPokemonFilter(filters, poke.id, poke.form), + this.hasRocketPokemonFilter(filters, poke.id), )) ) { return true @@ -1105,13 +1125,9 @@ class Pokestop extends Model { if ( info.secondReward && (hasConfirmed && invasion.confirmed - ? this.hasRocketPokemonFilter( - filters, - invasion.slot_2_pokemon_id, - invasion.slot_2_form, - ) + ? this.hasRocketPokemonFilter(filters, invasion.slot_2_pokemon_id) : info.encounters.second.some((poke) => - this.hasRocketPokemonFilter(filters, poke.id, poke.form), + this.hasRocketPokemonFilter(filters, poke.id), )) ) { return true @@ -1120,13 +1136,9 @@ class Pokestop extends Model { if ( info.thirdReward && (hasConfirmed && invasion.confirmed - ? this.hasRocketPokemonFilter( - filters, - invasion.slot_3_pokemon_id, - invasion.slot_3_form, - ) + ? this.hasRocketPokemonFilter(filters, invasion.slot_3_pokemon_id) : info.encounters.third.some((poke) => - this.hasRocketPokemonFilter(filters, poke.id, poke.form), + this.hasRocketPokemonFilter(filters, poke.id), )) ) { return true @@ -1311,18 +1323,15 @@ class Pokestop extends Model { } const questCondition = `${quest.quest_title}__${quest.quest_target}` - const filterMatchesQuest = (key) => { - const filter = filters[key] - if (!filter || !filter.adv || filter.all) return !!filter - const selectedConditions = Array.isArray(filter.adv) - ? filter.adv - : filter.adv.split(',') - return ( - !selectedConditions.length || - selectedConditions.includes(questCondition) - ) - } - const matchesFilter = filterMatchesQuest(newQuest.key) + // Task filter (`k<title>-<target>`) is the reverse of the reward + // filter above: enabled on its own, or narrowed via `.adv` to + // specific reward keys instead of specific task conditions. + // Additive - either "this reward is wanted" or "this task is + // wanted" can surface the quest. + const taskKey = `k${quest.quest_title}-${quest.quest_target}` + const matchesFilter = + matchesAdvancedFilter(filters[newQuest.key], questCondition) || + matchesAdvancedFilter(filters[taskKey], newQuest.key) if ( quest.quest_timestamp >= midnight && (filters.onlyAllPokestops || matchesFilter) @@ -1493,11 +1502,16 @@ class Pokestop extends Model { }) const availableSet = new Set(result.available) applyRocketPokemonFallback(availableSet) + dedupeRocketPokemonKeys(availableSet) log.info( TAGS.pokestops, `[POKESTOP] loaded available from ${mem}/api/fort/available — ${availableSet.size} filter keys (${res.quests.length} quests, ${res.invasions.length} invasions, ${(res.lures || []).length} lures, ${(res.showcases || []).length} showcases), ${Object.keys(result.conditions).length} reward conditions`, ) - return { available: [...availableSet], conditions: result.conditions } + return { + available: [...availableSet], + conditions: result.conditions, + taskConditions: result.taskConditions, + } } log.warn( TAGS.pokestops, @@ -1530,6 +1544,7 @@ class Pokestop extends Model { return query } + const taskConditions = {} const process = (key, title, target) => { if (title) { if (key in conditions) { @@ -1537,6 +1552,11 @@ class Pokestop extends Model { } else { conditions[key] = { [`${title}-${target}`]: { title, target } } } + // Mirrors `conditions` in the opposite direction: `k<title>-<target>` + // is a task-primary filter key, letting a user filter by task and + // optionally narrow to specific reward keys, the reverse of the + // reward-primary `.adv` narrowing above. + finalList.add(addTaskCondition(taskConditions, key, title, target)) } finalList.add(key) } @@ -2046,24 +2066,34 @@ class Pokestop extends Model { const fullGrunt = state.event.invasions[reward.grunt_type] if (fullGrunt?.firstReward) { - finalList.add( - `a${reward.slot_1_pokemon_id}-${reward.slot_1_form}`, + const form = getCanonicalRewardForm( + fullGrunt.encounters?.first, + reward.slot_1_pokemon_id, + reward.slot_1_form, ) + finalList.add(`a${reward.slot_1_pokemon_id}-${form}`) } if (fullGrunt?.secondReward) { - finalList.add( - `a${reward.slot_2_pokemon_id}-${reward.slot_2_form}`, + const form = getCanonicalRewardForm( + fullGrunt.encounters?.second, + reward.slot_2_pokemon_id, + reward.slot_2_form, ) + finalList.add(`a${reward.slot_2_pokemon_id}-${form}`) } if (fullGrunt?.thirdReward) { - finalList.add( - `a${reward.slot_3_pokemon_id}-${reward.slot_3_form}`, + const form = getCanonicalRewardForm( + fullGrunt.encounters?.third, + reward.slot_3_pokemon_id, + reward.slot_3_form, ) + finalList.add(`a${reward.slot_3_pokemon_id}-${form}`) } }) } applyRocketPokemonFallback(finalList) + dedupeRocketPokemonKeys(finalList) break case 'showcase': if (hasShowcaseData) { @@ -2105,6 +2135,7 @@ class Pokestop extends Model { return { available: [...finalList], conditions, + taskConditions, } } diff --git a/server/src/models/pokestopAvailableMapper.js b/server/src/models/pokestopAvailableMapper.js index d4afbe626..9bbae5b6b 100644 --- a/server/src/models/pokestopAvailableMapper.js +++ b/server/src/models/pokestopAvailableMapper.js @@ -117,13 +117,18 @@ function questRewardKey(quest) { * * @param {AvailablePokestops} api * @param {MapAvailablePokestopsCtx} ctx event invasion config (`state.event.invasions`), used to gate `a` keys - * @returns {{ available: string[], conditions: QuestConditions }} + * @returns {{ available: string[], conditions: QuestConditions, taskConditions: Record<string, {title: string | number, target: number, rewards: Record<string, boolean>}> }} */ function mapAvailablePokestops(api, ctx) { const { includeBaseQuests = true, includeAltQuests = true } = ctx const available = new Set() /** @type {QuestConditions} */ const conditions = {} + // Task-primary filter keys (`k<title>-<target>`), the reverse of + // `conditions` above - see `addTaskCondition` in + // `filters/pokestop/questTaskMatch.js` (not required here to keep this + // mapper dependency-free; kept in lockstep with that version by hand). + const taskConditions = {} const process = ( /** @type {string} */ key, @@ -136,6 +141,13 @@ function mapAvailablePokestops(api, ctx) { } else { conditions[key] = { [`${title}-${target}`]: { title, target } } } + const taskKey = `k${title}-${target}` + if (taskKey in taskConditions) { + taskConditions[taskKey].rewards[key] = true + } else { + taskConditions[taskKey] = { title, target, rewards: { [key]: true } } + } + available.add(taskKey) } available.add(key) } @@ -192,16 +204,42 @@ function mapAvailablePokestops(api, ctx) { const isRocketLeaderOrGiovanni = character >= 41 && character <= 44 if (confirmed && !isRocketLeaderOrGiovanni) { // Each slot the event config marks as a reward contributes an `a` key, - // mirroring the SQL path which reads confirmed slots 1/2/3. + // mirroring the SQL path which reads confirmed slots 1/2/3. The form is + // taken from the masterfile encounter pool, not the scanner-reported + // slot form: the two disagree often enough (Wobbuffet has shown up as + // both 602 and 2328 for the same grunt type) that building the key from + // whichever form THIS poll happened to report lets the same species + // mint a different key on a later poll, orphaning whatever a user + // already switched off. Keeping this in lockstep with the SQL path (see + // `getCanonicalRewardForm` there) means both always agree on one key. const cfg = ctx.invasions?.[character] + const canonicalForm = (encounters, pokemonId, fallbackForm) => { + const match = encounters?.find((poke) => poke.id === pokemonId) + return match ? match.form : (fallbackForm ?? 0) + } if (slot1_pokemon_id > 0 && cfg?.firstReward) { - available.add(`a${slot1_pokemon_id}-${slot1_form}`) + const form = canonicalForm( + cfg.encounters?.first, + slot1_pokemon_id, + slot1_form, + ) + available.add(`a${slot1_pokemon_id}-${form}`) } if (slot2_pokemon_id > 0 && cfg?.secondReward) { - available.add(`a${slot2_pokemon_id}-${slot2_form}`) + const form = canonicalForm( + cfg.encounters?.second, + slot2_pokemon_id, + slot2_form, + ) + available.add(`a${slot2_pokemon_id}-${form}`) } if (slot3_pokemon_id > 0 && cfg?.thirdReward) { - available.add(`a${slot3_pokemon_id}-${slot3_form}`) + const form = canonicalForm( + cfg.encounters?.third, + slot3_pokemon_id, + slot3_form, + ) + available.add(`a${slot3_pokemon_id}-${form}`) } } }) @@ -222,7 +260,7 @@ function mapAvailablePokestops(api, ctx) { } }) - return { available: [...available], conditions } + return { available: [...available], conditions, taskConditions } } module.exports = { mapAvailablePokestops, questRewardKey } diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index b19bd7106..0ba2a1774 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -97,6 +97,7 @@ class DbManager extends Logger { this.models = {} this.endpoints = {} this.questConditions = getCache('questConditions.json', {}) + this.taskConditions = getCache('taskConditions.json', {}) this.rarity = getCache('rarity.json', {}) this.historical = getCache('historical.json', {}) this.filterContext = getCache('filterContext.json', { @@ -755,7 +756,7 @@ class DbManager extends Logger { * EventManager generation gate, so a superseded refresh (e.g. one holding a * replaced Db after a reload) cannot overwrite current metadata. * @param {import("../models").ScannerModelKeys} model - * @returns {Promise<{ available: string[], conditions?: object, rarity?: object } | null>} + * @returns {Promise<{ available: string[], conditions?: object, taskConditions?: object, rarity?: object } | null>} */ async getAvailable(model) { if (!this.models[model]) return { available: [] } @@ -782,10 +783,14 @@ class DbManager extends Logger { // undefined so applyAvailableMetadata retains the last-good. if (results.length && model === 'Pokestop') { const newQuestConditions = {} + const newTaskConditions = {} results.forEach((result) => { if ('conditions' in result) { config.util.extendDeep(newQuestConditions, result.conditions) } + if ('taskConditions' in result) { + config.util.extendDeep(newTaskConditions, result.taskConditions) + } }) out.conditions = Object.fromEntries( Object.entries(newQuestConditions).map(([key, titles]) => [ @@ -793,6 +798,17 @@ class DbManager extends Logger { Object.values(titles), ]), ) + // Mirrors `conditions` in the opposite direction - one entry per task, + // carrying its own title/target plus every reward key seen for it + // (merged as a set across sources, so extendDeep unions cleanly). + out.taskConditions = Object.fromEntries( + Object.entries(newTaskConditions).map( + ([key, { title, target, rewards }]) => [ + key, + { title, target, rewards: Object.keys(rewards) }, + ], + ), + ) } if (results.length && model === 'Pokemon') { out.rarity = computeRarityTiers(results, false) @@ -820,12 +836,14 @@ class DbManager extends Logger { * EventManager calls it under the generation gate (so a superseded refresh * never reaches here); a failure-derived result leaves conditions/rarity * undefined, so a transient outage can't blank the drawer's metadata. - * @param {{ conditions?: object, rarity?: object } | null} result + * @param {{ conditions?: object, taskConditions?: object, rarity?: object } | null} result */ applyAvailableMetadata(result) { if (!result) return if (result.conditions !== undefined) this.questConditions = result.conditions + if (result.taskConditions !== undefined) + this.taskConditions = result.taskConditions if (result.rarity !== undefined) this.rarity = result.rarity } diff --git a/server/src/services/state.js b/server/src/services/state.js index 872f6aa65..d9d1d1127 100644 --- a/server/src/services/state.js +++ b/server/src/services/state.js @@ -186,6 +186,7 @@ const state = { setCache('available.json', this.event.available), setCache('filterContext.json', this.db.filterContext), setCache('questConditions.json', this.db.questConditions), + setCache('taskConditions.json', this.db.taskConditions), setCache('uaudio.json', this.event.uaudio), setCache('uicons.json', this.event.uicons), ]) diff --git a/server/src/ui/advMenus.js b/server/src/ui/advMenus.js index 4b2504353..c6b406f31 100644 --- a/server/src/ui/advMenus.js +++ b/server/src/ui/advMenus.js @@ -16,6 +16,7 @@ const CATEGORIES = /** @type {const} */ ({ 'quest_reward_3', 'quest_reward_1', 'general', + 'tasks', ], stations: ['pokemon'], pokemon: ['pokemon'], diff --git a/src/components/filters/Advanced.jsx b/src/components/filters/Advanced.jsx index 28f1619d0..2771e26b3 100644 --- a/src/components/filters/Advanced.jsx +++ b/src/components/filters/Advanced.jsx @@ -24,6 +24,7 @@ import { SliderTile } from '../inputs/SliderTile' import { Size } from './Size' import { GenderListItem } from './Gender' import { QuestConditionSelector } from './QuestConditions' +import { TaskRewardSelector } from './TaskConditions' export function AdvancedFilter() { const { category, id, selectedIds, open } = useLayoutStore( @@ -189,7 +190,12 @@ export function AdvancedFilter() { label="size_1-size_5" /> )} - {category === 'pokestops' && <QuestConditionSelector id={id} />} + {category === 'pokestops' && + (id.startsWith('k') ? ( + <TaskRewardSelector id={id} /> + ) : ( + <QuestConditionSelector id={id} /> + ))} {hasAll ? ( <DualBoolToggle items={ENABLED_ALL} diff --git a/src/components/filters/TaskConditions.jsx b/src/components/filters/TaskConditions.jsx new file mode 100644 index 000000000..3a0353a48 --- /dev/null +++ b/src/components/filters/TaskConditions.jsx @@ -0,0 +1,106 @@ +// @ts-check +import * as React from 'react' +import Typography from '@mui/material/Typography' +import MenuItem from '@mui/material/MenuItem' +import { useTranslation } from 'react-i18next' + +import { useMemory } from '@store/useMemory' +import { useDeepStore, useStorage } from '@store/useStorage' +import { useTranslateById } from '@hooks/useTranslateById' +import { FCSelect } from '@components/inputs/FCSelect' + +/** + * The reverse of QuestConditionSelector: narrows a task-primary filter + * (`k<title>-<target>`) down to specific reward keys, instead of narrowing a + * reward-primary filter down to specific task conditions. Same `.adv` + * mechanism, same UI shape, opposite direction. + * @param {{ id: string }} props + * @returns + */ +export function TaskRewardSelector({ id }) { + const { t } = useTranslation() + const { t: tId } = useTranslateById() + const [value, setValue] = useDeepStore( + `filters.pokestops.filter.${id}.adv`, + '', + ) + const all = useStorage((s) => !!s.filters.pokestops.filter[id].all) + const taskRewards = useMemory((s) => s.available.taskConditions[id]?.rewards) + const hasQuests = useMemory((s) => s.ui.pokestops?.quests) + + const [open, setOpen] = React.useState(false) + + const handleClose = () => setOpen(false) + + const handleOpen = () => setOpen(true) + + // Provides a reset if that reward is no longer available + React.useEffect(() => { + if (hasQuests) { + // user has quest permissions + if (!taskRewards && value) { + // reward is no longer available + setValue('') + } else { + // check if the value is still valid + const filtered = taskRewards + ? value.split(',').filter((each) => taskRewards.includes(each)) + : [] + setValue(filtered.length ? filtered.join(',') : '') + } + } else { + // user does not have quest permissions + setValue('') + } + }, [taskRewards, id, hasQuests]) + + if (!taskRewards) return null + + return ( + <FCSelect + label={t('task_reward')} + value={value.split(',')} + disabled={all} + fullWidth + open={open} + onOpen={handleOpen} + onClose={handleClose} + multiple + renderValue={(selected) => + Array.isArray(selected) + ? `${selected.length} ${t('selected')}` + : selected + } + onChange={(e, child) => { + if ( + typeof child === 'object' && + 'props' in child && + child.props.value === '' + ) { + setValue('') + handleClose() + } else { + setValue( + Array.isArray(e.target.value) + ? e.target.value.filter(Boolean).join(',') + : e.target.value, + ) + if (e.target.value.length === 0) handleClose() + } + }} + fcSx={{ my: 1 }} + > + <MenuItem value=""> + <Typography variant="caption">{t('all')}</Typography> + </MenuItem> + {taskRewards + .slice() + .sort((a, b) => tId(a).localeCompare(tId(b))) + .map((rewardKey) => ( + <MenuItem key={rewardKey} value={rewardKey}> + {tId(rewardKey, { omitFormSuffix: true })} + </MenuItem> + ))} + </FCSelect> + ) +} diff --git a/src/features/drawer/components/SelectorList.jsx b/src/features/drawer/components/SelectorList.jsx index 809a9be2f..89d7f8f9b 100644 --- a/src/features/drawer/components/SelectorList.jsx +++ b/src/features/drawer/components/SelectorList.jsx @@ -37,7 +37,7 @@ import { * @template {keyof import('@rm/types').Available} T * @typedef {{ * category: T, - * subCategory?: T extends 'gyms' ? 'raids' | 'pokemon' : T extends 'pokestops' ? 'lures' | 'invasions' | 'quests' | 'showcase' | 'rocketPokemon' | 'pokemon' : never + * subCategory?: T extends 'gyms' ? 'raids' | 'pokemon' : T extends 'pokestops' ? 'lures' | 'invasions' | 'quests' | 'showcase' | 'rocketPokemon' | 'pokemon' | 'tasks' : never * itemsPerRow?: number, * children?: React.ReactNode, * label?: string @@ -110,6 +110,8 @@ function SelectorList({ ) case 'rocketPokemon': return key.startsWith('a') + case 'tasks': + return key.startsWith('k') case 'pokemon': return Number.isInteger(Number(key.charAt(0))) default: @@ -134,11 +136,15 @@ function SelectorList({ .map((item) => item.id) }, [translated, search]) - const restoreStateFrom = React.useMemo( - () => getDrawerGridState(listScrollKey), - [listScrollKey], - ) + // Virtuoso cannot reliably measure a grid inside a hidden tab or a closed + // drawer. In particular, reopening the drawer directly onto a persisted tab + // leaves that grid mounted with the closed drawer's stale viewport until the + // user switches away and back. Only mount the active grid, and read its + // latest snapshot as it becomes active so the remount restores its position. const shouldPersistGridState = drawer && visible + const restoreStateFrom = shouldPersistGridState + ? getDrawerGridState(listScrollKey) + : null const scrollMemory = useDrawerScrollMemory( listScrollKey, shouldPersistGridState, @@ -240,15 +246,17 @@ function SelectorList({ : height } > - <VirtualGrid - data={items} - xs={4} - scrollerRef={scrollMemory.ref} - restoreStateFrom={restoreStateFrom} - stateChanged={handleStateChanged} - > - {(_, key) => <StandardItem id={key} category={category} />} - </VirtualGrid> + {shouldPersistGridState && ( + <VirtualGrid + data={items} + xs={4} + scrollerRef={scrollMemory.ref} + restoreStateFrom={restoreStateFrom} + stateChanged={handleStateChanged} + > + {(_, key) => <StandardItem id={key} category={category} />} + </VirtualGrid> + )} </Box> </List> ) diff --git a/src/features/drawer/pokestops/Quests.jsx b/src/features/drawer/pokestops/Quests.jsx index 740781509..050bbf3b7 100644 --- a/src/features/drawer/pokestops/Quests.jsx +++ b/src/features/drawer/pokestops/Quests.jsx @@ -40,6 +40,13 @@ const BaseQuestQuickSelect = () => { label="search_quests" height={350} /> + <SelectorListMemo + key="tasks" + category="pokestops" + subCategory="tasks" + label="search_tasks" + height={350} + /> </MultiSelectorList> </CollapsibleItem> ) diff --git a/src/features/profile/Backups.jsx b/src/features/profile/Backups.jsx index 98b656456..78b4ce951 100644 --- a/src/features/profile/Backups.jsx +++ b/src/features/profile/Backups.jsx @@ -21,6 +21,28 @@ import Box from '@mui/material/Box' import { useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' import { Query } from '@services/queries' +import { createBackupData } from './backupData' + +/** @param {unknown} err @param {(key: string) => string} t */ +function getBackupErrorMessage(err, t) { + let message = t('backup_error_generic') + if (err instanceof ApolloError) { + const { networkError } = err + if ( + networkError && + 'statusCode' in networkError && + networkError.statusCode === 413 + ) { + message = t('backup_error_too_large') + } else if (err.message) { + message = err.message + } + } + return message +} + +const getCurrentBackupData = () => + createBackupData(useStorage.getState(), useMemory.getState().filters) export function UserBackups() { const { t } = useTranslation() @@ -73,24 +95,11 @@ function CreateNew({ backups }) { setErrorMessage('') try { await create({ - variables: { backup: { name, data: useStorage.getState() } }, + variables: { backup: { name, data: getCurrentBackupData() } }, }) setName('') } catch (err) { - let message = t('backup_error_generic') - if (err instanceof ApolloError) { - const { networkError } = err - if ( - networkError && - 'statusCode' in networkError && - networkError.statusCode === 413 - ) { - message = t('backup_error_too_large') - } else if (err.message) { - message = err.message - } - } - setErrorMessage(message) + setErrorMessage(getBackupErrorMessage(err, t)) } }, [backups, create, loading, name, t, userBackupLimits]) @@ -139,6 +148,7 @@ function BackupItem({ backup }) { const { t } = useTranslation() const [name, setName] = React.useState(backup.name) const [loading, setLoading] = React.useState(false) + const [errorMessage, setErrorMessage] = React.useState('') const [update, { loading: l1 }] = useMutation(Query.user('UPDATE_BACKUP'), { refetchQueries: ['GetBackups'], @@ -153,6 +163,24 @@ function BackupItem({ backup }) { React.useEffect(() => setName(backup.name), [backup]) React.useEffect(() => setLoading(l1 || l2 || l3), [l1, l2, l3]) + const handleUpdate = React.useCallback(async () => { + if (loading) return + setErrorMessage('') + try { + await update({ + variables: { + backup: { + id: backup.id, + name, + data: getCurrentBackupData(), + }, + }, + }) + } catch (err) { + setErrorMessage(getBackupErrorMessage(err, t)) + } + }, [backup.id, loading, name, t, update]) + React.useEffect(() => { if (fullBackup?.backup?.data) { try { @@ -180,54 +208,56 @@ function BackupItem({ backup }) { }, [fullBackup]) return ( - <ListItem> - <TextField - label={`${t('name')}${ - localStorage.getItem('last-loaded') === backup.name ? '*' : '' - }`} - size="small" - value={name || ''} - onChange={(e) => setName(e.target.value)} - variant="outlined" - sx={{ mr: 2 }} - /> - <ButtonGroup variant="outlined" size="small"> - <Button - disabled={loading} - color="secondary" - onClick={() => { - load({ variables: { id: backup.id } }) - }} - > - {t('load')} - </Button> - <Button - disabled={loading} - color="secondary" - onClick={() => { - update({ - variables: { - backup: { - id: backup.id, - name, - data: useStorage.getState(), - }, - }, - }) - }} - > - {t('update')} - </Button> - <Button - disabled={loading} - color="primary" - onClick={() => { - remove({ variables: { id: backup.id } }) + <ListItem sx={{ flexDirection: 'column', alignItems: 'stretch' }}> + <Box sx={{ display: 'flex', width: '100%' }}> + <TextField + label={`${t('name')}${ + localStorage.getItem('last-loaded') === backup.name ? '*' : '' + }`} + size="small" + value={name || ''} + onChange={(e) => { + setErrorMessage('') + setName(e.target.value) }} + variant="outlined" + sx={{ mr: 2 }} + /> + <ButtonGroup variant="outlined" size="small"> + <Button + disabled={loading} + color="secondary" + onClick={() => { + setErrorMessage('') + load({ variables: { id: backup.id } }) + }} + > + {t('load')} + </Button> + <Button disabled={loading} color="secondary" onClick={handleUpdate}> + {t('update')} + </Button> + <Button + disabled={loading} + color="primary" + onClick={() => { + setErrorMessage('') + remove({ variables: { id: backup.id } }) + }} + > + {t('delete')} + </Button> + </ButtonGroup> + </Box> + {errorMessage ? ( + <Typography + variant="caption" + color="error" + sx={{ mt: 1, alignSelf: 'flex-start' }} > - {t('delete')} - </Button> - </ButtonGroup> + {errorMessage} + </Typography> + ) : null} </ListItem> ) } diff --git a/src/features/profile/backupData.js b/src/features/profile/backupData.js new file mode 100644 index 000000000..97f13713b --- /dev/null +++ b/src/features/profile/backupData.js @@ -0,0 +1,63 @@ +// @ts-check + +/** @param {unknown} value */ +const isPlainObject = (value) => + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + +/** + * Returns only values that differ from the matching defaults. + * Unknown keys are deliberately retained for forwards/backwards compatibility. + * + * @param {unknown} value + * @param {unknown} defaults + * @returns {unknown} + */ +function getDifference(value, defaults) { + if (Object.is(value, defaults)) return undefined + + if (Array.isArray(value) && Array.isArray(defaults)) { + if ( + value.length === defaults.length && + value.every((entry, index) => + Object.is(getDifference(entry, defaults[index]), undefined), + ) + ) { + return undefined + } + return value + } + + if (isPlainObject(value) && isPlainObject(defaults)) { + const difference = {} + Object.entries(value).forEach(([key, entry]) => { + const entryDifference = Object.prototype.hasOwnProperty.call( + defaults, + key, + ) + ? getDifference(entry, defaults[key]) + : entry + if (entryDifference !== undefined) difference[key] = entryDifference + }) + return Object.keys(difference).length ? difference : undefined + } + + return value +} + +/** + * Produces a JSON-safe profile payload. Filter values matching the current + * server defaults are omitted because useMapData merges those defaults back in + * when a profile is loaded. + * + * @param {Record<string, any>} state + * @param {Record<string, any>} defaultFilters + */ +export function createBackupData(state, defaultFilters) { + const backup = JSON.parse(JSON.stringify(state)) + backup.filters = + getDifference(backup.filters || {}, defaultFilters || {}) || {} + return backup +} diff --git a/src/hooks/useMapData.js b/src/hooks/useMapData.js index 2b6cf44a1..3aea16555 100644 --- a/src/hooks/useMapData.js +++ b/src/hooks/useMapData.js @@ -45,6 +45,7 @@ export function useMapData(once = false) { icons, audio, questConditions, + taskConditions, supportsShinyStats, } = data.available const { icons: userIcons, audio: userAudio } = useStorage.getState() @@ -99,6 +100,7 @@ export function useMapData(once = false) { available: { ...prev.available, questConditions, + taskConditions, }, featureFlags: { ...prev.featureFlags, diff --git a/src/hooks/useTranslateById.js b/src/hooks/useTranslateById.js index c434dc259..950ad342d 100644 --- a/src/hooks/useTranslateById.js +++ b/src/hooks/useTranslateById.js @@ -72,6 +72,16 @@ export function useTranslateById(options = {}) { case 'i': // invasions return i18n.t(`grunt${alt ? '_a' : ''}_${id.slice(1)}`) + case 'k': { + // quest tasks + const match = id.slice(1).match(/^(.+)-(\d+)$/) + if (!match) return '' + const [, taskTitle, taskTarget] = match + const normalized = `quest_title_${taskTitle.toLowerCase()}` + return i18n.i18n.exists(normalized) + ? i18n.t(normalized, { amount_0: Number(taskTarget) }) + : '' + } case 'l': // lures return i18n.t(`lure_${id.slice(1)}`) diff --git a/src/pages/map/hooks/useGenPokestops.js b/src/pages/map/hooks/useGenPokestops.js index f627d4e42..bf10a5948 100644 --- a/src/pages/map/hooks/useGenPokestops.js +++ b/src/pages/map/hooks/useGenPokestops.js @@ -4,7 +4,7 @@ import { useEffect } from 'react' import { useTranslation } from 'react-i18next' export function useGenPokestops() { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const pokemon = useMemory((s) => s.masterfile.pokemon) const pokestops = useMemory((s) => s.filters.pokestops) const categories = useMemory((s) => s.menus.pokestops.categories) @@ -180,6 +180,25 @@ export function useGenPokestops() { } } break + case 'k': + if (tempObj.tasks) { + const match = id.slice(1).match(/^(.+)-(\d+)$/) + if (match) { + const [, taskTitle, taskTarget] = match + const normalized = `quest_title_${taskTitle.toLowerCase()}` + const name = i18n.exists(normalized) + ? t(normalized, { amount_0: Number(taskTarget) }) + : taskTitle + tempObj.tasks[id] = { + name, + perms: ['quests'], + } + tempObj.tasks[id].searchMeta = `${t( + 'tasks', + ).toLowerCase()} ${name.toLowerCase()}` + } + } + break case 'u': if (tempObj.general) { tempObj.general[id] = { @@ -301,5 +320,5 @@ export function useGenPokestops() { useMemory.setState((prev) => ({ menuFilters: { ...prev.menuFilters, ...tempObj }, })) - }, [pokemon, pokestops, categories, t]) + }, [pokemon, pokestops, categories, t, i18n]) } diff --git a/src/services/Assets.js b/src/services/Assets.js index 6f0ece85e..16071adb6 100644 --- a/src/services/Assets.js +++ b/src/services/Assets.js @@ -259,6 +259,10 @@ export class UAssets { case 'j': // stations return this.getStation() + case 'k': + // quest tasks - not tied to a specific reward sprite, so this + // renders the same generic marker as the base pokestop filter (`s0`) + return this.getPokestops(0) case 'l': // lures return this.getPokestops(id.slice(1)) diff --git a/src/services/queries/available.js b/src/services/queries/available.js index debe67076..165807b1a 100644 --- a/src/services/queries/available.js +++ b/src/services/queries/available.js @@ -8,6 +8,7 @@ export const GET_MAP_DATA = gql` masterfile filters questConditions + taskConditions icons audio supportsShinyStats diff --git a/src/store/useMemory.js b/src/store/useMemory.js index d4ed1630c..7eb237b63 100644 --- a/src/store/useMemory.js +++ b/src/store/useMemory.js @@ -60,6 +60,7 @@ import { create } from 'zustand' * stations: string[], * tappables: string[], * questConditions: Record<string, { title: string, target?: number }[]>, + * taskConditions: Record<string, { title: string, target?: number, rewards: string[] }>, * } * manualParams: { * category: string, @@ -133,6 +134,7 @@ export const useMemory = create(() => ({ stations: [], tappables: [], questConditions: {}, + taskConditions: {}, }, Icons: null, Audio: null,