From ce0f73f04eff3146a60fe1594e00aebdce0cc502 Mon Sep 17 00:00:00 2001 From: DannyM300 Date: Mon, 3 Aug 2026 15:13:47 +0100 Subject: [PATCH 01/13] fix: one working Rocket reward filter per species Rocket reward filters were built from two sources that disagree about a reward's form id: confirmed scanner rewards (`incident`, form commonly 0) and the masterfile encounter pool (a real form, e.g. Deino 2291). Both reached the available list, so a species produced two identical-looking menu entries - `a276-0` and `a276-3163` - and `hasRocketPokemonFilter` compared the form exactly, so only whichever entry matched the grunt in hand did anything. Ticking the other silently did nothing. Match on the Pokemon ID instead, and collapse the available list to one key per species, keeping the non-zero masterfile form that saved filters already hold and the icon renderer expects. Grunt rewards do not meaningfully vary by form, and the SQL stage already selected stops by ID alone, so this makes the two stages agree. Both key shapes stay accepted, so existing saved filters keep resolving. Co-Authored-By: Claude Opus 5 --- .../src/filters/pokestop/rocketPokemonKeys.js | 68 +++++++++++++++ .../pokestop/rocketPokemonKeys.test.js | 85 +++++++++++++++++++ server/src/models/Pokestop.js | 51 ++++++----- 3 files changed, 181 insertions(+), 23 deletions(-) create mode 100644 server/src/filters/pokestop/rocketPokemonKeys.js create mode 100644 server/src/filters/pokestop/rocketPokemonKeys.test.js diff --git a/server/src/filters/pokestop/rocketPokemonKeys.js b/server/src/filters/pokestop/rocketPokemonKeys.js new file mode 100644 index 000000000..19f0e5b58 --- /dev/null +++ b/server/src/filters/pokestop/rocketPokemonKeys.js @@ -0,0 +1,68 @@ +// @ts-check + +/** Matches `a` and the legacy `a-` 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} 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} availableSet mutated in place + */ +const dedupeRocketPokemonKeys = (availableSet) => { + /** @type {Map} */ + 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) + }) +} + +module.exports = { + dedupeRocketPokemonKeys, + 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..a31690ff7 --- /dev/null +++ b/server/src/filters/pokestop/rocketPokemonKeys.test.js @@ -0,0 +1,85 @@ +const test = require('node:test') +const assert = require('node:assert/strict') + +const { + dedupeRocketPokemonKeys, + 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) +}) diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 5e6e8e28d..45057f54c 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -29,6 +29,10 @@ const { resolveQuestLayerSelection, } = require('../utils/questLayerMode') const { mapAvailablePokestops } = require('./pokestopAvailableMapper') +const { + dedupeRocketPokemonKeys, + hasRocketPokemonFilter, +} = require('../filters/pokestop/rocketPokemonKeys') const MEGA_RESOURCE_REWARD_TYPE = 12 const TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE = 20 @@ -1044,11 +1048,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` and the legacy `a-
` key shapes so + * existing saved filters keep resolving. + */ + static hasRocketPokemonFilter(filters, pokemonId) { + return hasRocketPokemonFilter(filters, pokemonId) } static invasionMatchesFilters( @@ -1090,13 +1105,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 +1116,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 +1127,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 @@ -1493,6 +1496,7 @@ 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`, @@ -2064,6 +2068,7 @@ class Pokestop extends Model { } applyRocketPokemonFallback(finalList) + dedupeRocketPokemonKeys(finalList) break case 'showcase': if (hasShowcaseData) { From 17c3c55d2c8d6e8562fdc7433106a9e162e7ee80 Mon Sep 17 00:00:00 2001 From: DannyM300 Date: Mon, 3 Aug 2026 16:12:59 +0100 Subject: [PATCH 02/13] fix: reward Pokemon filters default off, not on invasionPokemon defaulted to true, so every Rocket reward filter (~1,400+ species/form keys) started enabled for every user. A grunt is shown if ANY of its possible rewards is enabled - and an unconfirmed grunt's reward is often a pool of several species, not one - so with almost everything on by default, nearly every grunt matched via the reward path regardless of what the grunt-type filter said. Excluding a species did nothing observable unless every other species that could appear in the same reward pool was also excluded. Reward filtering is additive on top of grunt-type filtering (deliberate OR semantics), not a replacement for it. Defaulting it off lets grunt-type filters work on their own, with reward filters as an opt-in narrowing - ticking Deino adds Deino's grunts on top, rather than the whole reward list needing to be manually emptied out first. Existing saved filters were seeded from the old default and need a reset in the UI to pick this up. Co-Authored-By: Claude Opus 5 --- config/default.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/default.json b/config/default.json index ed36c09e9..9641fdace 100644 --- a/config/default.json +++ b/config/default.json @@ -541,7 +541,7 @@ "pokemon": true, "invasions": false, "allInvasions": true, - "invasionPokemon": true, + "invasionPokemon": false, "baseMegaEnergyAmounts": [], "stardust": { "min": 100, From 425077ba02b6c26f466693c9dbb27828644e338f Mon Sep 17 00:00:00 2001 From: DannyM300 Date: Mon, 3 Aug 2026 16:58:48 +0100 Subject: [PATCH 03/13] fix: derive Rocket reward keys from a stable, source-independent form Confirmed rewards built their available key from the scanner's raw slot form, while the unconfirmed fallback and the endpoint path built it from the masterfile encounter pool's form. The two disagree often enough (Wobbuffet has shown up as both 602 and 2328 for the same grunt type) that which key got minted depended on which source happened to answer a given poll - so the same species could produce a different key on a later poll than an earlier one did. dedupeRocketPokemonKeys only reconciles keys within a single poll's result, not across separate ones over time, so a user could switch off the key from poll 1 and have poll 2 mint a fresh, still-enabled twin they never see appear - the reward looks stuck on no matter what they toggle, and no reset fixes it, because the reset itself just rebuilds from whatever the next poll happens to report. Always resolve the form from the masterfile encounter pool - regardless of whether the reward came from a confirmed scanner slot or the unconfirmed fallback - so every poll agrees on the same key for a given species. There is nothing left to reconcile after the fact, because nothing can diverge in the first place. Co-Authored-By: Claude Opus 5 --- .../src/filters/pokestop/rocketPokemonKeys.js | 28 ++++++++++++++ .../pokestop/rocketPokemonKeys.test.js | 37 +++++++++++++++++++ server/src/models/Pokestop.js | 22 ++++++++--- server/src/models/pokestopAvailableMapper.js | 34 +++++++++++++++-- 4 files changed, 111 insertions(+), 10 deletions(-) diff --git a/server/src/filters/pokestop/rocketPokemonKeys.js b/server/src/filters/pokestop/rocketPokemonKeys.js index 19f0e5b58..3365ff2d5 100644 --- a/server/src/filters/pokestop/rocketPokemonKeys.js +++ b/server/src/filters/pokestop/rocketPokemonKeys.js @@ -62,7 +62,35 @@ const dedupeRocketPokemonKeys = (availableSet) => { }) } +/** + * 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 index a31690ff7..bbaab7601 100644 --- a/server/src/filters/pokestop/rocketPokemonKeys.test.js +++ b/server/src/filters/pokestop/rocketPokemonKeys.test.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const { dedupeRocketPokemonKeys, + getCanonicalRewardForm, hasRocketPokemonFilter, } = require('./rocketPokemonKeys') @@ -83,3 +84,39 @@ test('the survivor still matches, so the menu entry works', () => { // 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/models/Pokestop.js b/server/src/models/Pokestop.js index 45057f54c..59407ac69 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -31,6 +31,7 @@ const { const { mapAvailablePokestops } = require('./pokestopAvailableMapper') const { dedupeRocketPokemonKeys, + getCanonicalRewardForm, hasRocketPokemonFilter, } = require('../filters/pokestop/rocketPokemonKeys') @@ -2050,19 +2051,28 @@ 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}`) } }) } diff --git a/server/src/models/pokestopAvailableMapper.js b/server/src/models/pokestopAvailableMapper.js index d4afbe626..137774c7b 100644 --- a/server/src/models/pokestopAvailableMapper.js +++ b/server/src/models/pokestopAvailableMapper.js @@ -192,16 +192,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}`) } } }) From a2fe5081ccd2fa4372a4112e5704e63b457cde46 Mon Sep 17 00:00:00 2001 From: DannyM300 Date: Mon, 3 Aug 2026 17:10:38 +0100 Subject: [PATCH 04/13] fix: remove the hidden, always-on rocket filter generator Two separate places built Rocket reward filter keys. buildPokestops built one `a-` key per species that's an actual current grunt reward, sourced from the canonical available list. buildPokemon separately, unconditionally built a bare `a` key for every single Pokemon species in the masterfile - not just current rewards - then base.js merged both into the same filter object. The UI's Rocket Pokemon tab only renders tiles for the canonical available-list keys, so buildPokemon's bulk-generated bare keys were never shown, never reachable via Set All, and never touched by a reset - but hasRocketPokemonFilter still matched against them by species ID. A user could switch off every visible entry for a species and a hidden sibling from this second generator would keep the grunt showing, with no way to see or disable it from the UI. This also explains why the reward-filter list looked enormous: buildPokemon generated a key for every masterfile species regardless of availability, not just current rewards. Removing this generator leaves buildPokestops as the sole source of Rocket reward filter keys. Co-Authored-By: Claude Opus 5 --- server/src/filters/builder/base.js | 1 - server/src/filters/builder/pokemon.js | 7 ------- server/src/filters/builder/pokestop.js | 4 ++++ 3 files changed, 4 insertions(+), 8 deletions(-) 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..c79769f55 100644 --- a/server/src/filters/builder/pokestop.js +++ b/server/src/filters/builder/pokestop.js @@ -83,6 +83,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 From 6d9b2cd6a87cf49f1966c05a75a31290443b3e57 Mon Sep 17 00:00:00 2001 From: DannyM300 Date: Mon, 3 Aug 2026 17:21:43 +0100 Subject: [PATCH 05/13] fix: preserve default rocket filter setting --- config/default.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/default.json b/config/default.json index 9641fdace..ed36c09e9 100644 --- a/config/default.json +++ b/config/default.json @@ -541,7 +541,7 @@ "pokemon": true, "invasions": false, "allInvasions": true, - "invasionPokemon": false, + "invasionPokemon": true, "baseMegaEnergyAmounts": [], "stardust": { "min": 100, From b40219c9d7fc4cdb3fe03f667e7d09d7dcb895a3 Mon Sep 17 00:00:00 2001 From: DannyM300 Date: Mon, 3 Aug 2026 22:36:52 +0100 Subject: [PATCH 06/13] feat: filter quest tasks directly, narrowed to specific rewards Adds a Tasks tab alongside Items/Pokemon in the Quests filter panel. Task filtering was previously only reachable indirectly: pick a reward, then optionally narrow it to specific task conditions via its Advanced dialog. This adds the reverse: pick a task, then optionally narrow it to specific reward keys via the same Advanced mechanism - `k-<target>` is a task-primary filter key, matched the same additive way reward-primary filters already are. Server side, `addTaskCondition` builds the inverse of the existing `conditions[rewardKey]` map alongside it during the same available-list scan, so `taskConditions[taskKey]` accumulates every reward key seen for that task without a second pass over the data. `matchesAdvancedFilter` is shared between the reward-primary and task-primary checks - they were near-identical duplicates, now one tested function. Both flow through the existing conditions caching/dual-source-merge path (DbManager, disk cache, GraphQL) that questConditions already used. Client side, the new key prefix plugs into the same subCategory/tab pattern rocketPokemon and invasions already use, and reuses QuestConditionSelector's exact shape in TaskRewardSelector, just listing rewards instead of task conditions. Defaults to enabled (`tasks: true`), consistent with the other quest reward filters (items/pokemon/candy/etc), not the invasion reward default flip from earlier today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- config/default.json | 1 + docs/rocket-reward-filter-bug.md | 168 ++++++++++++++++++ packages/locales/lib/human/en.json | 3 + server/src/filters/builder/pokestop.js | 6 + server/src/filters/pokestop/questTaskMatch.js | 47 +++++ .../filters/pokestop/questTaskMatch.test.js | 93 ++++++++++ server/src/graphql/resolvers.js | 1 + server/src/graphql/typeDefs/map.graphql | 1 + server/src/models/Pokestop.js | 32 ++-- server/src/services/DbManager.js | 22 ++- server/src/services/state.js | 1 + server/src/ui/advMenus.js | 1 + src/components/filters/Advanced.jsx | 8 +- src/components/filters/TaskConditions.jsx | 106 +++++++++++ .../drawer/components/SelectorList.jsx | 4 +- src/features/drawer/pokestops/Quests.jsx | 7 + src/hooks/useMapData.js | 2 + src/hooks/useTranslateById.js | 10 ++ src/pages/map/hooks/useGenPokestops.js | 23 ++- src/services/Assets.js | 4 + src/services/queries/available.js | 1 + src/store/useMemory.js | 2 + 22 files changed, 525 insertions(+), 18 deletions(-) create mode 100644 docs/rocket-reward-filter-bug.md create mode 100644 server/src/filters/pokestop/questTaskMatch.js create mode 100644 server/src/filters/pokestop/questTaskMatch.test.js create mode 100644 src/components/filters/TaskConditions.jsx diff --git a/config/default.json b/config/default.json index ed36c09e9..45492e668 100644 --- a/config/default.json +++ b/config/default.json @@ -539,6 +539,7 @@ "candy": true, "xlCandy": true, "pokemon": true, + "tasks": true, "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<pokemonId>-<formId> +``` + +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/pokestop.js b/server/src/filters/builder/pokestop.js index c79769f55..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( 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/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 59407ac69..2b6fe1bce 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -34,6 +34,10 @@ const { 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 @@ -1315,18 +1319,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) @@ -1535,6 +1536,7 @@ class Pokestop extends Model { return query } + const taskConditions = {} const process = (key, title, target) => { if (title) { if (key in conditions) { @@ -1542,6 +1544,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) } @@ -2120,6 +2127,7 @@ class Pokestop extends Model { return { available: [...finalList], conditions, + taskConditions, } } 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..86ce392fc 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: 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/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, From 029a28d6cae7525c4170f4bfd110c91238f7eb1a Mon Sep 17 00:00:00 2001 From: DannyM300 <danny160sk3@gmail.com> Date: Mon, 3 Aug 2026 22:43:43 +0100 Subject: [PATCH 07/13] fix: build task conditions on the Golbat endpoint path too The Tasks tab rendered blank on any deployment sourcing quest data from the Golbat in-memory endpoint (/api/fort/available) rather than direct SQL - the same dual-source split that bit rocket rewards earlier. This scanner's raw pokestop table has zero rows with quest_reward_type set at all; quest data only ever came through the endpoint's own process() function in pokestopAvailableMapper.js, which never learned about task keys. Mirrors the SQL-side addTaskCondition logic inline (this mapper is dependency-free by design, so it can't require the shared module - see the earlier rocket-key fix for the same constraint), and threads taskConditions through the endpoint-path return in Pokestop.js the same way conditions already was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- server/src/models/Pokestop.js | 6 +++++- server/src/models/pokestopAvailableMapper.js | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 2b6fe1bce..df961c5bd 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -1503,7 +1503,11 @@ class Pokestop extends Model { 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, diff --git a/server/src/models/pokestopAvailableMapper.js b/server/src/models/pokestopAvailableMapper.js index 137774c7b..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) } @@ -248,7 +260,7 @@ function mapAvailablePokestops(api, ctx) { } }) - return { available: [...available], conditions } + return { available: [...available], conditions, taskConditions } } module.exports = { mapAvailablePokestops, questRewardKey } From c5e88835a9de11464249361d3a5824efc065182c Mon Sep 17 00:00:00 2001 From: DannyM300 <danny160sk3@gmail.com> Date: Mon, 3 Aug 2026 22:50:13 +0100 Subject: [PATCH 08/13] chore: temporary task matching debug logging (RM_DEBUG_TASK) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- server/src/models/Pokestop.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index df961c5bd..cc2fcc2b4 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -1328,6 +1328,22 @@ class Pokestop extends Model { const matchesFilter = matchesAdvancedFilter(filters[newQuest.key], questCondition) || matchesAdvancedFilter(filters[taskKey], newQuest.key) + if (process.env.RM_DEBUG_TASK) { + // TEMPORARY diagnostic - remove before merging. + const taskKeysInFilters = Object.keys(filters).filter((k) => + k.startsWith('k'), + ) + // eslint-disable-next-line no-console + console.log( + `[RM_DEBUG_TASK] rewardKey=${newQuest.key} taskKey=${taskKey} ` + + `taskFilterPresent=${taskKey in filters} ` + + `taskFilterValue=${JSON.stringify(filters[taskKey])} ` + + `rewardFilterValue=${JSON.stringify(filters[newQuest.key])} ` + + `matchesFilter=${matchesFilter} ` + + `totalTaskKeysSent=${taskKeysInFilters.length} ` + + `sampleTaskKeysSent=${JSON.stringify(taskKeysInFilters.slice(0, 5))}`, + ) + } if ( quest.quest_timestamp >= midnight && (filters.onlyAllPokestops || matchesFilter) From 754d516fc30021fd79418352b7d27fcc16042e0f Mon Sep 17 00:00:00 2001 From: DannyM300 <danny160sk3@gmail.com> Date: Mon, 3 Aug 2026 23:02:04 +0100 Subject: [PATCH 09/13] fix: expand task filters into their reward keys before DNF translation Task-only filtering did nothing: buildPokestopDnfFilters translates args.filters into the SQL/Golbat pre-filter that decides which rows are even fetched from the database, and its switch only understands reward keys - a task key (k<title>-<target>) fell through to the default case, failed Number.isFinite on a non-numeric id, and was silently dropped. With nothing else enabled, zero clauses were emitted for quests, so no task-only request ever fetched a matching row - the (correct) matching logic added earlier in secondaryFilter never got the chance to run, because the rows never arrived. expandTaskFilters resolves each enabled task key against state.db.taskConditions (the same data source that already powers the Tasks tab and its reward picker) and synthesizes the corresponding reward-key entries before the existing switch runs, so it picks them up through its own already-correct, already-tested clause logic rather than needing a new Golbat field invented for this. A task narrowed via .adv to specific rewards expands to only those; .all bypasses narrowing the same way it does for reward filters already. An explicit reward filter already present in args.filters is never overwritten by the synthesized one. This only affects the endpoint-backed (Golbat /api/fort/available) path, the only place buildPokestopDnfFilters is called - the SQL path builds its query separately and was already correctly handled by the secondaryFilter change. RM_DEBUG_TASK logging from the previous commit is left in for one more deploy to directly confirm rows now reach the matching stage; it'll come out once verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- server/src/filters/fort/pokestop.js | 45 ++++++++- server/src/filters/fort/pokestop.test.js | 118 +++++++++++++++++++++++ server/src/models/Pokestop.js | 6 +- 3 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 server/src/filters/fort/pokestop.test.js 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<title>-<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/models/Pokestop.js b/server/src/models/Pokestop.js index cc2fcc2b4..e1c12e468 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -876,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 From f6deaf038a80091f7b4dcdfaad467296eca9b814 Mon Sep 17 00:00:00 2001 From: DannyM300 <danny160sk3@gmail.com> Date: Mon, 3 Aug 2026 23:16:57 +0100 Subject: [PATCH 10/13] chore: remove temporary task matching debug logging RM_DEBUG_TASK confirmed the DNF expansion fix works end-to-end against live data (enabled task correctly matched its expected reward, other tasks correctly didn't) - no longer needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- server/src/models/Pokestop.js | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index e1c12e468..899b41f82 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -1332,22 +1332,6 @@ class Pokestop extends Model { const matchesFilter = matchesAdvancedFilter(filters[newQuest.key], questCondition) || matchesAdvancedFilter(filters[taskKey], newQuest.key) - if (process.env.RM_DEBUG_TASK) { - // TEMPORARY diagnostic - remove before merging. - const taskKeysInFilters = Object.keys(filters).filter((k) => - k.startsWith('k'), - ) - // eslint-disable-next-line no-console - console.log( - `[RM_DEBUG_TASK] rewardKey=${newQuest.key} taskKey=${taskKey} ` + - `taskFilterPresent=${taskKey in filters} ` + - `taskFilterValue=${JSON.stringify(filters[taskKey])} ` + - `rewardFilterValue=${JSON.stringify(filters[newQuest.key])} ` + - `matchesFilter=${matchesFilter} ` + - `totalTaskKeysSent=${taskKeysInFilters.length} ` + - `sampleTaskKeysSent=${JSON.stringify(taskKeysInFilters.slice(0, 5))}`, - ) - } if ( quest.quest_timestamp >= midnight && (filters.onlyAllPokestops || matchesFilter) From a0ed138abcaef64cf48547ca2b82c37318d57812 Mon Sep 17 00:00:00 2001 From: DannyM300 <danny160sk3@gmail.com> Date: Tue, 4 Aug 2026 11:27:49 +0100 Subject: [PATCH 11/13] fix: only mount the drawer grid while its tab is actually visible Virtuoso cannot reliably measure a grid inside a hidden tab or a closed drawer. Reopening the drawer directly onto a persisted tab left that grid mounted against the closed drawer's stale viewport until the user switched tabs away and back. Gate both the grid's mount and its restoreStateFrom snapshot on shouldPersistGridState (drawer open and tab visible), so the remount on becoming active picks up a fresh measurement instead of a stale one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../drawer/components/SelectorList.jsx | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/features/drawer/components/SelectorList.jsx b/src/features/drawer/components/SelectorList.jsx index 86ce392fc..89d7f8f9b 100644 --- a/src/features/drawer/components/SelectorList.jsx +++ b/src/features/drawer/components/SelectorList.jsx @@ -136,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, @@ -242,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> ) From 4469f8b74ade14671b3c02259fd0e66f7a7ddb72 Mon Sep 17 00:00:00 2001 From: DannyM300 <danny160sk3@gmail.com> Date: Tue, 4 Aug 2026 13:26:02 +0100 Subject: [PATCH 12/13] fix: trim saved profiles to only their non-default filters useStorage.getState() was saved verbatim, including every filter key at its default value - the whole point of a profile backup is the handful a user actually changed, not the full menu. The task filter feature just added a large new set of possible keys on top of the existing rocket/invasion/quest ones, making this far more likely to hit the existing 413 "backup too large" handling than before. createBackupData() diffs the current filters against the server's live defaults (useMemory.filters) and keeps only what differs. Safe on load: the backup write already clears localStorage and reloads the page, so useMapData's existing deepMerge(serverDefaults, loadedState) fills back in anything omitted because it matched the default at save time. Also adds error feedback to the Update button, which previously failed silently on the same 413 this was written to reduce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- src/features/profile/Backups.jsx | 152 +++++++++++++++++------------ src/features/profile/backupData.js | 63 ++++++++++++ 2 files changed, 154 insertions(+), 61 deletions(-) create mode 100644 src/features/profile/backupData.js 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 +} From 96f1a35eebfff3cf39f0bdb2cad56283c661d3ae Mon Sep 17 00:00:00 2001 From: DannyM300 <danny160sk3@gmail.com> Date: Mon, 10 Aug 2026 19:01:15 +0100 Subject: [PATCH 13/13] fix: default quest task filters off --- config/default.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/default.json b/config/default.json index 45492e668..6d21db925 100644 --- a/config/default.json +++ b/config/default.json @@ -539,7 +539,7 @@ "candy": true, "xlCandy": true, "pokemon": true, - "tasks": true, + "tasks": false, "invasions": false, "allInvasions": true, "invasionPokemon": true,