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-<target>`) into their + * reward-primary equivalents, so the switch below - which only understands + * reward keys - picks them up automatically without needing its own clause + * type. A task narrowed via `.adv` to specific rewards (the reverse Advanced + * dialog) expands to only those; an unnarrowed task expands to every reward + * `taskConditions` has ever seen it grant. + * + * Presence of a key in `filters` already means enabled - `trimFilters` + * strips disabled entries and the `enabled` field itself before the client + * ever sends this - so no `.enabled` check is needed here, matching every + * other key in this file. + * @param {Record<string, any>} filters + * @param {Record<string, {rewards?: string[]}>} [taskConditions] + * @returns {Record<string, any>} + */ +function expandTaskFilters(filters, taskConditions) { + if (!taskConditions) return filters + const taskKeys = Object.keys(filters).filter((key) => key.startsWith('k')) + if (!taskKeys.length) return filters + const expanded = { ...filters } + taskKeys.forEach((taskKey) => { + const filter = filters[taskKey] + const rewards = + filter?.adv && !filter.all + ? Array.isArray(filter.adv) + ? filter.adv + : filter.adv.split(',') + : taskConditions[taskKey]?.rewards + if (!rewards) return + rewards.forEach((rewardKey) => { + if (!expanded[rewardKey]) expanded[rewardKey] = { all: false, adv: '' } + }) + }) + return expanded +} + /** * Translate a pokestop's `args.filters` into ApiFortDnfFilter[] clauses. * @@ -38,12 +75,14 @@ const ROCKET_INCIDENT_DISPLAY_TYPES = [1, 2, 3, 4] * from an optional invasion check that no Golbat clause can safely track); * secondaryFilter confirms the specific reward. * - * @param {Record<string, any>} filters args.filters + * @param {Record<string, any>} rawFilters args.filters * @param {Record<string, any>} [eventInvasions] state.event.invasions (grunt→reward map, used for grunt-class exclusion) + * @param {Record<string, {rewards?: string[]}>} [taskConditions] state.db.taskConditions, used to expand task-primary keys into reward keys * @returns {object[]} */ -function buildPokestopDnfFilters(filters, eventInvasions) { - if (!filters || typeof filters !== 'object') return [] +function buildPokestopDnfFilters(rawFilters, eventInvasions, taskConditions) { + if (!rawFilters || typeof rawFilters !== 'object') return [] + const filters = expandTaskFilters(rawFilters, taskConditions) const { onlyAllPokestops, onlyArEligible, diff --git a/server/src/filters/fort/pokestop.test.js b/server/src/filters/fort/pokestop.test.js new file mode 100644 index 000000000..e4e8b9607 --- /dev/null +++ b/server/src/filters/fort/pokestop.test.js @@ -0,0 +1,118 @@ +const test = require('node:test') +const assert = require('node:assert/strict') + +const { buildPokestopDnfFilters } = require('./pokestop') + +const TASK_CONDITIONS = { + 'kcatch_pokemon-10': { + title: 'catch_pokemon', + target: 10, + rewards: ['7', 'q1', 'a633-2291'], + }, +} + +test('a task-only filter (no taskConditions passed) produces no quest clauses', () => { + // Without the third argument, expandTaskFilters is a no-op - the task key + // is dropped by the switch's default case, same as the original bug. + const filters = { + onlyQuests: true, + 'kcatch_pokemon-10': { all: false, adv: '' }, + } + const clauses = buildPokestopDnfFilters(filters, {}) + assert.deepEqual(clauses, []) +}) + +test('an unnarrowed enabled task expands to every reward it can grant', () => { + const filters = { + onlyQuests: true, + 'kcatch_pokemon-10': { all: false, adv: '' }, + } + const clauses = buildPokestopDnfFilters(filters, {}, TASK_CONDITIONS) + // '7-0' -> encounter (type 7), 'q1' -> item (type 2). 'a633-2291' is a + // rocket-reward key, which only ever produces clauses under onlyInvasions, + // not onlyQuests - so it correctly contributes nothing here. + assert.deepEqual( + clauses.sort((a, b) => a.quest_reward_type[0] - b.quest_reward_type[0]), + [ + { quest_reward_type: [2], quest_reward_item_id: [1] }, + { + quest_reward_type: [7], + quest_reward_pokemon: [{ pokemon_id: 7, form: 0 }], + }, + ], + ) +}) + +test('a task narrowed via .adv expands to only the selected rewards', () => { + const filters = { + onlyQuests: true, + 'kcatch_pokemon-10': { all: false, adv: 'q1' }, + } + const clauses = buildPokestopDnfFilters(filters, {}, TASK_CONDITIONS) + assert.deepEqual(clauses, [ + { quest_reward_type: [2], quest_reward_item_id: [1] }, + ]) +}) + +test('.all on a task bypasses narrowing, same as reward filters', () => { + const filters = { + onlyQuests: true, + 'kcatch_pokemon-10': { all: true, adv: 'q1' }, + } + const clauses = buildPokestopDnfFilters(filters, {}, TASK_CONDITIONS) + assert.deepEqual( + clauses.sort((a, b) => a.quest_reward_type[0] - b.quest_reward_type[0]), + [ + { quest_reward_type: [2], quest_reward_item_id: [1] }, + { + quest_reward_type: [7], + quest_reward_pokemon: [{ pokemon_id: 7, form: 0 }], + }, + ], + ) +}) + +test('an explicit reward filter already present is not overridden by expansion', () => { + const filters = { + onlyQuests: true, + 'kcatch_pokemon-10': { all: false, adv: '' }, + // User separately narrowed the reward filter itself to a specific task - + // expansion must not clobber that with a blank synthetic entry. + q1: { all: false, adv: 'other_task__5' }, + } + const clauses = buildPokestopDnfFilters(filters, {}, TASK_CONDITIONS) + // Both q1 (explicit) and 7-0 (synthesized) still produce clauses - the + // point is q1's *filter object* wasn't overwritten, which this test can't + // directly observe from clauses alone, but the item clause still appearing + // (rather than vanishing) confirms expansion didn't break the existing key. + const itemClause = clauses.find((c) => c.quest_reward_type?.[0] === 2) + assert.deepEqual(itemClause, { + quest_reward_type: [2], + quest_reward_item_id: [1], + }) +}) + +test('an unknown task key with no taskConditions entry expands to nothing, quietly', () => { + const filters = { + onlyQuests: true, + 'kmystery_task-1': { all: false, adv: '' }, + } + const clauses = buildPokestopDnfFilters(filters, {}, TASK_CONDITIONS) + assert.deepEqual(clauses, []) +}) + +test('a disabled task key (absent from filters) contributes nothing', () => { + // Matches the wire contract: disabled filters are never sent at all. + const filters = { onlyQuests: true } + const clauses = buildPokestopDnfFilters(filters, {}, TASK_CONDITIONS) + assert.deepEqual(clauses, []) +}) + +test('task expansion respects onlyQuests being off, same as any reward key', () => { + const filters = { + onlyQuests: false, + 'kcatch_pokemon-10': { all: false, adv: '' }, + } + const clauses = buildPokestopDnfFilters(filters, {}, TASK_CONDITIONS) + assert.deepEqual(clauses, []) +}) diff --git a/server/src/filters/pokestop/questTaskMatch.js b/server/src/filters/pokestop/questTaskMatch.js new file mode 100644 index 000000000..a0662a38d --- /dev/null +++ b/server/src/filters/pokestop/questTaskMatch.js @@ -0,0 +1,47 @@ +// @ts-check + +/** + * Shared core of the reward-primary and task-primary quest filter checks. + * + * A filter is enabled on its own (no `.adv` narrowing) matches unconditionally + * - that's the normal "I want this reward" / "I want this task" case. If + * `.adv` is set, the filter has been narrowed to a specific set of values on + * the OTHER axis (a reward filter narrowed to specific task conditions, or a + * task filter narrowed to specific reward keys) - only match if `matchValue` + * is in that set. `.all` bypasses narrowing entirely, matching the "Set All" + * bulk-enable semantics used elsewhere. + * @param {{ adv?: string | string[], all?: boolean } | undefined} filter + * @param {string} matchValue + */ +const matchesAdvancedFilter = (filter, matchValue) => { + if (!filter || !filter.adv || filter.all) return !!filter + const selected = Array.isArray(filter.adv) + ? filter.adv + : filter.adv.split(',') + return !selected.length || selected.includes(matchValue) +} + +/** + * Accumulates one reward key onto its task's entry, mutating `taskConditions` + * in place. Mirrors the reward-primary `conditions[rewardKey][conditionKey]` + * map in the opposite direction: one entry per distinct (title, target) pair + * - unlike a reward, which can come from many tasks, a task key IS one task, + * so `title`/`target` are stored once and `rewards` accumulates every reward + * key seen for it across however many quest rows share that task. + * @param {Record<string, {title: string, target: number, rewards: Record<string, boolean>}>} taskConditions + * @param {string} key reward key, e.g. `7-0`, `q123`, `a633-2291` + * @param {string} title + * @param {number} target + * @returns {string} the task key that was added/updated, e.g. `kcatch_pokemon-10` + */ +const addTaskCondition = (taskConditions, key, title, target) => { + const taskKey = `k${title}-${target}` + if (taskKey in taskConditions) { + taskConditions[taskKey].rewards[key] = true + } else { + taskConditions[taskKey] = { title, target, rewards: { [key]: true } } + } + return taskKey +} + +module.exports = { addTaskCondition, matchesAdvancedFilter } diff --git a/server/src/filters/pokestop/questTaskMatch.test.js b/server/src/filters/pokestop/questTaskMatch.test.js new file mode 100644 index 000000000..ae390aaa1 --- /dev/null +++ b/server/src/filters/pokestop/questTaskMatch.test.js @@ -0,0 +1,93 @@ +const test = require('node:test') +const assert = require('node:assert/strict') + +const { addTaskCondition, matchesAdvancedFilter } = require('./questTaskMatch') + +// --- matchesAdvancedFilter --- + +test('no filter never matches', () => { + assert.equal(matchesAdvancedFilter(undefined, 'anything'), false) +}) + +test('enabled filter with no narrowing matches unconditionally', () => { + assert.equal(matchesAdvancedFilter({}, 'anything'), true) + assert.equal(matchesAdvancedFilter({ enabled: true }, 'anything'), true) +}) + +test('.all bypasses narrowing entirely', () => { + assert.equal(matchesAdvancedFilter({ adv: 'x,y', all: true }, 'z'), true) +}) + +test('.adv as a comma string narrows to the listed values', () => { + const filter = { adv: 'a,b,c' } + assert.equal(matchesAdvancedFilter(filter, 'b'), true) + assert.equal(matchesAdvancedFilter(filter, 'z'), false) +}) + +test('.adv as an array narrows the same way as a comma string', () => { + const filter = { adv: ['a', 'b', 'c'] } + assert.equal(matchesAdvancedFilter(filter, 'b'), true) + assert.equal(matchesAdvancedFilter(filter, 'z'), false) +}) + +test('empty .adv (empty string) matches unconditionally', () => { + // split(',') on '' yields [''], and the falsy first element is filtered + // out by the caller before .adv is ever set to '' - but guard it anyway. + assert.equal(matchesAdvancedFilter({ adv: '' }, 'anything'), true) +}) + +// --- addTaskCondition --- + +test('creates a new task entry on first sight', () => { + const taskConditions = {} + const key = addTaskCondition(taskConditions, '7-0', 'catch_pokemon', 10) + assert.equal(key, 'kcatch_pokemon-10') + assert.deepEqual(taskConditions, { + 'kcatch_pokemon-10': { + title: 'catch_pokemon', + target: 10, + rewards: { '7-0': true }, + }, + }) +}) + +test('accumulates multiple reward keys onto the same task', () => { + const taskConditions = {} + addTaskCondition(taskConditions, '7-0', 'catch_pokemon', 10) + addTaskCondition(taskConditions, 'q1', 'catch_pokemon', 10) + addTaskCondition(taskConditions, 'a633-2291', 'catch_pokemon', 10) + assert.deepEqual( + Object.keys(taskConditions['kcatch_pokemon-10'].rewards).sort(), + ['7-0', 'a633-2291', 'q1'].sort(), + ) +}) + +test('the same reward seen twice for one task only appears once', () => { + const taskConditions = {} + addTaskCondition(taskConditions, '7-0', 'catch_pokemon', 10) + addTaskCondition(taskConditions, '7-0', 'catch_pokemon', 10) + assert.deepEqual(Object.keys(taskConditions['kcatch_pokemon-10'].rewards), [ + '7-0', + ]) +}) + +test('distinct (title, target) pairs stay in separate entries', () => { + const taskConditions = {} + addTaskCondition(taskConditions, '7-0', 'catch_pokemon', 10) + addTaskCondition(taskConditions, '7-0', 'catch_pokemon', 5) + addTaskCondition(taskConditions, '7-0', 'catch_water_pokemon', 10) + assert.deepEqual(Object.keys(taskConditions).sort(), [ + 'kcatch_pokemon-10', + 'kcatch_pokemon-5', + 'kcatch_water_pokemon-10', + ]) +}) + +test('round trip: a reward key added via addTaskCondition matches via matchesAdvancedFilter', () => { + const taskConditions = {} + const taskKey = addTaskCondition(taskConditions, '7-0', 'catch_pokemon', 10) + // Simulate a user narrowing the task filter to just this one reward. + const filters = { [taskKey]: { adv: '7-0' } } + assert.equal(matchesAdvancedFilter(filters[taskKey], '7-0'), true) + assert.equal(matchesAdvancedFilter(filters[taskKey], 'q1'), false) +}) diff --git a/server/src/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<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) @@ -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<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) } @@ -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<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) } @@ -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' && <QuestConditionSelector id={id} />} + {category === 'pokestops' && + (id.startsWith('k') ? ( + <TaskRewardSelector id={id} /> + ) : ( + <QuestConditionSelector id={id} /> + ))} {hasAll ? ( <DualBoolToggle items={ENABLED_ALL} diff --git a/src/components/filters/TaskConditions.jsx b/src/components/filters/TaskConditions.jsx new file mode 100644 index 000000000..3a0353a48 --- /dev/null +++ b/src/components/filters/TaskConditions.jsx @@ -0,0 +1,106 @@ +// @ts-check +import * as React from 'react' +import Typography from '@mui/material/Typography' +import MenuItem from '@mui/material/MenuItem' +import { useTranslation } from 'react-i18next' + +import { useMemory } from '@store/useMemory' +import { useDeepStore, useStorage } from '@store/useStorage' +import { useTranslateById } from '@hooks/useTranslateById' +import { FCSelect } from '@components/inputs/FCSelect' + +/** + * The reverse of QuestConditionSelector: narrows a task-primary filter + * (`k<title>-<target>`) down to specific reward keys, instead of narrowing a + * reward-primary filter down to specific task conditions. Same `.adv` + * mechanism, same UI shape, opposite direction. + * @param {{ id: string }} props + * @returns + */ +export function TaskRewardSelector({ id }) { + const { t } = useTranslation() + const { t: tId } = useTranslateById() + const [value, setValue] = useDeepStore( + `filters.pokestops.filter.${id}.adv`, + '', + ) + const all = useStorage((s) => !!s.filters.pokestops.filter[id].all) + const taskRewards = useMemory((s) => s.available.taskConditions[id]?.rewards) + const hasQuests = useMemory((s) => s.ui.pokestops?.quests) + + const [open, setOpen] = React.useState(false) + + const handleClose = () => setOpen(false) + + const handleOpen = () => setOpen(true) + + // Provides a reset if that reward is no longer available + React.useEffect(() => { + if (hasQuests) { + // user has quest permissions + if (!taskRewards && value) { + // reward is no longer available + setValue('') + } else { + // check if the value is still valid + const filtered = taskRewards + ? value.split(',').filter((each) => taskRewards.includes(each)) + : [] + setValue(filtered.length ? filtered.join(',') : '') + } + } else { + // user does not have quest permissions + setValue('') + } + }, [taskRewards, id, hasQuests]) + + if (!taskRewards) return null + + return ( + <FCSelect + label={t('task_reward')} + value={value.split(',')} + disabled={all} + fullWidth + open={open} + onOpen={handleOpen} + onClose={handleClose} + multiple + renderValue={(selected) => + Array.isArray(selected) + ? `${selected.length} ${t('selected')}` + : selected + } + onChange={(e, child) => { + if ( + typeof child === 'object' && + 'props' in child && + child.props.value === '' + ) { + setValue('') + handleClose() + } else { + setValue( + Array.isArray(e.target.value) + ? e.target.value.filter(Boolean).join(',') + : e.target.value, + ) + if (e.target.value.length === 0) handleClose() + } + }} + fcSx={{ my: 1 }} + > + <MenuItem value=""> + <Typography variant="caption">{t('all')}</Typography> + </MenuItem> + {taskRewards + .slice() + .sort((a, b) => tId(a).localeCompare(tId(b))) + .map((rewardKey) => ( + <MenuItem key={rewardKey} value={rewardKey}> + {tId(rewardKey, { omitFormSuffix: true })} + </MenuItem> + ))} + </FCSelect> + ) +} diff --git a/src/features/drawer/components/SelectorList.jsx b/src/features/drawer/components/SelectorList.jsx index 809a9be2f..89d7f8f9b 100644 --- a/src/features/drawer/components/SelectorList.jsx +++ b/src/features/drawer/components/SelectorList.jsx @@ -37,7 +37,7 @@ import { * @template {keyof import('@rm/types').Available} T * @typedef {{ * category: T, - * subCategory?: T extends 'gyms' ? 'raids' | 'pokemon' : T extends 'pokestops' ? 'lures' | 'invasions' | 'quests' | 'showcase' | 'rocketPokemon' | 'pokemon' : never + * subCategory?: T extends 'gyms' ? 'raids' | 'pokemon' : T extends 'pokestops' ? 'lures' | 'invasions' | 'quests' | 'showcase' | 'rocketPokemon' | 'pokemon' | 'tasks' : never * itemsPerRow?: number, * children?: React.ReactNode, * label?: string @@ -110,6 +110,8 @@ function SelectorList({ ) case 'rocketPokemon': return key.startsWith('a') + case 'tasks': + return key.startsWith('k') case 'pokemon': return Number.isInteger(Number(key.charAt(0))) default: @@ -134,11 +136,15 @@ function SelectorList({ .map((item) => item.id) }, [translated, search]) - const restoreStateFrom = React.useMemo( - () => getDrawerGridState(listScrollKey), - [listScrollKey], - ) + // Virtuoso cannot reliably measure a grid inside a hidden tab or a closed + // drawer. In particular, reopening the drawer directly onto a persisted tab + // leaves that grid mounted with the closed drawer's stale viewport until the + // user switches away and back. Only mount the active grid, and read its + // latest snapshot as it becomes active so the remount restores its position. const shouldPersistGridState = drawer && visible + const restoreStateFrom = shouldPersistGridState + ? getDrawerGridState(listScrollKey) + : null const scrollMemory = useDrawerScrollMemory( listScrollKey, shouldPersistGridState, @@ -240,15 +246,17 @@ function SelectorList({ : height } > - <VirtualGrid - data={items} - xs={4} - scrollerRef={scrollMemory.ref} - restoreStateFrom={restoreStateFrom} - stateChanged={handleStateChanged} - > - {(_, key) => <StandardItem id={key} category={category} />} - </VirtualGrid> + {shouldPersistGridState && ( + <VirtualGrid + data={items} + xs={4} + scrollerRef={scrollMemory.ref} + restoreStateFrom={restoreStateFrom} + stateChanged={handleStateChanged} + > + {(_, key) => <StandardItem id={key} category={category} />} + </VirtualGrid> + )} </Box> </List> ) diff --git a/src/features/drawer/pokestops/Quests.jsx b/src/features/drawer/pokestops/Quests.jsx index 740781509..050bbf3b7 100644 --- a/src/features/drawer/pokestops/Quests.jsx +++ b/src/features/drawer/pokestops/Quests.jsx @@ -40,6 +40,13 @@ const BaseQuestQuickSelect = () => { label="search_quests" height={350} /> + <SelectorListMemo + key="tasks" + category="pokestops" + subCategory="tasks" + label="search_tasks" + height={350} + /> </MultiSelectorList> </CollapsibleItem> ) diff --git a/src/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,