Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@
"candy": true,
"xlCandy": true,
"pokemon": true,
"tasks": false,
"invasions": false,
"allInvasions": true,
"invasionPokemon": true,
Expand Down
3 changes: 3 additions & 0 deletions packages/locales/lib/human/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions server/src/filters/builder/pokestop.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,19 @@ 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') &&
!avail.startsWith('a') &&
!avail.startsWith('b') &&
!avail.startsWith('f') &&
!avail.startsWith('h') &&
!avail.startsWith('k') &&
!Number.isInteger(+avail.charAt(0))
) {
log.warn(
Expand Down
45 changes: 42 additions & 3 deletions server/src/filters/fort/pokestop.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
Expand Down
118 changes: 118 additions & 0 deletions server/src/filters/fort/pokestop.test.js
Original file line number Diff line number Diff line change
@@ -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, [])
})
47 changes: 47 additions & 0 deletions server/src/filters/pokestop/questTaskMatch.js
Original file line number Diff line number Diff line change
@@ -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 }
93 changes: 93 additions & 0 deletions server/src/filters/pokestop/questTaskMatch.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
1 change: 1 addition & 0 deletions server/src/graphql/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions server/src/graphql/typeDefs/map.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ type MapData {
masterfile: JSON
filters: JSON
questConditions: JSON
taskConditions: JSON
icons: JSON
audio: JSON
supportsShinyStats: Boolean
Expand Down
Loading
Loading