diff --git a/config/default.json b/config/default.json
index 21d19f8bb..f64c86fca 100644
--- a/config/default.json
+++ b/config/default.json
@@ -538,6 +538,7 @@
"candy": true,
"xlCandy": true,
"pokemon": true,
+ "tasks": false,
"invasions": false,
"allInvasions": true,
"invasionPokemon": true,
diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json
index cd7f6201a..e9722b49c 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",
@@ -595,6 +596,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",
@@ -754,6 +756,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 3f77bf948..809919558 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/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
-`) 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} filters
+ * @param {Record} [taskConditions]
+ * @returns {Record}
+ */
+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} filters args.filters
+ * @param {Record} rawFilters args.filters
* @param {Record} [eventInvasions] state.event.invasions (grunt→reward map, used for grunt-class exclusion)
+ * @param {Record} [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}>} 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 50efb5235..3885f9ea3 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 96ba17da3..47e3e1535 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 {
+ addTaskCondition,
+ matchesAdvancedFilter,
+} = require('../filters/pokestop/questTaskMatch')
const MEGA_RESOURCE_REWARD_TYPE = 12
const TEMP_EVO_BRANCH_RESOURCE_REWARD_TYPE = 20
@@ -727,7 +731,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
@@ -1119,18 +1127,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-`) 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)
@@ -1254,7 +1259,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,
@@ -1275,6 +1284,7 @@ class Pokestop extends Model {
const shouldIncludeBaseQuests = questLayer !== 'without_ar'
const shouldIncludeAltQuests = hasAltQuests && questLayer !== 'with_ar'
+ const taskConditions = {}
const process = (key, title, target) => {
if (title) {
if (key in conditions) {
@@ -1282,6 +1292,11 @@ class Pokestop extends Model {
} else {
conditions[key] = { [`${title}-${target}`]: { title, target } }
}
+ // Mirrors `conditions` in the opposite direction: `k-`
+ // 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)
}
@@ -1760,6 +1775,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..84179e060 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}> }}
*/
function mapAvailablePokestops(api, ctx) {
const { includeBaseQuests = true, includeAltQuests = true } = ctx
const available = new Set()
/** @type {QuestConditions} */
const conditions = {}
+ // Task-primary filter keys (`k-`), 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)
}
@@ -222,7 +234,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 2360adbca..6d708028f 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', {
@@ -737,7 +738,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: [] }
@@ -764,10 +765,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]) => [
@@ -775,6 +780,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)
@@ -802,12 +818,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' && }
+ {category === 'pokestops' &&
+ (id.startsWith('k') ? (
+
+ ) : (
+
+ ))}
{hasAll ? (
-`) 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 (
+
+ 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 }}
+ >
+
+ {taskRewards
+ .slice()
+ .sort((a, b) => tId(a).localeCompare(tId(b)))
+ .map((rewardKey) => (
+
+ ))}
+
+ )
+}
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
}
>
-
- {(_, key) => }
-
+ {shouldPersistGridState && (
+
+ {(_, key) => }
+
+ )}
)
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}
/>
+
)
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,
+ * taskConditions: Record,
* }
* manualParams: {
* category: string,
@@ -133,6 +134,7 @@ export const useMemory = create(() => ({
stations: [],
tappables: [],
questConditions: {},
+ taskConditions: {},
},
Icons: null,
Audio: null,