diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index b60eeec7b..729636146 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -1431,6 +1431,10 @@ "__name": "DEFAULT_FILTERS_STATIONS_BATTLES", "__format": "boolean" }, + "includeUpcoming": { + "__name": "DEFAULT_FILTERS_STATIONS_INCLUDE_UPCOMING", + "__format": "boolean" + }, "gmaxStationed": { "__name": "DEFAULT_FILTERS_STATIONS_GMAX_STATIONED", "__format": "boolean" @@ -2481,4 +2485,4 @@ } } } -} \ No newline at end of file +} diff --git a/config/default.json b/config/default.json index 67aa516f5..4637dccc6 100644 --- a/config/default.json +++ b/config/default.json @@ -610,6 +610,7 @@ "pokemon": false, "battleTier": "all", "battles": false, + "includeUpcoming": true, "gmaxStationed": false, "interactionRanges": false, "customRange": 0, diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index a4ff952f1..b737bbf94 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -835,6 +835,7 @@ "stations_opacity": "Dynamic Power Spot Opacity", "inactive_stations": "Inactive Power Spots", "max_battles": "Max Battles", + "include_upcoming": "Include Upcoming", "dynamax": "Dynamax", "stations_subtitle": "Displays Power Spots on the map", "dynamax_subtitle": "Displays Dynamax Battles on the map", diff --git a/packages/types/lib/scanner.d.ts b/packages/types/lib/scanner.d.ts index e640c8aed..b7b3ef12f 100644 --- a/packages/types/lib/scanner.d.ts +++ b/packages/types/lib/scanner.d.ts @@ -379,6 +379,24 @@ export interface StationPokemon extends PokemonDisplay { bread_mode: number } +export interface StationBattle { + battle_level: number + battle_start?: number + battle_end?: number + updated?: number + battle_pokemon_id: number + battle_pokemon_form: number + battle_pokemon_costume: number + battle_pokemon_gender: Gender + battle_pokemon_alignment: number + battle_pokemon_bread_mode: number + battle_pokemon_move_1: number + battle_pokemon_move_2: number + battle_pokemon_stamina?: number + battle_pokemon_cp_multiplier?: number + battle_pokemon_estimated_cp?: number +} + export interface Station { id: string lat: number @@ -412,6 +430,7 @@ export interface Station { stationed_pokemon: Parsed extends true ? StationPokemon[] : string | StationPokemon[] + battles?: StationBattle[] updated: number } diff --git a/packages/types/lib/server.d.ts b/packages/types/lib/server.d.ts index 8d5b544ee..41e2c639c 100644 --- a/packages/types/lib/server.d.ts +++ b/packages/types/lib/server.d.ts @@ -46,6 +46,7 @@ export interface DbContext { hasShowcaseData: boolean hasShowcaseForm: boolean hasShowcaseType: boolean + hasMultiBattles: boolean hasStationedGmax: boolean hasBattlePokemonStats: boolean hasPokemonBackground: boolean diff --git a/server/src/filters/builder/base.js b/server/src/filters/builder/base.js index e5c865a8b..bc04e7b07 100644 --- a/server/src/filters/builder/base.js +++ b/server/src/filters/builder/base.js @@ -134,6 +134,9 @@ function buildDefaultFilters(perms) { maxBattles: perms.stations ? defaultFilters.stations.battles : undefined, + includeUpcoming: perms.dynamax + ? defaultFilters.stations.includeUpcoming + : undefined, filter: pokemon.stations, gmaxStationed: perms.dynamax ? defaultFilters.stations.gmaxStationed diff --git a/server/src/graphql/typeDefs/map.graphql b/server/src/graphql/typeDefs/map.graphql index 99c0cda73..bc40b9547 100644 --- a/server/src/graphql/typeDefs/map.graphql +++ b/server/src/graphql/typeDefs/map.graphql @@ -80,6 +80,7 @@ type Search { # battle_pokemon_evolution: Int battle_pokemon_alignment: Int battle_pokemon_bread_mode: Int + battle_start: Int battle_end: Int } diff --git a/server/src/graphql/typeDefs/scanner.graphql b/server/src/graphql/typeDefs/scanner.graphql index bc7ae2695..6b14245b2 100644 --- a/server/src/graphql/typeDefs/scanner.graphql +++ b/server/src/graphql/typeDefs/scanner.graphql @@ -319,6 +319,25 @@ type Station { battle_pokemon_stamina: Int battle_pokemon_cp_multiplier: Float battle_pokemon_estimated_cp: Int + battles: [StationBattle] +} + +type StationBattle { + battle_level: Int + battle_start: Int + battle_end: Int + updated: Int + battle_pokemon_id: Int + battle_pokemon_form: Int + battle_pokemon_costume: Int + battle_pokemon_gender: Int + battle_pokemon_alignment: Int + battle_pokemon_bread_mode: Int + battle_pokemon_move_1: Int + battle_pokemon_move_2: Int + battle_pokemon_stamina: Int + battle_pokemon_cp_multiplier: Float + battle_pokemon_estimated_cp: Int } type StationPokemon { diff --git a/server/src/models/Station.js b/server/src/models/Station.js index 7bfc90a99..1dbbf467d 100644 --- a/server/src/models/Station.js +++ b/server/src/models/Station.js @@ -1,5 +1,5 @@ // @ts-check -const { Model } = require('objection') +const { Model, raw } = require('objection') const config = require('@rm/config') const i18next = require('i18next') @@ -12,6 +12,60 @@ const { state } = require('../services/state') const { getSharedPvpWrapper } = require('../services/PvpWrapper') const DEFAULT_IV = 15 +const STATION_TABLE = 'station' +const STATION_BATTLE_ROW_ALIAS = 'station_battle_row' +const STATION_BATTLE_FILTER_ALIAS = 'station_battle_filter' +const STATION_BATTLE_SEARCH_ALIAS = 'station_battle_search' +const STATION_BATTLE_ROW_TABLE = `station_battle as ${STATION_BATTLE_ROW_ALIAS}` +const STATION_BATTLE_FILTER_TABLE = `station_battle as ${STATION_BATTLE_FILTER_ALIAS}` +const STATION_BATTLE_SEARCH_TABLE = `station_battle as ${STATION_BATTLE_SEARCH_ALIAS}` +const STATION_BATTLE_FIELDS = [ + 'battle_level', + 'battle_start', + 'battle_end', + 'battle_pokemon_id', + 'battle_pokemon_form', + 'battle_pokemon_costume', + 'battle_pokemon_gender', + 'battle_pokemon_alignment', + 'battle_pokemon_bread_mode', + 'battle_pokemon_move_1', + 'battle_pokemon_move_2', + 'battle_pokemon_stamina', + 'battle_pokemon_cp_multiplier', + 'updated', +] +const STATION_BATTLE_IDENTITY_FIELDS = STATION_BATTLE_FIELDS.filter( + (field) => + ![ + 'battle_pokemon_move_1', + 'battle_pokemon_move_2', + 'battle_pokemon_stamina', + 'battle_pokemon_cp_multiplier', + 'updated', + ].includes(field), +) +const STATION_BATTLE_DETAIL_MERGE_FIELDS = [ + 'battle_pokemon_move_1', + 'battle_pokemon_move_2', +] +const STATION_SEARCH_BATTLE_FIELDS = [ + 'battle_level', + 'battle_start', + 'battle_pokemon_id', + 'battle_pokemon_form', + 'battle_pokemon_costume', + 'battle_pokemon_gender', + 'battle_pokemon_alignment', + 'battle_pokemon_bread_mode', + 'battle_end', +] +const STATION_SEARCH_BATTLE_STRICT_FIELDS = [ + 'battle_pokemon_costume', + 'battle_pokemon_gender', + 'battle_pokemon_alignment', + 'battle_pokemon_bread_mode', +] /** * @param {import('ohbem').PokemonData | null} pokemonData @@ -54,6 +108,670 @@ function estimateStationCp(pokemonData, station) { return cp < 10 ? 10 : cp } +/** + * @param {string} alias + * @returns {string[]} + */ +function getAliasedStationBattleSelect(alias) { + return STATION_BATTLE_FIELDS.map( + (field) => `${alias}.${field} as ${alias}_${field}`, + ) +} + +/** + * @param {string} field + * @returns {string} + */ +function getStationColumn(field) { + return `${STATION_TABLE}.${field}` +} + +/** + * @param {string[]} fields + * @returns {string[]} + */ +function getStationSelect(fields) { + return fields.map((field) => `${getStationColumn(field)} as ${field}`) +} + +/** + * @param {string[]} fields + * @param {string} aliasPrefix + * @returns {string[]} + */ +function getAliasedStationSelect(fields, aliasPrefix) { + return fields.map( + (field) => `${getStationColumn(field)} as ${aliasPrefix}${field}`, + ) +} + +/** + * @param {number} ts + * @param {import('objection').QueryBuilder} builder + * @param {string} alias + */ +function addSearchBattleMatchOrder(builder, alias, ts) { + builder.orderByRaw( + `CASE + WHEN ${alias}.battle_start IS NULL + OR ${alias}.battle_start = 0 + OR ${alias}.battle_start <= ? + THEN 0 + ELSE 1 + END ASC`, + [ts], + ) + builder + .orderByRaw( + `CASE + WHEN ${alias}.battle_start IS NULL + OR ${alias}.battle_start = 0 + OR ${alias}.battle_start <= ? + THEN NULL + ELSE ${alias}.battle_start + END ASC`, + [ts], + ) + .orderBy(`${alias}.battle_end`, 'desc') + .orderBy(`${alias}.battle_pokemon_id`, 'asc') + .orderBy(`${alias}.battle_pokemon_form`, 'asc') + .orderBy(`${alias}.battle_pokemon_costume`, 'asc') + .orderBy(`${alias}.battle_pokemon_gender`, 'asc') + .orderBy(`${alias}.battle_pokemon_alignment`, 'asc') + .orderBy(`${alias}.battle_pokemon_bread_mode`, 'asc') + .orderBy(`${alias}.battle_level`, 'asc') +} + +/** + * @param {ReturnType} knexRef + * @param {number[]} pokemonIds + * @param {number} ts + */ +function getSearchBattleJsonSubquery(knexRef, pokemonIds, ts) { + const jsonFields = STATION_SEARCH_BATTLE_FIELDS.flatMap((field) => [ + `'${field}'`, + `${STATION_BATTLE_SEARCH_ALIAS}.${field}`, + ]).join(', ') + + return knexRef(STATION_BATTLE_SEARCH_TABLE) + .select(raw(`JSON_OBJECT(${jsonFields})`)) + .whereRaw(`${STATION_BATTLE_SEARCH_ALIAS}.station_id = ${STATION_TABLE}.id`) + .andWhere(`${STATION_BATTLE_SEARCH_ALIAS}.battle_end`, '>', ts) + .modify((builder) => { + if (pokemonIds.length) { + builder.whereIn( + `${STATION_BATTLE_SEARCH_ALIAS}.battle_pokemon_id`, + pokemonIds, + ) + } + addSearchBattleMatchOrder(builder, STATION_BATTLE_SEARCH_ALIAS, ts) + }) + .limit(1) +} + +/** + * @param {import('@rm/types').StationBattle | null | undefined} battle + * @param {import('ohbem').PokemonData | null} pokemonData + * @returns {import('@rm/types').StationBattle | null} + */ +function enrichStationBattle(battle, pokemonData) { + if (!battle) return null + return { + ...battle, + battle_pokemon_estimated_cp: pokemonData + ? estimateStationCp(pokemonData, battle) + : null, + } +} + +/** + * @param {Record} row + * @param {string} alias + * @param {number} ts + * @param {import('ohbem').PokemonData | null} pokemonData + * @returns {import('@rm/types').StationBattle | null} + */ +function getAliasedStationBattle(row, alias, ts, pokemonData) { + const battleEnd = Number(row?.[`${alias}_battle_end`]) + if (!(battleEnd > ts)) { + return null + } + return enrichStationBattle( + { + battle_level: row?.[`${alias}_battle_level`] ?? null, + battle_start: row?.[`${alias}_battle_start`] ?? null, + battle_end: row?.[`${alias}_battle_end`] ?? null, + updated: row?.[`${alias}_updated`] ?? null, + battle_pokemon_id: row?.[`${alias}_battle_pokemon_id`] ?? null, + battle_pokemon_form: row?.[`${alias}_battle_pokemon_form`] ?? null, + battle_pokemon_costume: row?.[`${alias}_battle_pokemon_costume`] ?? null, + battle_pokemon_gender: row?.[`${alias}_battle_pokemon_gender`] ?? null, + battle_pokemon_alignment: + row?.[`${alias}_battle_pokemon_alignment`] ?? null, + battle_pokemon_bread_mode: + row?.[`${alias}_battle_pokemon_bread_mode`] ?? null, + battle_pokemon_move_1: row?.[`${alias}_battle_pokemon_move_1`] ?? null, + battle_pokemon_move_2: row?.[`${alias}_battle_pokemon_move_2`] ?? null, + battle_pokemon_stamina: row?.[`${alias}_battle_pokemon_stamina`] ?? null, + battle_pokemon_cp_multiplier: + row?.[`${alias}_battle_pokemon_cp_multiplier`] ?? null, + }, + pokemonData, + ) +} + +/** + * @param {import('@rm/types').FullStation} station + * @param {number} ts + * @param {import('ohbem').PokemonData | null} pokemonData + * @returns {import('@rm/types').StationBattle | null} + */ +function getFallbackStationBattle(station, ts, pokemonData) { + if (!(Number(station?.battle_end) > ts)) return null + if ( + station?.battle_level === null && + station?.battle_pokemon_id === null && + station?.battle_pokemon_form === null + ) { + return null + } + return enrichStationBattle( + { + battle_level: station.battle_level ?? null, + battle_start: station.battle_start ?? null, + battle_end: station.battle_end ?? null, + updated: station.updated ?? null, + battle_pokemon_id: station.battle_pokemon_id ?? null, + battle_pokemon_form: station.battle_pokemon_form ?? null, + battle_pokemon_costume: station.battle_pokemon_costume ?? null, + battle_pokemon_gender: station.battle_pokemon_gender ?? null, + battle_pokemon_alignment: station.battle_pokemon_alignment ?? null, + battle_pokemon_bread_mode: station.battle_pokemon_bread_mode ?? null, + battle_pokemon_move_1: station.battle_pokemon_move_1 ?? null, + battle_pokemon_move_2: station.battle_pokemon_move_2 ?? null, + battle_pokemon_stamina: station.battle_pokemon_stamina ?? null, + battle_pokemon_cp_multiplier: + station.battle_pokemon_cp_multiplier ?? null, + }, + pokemonData, + ) +} + +/** + * @param {string} field + * @param {unknown} value + * @returns {string | number} + */ +function normalizeStationBattleIdentityField(field, value) { + if (field === 'battle_start') { + const battleStart = Number(value) + return !Number.isFinite(battleStart) || battleStart === 0 ? '' : battleStart + } + if (STATION_SEARCH_BATTLE_STRICT_FIELDS.includes(field)) { + if (value === null || value === undefined || value === '') { + return '' + } + const variantValue = Number(value) + return Number.isFinite(variantValue) ? variantValue : '' + } + const numericValue = Number(value) + return Number.isFinite(numericValue) && numericValue > 0 + ? numericValue + : (value ?? '') +} + +/** + * @param {import('@rm/types').StationBattle | null | undefined} left + * @param {import('@rm/types').StationBattle | null | undefined} right + * @returns {boolean} + */ +function canMergeStationBattleIdentity(left, right) { + return STATION_BATTLE_IDENTITY_FIELDS.every((field) => { + const leftValue = normalizeStationBattleIdentityField(field, left?.[field]) + const rightValue = normalizeStationBattleIdentityField( + field, + right?.[field], + ) + return leftValue === '' || rightValue === '' || leftValue === rightValue + }) +} + +/** + * @param {import('@rm/types').StationBattle | null | undefined} battle + * @returns {string} + */ +function getStationBattleIdentity(battle) { + return STATION_BATTLE_IDENTITY_FIELDS.map((field) => + normalizeStationBattleIdentityField(field, battle?.[field]), + ).join(':') +} + +/** + * @param {import('@rm/types').StationBattle | null | undefined} battle + * @param {number} ts + * @returns {boolean} + */ +function isStationBattleActive(battle, ts) { + const battleEnd = Number(battle?.battle_end) + if (!(battleEnd > ts)) return false + const battleStart = Number(battle?.battle_start) + return !Number.isFinite(battleStart) || battleStart === 0 || battleStart <= ts +} + +/** + * @param {import('@rm/types').StationBattle | null | undefined} left + * @param {import('@rm/types').StationBattle | null | undefined} right + * @param {number} ts + * @returns {number} + */ +function compareStationBattleTiming(left, right, ts) { + const leftActive = isStationBattleActive(left, ts) + const rightActive = isStationBattleActive(right, ts) + if (leftActive !== rightActive) { + return leftActive ? -1 : 1 + } + + if (!leftActive) { + const leftStart = Number(left?.battle_start) || Number.MAX_SAFE_INTEGER + const rightStart = Number(right?.battle_start) || Number.MAX_SAFE_INTEGER + if (leftStart !== rightStart) { + return leftStart - rightStart + } + } + + const leftEnd = Number(left?.battle_end) || 0 + const rightEnd = Number(right?.battle_end) || 0 + if (leftEnd !== rightEnd) { + return rightEnd - leftEnd + } + + return 0 +} + +/** + * @param {import('@rm/types').StationBattle | null | undefined} left + * @param {import('@rm/types').StationBattle | null | undefined} right + * @param {number} ts + * @returns {number} + */ +function compareStationBattles(left, right, ts) { + const timingComparison = compareStationBattleTiming(left, right, ts) + if (timingComparison) { + return timingComparison + } + if (canMergeStationBattleIdentity(left, right)) { + const completenessComparison = + getStationBattleCompleteness(right) - getStationBattleCompleteness(left) + if (completenessComparison) { + return completenessComparison + } + } + + return getStationBattleIdentity(left).localeCompare( + getStationBattleIdentity(right), + ) +} + +/** + * @param {import('@rm/types').StationBattle[]} battles + * @param {import('@rm/types').StationBattle | null | undefined} battle + * @param {import('ohbem').PokemonData | null} pokemonData + * @param {{ preserveExistingUpdated?: boolean }} [options] + * @returns {import('@rm/types').StationBattle[]} + */ +function appendDistinctStationBattle( + battles, + battle, + pokemonData, + { preserveExistingUpdated = false } = {}, +) { + if (!battle) return battles + const battleIdentity = getStationBattleIdentity(battle) + const exactBattle = battles.find( + (currentBattle) => + getStationBattleIdentity(currentBattle) === battleIdentity, + ) + const existingBattle = + exactBattle || + [...battles] + .filter((currentBattle) => + canMergeStationBattleIdentity(currentBattle, battle), + ) + .sort((left, right) => compareStationBattles(left, right, 0))[0] + if (existingBattle) { + STATION_BATTLE_IDENTITY_FIELDS.forEach((field) => { + const existingValue = normalizeStationBattleIdentityField( + field, + existingBattle[field], + ) + const nextValue = normalizeStationBattleIdentityField( + field, + battle[field], + ) + if (existingValue === '' && nextValue !== '') { + existingBattle[field] = battle[field] + } + }) + let statsChanged = false + ;['battle_pokemon_stamina', 'battle_pokemon_cp_multiplier'].forEach( + (field) => { + if (existingBattle[field] == null && battle[field] != null) { + existingBattle[field] = battle[field] + statsChanged = true + } + }, + ) + STATION_BATTLE_DETAIL_MERGE_FIELDS.forEach((field) => { + if (existingBattle[field] == null && battle[field] != null) { + existingBattle[field] = battle[field] + } + }) + const existingUpdated = Number(existingBattle.updated) + const nextUpdated = Number(battle.updated) + if ( + Number.isFinite(nextUpdated) && + nextUpdated > 0 && + (!preserveExistingUpdated || !Number.isFinite(existingUpdated)) && + (!Number.isFinite(existingUpdated) || nextUpdated > existingUpdated) + ) { + existingBattle.updated = battle.updated + } + if (statsChanged && pokemonData) { + existingBattle.battle_pokemon_estimated_cp = estimateStationCp( + pokemonData, + existingBattle, + ) + } else if ( + existingBattle.battle_pokemon_estimated_cp == null && + battle.battle_pokemon_estimated_cp != null + ) { + existingBattle.battle_pokemon_estimated_cp = + battle.battle_pokemon_estimated_cp + } + } else { + battles.push(battle) + } + return battles +} + +/** + * @param {string} field + * @param {unknown} value + * @returns {unknown} + */ +function normalizeStationBattleComparisonField(field, value) { + if (field === 'battle_start') { + const battleStart = Number(value) + return !Number.isFinite(battleStart) || battleStart === 0 + ? null + : battleStart + } + if (STATION_SEARCH_BATTLE_STRICT_FIELDS.includes(field)) { + const variantValue = Number(value) + return Number.isFinite(variantValue) ? variantValue : 0 + } + return value ?? null +} + +/** + * @param {import('@rm/types').StationBattle | null | undefined} left + * @param {import('@rm/types').StationBattle | null | undefined} right + * @returns {boolean} + */ +function canReuseStationBattleDetails(left, right) { + return STATION_SEARCH_BATTLE_FIELDS.every((field) => { + const leftValue = normalizeStationBattleComparisonField( + field, + left?.[field], + ) + const rightValue = normalizeStationBattleComparisonField( + field, + right?.[field], + ) + if (STATION_SEARCH_BATTLE_STRICT_FIELDS.includes(field)) { + return leftValue === rightValue + } + return leftValue == null || rightValue == null || leftValue === rightValue + }) +} + +/** + * @param {import('@rm/types').StationBattle | null | undefined} battle + * @returns {number} + */ +function getStationBattleCompleteness(battle) { + return STATION_SEARCH_BATTLE_FIELDS.reduce((count, field) => { + const value = normalizeStationBattleComparisonField(field, battle?.[field]) + return value == null ? count : count + 1 + }, 0) +} + +/** + * @param {(import('@rm/types').StationBattle | null | undefined)[]} battles + * @param {number} ts + * @returns {import('@rm/types').StationBattle | null} + */ +function getPreferredStationBattle(battles, ts) { + const availableBattles = battles.filter(Boolean) + if (!availableBattles.length) return null + return [...availableBattles].sort((left, right) => + compareStationBattles(left, right, ts), + )[0] +} + +/** + * @param {import('@rm/types').FullStation} station + * @returns {import('@rm/types').FullStation} + */ +function clearStationBattleFallback(station) { + ;[ + ...STATION_BATTLE_FIELDS.filter((field) => field !== 'updated'), + 'battle_pokemon_estimated_cp', + ].forEach((field) => { + station[field] = null + }) + station.is_battle_available = false + return station +} + +/** + * @param {import('objection').QueryBuilder} builder + * @param {string} prefix + * @param {{ + * ts: number + * includeUpcoming: boolean + * onlyBattleTier: string | number + * battleLevels: number[] + * battleCombos: { pokemonId: number, form: number | null }[] + * }} options + */ +function addBattleFilterClause( + builder, + prefix, + { ts, includeUpcoming, onlyBattleTier, battleLevels, battleCombos }, +) { + builder + .whereNotNull(`${prefix}battle_pokemon_id`) + .andWhere(`${prefix}battle_end`, '>', ts) + if (!includeUpcoming) { + builder.andWhere((active) => { + active + .whereNull(`${prefix}battle_start`) + .orWhere(`${prefix}battle_start`, 0) + .orWhere(`${prefix}battle_start`, '<=', ts) + }) + } + if (onlyBattleTier === 'all') { + builder.andWhere((match) => { + let matchApplied = false + if (battleLevels.length) { + const levelMethod = matchApplied ? 'orWhereIn' : 'whereIn' + match[levelMethod](`${prefix}battle_level`, battleLevels) + matchApplied = true + } + battleCombos.forEach(({ pokemonId, form }) => { + const comboMethod = matchApplied ? 'orWhere' : 'where' + match[comboMethod]((combo) => { + combo.where(`${prefix}battle_pokemon_id`, pokemonId) + if (form === null) { + combo.andWhereNull(`${prefix}battle_pokemon_form`) + } else { + combo.andWhere(`${prefix}battle_pokemon_form`, form) + } + }) + matchApplied = true + }) + if (!matchApplied) { + match.whereRaw('0 = 1') + } + }) + } else { + builder.andWhere(`${prefix}battle_level`, onlyBattleTier) + } +} + +/** + * @param {import('@rm/types').StationBattle | null | undefined} battle + * @param {{ + * ts: number + * includeUpcoming: boolean + * onlyBattleTier: string | number + * battleLevels: number[] + * battleCombos: { pokemonId: number, form: number | null }[] + * }} options + */ +function matchesStationBattleFilter( + battle, + { ts, includeUpcoming, onlyBattleTier, battleLevels, battleCombos }, +) { + if ( + battle?.battle_pokemon_id === null || + battle?.battle_pokemon_id === undefined + ) { + return false + } + if (!(Number(battle?.battle_end) > ts)) { + return false + } + if (!includeUpcoming && !isStationBattleActive(battle, ts)) { + return false + } + if (onlyBattleTier !== 'all') { + return Number(battle?.battle_level) === Number(onlyBattleTier) + } + + let matchApplied = false + let matched = false + if (battleLevels.length) { + matchApplied = true + matched = battleLevels.includes(Number(battle?.battle_level)) + } + if ( + battleCombos.some(({ pokemonId, form }) => { + if (Number(battle?.battle_pokemon_id) !== pokemonId) { + return false + } + if (form === null) { + return battle?.battle_pokemon_form === null + } + return Number(battle?.battle_pokemon_form) === form + }) + ) { + matchApplied = true + matched = true + } + + return matchApplied && matched +} + +/** + * @param {import('@rm/types').StationBattle | null | undefined} left + * @param {import('@rm/types').StationBattle | null | undefined} right + * @returns {number} + */ +function compareVisibleStationBattles(left, right) { + const leftEnd = Number(left?.battle_end) || Number.MAX_SAFE_INTEGER + const rightEnd = Number(right?.battle_end) || Number.MAX_SAFE_INTEGER + if (leftEnd !== rightEnd) { + return leftEnd - rightEnd + } + + const leftStart = Number(left?.battle_start) || 0 + const rightStart = Number(right?.battle_start) || 0 + if (leftStart !== rightStart) { + return leftStart - rightStart + } + + return getStationBattleIdentity(left).localeCompare( + getStationBattleIdentity(right), + ) +} + +/** + * @param {(import('@rm/types').StationBattle | null | undefined)[]} battles + * @param {number} ts + * @returns {import('@rm/types').StationBattle | null} + */ +function getVisibleStationBattle(battles, ts) { + return ( + [...(battles || [])] + .filter((battle) => isStationBattleActive(battle, ts)) + .sort(compareVisibleStationBattles)[0] || null + ) +} + +/** + * @param {import('@rm/types').FullStation} station + * @param {import('ohbem').PokemonData | null} pokemonData + * @param {number} ts + */ +function finalizeStation(station, pokemonData, ts) { + const hasJoinedAvailableBattle = + Array.isArray(station.battles) && + station.battles.some((battle) => { + if ( + battle?.battle_pokemon_id == null || + !(Number(battle?.battle_end) > ts) + ) { + return false + } + const battleStart = Number(battle?.battle_start) + return ( + !Number.isFinite(battleStart) || battleStart === 0 || battleStart <= ts + ) + }) + if (hasJoinedAvailableBattle) { + station.is_battle_available = true + } else if ( + station.is_battle_available && + station.battle_pokemon_id === null + ) { + station.is_battle_available = false + } + if (station.total_stationed_pokemon === null) { + station.total_stationed_pokemon = 0 + } + if ( + station.stationed_pokemon && + (station.total_stationed_gmax === undefined || + station.total_stationed_gmax === null) + ) { + const list = + typeof station.stationed_pokemon === 'string' + ? JSON.parse(station.stationed_pokemon) + : station.stationed_pokemon || [] + let count = 0 + if (list) + for (let i = 0; i < list.length; ++i) + if (list[i].bread_mode === 2 || list[i].bread_mode === 3) ++count + station.total_stationed_gmax = count + } + station.battle_pokemon_estimated_cp = pokemonData + ? estimateStationCp(pokemonData, station) + : null + return station +} + class Station extends Model { static get tableName() { return 'station' @@ -69,7 +787,7 @@ class Station extends Model { static async getAll( perms, args, - { isMad, hasStationedGmax, hasBattlePokemonStats }, + { isMad, hasMultiBattles, hasStationedGmax, hasBattlePokemonStats }, ) { const { areaRestrictions } = perms const { stationUpdateLimit, stationInactiveLimitDays } = @@ -79,6 +797,7 @@ class Station extends Model { onlyAllStations, onlyMaxBattles, onlyBattleTier, + onlyIncludeUpcoming = true, onlyGmaxStationed, onlyInactiveStations, } = args.filters @@ -114,13 +833,17 @@ class Station extends Model { } const battleLevels = [...battleLevelFilters] const battleCombos = [...battleComboFilters.values()] + const hasBattleConditions = + onlyBattleTier !== 'all' || + battleLevels.length > 0 || + battleCombos.length > 0 if (!onlyAllStations && !onlyInactiveStations && !perms.dynamax) { return [] } const ts = getEpoch() - const baseSelect = [ + const baseSelect = getStationSelect([ 'id', 'name', 'lat', @@ -128,13 +851,13 @@ class Station extends Model { 'updated', 'start_time', 'end_time', - ] + ]) const manualFilterOptions = { manualId: args.filters.onlyManualId, - latColumn: 'lat', - lonColumn: 'lon', - idColumn: 'id', + latColumn: getStationColumn('lat'), + lonColumn: getStationColumn('lon'), + idColumn: getStationColumn('id'), bounds: { minLat: args.minLat, maxLat: args.maxLat, @@ -144,34 +867,65 @@ class Station extends Model { } const select = [...baseSelect] + const includeBattleData = + perms.dynamax && (onlyMaxBattles || onlyGmaxStationed) const query = this.query() applyManualIdFilter(query, manualFilterOptions) const now = Date.now() / 1000 const activeCutoff = now - stationUpdateLimit * 60 * 60 const inactiveCutoff = now - stationInactiveLimitDays * 24 * 60 * 60 + const battleFilterOptions = { + ts, + includeUpcoming: !!onlyIncludeUpcoming, + onlyBattleTier, + battleLevels, + battleCombos, + } + const shouldRestrictReturnedBattles = onlyMaxBattles && hasBattleConditions - if (perms.dynamax && (onlyMaxBattles || onlyGmaxStationed)) { + if (includeBattleData) { select.push( - 'is_battle_available', - 'battle_level', - 'battle_start', - 'battle_end', - 'battle_pokemon_id', - 'battle_pokemon_form', - 'battle_pokemon_costume', - 'battle_pokemon_gender', - 'battle_pokemon_alignment', - 'battle_pokemon_bread_mode', - 'battle_pokemon_move_1', - 'battle_pokemon_move_2', - 'total_stationed_pokemon', + ...getStationSelect([ + 'is_battle_available', + 'battle_level', + 'battle_start', + 'battle_end', + 'battle_pokemon_id', + 'battle_pokemon_form', + 'battle_pokemon_costume', + 'battle_pokemon_gender', + 'battle_pokemon_alignment', + 'battle_pokemon_bread_mode', + 'battle_pokemon_move_1', + 'battle_pokemon_move_2', + 'total_stationed_pokemon', + ]), ) select.push( - hasStationedGmax ? 'total_stationed_gmax' : 'stationed_pokemon', + `${getStationColumn( + hasStationedGmax ? 'total_stationed_gmax' : 'stationed_pokemon', + )} as ${hasStationedGmax ? 'total_stationed_gmax' : 'stationed_pokemon'}`, ) if (hasBattlePokemonStats) { - select.push('battle_pokemon_stamina', 'battle_pokemon_cp_multiplier') + select.push( + ...getStationSelect([ + 'battle_pokemon_stamina', + 'battle_pokemon_cp_multiplier', + ]), + ) + } + if (hasMultiBattles) { + select.push(...getAliasedStationBattleSelect(STATION_BATTLE_ROW_ALIAS)) + query.leftJoin(STATION_BATTLE_ROW_TABLE, (join) => { + join + .on('station.id', '=', `${STATION_BATTLE_ROW_ALIAS}.station_id`) + .andOn( + `${STATION_BATTLE_ROW_ALIAS}.battle_end`, + '>', + raw('?', [ts]), + ) + }) } } @@ -186,42 +940,40 @@ class Station extends Model { let applied = false if (onlyMaxBattles) { - const hasBattleConditions = - onlyBattleTier !== 'all' || - battleLevels.length > 0 || - battleCombos.length > 0 if (hasBattleConditions) { const method = applied ? 'orWhere' : 'where' station[method]((battle) => { - battle - .whereNotNull('battle_pokemon_id') - .andWhere('battle_end', '>', ts) - if (onlyBattleTier === 'all') { - battle.andWhere((match) => { - let matchApplied = false - if (battleLevels.length) { - const levelMethod = matchApplied ? 'orWhereIn' : 'whereIn' - match[levelMethod]('battle_level', battleLevels) - matchApplied = true - } - battleCombos.forEach(({ pokemonId, form }) => { - const comboMethod = matchApplied ? 'orWhere' : 'where' - match[comboMethod]((combo) => { - combo.where('battle_pokemon_id', pokemonId) - if (form === null) { - combo.andWhereNull('battle_pokemon_form') - } else { - combo.andWhere('battle_pokemon_form', form) - } - }) - matchApplied = true + if (hasMultiBattles) { + battle.where((multiBattle) => { + multiBattle.whereExists( + this.knex() + .select(1) + .from(STATION_BATTLE_FILTER_TABLE) + .whereRaw( + `${STATION_BATTLE_FILTER_ALIAS}.station_id = station.id`, + ) + .modify((subquery) => { + addBattleFilterClause( + subquery, + `${STATION_BATTLE_FILTER_ALIAS}.`, + battleFilterOptions, + ) + }), + ) + multiBattle.orWhere((legacyBattle) => { + addBattleFilterClause( + legacyBattle, + `${STATION_TABLE}.`, + battleFilterOptions, + ) }) - if (!matchApplied) { - match.whereRaw('0 = 1') - } }) } else { - battle.andWhere('battle_level', onlyBattleTier) + addBattleFilterClause( + battle, + `${STATION_TABLE}.`, + battleFilterOptions, + ) } }) applied = true @@ -231,17 +983,17 @@ class Station extends Model { if (onlyGmaxStationed) { if (hasStationedGmax) { const method = applied ? 'orWhere' : 'where' - station[method]('total_stationed_gmax', '>', 0) + station[method](getStationColumn('total_stationed_gmax'), '>', 0) applied = true } else { const method = applied ? 'orWhere' : 'where' station[method]((gmax) => { gmax.whereRaw( - "JSON_SEARCH(COALESCE(stationed_pokemon, '[]'), 'one', ?, NULL, '$[*].bread_mode') IS NOT NULL", + "JSON_SEARCH(COALESCE(station.stationed_pokemon, '[]'), 'one', ?, NULL, '$[*].bread_mode') IS NOT NULL", ['2'], ) gmax.orWhereRaw( - "JSON_SEARCH(COALESCE(stationed_pokemon, '[]'), 'one', ?, NULL, '$[*].bread_mode') IS NOT NULL", + "JSON_SEARCH(COALESCE(station.stationed_pokemon, '[]'), 'one', ?, NULL, '$[*].bread_mode') IS NOT NULL", ['3'], ) }) @@ -256,24 +1008,32 @@ class Station extends Model { } query.select(select) + if (includeBattleData && hasMultiBattles) { + query + .orderBy('station.id', 'asc') + .orderBy(`${STATION_BATTLE_ROW_ALIAS}.battle_end`, 'desc') + .orderBy(`${STATION_BATTLE_ROW_ALIAS}.battle_start`, 'asc') + } if (onlyInactiveStations) { query.andWhere((builder) => { builder.where((active) => { active - .where('end_time', '>', ts) - .andWhere('updated', '>', activeCutoff) + .where(getStationColumn('end_time'), '>', ts) + .andWhere(getStationColumn('updated'), '>', activeCutoff) applyStationFilters(active) }) // Battle data etc of inactive stations should be ignored since they are outdated by design builder.orWhere((inactive) => inactive - .where('end_time', '<=', ts) - .andWhere('updated', '>', inactiveCutoff), + .where(getStationColumn('end_time'), '<=', ts) + .andWhere(getStationColumn('updated'), '>', inactiveCutoff), ) }) } else { - query.andWhere('end_time', '>', ts).andWhere('updated', '>', activeCutoff) + query + .andWhere(getStationColumn('end_time'), '>', ts) + .andWhere(getStationColumn('updated'), '>', activeCutoff) applyStationFilters(query) } @@ -282,14 +1042,21 @@ class Station extends Model { } /** @type {import('@rm/types').FullStation[]} */ - const stations = await query + const stationRows = await query let pokemonData = null - if (hasBattlePokemonStats && perms.dynamax) { - const needsEstimatedCp = stations.some((station) => { - if (!station || !station.battle_pokemon_id) return false + if (perms.dynamax && (hasBattlePokemonStats || hasMultiBattles)) { + const needsEstimatedCp = stationRows.some((station) => { + if (!station) return false const multiplier = Number(station.battle_pokemon_cp_multiplier) + const joinedMultiplier = Number( + station?.[`${STATION_BATTLE_ROW_ALIAS}_battle_pokemon_cp_multiplier`], + ) return Number.isFinite(multiplier) && multiplier > 0 + ? !!station.battle_pokemon_id + : Number.isFinite(joinedMultiplier) && + joinedMultiplier > 0 && + !!station?.[`${STATION_BATTLE_ROW_ALIAS}_battle_pokemon_id`] }) if (needsEstimatedCp) { try { @@ -304,33 +1071,82 @@ class Station extends Model { } } - return stations.map((station) => { - if (station.is_battle_available && station.battle_pokemon_id === null) { - station.is_battle_available = false - } - if (station.total_stationed_pokemon === null) { - station.total_stationed_pokemon = 0 + if (!includeBattleData || !hasMultiBattles) { + return stationRows.map((station) => { + if (includeBattleData) { + const fallbackBattle = getFallbackStationBattle( + station, + ts, + pokemonData, + ) + station.battles = fallbackBattle ? [fallbackBattle] : [] + } + return finalizeStation(station, pokemonData, ts) + }) + } + + /** @type {Map} */ + const grouped = new Map() + + stationRows.forEach((row) => { + let station = grouped.get(row.id) + if (!station) { + station = { + ...row, + battles: [], + } + grouped.set(row.id, station) } - if ( - station.stationed_pokemon && - (station.total_stationed_gmax === undefined || - station.total_stationed_gmax === null) - ) { - const list = - typeof station.stationed_pokemon === 'string' - ? JSON.parse(station.stationed_pokemon) - : station.stationed_pokemon || [] - let count = 0 - if (list) - for (let i = 0; i < list.length; ++i) - if (list[i].bread_mode === 2 || list[i].bread_mode === 3) ++count - station.total_stationed_gmax = count + const battle = getAliasedStationBattle( + row, + STATION_BATTLE_ROW_ALIAS, + ts, + pokemonData, + ) + if (battle) { + station.battles.push(battle) } - station.battle_pokemon_estimated_cp = pokemonData - ? estimateStationCp(pokemonData, station) - : null - return station }) + + return [...grouped.values()] + .map((station) => { + if (Number(station?.end_time) <= ts) { + station.battles = [] + clearStationBattleFallback(station) + return finalizeStation(station, pokemonData, ts) + } + const fallbackBattle = getFallbackStationBattle( + station, + ts, + pokemonData, + ) + station.battles = appendDistinctStationBattle( + [...station.battles], + fallbackBattle, + pokemonData, + { preserveExistingUpdated: true }, + ) + if (!onlyIncludeUpcoming) { + const visibleBattle = getVisibleStationBattle(station.battles, ts) + station.battles = visibleBattle ? [visibleBattle] : [] + if (!station.battles.length) { + clearStationBattleFallback(station) + } + } + const hasMatchingReturnedBattle = station.battles.some((battle) => + matchesStationBattleFilter(battle, battleFilterOptions), + ) + if ( + !onlyAllStations && + shouldRestrictReturnedBattles && + !hasMatchingReturnedBattle && + !onlyGmaxStationed + ) { + return null + } + return finalizeStation(station, pokemonData, ts) + }) + .filter(Boolean) } /** @@ -361,21 +1177,69 @@ class Station extends Model { : result.stationed_pokemon || [] } - static async getAvailable() { + static async getAvailable({ hasMultiBattles }) { /** @type {import('@rm/types').FullStation[]} */ const ts = getEpoch() const { stationUpdateLimit } = config.getSafe('api') - const results = await this.query() - .distinct(['battle_pokemon_id', 'battle_pokemon_form', 'battle_level']) - .where('is_inactive', false) - .andWhere('battle_end', '>', ts) - .andWhere( - 'updated', - '>', - Date.now() / 1000 - stationUpdateLimit * 60 * 60, - ) - .groupBy(['battle_pokemon_id', 'battle_pokemon_form', 'battle_level']) - .orderBy('battle_pokemon_id', 'asc') + const activeCutoff = Date.now() / 1000 - stationUpdateLimit * 60 * 60 + const results = hasMultiBattles + ? await this.query() + .distinct( + getStationSelect([ + 'battle_pokemon_id', + 'battle_pokemon_form', + 'battle_level', + ]), + ) + .where(getStationColumn('is_inactive'), false) + .andWhere(getStationColumn('battle_end'), '>', ts) + .andWhere(getStationColumn('updated'), '>', activeCutoff) + .union((builder) => { + builder + .select([ + raw( + `${STATION_BATTLE_ROW_ALIAS}.battle_pokemon_id AS battle_pokemon_id`, + ), + raw( + `${STATION_BATTLE_ROW_ALIAS}.battle_pokemon_form AS battle_pokemon_form`, + ), + raw(`${STATION_BATTLE_ROW_ALIAS}.battle_level AS battle_level`), + ]) + .from(STATION_TABLE) + .join(STATION_BATTLE_ROW_TABLE, (join) => { + join + .on( + 'station.id', + '=', + `${STATION_BATTLE_ROW_ALIAS}.station_id`, + ) + .andOn( + `${STATION_BATTLE_ROW_ALIAS}.battle_end`, + '>', + raw('?', [ts]), + ) + }) + .where(getStationColumn('is_inactive'), false) + .andWhere(getStationColumn('updated'), '>', activeCutoff) + }) + .orderBy('battle_pokemon_id', 'asc') + : await this.query() + .distinct( + getStationSelect([ + 'battle_pokemon_id', + 'battle_pokemon_form', + 'battle_level', + ]), + ) + .where(getStationColumn('is_inactive'), false) + .andWhere(getStationColumn('battle_end'), '>', ts) + .andWhere(getStationColumn('updated'), '>', activeCutoff) + .groupBy([ + getStationColumn('battle_pokemon_id'), + getStationColumn('battle_pokemon_form'), + getStationColumn('battle_level'), + ]) + .orderBy('battle_pokemon_id', 'asc') return { available: [ ...new Set( @@ -399,54 +1263,95 @@ class Station extends Model { * @param {ReturnType} bbox * @returns {Promise} */ - static async search(perms, args, { isMad }, distance, bbox) { + static async search(perms, args, { isMad, hasMultiBattles }, distance, bbox) { const { areaRestrictions } = perms const { onlyAreas = [], search = '', locale } = args const { searchResultsLimit, stationUpdateLimit } = config.getSafe('api') const ts = getEpoch() + const knexRef = this.knex() + const trimmedSearch = search.trim() + const normalizedSearch = trimmedSearch.toLowerCase() + + if (!normalizedSearch) { + return [] + } - const pokemonIds = Object.keys(state.event.masterfile.pokemon).filter( - (pkmn) => + const pokemonIds = Object.keys(state.event.masterfile.pokemon) + .filter((pkmn) => i18next .t(`poke_${pkmn}`, { lng: locale }) .toLowerCase() - .includes(search), - ) + .includes(normalizedSearch), + ) + .map(Number) + .filter(Number.isFinite) - const select = ['id', 'name', 'lat', 'lon', distance] + const select = [...getStationSelect(['id', 'name', 'lat', 'lon']), distance] if (perms.dynamax) { - select.push( - 'battle_level', - 'battle_pokemon_id', - 'battle_pokemon_form', - 'battle_pokemon_costume', - 'battle_pokemon_gender', - 'battle_pokemon_alignment', - 'battle_pokemon_bread_mode', - 'battle_end', - ) + if (hasMultiBattles) { + select.push( + ...getAliasedStationSelect(STATION_SEARCH_BATTLE_FIELDS, 'station_'), + getSearchBattleJsonSubquery(knexRef, [], ts).as('display_battle'), + ) + if (pokemonIds.length) { + select.push( + getSearchBattleJsonSubquery(knexRef, pokemonIds, ts).as( + 'matched_battle', + ), + ) + } + } else { + select.push(...getStationSelect(STATION_SEARCH_BATTLE_FIELDS)) + } } const query = this.query() .select(select) - .whereBetween('lat', [bbox.minLat, bbox.maxLat]) - .andWhereBetween('lon', [bbox.minLon, bbox.maxLon]) + .whereBetween(getStationColumn('lat'), [bbox.minLat, bbox.maxLat]) + .andWhereBetween(getStationColumn('lon'), [bbox.minLon, bbox.maxLon]) .andWhere( - 'updated', + getStationColumn('updated'), '>', Date.now() / 1000 - stationUpdateLimit * 60 * 60, ) - .andWhere('end_time', '>', ts) + .andWhere(getStationColumn('end_time'), '>', ts) .andWhere((builder) => { if (perms.stations) { - builder.orWhereILike('name', `%${search}%`) + builder.orWhereILike(getStationColumn('name'), `%${trimmedSearch}%`) } if (perms.dynamax) { - builder.orWhere((builder2) => { - builder2 - .whereIn('battle_pokemon_id', pokemonIds) - .andWhere('battle_end', '>', ts) - }) + if (hasMultiBattles) { + builder.orWhere((battleMatch) => { + battleMatch.orWhereExists( + knexRef + .select(1) + .from(STATION_BATTLE_FILTER_TABLE) + .whereRaw( + `${STATION_BATTLE_FILTER_ALIAS}.station_id = station.id`, + ) + .whereIn( + `${STATION_BATTLE_FILTER_ALIAS}.battle_pokemon_id`, + pokemonIds, + ) + .andWhere( + `${STATION_BATTLE_FILTER_ALIAS}.battle_end`, + '>', + ts, + ), + ) + battleMatch.orWhere((legacyBattle) => { + legacyBattle + .whereIn(getStationColumn('battle_pokemon_id'), pokemonIds) + .andWhere(getStationColumn('battle_end'), '>', ts) + }) + }) + } else { + builder.orWhere((builder2) => { + builder2 + .whereIn(getStationColumn('battle_pokemon_id'), pokemonIds) + .andWhere(getStationColumn('battle_end'), '>', ts) + }) + } } }) .limit(searchResultsLimit) @@ -454,7 +1359,59 @@ class Station extends Model { if (!getAreaSql(query, areaRestrictions, onlyAreas, isMad)) { return [] } - return query + const rows = await query + if (!(perms.dynamax && hasMultiBattles)) { + return rows + } + return rows.map((row) => { + const matchedBattle = + typeof row.matched_battle === 'string' + ? JSON.parse(row.matched_battle) + : row.matched_battle + const searchBattle = + typeof row.display_battle === 'string' + ? JSON.parse(row.display_battle) + : row.display_battle + const legacyBattle = getAliasedStationBattle(row, 'station', ts, null) + const matchedLegacyBattle = + pokemonIds.length && + legacyBattle && + pokemonIds.includes(legacyBattle.battle_pokemon_id) + ? legacyBattle + : null + let matchedDisplayBattle = matchedBattle || matchedLegacyBattle || null + if (matchedBattle && matchedLegacyBattle) { + const timingComparison = compareStationBattleTiming( + matchedBattle, + matchedLegacyBattle, + ts, + ) + if (timingComparison > 0) { + matchedDisplayBattle = matchedLegacyBattle + } else if ( + timingComparison === 0 && + canReuseStationBattleDetails(matchedBattle, matchedLegacyBattle) + ) { + matchedDisplayBattle = + getStationBattleCompleteness(matchedBattle) >= + getStationBattleCompleteness(matchedLegacyBattle) + ? matchedBattle + : matchedLegacyBattle + } + } + const displayBattle = + matchedDisplayBattle || + getPreferredStationBattle([legacyBattle, searchBattle], ts) + STATION_SEARCH_BATTLE_FIELDS.forEach((field) => { + row[field] = displayBattle + ? (displayBattle[field] ?? null) + : (row[`station_${field}`] ?? null) + delete row[`station_${field}`] + }) + delete row.display_battle + delete row.matched_battle + return row + }) } } diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index e55ebe5f3..51292a49d 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -9,6 +9,24 @@ const { Logger, TAGS } = require('@rm/logger') const { getBboxFromCenter } = require('../utils/getBbox') const { getCache } = require('./cache') +const STATION_BATTLE_REQUIRED_COLUMNS = [ + 'station_id', + 'battle_level', + 'battle_start', + 'battle_end', + 'battle_pokemon_id', + 'battle_pokemon_form', + 'battle_pokemon_costume', + 'battle_pokemon_gender', + 'battle_pokemon_alignment', + 'battle_pokemon_bread_mode', + 'battle_pokemon_move_1', + 'battle_pokemon_move_2', + 'battle_pokemon_stamina', + 'battle_pokemon_cp_multiplier', + 'updated', +] + /** * @type {import("@rm/types").DbManagerClass} */ @@ -158,7 +176,17 @@ class DbManager extends Logger { 'showcase_pokemon_form_id' in columns, 'showcase_pokemon_type_id' in columns, ]) - const stationColumns = await schema('station').columnInfo() + const [stationColumns, stationBattleColumns] = await Promise.all([ + schema('station').columnInfo(), + schema('station_battle') + .columnInfo() + .catch(() => null), + ]) + const hasMultiBattles = stationBattleColumns + ? STATION_BATTLE_REQUIRED_COLUMNS.every( + (column) => column in stationBattleColumns, + ) + : false const hasStationedGmax = 'total_stationed_gmax' in stationColumns const hasBattlePokemonStats = 'battle_pokemon_stamina' in stationColumns && @@ -218,6 +246,7 @@ class DbManager extends Logger { hasShowcaseData, hasShowcaseForm, hasShowcaseType, + hasMultiBattles, hasStationedGmax, hasBattlePokemonStats, hasShortcode, diff --git a/src/features/drawer/Stations.jsx b/src/features/drawer/Stations.jsx index de1c4f5eb..dcf61f975 100644 --- a/src/features/drawer/Stations.jsx +++ b/src/features/drawer/Stations.jsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next' import { useDeepStore, useStorage } from '@store/useStorage' import { useGetAvailable } from '@hooks/useGetAvailable' import { FCSelect } from '@components/inputs/FCSelect' +import { BoolToggle } from '@components/inputs/BoolToggle' import { CollapsibleItem } from './components/CollapsibleItem' import { SelectorListMemo } from './components/SelectorList' @@ -84,10 +85,27 @@ function StationsQuickSelect() { ) } +function IncludeUpcomingToggle() { + const enabled = useStorage( + (s) => + !!s.filters?.stations?.maxBattles && !s.filters?.stations?.allStations, + ) + + return ( + + + + ) +} + export function StationsDrawer() { return ( <> + ) diff --git a/src/features/search/OptionImage.jsx b/src/features/search/OptionImage.jsx index 6a3aea1ae..acb852f9a 100644 --- a/src/features/search/OptionImage.jsx +++ b/src/features/search/OptionImage.jsx @@ -206,7 +206,4 @@ function OptionImage(props) { return } -export const OptionImageMemo = React.memo( - OptionImage, - (prev, next) => prev.id === next.id, -) +export const OptionImageMemo = React.memo(OptionImage) diff --git a/src/features/search/renderOption.jsx b/src/features/search/renderOption.jsx index 73be9346b..eca815d2a 100644 --- a/src/features/search/renderOption.jsx +++ b/src/features/search/renderOption.jsx @@ -92,8 +92,16 @@ const InvasionSubtitle = ({ ) } -const Timer = ({ expireTime }) => { - const time = useRelativeTimer(expireTime || 0) +const Timer = ({ expireTime, startTime = 0 }) => { + const [now, setNow] = React.useState(() => Math.floor(Date.now() / 1000)) + React.useEffect(() => { + const interval = setInterval(() => { + setNow(Math.floor(Date.now() / 1000)) + }, 1000) + return () => clearInterval(interval) + }, []) + const target = startTime > now ? startTime : expireTime + const time = useRelativeTimer(target || 0) return time } @@ -170,7 +178,10 @@ export const renderOption = ({ key, ...props }, option) => { ) : searchTab === 'pokemon' ? ( ) : searchTab === 'stations' ? ( - + ) : ( '' ) diff --git a/src/features/station/StationPopup.jsx b/src/features/station/StationPopup.jsx index 5b13d60ed..7ace279c3 100644 --- a/src/features/station/StationPopup.jsx +++ b/src/features/station/StationPopup.jsx @@ -16,6 +16,7 @@ import MenuItem from '@mui/material/MenuItem' import Typography from '@mui/material/Typography' import Stack from '@mui/material/Stack' import Box from '@mui/material/Box' +import Divider from '@mui/material/Divider' import { useMemory } from '@store/useMemory' import { setDeepStore, useGetDeepStore, useStorage } from '@store/useStorage' @@ -36,42 +37,151 @@ import { } from '@components/inputs/ExpandCollapse' import { VirtualGrid } from '@components/virtual/VirtualGrid' import { getStationDamageBoost } from '@utils/getAttackBonus' -import { getTimeUntil } from '@utils/getTimeUntil' import { getFormDisplay } from '@utils/getFormDisplay' import { CopyCoords } from '@components/popups/Coords' import Tooltip from '@mui/material/Tooltip' import { usePokemonBackgroundVisuals } from '@hooks/usePokemonBackgroundVisuals' +import { getStationBattleKey, getStationBattleState } from './battleState' import { useGetStationMons } from './useGetStationMons' +/** + * @param {import('@rm/types').AllFilters['stations'] | undefined} filters + */ +function getDisplayedBattleFilters(filters) { + if (!filters?.maxBattles || filters?.allStations) { + return null + } + + const onlyBattleTier = filters.battleTier ?? 'all' + const battleLevels = new Set() + const battleCombos = new Map() + + if (onlyBattleTier === 'all') { + Object.entries(filters.filter || {}).forEach(([key, value]) => { + if (!value?.enabled) return + if (key.startsWith('j')) { + const parsedLevel = Number(key.slice(1)) + if (Number.isFinite(parsedLevel)) { + battleLevels.add(parsedLevel) + } + return + } + if (/^\d+-/.test(key)) { + const [idPart, formPart] = key.split('-', 2) + const pokemonId = Number(idPart) + if (!Number.isFinite(pokemonId)) return + let formValue = null + if (formPart && formPart !== 'null') { + const parsedForm = Number(formPart) + if (!Number.isFinite(parsedForm)) return + formValue = parsedForm + } + battleCombos.set(`${pokemonId}-${formValue ?? 'null'}`, { + pokemonId, + form: formValue, + }) + } + }) + } + + if ( + onlyBattleTier === 'all' && + battleLevels.size === 0 && + battleCombos.size === 0 + ) { + return null + } + + return { + onlyBattleTier, + battleLevels: [...battleLevels], + battleCombos: [...battleCombos.values()], + } +} + +/** + * @param {import('@rm/types').StationBattle | null | undefined} battle + * @param {ReturnType} filters + */ +function matchesDisplayedBattleFilter(battle, filters) { + if (!filters) return true + if (filters.onlyBattleTier !== 'all') { + return Number(battle?.battle_level) === Number(filters.onlyBattleTier) + } + const battleLevel = Number(battle?.battle_level) + const pokemonId = Number(battle?.battle_pokemon_id) + const pokemonForm = + battle?.battle_pokemon_form === null + ? null + : Number(battle?.battle_pokemon_form) + + return ( + filters.battleLevels.includes(battleLevel) || + filters.battleCombos.some( + ({ pokemonId: filterPokemonId, form }) => + pokemonId === filterPokemonId && pokemonForm === form, + ) + ) +} + /** @param {import('@rm/types').Station} station */ export function StationPopup(station) { useAnalytics('Popup', 'Station') + const now = Date.now() / 1000 + const stationFilters = useStorage((s) => s.filters?.stations) + const battleState = getStationBattleState(station, now, { + includeUpcoming: stationFilters?.includeUpcoming ?? true, + }) + const visibleBattleKey = battleState.visibleBattle + ? getStationBattleKey(battleState.visibleBattle) + : '' + const displayedBattleFilters = React.useMemo( + () => getDisplayedBattleFilters(stationFilters), + [stationFilters], + ) + const displayedPopupBattles = React.useMemo( + () => + battleState.popupBattles.filter( + (battle) => + getStationBattleKey(battle) === visibleBattleKey || + matchesDisplayedBattleFilter(battle, displayedBattleFilters), + ), + [battleState.popupBattles, displayedBattleFilters, visibleBattleKey], + ) + const hasVisibleBattle = !!battleState.visibleBattle + const menuBattle = + displayedPopupBattles[0] || + battleState.visibleBattle || + battleState.popupBattles[0] || + null return ( - + - - {station.battle_start < Date.now() / 1000 && - station.battle_end > Date.now() / 1000 && - !!station.total_stationed_pokemon && ( - - - - - - - - )} + + {hasVisibleBattle && !!station.total_stationed_pokemon && ( + + + + + + + + )}