From 3d2e8934338eb5f1537f022cca32defe5007d7e4 Mon Sep 17 00:00:00 2001 From: Mygod Date: Sat, 4 Apr 2026 20:11:33 -0400 Subject: [PATCH 01/44] feat: multi-battle for stations --- config/custom-environment-variables.json | 6 +- config/default.json | 1 + packages/locales/lib/human/en.json | 1 + packages/types/lib/scanner.d.ts | 18 + packages/types/lib/server.d.ts | 1 + server/src/filters/builder/base.js | 3 + server/src/graphql/typeDefs/scanner.graphql | 18 + server/src/models/Station.js | 591 ++++++++++++++++---- server/src/services/DbManager.js | 28 +- server/src/ui/drawer.js | 1 + src/features/drawer/Stations.jsx | 18 + src/features/station/StationPopup.jsx | 126 ++++- src/features/station/StationTile.jsx | 41 +- src/features/station/battleState.js | 173 ++++++ src/features/station/useStationMarker.js | 30 +- src/services/queries/station.js | 16 + 16 files changed, 898 insertions(+), 174 deletions(-) create mode 100644 src/features/station/battleState.js 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..fac1d37f6 100644 --- a/packages/types/lib/scanner.d.ts +++ b/packages/types/lib/scanner.d.ts @@ -379,6 +379,23 @@ export interface StationPokemon extends PokemonDisplay { bread_mode: number } +export interface StationBattle { + battle_level: number + battle_start?: number + battle_end?: 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 +429,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/scanner.graphql b/server/src/graphql/typeDefs/scanner.graphql index bc7ae2695..cdff3131c 100644 --- a/server/src/graphql/typeDefs/scanner.graphql +++ b/server/src/graphql/typeDefs/scanner.graphql @@ -319,6 +319,24 @@ 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 + 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..d344dcdea 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,26 @@ 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_ROW_TABLE = `station_battle as ${STATION_BATTLE_ROW_ALIAS}` +const STATION_BATTLE_FILTER_TABLE = `station_battle as ${STATION_BATTLE_FILTER_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', +] /** * @param {import('ohbem').PokemonData | null} pokemonData @@ -54,6 +74,206 @@ 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 {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, + 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, + 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 {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').FullStation} station + * @param {import('ohbem').PokemonData | null} pokemonData + */ +function finalizeStation(station, pokemonData) { + 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 +289,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 +299,7 @@ class Station extends Model { onlyAllStations, onlyMaxBattles, onlyBattleTier, + onlyIncludeUpcoming = true, onlyGmaxStationed, onlyInactiveStations, } = args.filters @@ -120,7 +341,7 @@ class Station extends Model { } const ts = getEpoch() - const baseSelect = [ + const baseSelect = getStationSelect([ 'id', 'name', 'lat', @@ -128,13 +349,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,6 +365,8 @@ class Station extends Model { } const select = [...baseSelect] + const includeBattleData = + perms.dynamax && (onlyMaxBattles || onlyGmaxStationed) const query = this.query() applyManualIdFilter(query, manualFilterOptions) @@ -151,27 +374,48 @@ class Station extends Model { const activeCutoff = now - stationUpdateLimit * 60 * 60 const inactiveCutoff = now - stationInactiveLimitDays * 24 * 60 * 60 - 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]), + ) + }) } } @@ -193,35 +437,36 @@ class Station extends Model { 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 (!matchApplied) { - match.whereRaw('0 = 1') - } - }) + if (hasMultiBattles) { + battle.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}.`, + { + ts, + includeUpcoming: !!onlyIncludeUpcoming, + onlyBattleTier, + battleLevels, + battleCombos, + }, + ), + ), + ) } else { - battle.andWhere('battle_level', onlyBattleTier) + addBattleFilterClause(battle, `${STATION_TABLE}.`, { + ts, + includeUpcoming: !!onlyIncludeUpcoming, + onlyBattleTier, + battleLevels, + battleCombos, + }) } }) applied = true @@ -231,17 +476,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 +501,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 +535,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 + 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,32 +564,55 @@ class Station extends Model { } } - return stations.map((station) => { - if (station.is_battle_available && station.battle_pokemon_id === null) { - station.is_battle_available = false + if (!includeBattleData || !hasMultiBattles) { + return stationRows.map((station) => { + if (includeBattleData) { + const fallbackBattle = getFallbackStationBattle( + station, + ts, + pokemonData, + ) + station.battles = fallbackBattle ? [fallbackBattle] : [] + } + return finalizeStation(station, pokemonData) + }) + } + + /** @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.total_stationed_pokemon === null) { - station.total_stationed_pokemon = 0 + const battle = getAliasedStationBattle( + row, + STATION_BATTLE_ROW_ALIAS, + ts, + pokemonData, + ) + if (battle) { + station.battles.push(battle) } - 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 + }) + + return [...grouped.values()].map((station) => { + if (!station.battles.length) { + const fallbackBattle = getFallbackStationBattle( + station, + ts, + pokemonData, + ) + if (fallbackBattle) { + station.battles = [fallbackBattle] + } } - station.battle_pokemon_estimated_cp = pokemonData - ? estimateStationCp(pokemonData, station) - : null - return station + return finalizeStation(station, pokemonData) }) } @@ -361,21 +644,77 @@ 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 results = hasMultiBattles + ? await this.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]), + ) + }) + .distinct([ + raw( + `COALESCE(${STATION_BATTLE_ROW_ALIAS}.battle_pokemon_id, station.battle_pokemon_id) AS battle_pokemon_id`, + ), + raw( + `COALESCE(${STATION_BATTLE_ROW_ALIAS}.battle_pokemon_form, station.battle_pokemon_form) AS battle_pokemon_form`, + ), + raw( + `COALESCE(${STATION_BATTLE_ROW_ALIAS}.battle_level, station.battle_level) AS battle_level`, + ), + ]) + .where(getStationColumn('is_inactive'), false) + .andWhere( + raw( + `COALESCE(${STATION_BATTLE_ROW_ALIAS}.battle_end, station.battle_end) > ?`, + [ts], + ), + ) + .andWhere( + getStationColumn('updated'), + '>', + Date.now() / 1000 - stationUpdateLimit * 60 * 60, + ) + .groupBy([ + raw( + `COALESCE(${STATION_BATTLE_ROW_ALIAS}.battle_pokemon_id, station.battle_pokemon_id)`, + ), + raw( + `COALESCE(${STATION_BATTLE_ROW_ALIAS}.battle_pokemon_form, station.battle_pokemon_form)`, + ), + raw( + `COALESCE(${STATION_BATTLE_ROW_ALIAS}.battle_level, station.battle_level)`, + ), + ]) + .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'), + '>', + Date.now() / 1000 - stationUpdateLimit * 60 * 60, + ) + .groupBy([ + getStationColumn('battle_pokemon_id'), + getStationColumn('battle_pokemon_form'), + getStationColumn('battle_level'), + ]) + .orderBy('battle_pokemon_id', 'asc') return { available: [ ...new Set( @@ -399,7 +738,7 @@ 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') @@ -413,40 +752,58 @@ class Station extends Model { .includes(search), ) - 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', + ...getStationSelect([ + 'battle_level', + 'battle_pokemon_id', + 'battle_pokemon_form', + 'battle_pokemon_costume', + 'battle_pokemon_gender', + 'battle_pokemon_alignment', + 'battle_pokemon_bread_mode', + 'battle_end', + ]), ) } 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'), `%${search}%`) } if (perms.dynamax) { - builder.orWhere((builder2) => { - builder2 - .whereIn('battle_pokemon_id', pokemonIds) - .andWhere('battle_end', '>', ts) - }) + if (hasMultiBattles) { + builder.orWhereExists( + this.knex() + .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), + ) + } else { + builder.orWhere((builder2) => { + builder2 + .whereIn(getStationColumn('battle_pokemon_id'), pokemonIds) + .andWhere(getStationColumn('battle_end'), '>', ts) + }) + } } }) .limit(searchResultsLimit) diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index e55ebe5f3..a6ec0bfb5 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -9,6 +9,23 @@ 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', +] + /** * @type {import("@rm/types").DbManagerClass} */ @@ -158,7 +175,15 @@ class DbManager extends Logger { 'showcase_pokemon_form_id' in columns, 'showcase_pokemon_type_id' in columns, ]) - const stationColumns = await schema('station').columnInfo() + const [stationColumns, hasMultiBattles] = await Promise.all([ + schema('station').columnInfo(), + schema('station_battle') + .columnInfo() + .then((columns) => + STATION_BATTLE_REQUIRED_COLUMNS.every((column) => column in columns), + ) + .catch(() => false), + ]) const hasStationedGmax = 'total_stationed_gmax' in stationColumns const hasBattlePokemonStats = 'battle_pokemon_stamina' in stationColumns && @@ -218,6 +243,7 @@ class DbManager extends Logger { hasShowcaseData, hasShowcaseForm, hasShowcaseType, + hasMultiBattles, hasStationedGmax, hasBattlePokemonStats, hasShortcode, diff --git a/server/src/ui/drawer.js b/server/src/ui/drawer.js index a9491f44c..e64a71ebd 100644 --- a/server/src/ui/drawer.js +++ b/server/src/ui/drawer.js @@ -74,6 +74,7 @@ function drawer(req, perms) { ? { allStations: perms.stations || BLOCKED, maxBattles: perms.dynamax || BLOCKED, + includeUpcoming: perms.dynamax || BLOCKED, gmaxStationed: perms.dynamax || BLOCKED, inactiveStations: perms.stations || BLOCKED, } 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/station/StationPopup.jsx b/src/features/station/StationPopup.jsx index 5b13d60ed..d21146bd2 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' @@ -42,11 +43,17 @@ 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').Station} station */ export function StationPopup(station) { useAnalytics('Popup', 'Station') + const now = Date.now() / 1000 + const battleState = getStationBattleState(station, now) + const hasVisibleBattle = !!battleState.visibleBattle + const hasStationMons = + hasVisibleBattle && !!battleState.visibleBattle?.battle_pokemon_id return ( @@ -54,9 +61,14 @@ export function StationPopup(station) { - - {station.battle_start < Date.now() / 1000 && - station.battle_end > Date.now() / 1000 && + + {hasVisibleBattle && + hasStationMons && !!station.total_stationed_pokemon && ( @@ -224,8 +236,48 @@ function StationMenu({ ) } -/** @param {import('@rm/types').Station} props */ -function StationMedia({ +/** + * @param {{ + * popupBattles: import('@rm/types').StationBattle[] + * visibleBattle: import('@rm/types').StationBattle | null + * end_time?: number + * is_battle_available: boolean + * }} props + */ +function StationBattles({ + popupBattles, + visibleBattle, + end_time, + is_battle_available, +}) { + if (!popupBattles.length) { + return null + } + + return popupBattles.map((battle, index) => ( + + {!!index && } + + )) +} + +/** + * @param {import('@rm/types').StationBattle & { + * end_time?: number + * hidden?: boolean + * is_battle_available: boolean + * }} props + */ +function StationBattleSection({ battle_pokemon_id, battle_pokemon_form, battle_pokemon_alignment, @@ -236,6 +288,7 @@ function StationMedia({ battle_end, battle_start, end_time, + hidden = false, is_battle_available, battle_pokemon_stamina, battle_pokemon_cp_multiplier, @@ -280,6 +333,7 @@ function StationMedia({ : null const cpLabel = t('cp') const hpLabel = t('hp') + const textColor = hidden ? 'GrayText' : 'inherit' const pokemonFormLabel = getFormDisplay( battle_pokemon_id, battle_pokemon_form, @@ -297,7 +351,7 @@ function StationMedia({ } } const cpTypography = ( - + {cpLabel} {estimatedCpDisplay} ) @@ -319,7 +373,7 @@ function StationMedia({ ) } else if (cpMultiplierDisplay) { cpLine = ( - + {t('station_battle_cp_multiplier', { value: cpMultiplierDisplay })} ) @@ -333,9 +387,13 @@ function StationMedia({ const countdownContent = showBattleCountdown ? ( - +