diff --git a/.eslintignore b/.eslintignore index 561aecdcf..5e910a6c6 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,3 +6,4 @@ public/images/custom public/images/uicons server/src/configs/ packages/**/*.d.ts +AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index 463d55544..2e11049da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,40 +1,17 @@ -# Repository Guidelines +Repository guidelines for agents: Project structure and module organization - React client code lives in `src/`, with shared UI in `src/components`, feature bundles in `src/features`, and hooks/services under `src/hooks`, `src/services`, and `src/utils`. The Node.js API, GraphQL schema, and auth logic are in `server/src`; configs and migrations sit under `server/src/configs` and `server/src/db`. Workspace packages in `packages/` expose shared config (`@rm/config`), localization, logging, masterfile data, and build plugins; treat them as the source of truth for cross-app utilities. Static assets are sourced from `public/`, while built bundles land in `dist/`; avoid committing build artefacts or anything listed in `.gitignore`. -## Project Structure & Module Organization +Build, test, and development commands - `yarn install` installs workspace dependencies; rerun after pulling lockfile changes. `yarn dev` starts the full dev stack (Nodemon backend + Vite) using local config. `yarn watch` provides Vite-only hot reload for rapid UI work when the API is proxied elsewhere. `yarn build` creates a production bundle in `dist/`; ensure it succeeds before release PRs. `yarn lint` / `yarn lint:fix` run ESLint with the Airbnb ruleset; lint must pass pre-commit. `yarn prettier` / `yarn prettier:fix` enforce formatting for JS/JSX, CSS, HTML, and YAML. `yarn config:env` and `yarn locales:generate` regenerate env files and derived locales after editing base config or strings. -- React client code lives in `src/`, with shared UI in `src/components`, feature bundles in `src/features`, and hooks/services under `src/hooks`, `src/services`, and `src/utils`. -- The Node.js API, GraphQL schema, and auth logic are in `server/src`; configs and migrations sit under `server/src/configs` and `server/src/db`. -- Workspace packages in `packages/` expose shared config (`@rm/config`), localization, logging, masterfile data, and build plugins; treat them as the source of truth for cross-app utilities. -- Static assets are sourced from `public/`, while built bundles land in `dist/`; avoid committing build artefacts or anything listed in `.gitignore`. +Coding style and naming conventions - Prettier governs formatting (2-space indent, single quotes in JS, semicolons off); never hand-format conflicting styles. Prefer functional React components, PascalCase for components, camelCase for helpers, and `use` prefixes for hooks. -## Build, Test, and Development Commands +Spacing discipline - DO NOT introduce or adjust margins, padding, gaps, line-height, or other spacing-related styles anywhere in the repo without explicit written permission from the user. -- `yarn install` – install workspace dependencies; rerun after pulling lockfile changes. -- `yarn dev` – start the full dev stack (Nodemon backend + Vite) using local config. -- `yarn watch` – Vite-only hot reload for rapid UI work when the API is proxied elsewhere. -- `yarn build` – create a production bundle in `dist/`; ensure it succeeds before release PRs. -- `yarn lint` / `yarn lint:fix` – run ESLint with the Airbnb ruleset; lint must pass pre-commit. -- `yarn prettier` / `yarn prettier:fix` – enforce formatting for JS/JSX, CSS, HTML, and YAML. -- `yarn config:env` and `yarn locales:generate` – regenerate env files and derived locales after editing base config or strings. +Testing guidelines - No dedicated Jest suite today; rely on `yarn lint`, type checks from editor tooling, and manual verification in a local dev session. When adding backend features, exercise relevant GraphQL/REST paths via the dev server and document sanity checks in the PR description. -## Coding Style & Naming Conventions +Commit and pull request guidelines - Use Conventional Commits (`type(scope): summary`), matching existing history (e.g. `feat(map): add weather overlays`). Each PR should describe scope, link related issues, list testing steps, and include screenshots or GIFs for UI changes. Re-run `yarn lint`, `yarn build`, and integration steps touched by the change before requesting review. -- Prettier governs formatting (2-space indent, single quotes in JS, semicolons off); never hand-format conflicting styles. -- Prefer functional React components, PascalCase for components, camelCase for helpers, and `use` prefixes for hooks. +Localization notes - Update English copy only in `packages/locales/lib/human/en.json`; run `yarn locales:generate` to refresh derived languages. When adding a new translation key (for example when calling `t('some_key')`), create the English entry in `packages/locales/lib/human/en.json` in the same change. NEVER use fallback strings. Never edit generated locale files directly - the automation pipeline syncs translations downstream. -## Testing Guidelines +Naming and plumbing for new filters - For example, `station` is the internal terminology and “Power Spots” is the player-facing label. When adding a toggle like inactive visibility, check (and update only if needed) every touch point with the same internal key: `config/default.json`, drawer permissions, hooks such as `usePermCheck`, and the server model. -- No dedicated Jest suite today; rely on `yarn lint`, type checks from editor tooling, and manual verification in a local dev session. -- When adding backend features, exercise relevant GraphQL/REST paths via the dev server and document sanity checks in the PR description. - -## Commit & Pull Request Guidelines - -- Use Conventional Commits (`type(scope): summary`), matching existing history (e.g. `feat(map): add weather overlays`). -- Each PR should describe scope, link related issues, list testing steps, and include screenshots or GIFs for UI changes. -- Re-run `yarn lint`, `yarn build`, and integration steps touched by the change before requesting review. - -## Localization Notes - -- Update English copy only in `packages/locales/lib/human/en.json`; run `yarn locales:generate` to refresh derived languages. -- When adding a new translation key (for example when calling `t('some_key')`), create the English entry in `packages/locales/lib/human/en.json` in the same change. NEVER use fallback strings. -- Never edit generated locale files directly—the automation pipeline syncs translations downstream. +Model queries - Avoid unnecessary second queries. Try to bunch all queries into a single SQL query whenever possible. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fa72d813..35fe48c44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,276 @@ +# [1.40.0-develop.34](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.33...v1.40.0-develop.34) (2025-11-23) + + +### Bug Fixes + +* refine gym defender time ([30106c2](https://github.com/WatWowMap/ReactMap/commit/30106c28290c0b9048b089752fab437f593ee6f2)) + +# [1.40.0-develop.33](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.32...v1.40.0-develop.33) (2025-11-23) + + +### Features + +* show caught Pokemon in raid popup ([95eda89](https://github.com/WatWowMap/ReactMap/commit/95eda897e98fd77b67d1db204cf8c4f303a10564)) + +# [1.40.0-develop.32](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.31...v1.40.0-develop.32) (2025-11-23) + + +### Features + +* gym defender updates every second ([e99d020](https://github.com/WatWowMap/ReactMap/commit/e99d02090dcbe1dad3cab3fd066f4231bc35603a)) + +# [1.40.0-develop.31](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.30...v1.40.0-develop.31) (2025-11-23) + + +### Bug Fixes + +* fix AdvAccordion backgroundColor ([4061ee2](https://github.com/WatWowMap/ReactMap/commit/4061ee23c4376098ebc1c5e3efaea1a97e1d50a3)) + +# [1.40.0-develop.30](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.29...v1.40.0-develop.30) (2025-11-23) + + +### Features + +* (quest) pokemon background on map ([0676dd4](https://github.com/WatWowMap/ReactMap/commit/0676dd48270e3d50fd6be89e417a2479f7993ba4)) + +# [1.40.0-develop.29](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.28...v1.40.0-develop.29) (2025-11-20) + + +### Bug Fixes + +* resetState respects server default ([e85f1e4](https://github.com/WatWowMap/ReactMap/commit/e85f1e413d93b91c3bbf009026c7c2e67bd3a4b7)) + +# [1.40.0-develop.28](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.27...v1.40.0-develop.28) (2025-11-20) + + +### Bug Fixes + +* alias support for cooldown ([73e8157](https://github.com/WatWowMap/ReactMap/commit/73e815794f630c5b9297bf0833b95b6c7ac12f59)) + + +### Features + +* cooldown bypass ([#1137](https://github.com/WatWowMap/ReactMap/issues/1137)) ([9ceefef](https://github.com/WatWowMap/ReactMap/commit/9ceefef1b1a64eb49b40b179f3450e1e5b2c1ff0)) + +# [1.40.0-develop.27](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.26...v1.40.0-develop.27) (2025-11-20) + + +### Bug Fixes + +* more robust translation fetching ([eaa2acf](https://github.com/WatWowMap/ReactMap/commit/eaa2acf712a5ec8dfad224aadcb18002a520e79c)) + +# [1.40.0-develop.26](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.25...v1.40.0-develop.26) (2025-11-20) + + +### Bug Fixes + +* extra space around placed pokemon ([898e1e2](https://github.com/WatWowMap/ReactMap/commit/898e1e2b936d02787f1fa2260780b28dd6cd31d3)) + + +### Features + +* reworked placed pokemon in station popup ([252f327](https://github.com/WatWowMap/ReactMap/commit/252f32700a080f1ebe59fb916d044f74fdc8a9f4)) + +# [1.40.0-develop.25](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.24...v1.40.0-develop.25) (2025-11-20) + + +### Bug Fixes + +* default icon/audio selection ([248edda](https://github.com/WatWowMap/ReactMap/commit/248eddaf8049f3df22960630b455cbc9df43cc98)) +* improve touch tooltip experience ([945c24a](https://github.com/WatWowMap/ReactMap/commit/945c24af97d772a93ac7a7a603a67559f30e7b57)) + +# [1.40.0-develop.24](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.23...v1.40.0-develop.24) (2025-11-13) + + +### Bug Fixes + +* seen raid level ([6c543c3](https://github.com/WatWowMap/ReactMap/commit/6c543c3cbf20b9ac58c431de81848c644f774209)) +* unknown_costume translatable ([3f4bb57](https://github.com/WatWowMap/ReactMap/commit/3f4bb5794d19755ac3f94e9d4dc0c4471cfbca57)) + +# [1.40.0-develop.23](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.22...v1.40.0-develop.23) (2025-11-07) + + +### Features + +* better nearby pokemon display and randomization ([425f840](https://github.com/WatWowMap/ReactMap/commit/425f8401c3f49000169d68ad20860bd2db1f8d7b)) + +# [1.40.0-develop.22](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.21...v1.40.0-develop.22) (2025-11-02) + + +### Bug Fixes + +* eggs available ([48580d1](https://github.com/WatWowMap/ReactMap/commit/48580d1ab9e6dfe1233974d25542e810703dc115)) + +# [1.40.0-develop.21](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.20...v1.40.0-develop.21) (2025-10-30) + + +### Bug Fixes + +* dup gym pokemon ([d099945](https://github.com/WatWowMap/ReactMap/commit/d099945d55c55a3e5c51a8f507507fc6ba6f273b)) +* expert filter per-Pokemon ([f06fae8](https://github.com/WatWowMap/ReactMap/commit/f06fae8155fb3b1c2d2f4bc745bb57d0811f4822)) +* isolate pokemon popup open state ([dc5fdc0](https://github.com/WatWowMap/ReactMap/commit/dc5fdc0d0c71a53e6862865d6d84ffbcee9730db)) + +# [1.40.0-develop.20](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.19...v1.40.0-develop.20) (2025-10-30) + + +### Features + +* background support in popups ([#1152](https://github.com/WatWowMap/ReactMap/issues/1152)) ([3354ef7](https://github.com/WatWowMap/ReactMap/commit/3354ef798e7aef95d0fc8feb5e077e5d9ebf7cf1)) + +# [1.40.0-develop.19](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.18...v1.40.0-develop.19) (2025-10-26) + + +### Features + +* search auto focus ([29f1cef](https://github.com/WatWowMap/ReactMap/commit/29f1cefd59203a197b719a5b61f12ae99f0e8099)) + +# [1.40.0-develop.18](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.17...v1.40.0-develop.18) (2025-10-25) + + +### Bug Fixes + +* apply to all should flip global ([5c23140](https://github.com/WatWowMap/ReactMap/commit/5c23140b0809221b86854cab76c1ae529c7d6a98)) +* backup error handling ([c6585e6](https://github.com/WatWowMap/ReactMap/commit/c6585e64ee9bdbb7b0317ed60937651e1ca5f0f9)) +* dup keys ([91f1f58](https://github.com/WatWowMap/ReactMap/commit/91f1f58dfd3e40ee4689fe49724db6b43f6a6c30)) + +# [1.40.0-develop.17](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.16...v1.40.0-develop.17) (2025-10-17) + + +### Features + +* jump to area ([#1149](https://github.com/WatWowMap/ReactMap/issues/1149)) ([179ca24](https://github.com/WatWowMap/ReactMap/commit/179ca24e7230944b823041a71384143875e7d559)) + +# [1.40.0-develop.16](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.15...v1.40.0-develop.16) (2025-10-17) + + +### Bug Fixes + +* update for Golbat [#312](https://github.com/WatWowMap/ReactMap/issues/312) ([#1151](https://github.com/WatWowMap/ReactMap/issues/1151)) ([3bd05d4](https://github.com/WatWowMap/ReactMap/commit/3bd05d42fd629b6f4819011d02b2f8056b6c290d)) + +# [1.40.0-develop.15](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.14...v1.40.0-develop.15) (2025-10-16) + + +### Bug Fixes + +* showcase icon missing when invasion blocked ([12e6512](https://github.com/WatWowMap/ReactMap/commit/12e6512c9e9204524ecc98719e3bcea170de393c)) + +# [1.40.0-develop.14](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.13...v1.40.0-develop.14) (2025-10-16) + + +### Features + +* mega energy marker can be taller ([5dad220](https://github.com/WatWowMap/ReactMap/commit/5dad22056d171ee108b8e819ca6c193f01f1eb67)) +* more compact pokestop marker ([622ae3d](https://github.com/WatWowMap/ReactMap/commit/622ae3d184c6fffe0d3288f2fb07a52d3313c8b8)) +* support tappable seen_type ([c74c607](https://github.com/WatWowMap/ReactMap/commit/c74c607e8a3aa1fc80f867a3b5a21a4f0c63e9aa)) +* tappable from lure in popup ([69ce017](https://github.com/WatWowMap/ReactMap/commit/69ce0171f417ef4939eb4ca5539be4e40bd5bfa3)) + +# [1.40.0-develop.13](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.12...v1.40.0-develop.13) (2025-10-15) + + +### Features + +* display amount in quest tooltip ([5e57d50](https://github.com/WatWowMap/ReactMap/commit/5e57d502f85dfc43370aa4e2f6262fad3ee0c5af)) +* tappables ([#1143](https://github.com/WatWowMap/ReactMap/issues/1143)) ([943019f](https://github.com/WatWowMap/ReactMap/commit/943019f2f36c659b174cde327e1acbcfc1ad95d2)) + +# [1.40.0-develop.12](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.11...v1.40.0-develop.12) (2025-10-05) + + +### Bug Fixes + +* pvp50 enabled by default ([ad2df83](https://github.com/WatWowMap/ReactMap/commit/ad2df839bbce9ba8f269cf0f92408eeb28726201)) +* station display ([3d72e14](https://github.com/WatWowMap/ReactMap/commit/3d72e144835bae9659f842fbe5b0604bfd4e3256)) + +# [1.40.0-develop.11](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.10...v1.40.0-develop.11) (2025-10-05) + + +### Bug Fixes + +* refine station timer ([1353beb](https://github.com/WatWowMap/ReactMap/commit/1353beb65254b5f44a8c659803fe8d778fb8d788)) +* separate station/battle opacity ([ccdbcec](https://github.com/WatWowMap/ReactMap/commit/ccdbcec52f1b25410dcc3dc31ea56719fde734dd)) + +# [1.40.0-develop.10](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.9...v1.40.0-develop.10) (2025-10-04) + + +### Bug Fixes + +* route crash ([206f558](https://github.com/WatWowMap/ReactMap/commit/206f5581f39ecb86b05486ca3fe051036fbcfe76)) + + +### Features + +* estimated max battle cp ([275f6f4](https://github.com/WatWowMap/ReactMap/commit/275f6f4e2ff44de57de0c49fa1f18fc7da96a70b)) + +# [1.40.0-develop.9](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.8...v1.40.0-develop.9) (2025-10-03) + + +### Features + +* max battle stats display ([211e0ea](https://github.com/WatWowMap/ReactMap/commit/211e0ea35897e0ecf793c8d2bc4e2ba535505cb8)) +* quest bread mode ([b0fcf4f](https://github.com/WatWowMap/ReactMap/commit/b0fcf4fd2ea132a18058b1a3f40db07f31ade2b0)) + +# [1.40.0-develop.8](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.7...v1.40.0-develop.8) (2025-10-02) + + +### Bug Fixes + +* gym range color should dynamically update ([7f9f95b](https://github.com/WatWowMap/ReactMap/commit/7f9f95b6df1bddd8fe21ed49a61969bf404e82e4)) + +# [1.40.0-develop.7](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.6...v1.40.0-develop.7) (2025-10-02) + + +### Bug Fixes + +* battle popup caching issue ([8cac1ce](https://github.com/WatWowMap/ReactMap/commit/8cac1ce4914911680f1d03dbfb9a0bc663657166)) + +# [1.40.0-develop.6](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.5...v1.40.0-develop.6) (2025-10-01) + + +### Features + +* faster extraUserFields ([#1144](https://github.com/WatWowMap/ReactMap/issues/1144)) ([2216787](https://github.com/WatWowMap/ReactMap/commit/2216787b14a09e1297835bfa47c8ab746b89925e)) + +# [1.40.0-develop.5](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.4...v1.40.0-develop.5) (2025-10-01) + + +### Bug Fixes + +* always show end route count ([5226575](https://github.com/WatWowMap/ReactMap/commit/5226575ba463923ab32a3c74345f0585dda10072)) +* route badge color should be consistent with their roles ([0c32962](https://github.com/WatWowMap/ReactMap/commit/0c32962e75d252d0f83493fb4ae345fad991c647)) +* self loop route counting ([e415caa](https://github.com/WatWowMap/ReactMap/commit/e415caae0aaa615ffab8fb0274894caf0d2764d7)) + + +### Features + +* configurable inactive station limit ([f8efbcd](https://github.com/WatWowMap/ReactMap/commit/f8efbcdb6849e067d967b0e1ed75110c84f6dc93)) + +# [1.40.0-develop.4](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.3...v1.40.0-develop.4) (2025-09-29) + + +### Features + +* expired stations ([0521045](https://github.com/WatWowMap/ReactMap/commit/0521045f597aa755b3ec8b37b19022f9c280d765)) + +# [1.40.0-develop.3](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.2...v1.40.0-develop.3) (2025-09-26) + + +### Bug Fixes + +* better hoverability on shiny rate ([e29a68b](https://github.com/WatWowMap/ReactMap/commit/e29a68b70c5ad1b724ca36a6ab2322006b8ba0a6)) + +# [1.40.0-develop.2](https://github.com/WatWowMap/ReactMap/compare/v1.40.0-develop.1...v1.40.0-develop.2) (2025-09-25) + + +### Bug Fixes + +* stationed pokemon maxHeight ([5c46dbd](https://github.com/WatWowMap/ReactMap/commit/5c46dbde0f5cbda6bab6a33616676f38aea54c6a)), closes [#1078](https://github.com/WatWowMap/ReactMap/issues/1078) + +# [1.40.0-develop.1](https://github.com/WatWowMap/ReactMap/compare/v1.39.0...v1.40.0-develop.1) (2025-09-25) + + +### Features + +* persistent popups ([5cd5be0](https://github.com/WatWowMap/ReactMap/commit/5cd5be0371842e67473e3c7764ab8a7e0b92e153)), closes [#1135](https://github.com/WatWowMap/ReactMap/issues/1135) + # [1.39.0](https://github.com/WatWowMap/ReactMap/compare/v1.38.0...v1.39.0) (2025-09-24) diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index 197928b25..87597216e 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -208,6 +208,10 @@ "stations": { "__name": "API_QUERY_ON_SESSION_INIT_STATIONS", "__format": "boolean" + }, + "tappables": { + "__name": "API_QUERY_ON_SESSION_INIT_TAPPABLES", + "__format": "boolean" } }, "dataRequestLimits": { @@ -247,6 +251,10 @@ "stations": { "__name": "API_DATA_REQUEST_LIMITS_CATEGORIES_STATIONS", "__format": "number" + }, + "tappables": { + "__name": "API_DATA_REQUEST_LIMITS_CATEGORIES_TAPPABLES", + "__format": "number" } }, "time": { @@ -290,6 +298,10 @@ "stations": { "__name": "API_QUERY_LIMITS_STATIONS", "__format": "number" + }, + "tappables": { + "__name": "API_QUERY_LIMITS_TAPPABLES", + "__format": "number" } }, "pvp": { @@ -332,6 +344,10 @@ "__name": "API_STATION_UPDATE_LIMIT", "__format": "number" }, + "stationInactiveLimitDays": { + "__name": "API_STATION_INACTIVE_LIMIT_DAYS", + "__format": "number" + }, "searchResultsLimit": { "__name": "API_SEARCH_RESULTS_LIMIT", "__format": "number" @@ -532,6 +548,10 @@ "__name": "MAP_MISC_ENABLE_STATION_POPUP_COORDS_SELECTOR", "__format": "boolean" }, + "enableTappablePopupCoordsSelector": { + "__name": "MAP_MISC_ENABLE_TAPPABLE_POPUP_COORDS_SELECTOR", + "__format": "boolean" + }, "customFloatingIcons": { "__name": "MAP_MISC_CUSTOM_FLOATING_ICONS", "__format": "json" @@ -610,6 +630,16 @@ "__name": "MAP_CLUSTERING_STATIONS_FORCED_LIMIT", "__format": "number" } + }, + "tappables": { + "zoomLevel": { + "__name": "MAP_CLUSTERING_TAPPABLES_ZOOM_LEVEL", + "__format": "number" + }, + "forcedLimit": { + "__name": "MAP_CLUSTERING_TAPPABLES_FORCED_LIMIT", + "__format": "number" + } } }, "messageOfTheDay": { @@ -862,6 +892,44 @@ "__format": "number" } }, + "tappables": { + "clustering": { + "__name": "CLIENT_SIDE_OPTIONS_TAPPABLES_CLUSTERING", + "__format": "boolean" + }, + "tappableTimers": { + "__name": "CLIENT_SIDE_OPTIONS_TAPPABLES_TAPPABLE_TIMERS", + "__format": "boolean" + }, + "interactionRanges": { + "__name": "CLIENT_SIDE_OPTIONS_TAPPABLES_INTERACTION_RANGES", + "__format": "boolean" + }, + "spacialRendRange": { + "__name": "CLIENT_SIDE_OPTIONS_TAPPABLES_SPACIAL_REND_RANGE", + "__format": "boolean" + }, + "tappablesOpacity": { + "__name": "CLIENT_SIDE_OPTIONS_TAPPABLES_TAPPABLES_OPACITY", + "__format": "boolean" + }, + "enableTappablePopupCoords": { + "__name": "CLIENT_SIDE_OPTIONS_TAPPABLES_ENABLE_TAPPABLE_POPUP_COORDS", + "__format": "boolean" + }, + "opacityTenMinutes": { + "__name": "CLIENT_SIDE_OPTIONS_TAPPABLES_OPACITY_TEN_MINUTES", + "__format": "number" + }, + "opacityFiveMinutes": { + "__name": "CLIENT_SIDE_OPTIONS_TAPPABLES_OPACITY_FIVE_MINUTES", + "__format": "number" + }, + "opacityOneMinute": { + "__name": "CLIENT_SIDE_OPTIONS_TAPPABLES_OPACITY_ONE_MINUTE", + "__format": "number" + } + }, "pokemon": { "clustering": { "__name": "CLIENT_SIDE_OPTIONS_POKEMON_CLUSTERING", @@ -911,6 +979,10 @@ "__name": "CLIENT_SIDE_OPTIONS_POKEMON_SHOW_ALL_PVP_RANKS", "__format": "boolean" }, + "pvp50": { + "__name": "CLIENT_SIDE_OPTIONS_POKEMON_PVP50", + "__format": "boolean" + }, "linkGlobalAndAdvanced": { "__name": "CLIENT_SIDE_OPTIONS_POKEMON_LINK_GLOBAL_AND_ADVANCED", "__format": "boolean" @@ -1207,6 +1279,16 @@ } } }, + "tappables": { + "enabled": { + "__name": "DEFAULT_FILTERS_TAPPABLES_ENABLED", + "__format": "boolean" + }, + "items": { + "__name": "DEFAULT_FILTERS_TAPPABLES_ITEMS", + "__format": "boolean" + } + }, "pokemon": { "enabled": { "__name": "DEFAULT_FILTERS_POKEMON_ENABLED", @@ -1352,6 +1434,10 @@ "customRange": { "__name": "DEFAULT_FILTERS_STATIONS_CUSTOM_RANGE", "__format": "number" + }, + "inactiveStations": { + "__name": "DEFAULT_FILTERS_STATIONS_INACTIVE_STATIONS", + "__format": "boolean" } }, "s2cells": { @@ -1466,6 +1552,20 @@ "__name": "SCANNER_SCAN_NEXT_USER_COOLDOWN_SECONDS", "__format": "number" }, + "cooldownBypass": { + "discordRoles": { + "__name": "SCANNER_SCAN_NEXT_COOLDOWN_BYPASS_DISCORD_ROLES", + "__format": "json" + }, + "local": { + "__name": "SCANNER_SCAN_NEXT_COOLDOWN_BYPASS_LOCAL", + "__format": "json" + }, + "telegramGroups": { + "__name": "SCANNER_SCAN_NEXT_COOLDOWN_BYPASS_TELEGRAM_GROUPS", + "__format": "json" + } + }, "scanNextAreaRestriction": { "__name": "SCANNER_SCAN_NEXT_SCAN_NEXT_AREA_RESTRICTION", "__format": "json" @@ -1524,6 +1624,20 @@ "__name": "SCANNER_SCAN_ZONE_USER_COOLDOWN_SECONDS", "__format": "number" }, + "cooldownBypass": { + "discordRoles": { + "__name": "SCANNER_SCAN_ZONE_COOLDOWN_BYPASS_DISCORD_ROLES", + "__format": "json" + }, + "local": { + "__name": "SCANNER_SCAN_ZONE_COOLDOWN_BYPASS_LOCAL", + "__format": "json" + }, + "telegramGroups": { + "__name": "SCANNER_SCAN_ZONE_COOLDOWN_BYPASS_TELEGRAM_GROUPS", + "__format": "json" + } + }, "advancedScanZoneOptions": { "__name": "SCANNER_SCAN_ZONE_ADVANCED_SCAN_ZONE_OPTIONS", "__format": "boolean" @@ -1717,6 +1831,20 @@ "__format": "json" } }, + "tappables": { + "enabled": { + "__name": "AUTHENTICATION_PERMS_TAPPABLES_ENABLED", + "__format": "boolean" + }, + "trialPeriodEligible": { + "__name": "AUTHENTICATION_PERMS_TAPPABLES_TRIAL_PERIOD_ELIGIBLE", + "__format": "boolean" + }, + "roles": { + "__name": "AUTHENTICATION_PERMS_TAPPABLES_ROLES", + "__format": "json" + } + }, "lures": { "enabled": { "__name": "AUTHENTICATION_PERMS_LURES_ENABLED", @@ -2225,6 +2353,24 @@ "__name": "ICONS_SIZES_DYNAMAX_XL", "__format": "number" } + }, + "tappable": { + "sm": { + "__name": "ICONS_SIZES_TAPPABLE_SM", + "__format": "number" + }, + "md": { + "__name": "ICONS_SIZES_TAPPABLE_MD", + "__format": "number" + }, + "lg": { + "__name": "ICONS_SIZES_TAPPABLE_LG", + "__format": "number" + }, + "xl": { + "__name": "ICONS_SIZES_TAPPABLE_XL", + "__format": "number" + } } } }, diff --git a/config/default.json b/config/default.json index c1f2b8d8d..db377b97f 100644 --- a/config/default.json +++ b/config/default.json @@ -64,7 +64,8 @@ "quests": false, "raids": true, "nests": false, - "stations": false + "stations": false, + "tappables": false }, "dataRequestLimits": { "categories": { @@ -76,7 +77,8 @@ "portals": 0, "routes": 0, "weather": 0, - "stations": 0 + "stations": 0, + "tappables": 0 }, "time": 60 }, @@ -89,7 +91,8 @@ "spawnpoints": 10000, "nests": 2500, "scanCells": 5000, - "stations": 5000 + "stations": 5000, + "tappables": 5000 }, "pvp": { "leagues": [ @@ -117,6 +120,7 @@ "portalUpdateLimit": 30, "weatherCellLimit": 3, "stationUpdateLimit": 9, + "stationInactiveLimitDays": 120, "searchResultsLimit": 15, "searchSoftKmLimit": 10, "searchHardKmLimit": 100, @@ -157,6 +161,7 @@ "gyms", "nests", "pokestops", + "tappables", "stations", "pokemon", "routes", @@ -265,6 +270,7 @@ "enablePokestopPopupCoordsSelector": false, "enablePortalPopupCoordsSelector": false, "enableStationPopupCoordsSelector": false, + "enableTappablePopupCoordsSelector": false, "customFloatingIcons": [], "expandAllScanAreas": false, "enableRouteDownload": false @@ -298,6 +304,10 @@ "stations": { "zoomLevel": 14, "forcedLimit": 2500 + }, + "tappables": { + "zoomLevel": 14, + "forcedLimit": 2500 } }, "messageOfTheDay": { @@ -388,6 +398,17 @@ "opacityFiveMinutes": 0.5, "opacityOneMinute": 0.25 }, + "tappables": { + "clustering": true, + "tappableTimers": false, + "interactionRanges": false, + "spacialRendRange": false, + "tappablesOpacity": true, + "enableTappablePopupCoords": false, + "opacityTenMinutes": 0.75, + "opacityFiveMinutes": 0.5, + "opacityOneMinute": 0.25 + }, "pokemon": { "clustering": true, "pokemonTimers": false, @@ -401,6 +422,7 @@ "showDexNumInPopup": false, "pvpMega": false, "showAllPvpRanks": false, + "pvp50": true, "linkGlobalAndAdvanced": false, "showSizeIndicator": false, "enablePokemonPopupCoords": false, @@ -535,6 +557,10 @@ "enabled": true } }, + "tappables": { + "enabled": false, + "items": true + }, "pokemon": { "enabled": false, "easyMode": true, @@ -584,7 +610,8 @@ "battles": false, "gmaxStationed": false, "interactionRanges": false, - "customRange": 0 + "customRange": 0, + "inactiveStations": false }, "s2cells": { "enabled": false, @@ -632,6 +659,11 @@ "scanNextDevice": "Device01", "scanNextSleeptime": 5, "userCooldownSeconds": 0, + "cooldownBypass": { + "discordRoles": [], + "local": [], + "telegramGroups": [] + }, "scanNextAreaRestriction": [], "discordRoles": [], "local": [], @@ -648,6 +680,11 @@ "gmf": false, "scanZoneMaxSize": 10, "userCooldownSeconds": 0, + "cooldownBypass": { + "discordRoles": [], + "local": [], + "telegramGroups": [] + }, "advancedScanZoneOptions": false, "scanZoneRadius": { "pokemon": 70, @@ -788,6 +825,11 @@ "trialPeriodEligible": false, "roles": [] }, + "tappables": { + "enabled": true, + "trialPeriodEligible": false, + "roles": [] + }, "lures": { "enabled": true, "trialPeriodEligible": false, @@ -1015,6 +1057,12 @@ "md": 25, "lg": 35, "xl": 45 + }, + "tappable": { + "sm": 15, + "md": 25, + "lg": 35, + "xl": 45 } } }, diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 000000000..14940242f --- /dev/null +++ b/nodemon.json @@ -0,0 +1,11 @@ +{ + "signal": "SIGTERM", + "watch": ["server/src/**/*.js", "config/default.json", "packages/**/*"], + "ignore": [ + "server/src/configs/koji_backups/**", + "server/src/models/queries/**", + "packages/masterfile/lib/data/*", + "logs", + "server/.cache/**" + ] +} diff --git a/package.json b/package.json index 017fea3f2..ef8acd2c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reactmap", - "version": "1.39.0", + "version": "1.40.0-develop.34", "private": true, "description": "React based frontend map.", "license": "MIT", @@ -152,6 +152,7 @@ "leaflet-arrowheads": "^1.4.0", "leaflet.locatecontrol": "0.81.0", "lodash": "^4.17.21", + "long": "^4.0.0", "moment-timezone": "^0.5.43", "mysql2": "3.11.0", "node-cache": "^5.1.2", @@ -174,7 +175,7 @@ "source-map": "^0.7.4", "suncalc": "^1.9.0", "supercluster": "^8.0.1", - "uicons.js": "2.0.3", + "uicons.js": "2.2.0", "zustand": "4.4.6" }, "devDependencies": { @@ -209,7 +210,7 @@ "rollup-plugin-delete": "^2.0.0", "semantic-release": "^22", "typescript": "5.5.4", - "vite": "^6.3.6", + "vite": "^6.4.1", "vite-plugin-checker": "0.7.2" }, "resolutions": { diff --git a/packages/config/.configref b/packages/config/.configref index 60f84aac5..ad8102533 100644 --- a/packages/config/.configref +++ b/packages/config/.configref @@ -1 +1 @@ -25552 \ No newline at end of file +26702 \ No newline at end of file diff --git a/packages/config/lib/mutations.js b/packages/config/lib/mutations.js index baa4fdc56..aad7c2c08 100644 --- a/packages/config/lib/mutations.js +++ b/packages/config/lib/mutations.js @@ -282,14 +282,22 @@ const applyMutations = (config) => { Array.isArray(role) ? role.flatMap((r) => aliasObj[r] ?? r) : (aliasObj[role] ?? role) + const replaceRoleArray = (roles) => + Array.isArray(roles) ? roles.flatMap(replaceAliases) : undefined + const replaceCooldownAliases = (cooldownBypass = {}) => ({ + ...cooldownBypass, + discordRoles: replaceRoleArray(cooldownBypass.discordRoles), + telegramGroups: replaceRoleArray(cooldownBypass.telegramGroups), + local: replaceRoleArray(cooldownBypass.local), + }) const replaceBothAliases = (incomingObj) => ({ ...incomingObj, - discordRoles: Array.isArray(incomingObj.discordRoles) - ? incomingObj.discordRoles.flatMap(replaceAliases) - : undefined, - telegramGroups: Array.isArray(incomingObj.telegramGroups) - ? incomingObj.telegramGroups.flatMap(replaceAliases) + discordRoles: replaceRoleArray(incomingObj.discordRoles), + telegramGroups: replaceRoleArray(incomingObj.telegramGroups), + local: replaceRoleArray(incomingObj.local), + cooldownBypass: incomingObj.cooldownBypass + ? replaceCooldownAliases(incomingObj.cooldownBypass) : undefined, }) diff --git a/packages/locales/lib/create.js b/packages/locales/lib/create.js index 773ea0ab6..adec2bbaa 100644 --- a/packages/locales/lib/create.js +++ b/packages/locales/lib/create.js @@ -19,16 +19,33 @@ async function create() { const endpoint = config.getSafe('api.pogoApiEndpoints.translations') if (!endpoint) { - log.error(TAGS.locales, 'No translations endpoint') - return + log.error( + TAGS.locales, + 'No translations endpoint configured, using local translations only', + ) } const localTranslations = readLocaleDirectory(true) const englishRef = await readAndParseJson('en.json', true) - const availableRemote = await fetch(`${endpoint}/index.json`) - .then((res) => res.json()) - .then((res) => new Set(res)) + /** @type {Set | null} */ + let availableRemote = null + if (endpoint) { + try { + const response = await fetch(`${endpoint}/index.json`) + if (!response.ok) { + throw new Error(`GET ${endpoint}/index.json -> ${response.status}`) + } + const remoteIndex = await response.json() + availableRemote = new Set(remoteIndex) + } catch (e) { + log.warn( + TAGS.locales, + 'Unable to fetch remote translation index, using local translations only', + e, + ) + } + } const translated = await Promise.allSettled( localTranslations.map(async (fileName) => { @@ -37,7 +54,8 @@ async function create() { const humanLocales = await readAndParseJson(fileName, true) const aiLocales = await readAndParseJson(fileName, false) - if (!availableRemote.has(fileName)) { + const hasRemote = availableRemote?.has(fileName) + if (availableRemote && !hasRemote) { log.warn( TAGS.locales, 'No remote translation found for', @@ -46,10 +64,10 @@ async function create() { ) } - const trimmedRemoteFiles = await fetchRemote( - availableRemote.has(fileName) ? locale : 'en', - endpoint, - ) + const trimmedRemoteFiles = + endpoint && availableRemote + ? await fetchRemote(hasRemote ? locale : 'en', endpoint) + : {} /** @type {Record} */ const finalTranslations = { diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index c2e936cda..b99cb961e 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -65,6 +65,7 @@ "pokemon": "Pokémon", "wayfarer": "Wayfarer", "scan_areas": "Scan Areas", + "jump_to_areas_attribution": "Search powered by OpenStreetMap", "s2cells": "S2 Cells", "weather": "Weather", "admin": "Admin", @@ -127,6 +128,8 @@ "feedback_form": "Feedback Form", "unknown_pokestop": "Unknown PokéStop Name", "unknown_gym": "Unknown Gym Name", + "unknown_background_with_id": "Unknown Background #{{id}}", + "unknown_costume": "Unknown Costume", "exclude": "Exclude", "exclude_quest": "Exclude Quest", "exclude_invasion": "Exclude Invasion", @@ -149,6 +152,7 @@ "total_cp": "Total CP", "first_seen": "First Seen", "last_seen": "Last Seen", + "last_active": "Last active: {{time}}", "last_modified": "Last Modified", "last_updated": "Last Updated", "imported": "Imported", @@ -602,7 +606,10 @@ "seen_nearby_cell": "Seen in this Cell", "seen_lure_encounter": "Lure Encounter", "seen_lure_wild": "Lure Spawn", + "seen_tappable_encounter": "Tappable Encounter", + "seen_tappable_lure_encounter": "Tappable Lure Encounter", "seen_wild": "Wild Spawn", + "tappable_origin_from": "from", "event_stops": "Event Stops", "event_stop_timers": "Event Stop Timers", "size_0": "Any", @@ -620,6 +627,8 @@ "load": "Load", "update": "Update", "delete": "Delete", + "backup_error_generic": "Unable to create backup. Please try again.", + "backup_error_too_large": "Backup is too large to upload. Reduce its size and retry.", "name": "Name", "only_confirmed": "Only Confirmed", "only_exclude_grunts": "Exclude Grunts", @@ -804,17 +813,24 @@ "exclude_battle": "Exclude Max Battle", "station": "Power Spot", "stations": "Power Spots", + "tappables": "Tappables", + "tappable_type_breakfast": "Zygarde Cell", + "tappable_type_hat": "Party Hat", + "tappable_type_maple": "Apple", + "tappable_type_pokeball": "Poké Ball", "stations_filters": "Power Spots Filter Settings", "stations_options": "Power Spot Options", "all_stations": "All Power Spots", "gmax_stationed": "Gigantamax Placed", "search_battles": "Search Max Battles", + "search_tappables": "Search Tappables", "started": "Started", "ended": "Ended", "search_stations": "Search Power Spots", "global_search_stations": "Enter Power Spot Name or Dynamax Pokémon...", "station_timers": "Power Spot Timers", "stations_opacity": "Dynamic Power Spot Opacity", + "inactive_stations": "Inactive Power Spots", "max_battles": "Max Battles", "dynamax": "Dynamax", "stations_subtitle": "Displays Power Spots on the map", @@ -823,8 +839,11 @@ "placed_pokemon": "Placed Pokémon", "attack_bonus": "Attack Bonus", "battle_bonus": "Battle Bonus", + "station_battle_cp_multiplier": "CP Multiplier: {{value}}", + "station_battle_cp_tooltip_extra": ". Additional hidden multipliers might be present for 5+ star battles", "copy_coordinates": "Copy Coordinates", "enable_station_popup_coords": "Show Power Spot Coords", + "hp": "HP", "station_icons": "Power Spot Icons", "dynamic_opacity": "Dynamic Opacity", "tooltips": "Tooltips", @@ -832,6 +851,7 @@ "filters": "Filters", "active": "Active", "inactive": "Inactive", + "inactive_since": "Inactive since {{time}}", "bread_time_window": "You can take on Max Battles between 6AM and 9PM.", "rsvp_0": "no rsvp", "rsvp_1": "rsvp", diff --git a/packages/locales/lib/utils.js b/packages/locales/lib/utils.js index 34e55cbe0..271861d89 100644 --- a/packages/locales/lib/utils.js +++ b/packages/locales/lib/utils.js @@ -17,9 +17,12 @@ const AI_LOCALES = resolve(__dirname, './generated') */ async function fetchRemote(locale, endpoint) { try { - const remoteFiles = await fetch( - `${endpoint}/static/locales/${locale}.json`, - ).then((res) => res.json()) + const remoteEndpoint = `${endpoint}/static/locales/${locale}.json` + const response = await fetch(remoteEndpoint) + if (!response.ok) { + throw new Error(`GET ${remoteEndpoint} -> ${response.status}`) + } + const remoteFiles = await response.json() return Object.fromEntries( Object.entries(remoteFiles) @@ -36,6 +39,7 @@ async function fetchRemote(locale, endpoint) { ) } catch (e) { log.error(TAGS.locales, `[${locale}]`, e) + return {} } } diff --git a/packages/masterfile/lib/index.d.ts b/packages/masterfile/lib/index.d.ts index 6614cabb1..6eebef850 100644 --- a/packages/masterfile/lib/index.d.ts +++ b/packages/masterfile/lib/index.d.ts @@ -68,6 +68,14 @@ export interface MasterfileWeather { types: number[] } +export interface MasterfileLocationCard { + proto: string + formatted: string + cardType?: string + imageUrl?: string + vfxAddress?: string +} + export interface Masterfile { pokemon: Record types: MasterfileObject @@ -78,6 +86,8 @@ export interface Masterfile { weather: Record teams: (typeof mfjson)['teams'] raids: (typeof mfjson)['raids'] + routeTypes: (typeof mfjson)['routeTypes'] + locationCards: Record } export declare function generate( diff --git a/packages/types/lib/config.d.ts b/packages/types/lib/config.d.ts index 3c17ecf67..4c098138e 100644 --- a/packages/types/lib/config.d.ts +++ b/packages/types/lib/config.d.ts @@ -136,11 +136,21 @@ export type Config = DeepMerge< discordRoles: string[] telegramGroups: string[] local: string[] + cooldownBypass: { + discordRoles: string[] + telegramGroups: string[] + local: string[] + } } scanZone: { discordRoles: string[] telegramGroups: string[] local: string[] + cooldownBypass: { + discordRoles: string[] + telegramGroups: string[] + local: string[] + } } } icons: Icons diff --git a/packages/types/lib/scanner.d.ts b/packages/types/lib/scanner.d.ts index 685a9343c..003c7c9aa 100644 --- a/packages/types/lib/scanner.d.ts +++ b/packages/types/lib/scanner.d.ts @@ -10,6 +10,7 @@ import SpawnpointModel = require('server/src/models/Spawnpoint') import WeatherModel = require('server/src/models/Weather') import RouteModel = require('server/src/models/Route') import StationModel = require('server/src/models/Station') +import TappableModel = require('server/src/models/Tappable') import { S2Polygon } from './general' @@ -38,7 +39,7 @@ export interface PokemonDisplay { temp_evolution_finish_ms?: number alignment: number badge: number - location_card: number + background: number } export interface PokemonShinyStats { @@ -139,6 +140,8 @@ export interface Quest { quest_form_id: number quest_gender_id: Gender quest_costume_id: number + quest_background: number + quest_bread_mode: number quest_shiny: number quest_shiny_probability?: number mega_pokemon_id: number @@ -241,6 +244,7 @@ export interface Pokemon { def_iv: number sta_iv: number weather: number + background?: number capture_1: number capture_2: number capture_3: number @@ -274,6 +278,21 @@ export interface Portal { export type FullPortal = FullModel +export interface Tappable { + id: string + lat: number + lon: number + type: string + fort_id: string | null + item_id: number + count: number | null + expire_timestamp: number | null + expire_timestamp_verified: boolean + updated: number +} + +export type FullTappable = FullModel + export interface ScanCell { id?: string level?: number @@ -385,6 +404,9 @@ export interface Station { 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 total_stationed_pokemon: number total_stationed_gmax: number diff --git a/packages/types/lib/server.d.ts b/packages/types/lib/server.d.ts index e4211fdd9..8d5b544ee 100644 --- a/packages/types/lib/server.d.ts +++ b/packages/types/lib/server.d.ts @@ -5,6 +5,7 @@ import type { RmModelKeys, ModelKeys, Station, + Tappable, Backup, Nest, NestSubmission, @@ -46,6 +47,8 @@ export interface DbContext { hasShowcaseForm: boolean hasShowcaseType: boolean hasStationedGmax: boolean + hasBattlePokemonStats: boolean + hasPokemonBackground: boolean hasPokemonShinyStats?: boolean connection?: number } @@ -70,6 +73,7 @@ export interface Available { pokestops: ModelReturn nests: ModelReturn stations: ModelReturn + tappables: ModelReturn } export interface ApiEndpoint { @@ -150,6 +154,7 @@ type BasePerms = { [K in keyof Config['authentication']['perms']]: boolean } export interface Permissions extends BasePerms { blockedGuildNames: string[] scanner: string[] + scannerCooldownBypass: string[] areaRestrictions: string[] webhooks: string[] trial: boolean @@ -204,6 +209,7 @@ export type AdvCategories = | 'pokestops' | 'nests' | 'stations' + | 'tappables' export type UIObject = ReturnType< (typeof import('server/src/ui/drawer'))['drawer'] diff --git a/packages/vite-plugins/package.json b/packages/vite-plugins/package.json index 5be7a51cd..81a87e051 100644 --- a/packages/vite-plugins/package.json +++ b/packages/vite-plugins/package.json @@ -16,6 +16,6 @@ "@rm/logger": "*" }, "devDependencies": { - "vite": "^6.3.6" + "vite": "^6.4.1" } } diff --git a/server/src/filters/builder/base.js b/server/src/filters/builder/base.js index a800ba163..e5c865a8b 100644 --- a/server/src/filters/builder/base.js +++ b/server/src/filters/builder/base.js @@ -5,6 +5,7 @@ const { state } = require('../../services/state') const { buildPokemon } = require('./pokemon') const { buildPokestops } = require('./pokestop') const { buildGyms } = require('./gym') +const { buildTappables } = require('./tappable') const { BaseFilter } = require('../Base') const { PokemonFilter } = require('../pokemon/Frontend') @@ -111,6 +112,14 @@ function buildDefaultFilters(perms) { }, } : undefined, + tappables: + perms.tappables && state.db.models.Tappable + ? { + enabled: defaultFilters.tappables.enabled, + standard: new BaseFilter(), + filter: buildTappables(perms, defaultFilters.tappables), + } + : undefined, stations: stationReducer && state.db.models.Station ? { @@ -129,6 +138,9 @@ function buildDefaultFilters(perms) { gmaxStationed: perms.dynamax ? defaultFilters.stations.gmaxStationed : undefined, + inactiveStations: perms.stations + ? defaultFilters.stations.inactiveStations + : undefined, } : undefined, pokemon: diff --git a/server/src/filters/builder/tappable.js b/server/src/filters/builder/tappable.js new file mode 100644 index 000000000..be7780d90 --- /dev/null +++ b/server/src/filters/builder/tappable.js @@ -0,0 +1,29 @@ +// @ts-check +const { state } = require('../../services/state') +const { BaseFilter } = require('../Base') + +/** + * @param {import('@rm/types').Permissions} perms + * @param {import('@rm/types').Config['defaultFilters']['tappables']} defaults + * @returns {Record} + */ +function buildTappables(perms, defaults) { + const filters = { q0: new BaseFilter() } + if (!perms.tappables) { + return filters + } + + Object.keys(state.event.masterfile.items).forEach((itemId) => { + filters[`q${itemId}`] = new BaseFilter(defaults.items) + }) + + state.event.getAvailable('tappables').forEach((key) => { + if (!filters[key]) { + filters[key] = new BaseFilter(defaults.items) + } + }) + + return filters +} + +module.exports = { buildTappables } diff --git a/server/src/filters/pokemon/Backend.js b/server/src/filters/pokemon/Backend.js index eb8d1e7c9..6c11ef166 100644 --- a/server/src/filters/pokemon/Backend.js +++ b/server/src/filters/pokemon/Backend.js @@ -276,10 +276,13 @@ class PkmnBackend { xxl, ...rest } = this.filter + if (pokemon === undefined && this.id !== 'global') { + pokemon = [{ id: this.pokemon, form: this.form }] + } + if (this.mods.onlyLegacy) { + return dnfifyIvFilter(adv, pokemon) + } if (this.id !== 'global') { - if (pokemon === undefined) { - pokemon = [{ id: this.pokemon, form: this.form }] - } if (!this.filterKeys.size || (!this.perms.iv && !this.perms.pvp)) { return [{ pokemon, iv: { min: -1, max: 100 } }] } @@ -287,9 +290,6 @@ class PkmnBackend { return [] } } - if (this.mods.onlyLegacy) { - return dnfifyIvFilter(adv, pokemon) - } const results = /** @type {import('@rm/types').DnfFilter[]} */ ([]) if ( this.perms.iv && @@ -432,6 +432,7 @@ class PkmnBackend { display_pokemon_id: pokemon.display_pokemon_id, seen_type: pokemon.seen_type, changed: !!pokemon.changed, + background: pokemon.background, } if (!result.seen_type) { if (result.spawn_id === null) { diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index a0b4705bf..cf27d5fd8 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -92,6 +92,8 @@ const resolvers = { }), availableStations: (_, _args, { Event, perms }) => perms?.dynamax ? Event.available.stations : [], + availableTappables: (_, _args, { Event, perms }) => + perms?.tappables ? Event.available.tappables : [], backup: (_, args, { req, perms, Db }) => { if (perms?.backups && req?.user?.id) { return Db.models.Backup.getOne(args.id, req?.user?.id) @@ -403,30 +405,38 @@ const resolvers = { }, scannerConfig: (_, { mode }, { perms }) => { const scanner = config.getSafe('scanner') + const modeConfig = scanner[mode] - if (perms.scanner?.includes(mode) && scanner[mode].enabled) { - return mode === 'scanZone' - ? { - scannerType: scanner.backendConfig.platform, - showScanCount: scanner.scanZone.showScanCount, - showScanQueue: scanner.scanZone.showScanQueue, - advancedOptions: scanner.scanZone.advancedScanZoneOptions, - pokemonRadius: scanner.scanZone.scanZoneRadius.pokemon, - gymRadius: scanner.scanZone.scanZoneRadius.gym, - spacing: scanner.scanZone.scanZoneSpacing, - maxSize: scanner.scanZone.scanZoneMaxSize, - cooldown: scanner.scanZone.userCooldownSeconds, - refreshQueue: scanner.backendConfig.queueRefreshInterval, - enabled: scanner[mode].enabled, - } - : { - scannerType: scanner.backendConfig.platform, - showScanCount: scanner.scanNext.showScanCount, - showScanQueue: scanner.scanNext.showScanQueue, - cooldown: scanner.scanNext.userCooldownSeconds, - refreshQueue: scanner.backendConfig.queueRefreshInterval, - enabled: scanner[mode].enabled, - } + if (perms.scanner?.includes(mode) && modeConfig?.enabled) { + const bypassCooldown = perms.scannerCooldownBypass?.includes(mode) + const cooldownSeconds = bypassCooldown + ? 0 + : modeConfig.userCooldownSeconds + + if (mode === 'scanZone') { + return { + scannerType: scanner.backendConfig.platform, + showScanCount: scanner.scanZone.showScanCount, + showScanQueue: scanner.scanZone.showScanQueue, + advancedOptions: scanner.scanZone.advancedScanZoneOptions, + pokemonRadius: scanner.scanZone.scanZoneRadius.pokemon, + gymRadius: scanner.scanZone.scanZoneRadius.gym, + spacing: scanner.scanZone.scanZoneSpacing, + maxSize: scanner.scanZone.scanZoneMaxSize, + cooldown: cooldownSeconds, + refreshQueue: scanner.backendConfig.queueRefreshInterval, + enabled: modeConfig.enabled, + } + } + + return { + scannerType: scanner.backendConfig.platform, + showScanCount: scanner.scanNext.showScanCount, + showScanQueue: scanner.scanNext.showScanQueue, + cooldown: cooldownSeconds, + refreshQueue: scanner.backendConfig.queueRefreshInterval, + enabled: modeConfig.enabled, + } } return null }, @@ -526,6 +536,19 @@ const resolvers = { } return [] }, + tappables: (_, args, { perms, Db }) => { + if (perms?.tappables) { + return Db.query('Tappable', 'getAll', perms, args) + } + return [] + }, + tappableById: async (_, { id }, { perms, Db }) => { + if (perms?.tappables) { + const results = await Db.query('Tappable', 'getById', perms, id) + return Array.isArray(results) ? results[0] || null : results + } + return null + }, submissionCells: async (_, args, { req, perms, Db }) => { const { submissionZoom } = config.getMapConfig(req).general if (perms?.submissionCells && args.zoom >= submissionZoom - 1) { @@ -639,18 +662,24 @@ const resolvers = { if (category === 'getQueue') { return scannerApi(category, method, data, req?.user) } + const bypassCooldown = perms?.scannerCooldownBypass?.includes(category) + const cooldownExpired = + !req.session.cooldown || req.session.cooldown < Date.now() + if ( perms?.scanner?.includes(category) && - (!req.session.cooldown || req.session.cooldown < Date.now()) + (bypassCooldown || cooldownExpired) ) { const validCoords = getValidCoords(category, data?.scanCoords, perms) - const cooldown = - config.getSafe(`scanner.${category}.userCooldownSeconds`) * - validCoords.filter(Boolean).length * - 1000 + - Date.now() - req.session.cooldown = cooldown + if (!bypassCooldown) { + const cooldown = + config.getSafe(`scanner.${category}.userCooldownSeconds`) * + validCoords.filter(Boolean).length * + 1000 + + Date.now() + req.session.cooldown = cooldown + } return scannerApi( category, method, diff --git a/server/src/graphql/typeDefs/index.graphql b/server/src/graphql/typeDefs/index.graphql index e2bd1aaa2..ab7208943 100644 --- a/server/src/graphql/typeDefs/index.graphql +++ b/server/src/graphql/typeDefs/index.graphql @@ -7,6 +7,7 @@ type Query { availableGyms: [String] availableNests: [String] availableStations: [String] + availableTappables: [String] badges: [Badge] backup(id: ID): Backup backups: [Backup] @@ -67,6 +68,14 @@ type Query { filters: JSON ): [Station] stationPokemon(id: ID): [StationPokemon] + tappables( + minLat: Float + maxLat: Float + minLon: Float + maxLon: Float + filters: JSON + ): [Tappable] + tappableById(id: ID!): Tappable s2cells( minLat: Float maxLat: Float diff --git a/server/src/graphql/typeDefs/map.graphql b/server/src/graphql/typeDefs/map.graphql index 4621b1c95..99c0cda73 100644 --- a/server/src/graphql/typeDefs/map.graphql +++ b/server/src/graphql/typeDefs/map.graphql @@ -123,6 +123,8 @@ type SearchQuest { quest_form_id: Int quest_gender_id: Int quest_costume_id: Int + quest_background: Int + quest_bread_mode: Int quest_item_id: Int quest_reward_type: Int quest_shiny: Int diff --git a/server/src/graphql/typeDefs/scanner.graphql b/server/src/graphql/typeDefs/scanner.graphql index b28474aef..4296affec 100644 --- a/server/src/graphql/typeDefs/scanner.graphql +++ b/server/src/graphql/typeDefs/scanner.graphql @@ -83,6 +83,8 @@ type Quest { quest_form_id: Int quest_gender_id: Int quest_costume_id: Int + quest_background: Int + quest_bread_mode: Int quest_shiny: Int quest_shiny_probability: Float mega_pokemon_id: Int @@ -177,6 +179,7 @@ type Pokemon { def_iv: Int sta_iv: Int weather: Int + background: Int capture_1: Float capture_2: Float capture_3: Float @@ -201,6 +204,19 @@ type Portal { updated: Int } +type Tappable { + id: ID + lat: Float + lon: Float + type: String + fort_id: String + item_id: Int + count: Int + expire_timestamp: Int + expire_timestamp_verified: Boolean + updated: Int +} + type ScanCell { id: ID level: Int @@ -297,6 +313,9 @@ type Station { 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/middleware/apollo.js b/server/src/middleware/apollo.js index 5e9ac9431..301e84c37 100644 --- a/server/src/middleware/apollo.js +++ b/server/src/middleware/apollo.js @@ -44,7 +44,10 @@ function apolloMiddleware(server) { endpoint: userDataLimit.category, } - if (clientV && serverV && clientV !== serverV) { + // Allow the hot-reload dev client to bypass strict version matching + const isDevClient = clientV === 'development' + + if (clientV && serverV && clientV !== serverV && !isDevClient) { throw new GraphQLError('old_client', { extensions: { ...errorCtx, diff --git a/server/src/models/Gym.js b/server/src/models/Gym.js index ffb49a834..d749b5428 100644 --- a/server/src/models/Gym.js +++ b/server/src/models/Gym.js @@ -9,6 +9,8 @@ const config = require('@rm/config') const { getAreaSql } = require('../utils/getAreaSql') const { state } = require('../services/state') +const { applyManualIdFilter } = require('../utils/manualFilter') + const coreFields = [ 'id', 'name', @@ -83,6 +85,7 @@ class Gym extends Model { onlyGymBadges, onlyBadge, onlyAreas = [], + onlyManualId, } = args.filters const ts = Math.floor(Date.now() / 1000) const query = this.query() @@ -130,9 +133,22 @@ class Gym extends Model { } else if (hideOldGyms) { query.where('updated', '>', ts - gymValidDataLimit * 86400) } - query - .whereBetween(isMad ? 'latitude' : 'lat', [args.minLat, args.maxLat]) - .andWhereBetween(isMad ? 'longitude' : 'lon', [args.minLon, args.maxLon]) + const latCol = isMad ? 'latitude' : 'lat' + const lonCol = isMad ? 'longitude' : 'lon' + const idCol = isMad ? 'gym.gym_id' : 'id' + + applyManualIdFilter(query, { + manualId: onlyManualId, + latColumn: latCol, + lonColumn: lonCol, + idColumn: idCol, + bounds: { + minLat: args.minLat, + maxLat: args.maxLat, + minLon: args.minLon, + maxLon: args.maxLon, + }, + }) Gym.onlyValid(query, isMad) const raidBosses = new Set() @@ -444,6 +460,7 @@ class Gym extends Model { isMad ? 'level' : 'raid_level', ]) .orderBy(isMad ? 'pokemon_id' : 'raid_pokemon_id', 'asc') + .orderBy(isMad ? 'level' : 'raid_level', 'asc') const teamResults = await this.query() .select([ 'team_id AS team', @@ -460,15 +477,28 @@ class Gym extends Model { }) return [...unique] }) + const seenBosses = new Set() + const seenEggLevels = new Set() + const seenRaidLevels = new Set() + results.forEach((result) => { + if (result.raid_pokemon_id) { + seenBosses.add(`${result.raid_pokemon_id}-${result.raid_pokemon_form}`) + } else { + seenEggLevels.add(result.raid_level) + } + seenRaidLevels.add(result.raid_level) + }) + return { available: [ ...teamResults, - ...results.flatMap((result) => { - if (result.raid_pokemon_id) { - return `${result.raid_pokemon_id}-${result.raid_pokemon_form}` - } - return [`e${result.raid_level}`, `r${result.raid_level}`] - }), + ...Array.from(seenBosses), + ...Array.from(seenEggLevels) + .sort((a, b) => Number(a) - Number(b)) + .map((level) => `e${level}`), + ...Array.from(seenRaidLevels) + .sort((a, b) => Number(a) - Number(b)) + .map((level) => `r${level}`), ], } } diff --git a/server/src/models/Nest.js b/server/src/models/Nest.js index 4a7020fe4..7bb9af8f2 100644 --- a/server/src/models/Nest.js +++ b/server/src/models/Nest.js @@ -5,6 +5,7 @@ const config = require('@rm/config') const { state } = require('../services/state') const { getAreaSql } = require('../utils/getAreaSql') +const { applyManualIdFilter } = require('../utils/manualFilter') /** @typedef {Nest & Partial} FullNest */ @@ -27,11 +28,14 @@ class Nest extends Model { static async getAll(perms, args, { polygon }) { const { areaRestrictions } = perms const { minLat, minLon, maxLat, maxLon, filters } = args - const query = this.query() - .select(['*', 'nest_id AS id']) - // .whereNotNull('pokemon_id') - .whereBetween('lat', [minLat, maxLat]) - .andWhereBetween('lon', [minLon, maxLon]) + const query = this.query().select(['*', 'nest_id AS id']) + applyManualIdFilter(query, { + manualId: filters.onlyManualId, + latColumn: 'lat', + lonColumn: 'lon', + idColumn: 'nest_id', + bounds: { minLat, maxLat, minLon, maxLon }, + }) const pokemon = [] if (filters.onlyPokemon) { diff --git a/server/src/models/Pokemon.js b/server/src/models/Pokemon.js index 55b427bde..9b7047247 100644 --- a/server/src/models/Pokemon.js +++ b/server/src/models/Pokemon.js @@ -14,6 +14,10 @@ const config = require('@rm/config') const { getAreaSql } = require('../utils/getAreaSql') const { filterRTree } = require('../utils/filterRTree') const { fetchJson } = require('../utils/fetchJson') +const { + applyManualIdFilter, + normalizeManualId, +} = require('../utils/manualFilter') const { IV_CALC, LEVEL_CALC, @@ -128,7 +132,16 @@ class Pokemon extends Model { static async getAll(perms, args, ctx) { const { iv: ivs, pvp, areaRestrictions } = perms const { onlyIvOr, onlyHundoIv, onlyZeroIv, onlyAreas = [] } = args.filters - const { hasSize, hasHeight, isMad, mem, secret, httpAuth, pvpV2 } = ctx + const { + hasSize, + hasHeight, + hasPokemonBackground, + isMad, + mem, + secret, + httpAuth, + pvpV2, + } = ctx const { filterMap, globalFilter } = this.getFilters(perms, args, ctx) let queryPvp = config @@ -148,6 +161,10 @@ class Pokemon extends Model { const query = this.query() + const manualIdFilter = normalizeManualId(args.filters.onlyManualId) + + let manualId = manualIdFilter + const pokemonIds = [] const pokemonForms = [] Object.values(filterMap).forEach((filter) => { @@ -167,77 +184,90 @@ class Pokemon extends Model { if (isMad) { Pokemon.getMadSql(query) } else { - query.select(['*', hasSize && !hasHeight ? 'size AS height' : 'size']) + const selectColumns = [ + '*', + hasSize && !hasHeight ? 'size AS height' : 'size', + ] + if (hasPokemonBackground) { + selectColumns.push('background') + } + query.select(selectColumns) } - query - .where( - isMad ? 'disappear_time' : 'expire_timestamp', - '>=', - isMad ? this.knex().fn.now() : ts, - ) - .andWhereBetween(isMad ? 'pokemon.latitude' : 'lat', [ - args.minLat, - args.maxLat, - ]) - .andWhereBetween(isMad ? 'pokemon.longitude' : 'lon', [ - args.minLon, - args.maxLon, - ]) - .andWhere((ivOr) => { - if (ivs || pvp) { - if (globalFilter.filterKeys.size) { - ivOr.andWhere((pkmn) => { - const keys = globalFilter.keyArray - for (let i = 0; i < keys.length; i += 1) { - const key = keys[i] - switch (key) { - case 'xxs': - case 'xxl': - if (hasSize) { - pkmn.orWhere('pokemon.size', key === 'xxl' ? 5 : 1) - } - break - case 'gender': - pkmn.andWhere('pokemon.gender', onlyIvOr[key]) - break - case 'cp': - case 'level': - case 'atk_iv': - case 'def_iv': - case 'sta_iv': - case 'iv': - if (perms.iv) { - pkmn.andWhereBetween( - isMad ? MAD_KEY_MAP[key] : key, - onlyIvOr[key], - ) - } - break - default: - if ( - perms.pvp && - BASE_KEYS.every((x) => !globalFilter.filterKeys.has(x)) - ) { - // doesn't return everything if only pvp stats for individual pokemon - pkmn.whereNull('pokemon_id') - } - break - } + query.where( + isMad ? 'disappear_time' : 'expire_timestamp', + '>=', + isMad ? this.knex().fn.now() : ts, + ) + manualId = applyManualIdFilter(query, { + manualId: manualIdFilter, + latColumn: isMad ? 'pokemon.latitude' : 'lat', + lonColumn: isMad ? 'pokemon.longitude' : 'lon', + idColumn: isMad ? 'pokemon.encounter_id' : 'id', + bounds: { + minLat: args.minLat, + maxLat: args.maxLat, + minLon: args.minLon, + maxLon: args.maxLon, + }, + }) + query.andWhere((ivOr) => { + if (ivs || pvp) { + if (globalFilter.filterKeys.size) { + ivOr.andWhere((pkmn) => { + const keys = globalFilter.keyArray + for (let i = 0; i < keys.length; i += 1) { + const key = keys[i] + switch (key) { + case 'xxs': + case 'xxl': + if (hasSize) { + pkmn.orWhere('pokemon.size', key === 'xxl' ? 5 : 1) + } + break + case 'gender': + pkmn.andWhere('pokemon.gender', onlyIvOr[key]) + break + case 'cp': + case 'level': + case 'atk_iv': + case 'def_iv': + case 'sta_iv': + case 'iv': + if (perms.iv) { + pkmn.andWhereBetween( + isMad ? MAD_KEY_MAP[key] : key, + onlyIvOr[key], + ) + } + break + default: + if ( + perms.pvp && + BASE_KEYS.every((x) => !globalFilter.filterKeys.has(x)) + ) { + // doesn't return everything if only pvp stats for individual pokemon + pkmn.whereNull('pokemon_id') + } + break } - }) - } else { - ivOr.whereNull('pokemon_id') - } - ivOr.orWhereIn('pokemon_id', pokemonIds) - ivOr.orWhereIn('pokemon.form', pokemonForms) + } + }) + } else { + ivOr.whereNull('pokemon_id') } - if (onlyZeroIv && ivs) { - ivOr.orWhere(isMad ? raw(IV_CALC) : 'iv', 0) - } - if (onlyHundoIv && ivs) { - ivOr.orWhere(isMad ? raw(IV_CALC) : 'iv', 100) - } - }) + ivOr.orWhereIn('pokemon_id', pokemonIds) + ivOr.orWhereIn('pokemon.form', pokemonForms) + } + if (onlyZeroIv && ivs) { + ivOr.orWhere(isMad ? raw(IV_CALC) : 'iv', 0) + } + if (onlyHundoIv && ivs) { + ivOr.orWhere(isMad ? raw(IV_CALC) : 'iv', 100) + } + if (manualId !== null) { + ivOr.orWhereIn(isMad ? 'pokemon.encounter_id' : 'id', [manualId]) + } + }) if (!getAreaSql(query, areaRestrictions, onlyAreas, isMad, 'pokemon')) { return [] } @@ -272,7 +302,7 @@ class Pokemon extends Model { filters.push({ iv: { min: 100, max: 100 }, pokemon: globalPokes }) } /** @type {import("@rm/types").Pokemon[]} */ - const results = await this.evalQuery( + let results = await this.evalQuery( mem ? `${mem}/api/pokemon/v2/scan` : null, mem ? JSON.stringify({ @@ -293,6 +323,30 @@ class Pokemon extends Model { httpAuth, ) + if (mem && manualId !== null) { + const loadedIds = Array.isArray(results) + ? new Set(results.map((pkmn) => `${pkmn.id}`)) + : new Set() + if (!loadedIds.has(`${manualId}`)) { + const manualResult = await this.evalQuery( + `${mem}/api/pokemon/id/${manualId}`, + null, + 'GET', + secret, + httpAuth, + ).catch(() => null) + if (manualResult) { + results = Array.isArray(results) + ? [...results, manualResult] + : [manualResult] + } + } + } + + if (!Array.isArray(results)) { + results = [] + } + const finalResults = [] const pvpResults = [] const listOfIds = [] @@ -326,20 +380,23 @@ class Pokemon extends Model { if (isMad) { Pokemon.getMadSql(pvpQuery) } - pvpQuery - .where( - isMad ? 'disappear_time' : 'expire_timestamp', - '>=', - isMad ? this.knex().fn.now() : ts, - ) - .andWhereBetween(isMad ? 'pokemon.latitude' : 'lat', [ - args.minLat, - args.maxLat, - ]) - .andWhereBetween(isMad ? 'pokemon.longitude' : 'lon', [ - args.minLon, - args.maxLon, - ]) + pvpQuery.where( + isMad ? 'disappear_time' : 'expire_timestamp', + '>=', + isMad ? this.knex().fn.now() : ts, + ) + applyManualIdFilter(pvpQuery, { + manualId, + latColumn: isMad ? 'pokemon.latitude' : 'lat', + lonColumn: isMad ? 'pokemon.longitude' : 'lon', + idColumn: isMad ? 'pokemon.encounter_id' : 'id', + bounds: { + minLat: args.minLat, + maxLat: args.maxLat, + minLon: args.minLon, + maxLon: args.maxLon, + }, + }) if (isMad && listOfIds.length) { pvpQuery.whereRaw( `pokemon.encounter_id NOT IN ( ${listOfIds.join(',')} )`, @@ -612,28 +669,16 @@ class Pokemon extends Model { } const statsMap = new Map() - const today = new Date() - today.setHours(0, 0, 0, 0) - const cutoff = new Date(today) - cutoff.setDate(cutoff.getDate() - 1) - const cutoffStr = cutoff.toISOString().slice(0, 10) - grouped.forEach((entries, key) => { let shinySum = 0 let checkSum = 0 let sinceDate = null - for (let i = 0; i < entries.length; i += 1) { + // 20000 checks would give >99% of distinguishing even 1/512 from 1/256 + for (let i = 0; i < entries.length && checkSum < 20000; i++) { const { shiny, checks, date } = entries[i] - const includeRecent = date >= cutoffStr - // 20000 checks would give >99% of distinguishing even 1/512 from 1/256 - if (!includeRecent && checkSum >= 20000) { - break - } shinySum += shiny checkSum += checks - if (!sinceDate || date < sinceDate) { - sinceDate = date - } + sinceDate = date } statsMap.set(key, { shiny_seen: shinySum, @@ -655,6 +700,7 @@ class Pokemon extends Model { const { isMad, hasSize, hasHeight, mem, secret, httpAuth } = ctx const ts = Math.floor(Date.now() / 1000) const { filterMap, globalFilter } = this.getFilters(perms, args, ctx) + const manualIdFilter = normalizeManualId(args.filters.onlyManualId) const queryLimits = config.getSafe('api.queryLimits') if (!perms.iv && !perms.pvp) { @@ -694,14 +740,25 @@ class Pokemon extends Model { ) { return [] } - + const manualId = applyManualIdFilter(query, { + manualId: manualIdFilter, + latColumn: isMad ? 'pokemon.latitude' : 'lat', + lonColumn: isMad ? 'pokemon.longitude' : 'lon', + idColumn: isMad ? 'pokemon.encounter_id' : 'id', + bounds: { + minLat: args.minLat, + maxLat: args.maxLat, + minLon: args.minLon, + maxLon: args.maxLon, + }, + }) const filters = mem ? Object.values(filterMap).flatMap((filter) => filter.buildApiFilter()) : [] if ((perms.iv || perms.pvp) && mem) filters.push(...globalFilter.buildApiFilter()) - const results = await this.evalQuery( + let results = await this.evalQuery( mem ? `${mem}/api/pokemon/v2/scan` : null, mem ? JSON.stringify({ @@ -721,6 +778,22 @@ class Pokemon extends Model { secret, httpAuth, ) + + if (mem && manualId !== null) { + const loaded = new Set(results.map((pkmn) => `${pkmn.id}`)) + if (!loaded.has(`${manualId}`)) { + const manualResult = await this.evalQuery( + `${mem}/api/pokemon/id/${manualId}`, + null, + 'GET', + secret, + httpAuth, + ).catch(() => null) + if (manualResult) { + results = [...results, manualResult] + } + } + } const filtered = results.filter( (item) => !mem || diff --git a/server/src/models/Pokestop.js b/server/src/models/Pokestop.js index 05a06a76d..25e12d84a 100644 --- a/server/src/models/Pokestop.js +++ b/server/src/models/Pokestop.js @@ -6,6 +6,7 @@ const i18next = require('i18next') const config = require('@rm/config') const { getAreaSql } = require('../utils/getAreaSql') +const { applyManualIdFilter } = require('../utils/manualFilter') const { getUserMidnight } = require('../utils/getClientTime') const { state } = require('../services/state') @@ -138,7 +139,6 @@ class Pokestop extends Model { const ts = Math.floor(Date.now() / 1000) const { queryLimits, stopValidDataLimit, hideOldPokestops } = config.getSafe('api') - const { lures: lurePerms, quests: questPerms, @@ -184,9 +184,18 @@ class Pokestop extends Model { query.where('pokestop.updated', '>', ts - stopValidDataLimit * 86400) } Pokestop.joinIncident(query, hasMultiInvasions, isMad, multiInvasionMs) - query - .whereBetween(isMad ? 'latitude' : 'lat', [args.minLat, args.maxLat]) - .andWhereBetween(isMad ? 'longitude' : 'lon', [args.minLon, args.maxLon]) + applyManualIdFilter(query, { + manualId: args.filters.onlyManualId, + latColumn: isMad ? 'latitude' : 'pokestop.lat', + lonColumn: isMad ? 'longitude' : 'pokestop.lon', + idColumn: isMad ? 'pokestop.pokestop_id' : 'pokestop.id', + bounds: { + minLat: args.minLat, + maxLat: args.maxLat, + minLon: args.minLon, + maxLon: args.maxLon, + }, + }) if (!getAreaSql(query, areaRestrictions, onlyAreas, isMad)) { return [] @@ -927,6 +936,8 @@ class Pokestop extends Model { 'quest_gender_id', 'quest_shiny', 'quest_shiny_probability', + 'quest_background', + 'quest_bread_mode', ) break case 9: @@ -1675,6 +1686,8 @@ class Pokestop extends Model { break case 7: Object.keys(info).forEach((x) => (quest[`quest_${x}`] = info[x])) + quest.quest_background = quest.quest_background || 0 + quest.quest_bread_mode = quest.quest_bread_mode || 0 break case 9: Object.keys(info).forEach((x) => (quest[`xl_candy_${x}`] = info[x])) diff --git a/server/src/models/Portal.js b/server/src/models/Portal.js index 79a9d3aa1..75beef5df 100644 --- a/server/src/models/Portal.js +++ b/server/src/models/Portal.js @@ -3,6 +3,7 @@ const { Model } = require('objection') const config = require('@rm/config') const { getAreaSql } = require('../utils/getAreaSql') +const { applyManualIdFilter } = require('../utils/manualFilter') class Portal extends Model { static get tableName() { @@ -29,13 +30,18 @@ class Portal extends Model { maxLon, } = args const query = this.query() - .whereBetween('lat', [minLat, maxLat]) - .andWhereBetween('lon', [minLon, maxLon]) - .andWhere( - 'updated', - '>', - Date.now() / 1000 - portalUpdateLimit * 60 * 60 * 24, - ) + applyManualIdFilter(query, { + manualId: args.filters.onlyManualId, + latColumn: 'lat', + lonColumn: 'lon', + idColumn: 'id', + bounds: { minLat, maxLat, minLon, maxLon }, + }) + query.andWhere( + 'updated', + '>', + Date.now() / 1000 - portalUpdateLimit * 60 * 60 * 24, + ) if (!getAreaSql(query, areaRestrictions, onlyAreas)) { return [] } diff --git a/server/src/models/Route.js b/server/src/models/Route.js index e4d696a40..a78c1e556 100644 --- a/server/src/models/Route.js +++ b/server/src/models/Route.js @@ -4,6 +4,7 @@ const config = require('@rm/config') const { getAreaSql } = require('../utils/getAreaSql') const { getEpoch } = require('../utils/getClientTime') +const { applyManualIdFilter } = require('../utils/manualFilter') const GET_ALL_SELECT = /** @type {const} */ ([ 'id', @@ -44,17 +45,29 @@ class Route extends Model { const ts = getEpoch() - config.getSafe('api.routeUpdateLimit') * 24 * 60 * 60 const distanceInMeters = (onlyDistance || [0.5, 100]).map((x) => x * 1000) - const startLatitude = isMad ? 'start_poi_latitude' : 'start_lat' const startLongitude = isMad ? 'start_poi_longitude' : 'start_lon' const distanceMeters = isMad ? 'route_distance_meters' : 'distance_meters' const endLatitude = isMad ? 'end_poi_latitude' : 'end_lat' const endLongitude = isMad ? 'end_poi_longitude' : 'end_lon' - const query = this.query() - .select(isMad ? GET_MAD_ALL_SELECT : GET_ALL_SELECT) - .whereBetween(startLatitude, [args.minLat, args.maxLat]) - .andWhereBetween(startLongitude, [args.minLon, args.maxLon]) + const idColumn = isMad ? 'route_id' : 'id' + const query = this.query().select( + isMad ? GET_MAD_ALL_SELECT : GET_ALL_SELECT, + ) + const manualId = applyManualIdFilter(query, { + manualId: args.filters.onlyManualId, + latColumn: startLatitude, + lonColumn: startLongitude, + idColumn, + bounds: { + minLat: args.minLat, + maxLat: args.maxLat, + minLon: args.minLon, + maxLon: args.maxLon, + }, + }) + query .andWhereBetween(distanceMeters, distanceInMeters) .andWhere((builder) => { builder.where( @@ -68,9 +81,19 @@ class Route extends Model { }) .union((qb) => { qb.select(isMad ? GET_MAD_ALL_SELECT : GET_ALL_SELECT) - .whereBetween(endLatitude, [args.minLat, args.maxLat]) - .andWhereBetween(endLongitude, [args.minLon, args.maxLon]) - .andWhereBetween(distanceMeters, distanceInMeters) + applyManualIdFilter(qb, { + manualId, + latColumn: endLatitude, + lonColumn: endLongitude, + idColumn, + bounds: { + minLat: args.minLat, + maxLat: args.maxLat, + minLon: args.minLon, + maxLon: args.maxLon, + }, + }) + qb.andWhereBetween(distanceMeters, distanceInMeters) .andWhere((builder) => { builder.where( isMad ? raw('UNIX_TIMESTAMP(last_updated)') : 'updated', diff --git a/server/src/models/Station.js b/server/src/models/Station.js index 46172f0ab..7bfc90a99 100644 --- a/server/src/models/Station.js +++ b/server/src/models/Station.js @@ -3,9 +3,56 @@ const { Model } = require('objection') const config = require('@rm/config') const i18next = require('i18next') +const { log, TAGS } = require('@rm/logger') + const { getAreaSql } = require('../utils/getAreaSql') +const { applyManualIdFilter } = require('../utils/manualFilter') const { getEpoch } = require('../utils/getClientTime') const { state } = require('../services/state') +const { getSharedPvpWrapper } = require('../services/PvpWrapper') + +const DEFAULT_IV = 15 + +/** + * @param {import('ohbem').PokemonData | null} pokemonData + * @param {import('@rm/types').FullStation} station + * @returns {number | null} + */ +function estimateStationCp(pokemonData, station) { + const { battle_pokemon_id: pokemonId, battle_pokemon_form: form } = station + const multiplier = Number(station.battle_pokemon_cp_multiplier) + + if ( + !pokemonData || + !pokemonId || + !Number.isFinite(multiplier) || + multiplier <= 0 + ) { + return null + } + + const base = pokemonData.findBaseStats(pokemonId, form ?? 0, 0) + if (!base) return null + const { attack, defense } = base + if (typeof attack !== 'number' || typeof defense !== 'number') { + return null + } + + const attackStat = attack + DEFAULT_IV + const defenseStat = defense + DEFAULT_IV + const staminaStat = Number(station.battle_pokemon_stamina) + if (!Number.isFinite(staminaStat)) { + return null + } + + const cp = Math.floor( + (attackStat * + multiplier * + Math.sqrt(defenseStat * multiplier * staminaStat)) / + 10, + ) + return cp < 10 ? 10 : cp +} class Station extends Model { static get tableName() { @@ -19,19 +66,61 @@ class Station extends Model { * @param {import("@rm/types").DbContext} ctx * @returns {Promise} */ - static async getAll(perms, args, { isMad, hasStationedGmax }) { + static async getAll( + perms, + args, + { isMad, hasStationedGmax, hasBattlePokemonStats }, + ) { const { areaRestrictions } = perms - const { stationUpdateLimit } = config.getSafe('api') + const { stationUpdateLimit, stationInactiveLimitDays } = + config.getSafe('api') const { onlyAreas, onlyAllStations, onlyMaxBattles, onlyBattleTier, onlyGmaxStationed, + onlyInactiveStations, } = args.filters - const ts = getEpoch() - const select = [ + const battleLevelFilters = new Set() + const battleComboFilters = new Map() + if (onlyMaxBattles && onlyBattleTier === 'all') { + Object.entries(args.filters || {}).forEach(([key, value]) => { + if (!value) return + if (key.startsWith('j')) { + const parsedLevel = Number(key.slice(1)) + if (Number.isFinite(parsedLevel)) { + battleLevelFilters.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 + } + const comboKey = `${pokemonId}-${formValue ?? 'null'}` + if (!battleComboFilters.has(comboKey)) { + battleComboFilters.set(comboKey, { pokemonId, form: formValue }) + } + } + }) + } + const battleLevels = [...battleLevelFilters] + const battleCombos = [...battleComboFilters.values()] + + if (!onlyAllStations && !onlyInactiveStations && !perms.dynamax) { + return [] + } + + const ts = getEpoch() + const baseSelect = [ 'id', 'name', 'lat', @@ -41,16 +130,26 @@ class Station extends Model { 'end_time', ] + const manualFilterOptions = { + manualId: args.filters.onlyManualId, + latColumn: 'lat', + lonColumn: 'lon', + idColumn: 'id', + bounds: { + minLat: args.minLat, + maxLat: args.maxLat, + minLon: args.minLon, + maxLon: args.maxLon, + }, + } + + const select = [...baseSelect] + const query = this.query() - .whereBetween('lat', [args.minLat, args.maxLat]) - .andWhereBetween('lon', [args.minLon, args.maxLon]) - .andWhere('end_time', '>', ts) - .andWhere( - 'updated', - '>', - Date.now() / 1000 - stationUpdateLimit * 60 * 60, - ) - // .where('is_inactive', false) + applyManualIdFilter(query, manualFilterOptions) + const now = Date.now() / 1000 + const activeCutoff = now - stationUpdateLimit * 60 * 60 + const inactiveCutoff = now - stationInactiveLimitDays * 24 * 60 * 60 if (perms.dynamax && (onlyMaxBattles || onlyGmaxStationed)) { select.push( @@ -71,97 +170,167 @@ class Station extends Model { select.push( hasStationedGmax ? 'total_stationed_gmax' : 'stationed_pokemon', ) + if (hasBattlePokemonStats) { + select.push('battle_pokemon_stamina', 'battle_pokemon_cp_multiplier') + } + } + + const applyStationFilters = (builder) => { + if (onlyAllStations) return + if (!perms.dynamax) { + builder.whereRaw('0 = 1') + return + } - if (!onlyAllStations) { - query.whereNotNull('battle_pokemon_id').andWhere('battle_end', '>', ts) + builder.andWhere((station) => { + let applied = false - query.andWhere((station) => { - if (hasStationedGmax || !onlyGmaxStationed) - station.where((battle) => { + 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') { - const battleBosses = new Set() - const battleForms = new Set() - const battleLevels = new Set() - - Object.keys(args.filters).forEach((key) => { - switch (key.charAt(0)) { - case 'o': - break - case 'j': - battleLevels.add(key.slice(1)) - break - default: - { - const [id, form] = key.split('-') - if (id) battleBosses.add(id) - if (form) battleForms.add(form) + 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) } - break + }) + matchApplied = true + }) + if (!matchApplied) { + match.whereRaw('0 = 1') } }) - if (battleBosses.size) { - battle.andWhere('battle_pokemon_id', 'in', [...battleBosses]) - } - if (battleForms.size) { - battle.andWhere('battle_pokemon_form', 'in', [...battleForms]) - } - if (battleLevels.size) { - battle.andWhere('battle_level', 'in', [...battleLevels]) - } } else { battle.andWhere('battle_level', onlyBattleTier) } }) - if (hasStationedGmax && onlyGmaxStationed) - station.orWhere('total_stationed_gmax', '>', 0) + applied = true + } + } + + if (onlyGmaxStationed) { + if (hasStationedGmax) { + const method = applied ? 'orWhere' : 'where' + station[method]('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", + ['2'], + ) + gmax.orWhereRaw( + "JSON_SEARCH(COALESCE(stationed_pokemon, '[]'), 'one', ?, NULL, '$[*].bread_mode') IS NOT NULL", + ['3'], + ) + }) + applied = true + } + } + + if (!applied) { + station.whereRaw('0 = 1') + } + }) + } + + query.select(select) + + if (onlyInactiveStations) { + query.andWhere((builder) => { + builder.where((active) => { + active + .where('end_time', '>', ts) + .andWhere('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), + ) + }) + } else { + query.andWhere('end_time', '>', ts).andWhere('updated', '>', activeCutoff) + applyStationFilters(query) } if (!getAreaSql(query, areaRestrictions, onlyAreas, isMad)) { return [] } - /** @type {import("@rm/types").FullStation[]} */ - const results = await query.select(select) - return results - .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 ( - 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 station + /** @type {import('@rm/types').FullStation[]} */ + const stations = await query + + let pokemonData = null + if (hasBattlePokemonStats && perms.dynamax) { + const needsEstimatedCp = stations.some((station) => { + if (!station || !station.battle_pokemon_id) return false + const multiplier = Number(station.battle_pokemon_cp_multiplier) + return Number.isFinite(multiplier) && multiplier > 0 }) - .filter( - (station) => - onlyAllStations || - (perms.dynamax && - ((onlyMaxBattles && - (onlyBattleTier === 'all' - ? args.filters[`j${station.battle_level}`] || - args.filters[ - `${station.battle_pokemon_id}-${station.battle_pokemon_form}` - ] - : onlyBattleTier === station.battle_level)) || - (onlyGmaxStationed && station.total_stationed_gmax))), - ) + if (needsEstimatedCp) { + try { + pokemonData = await getSharedPvpWrapper().ensurePokemonData() + } catch (e) { + log.warn( + TAGS.fetch, + 'Unable to load ohbem basics for station CP estimation', + e, + ) + } + } + } + + 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 ( + 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 + }) } /** diff --git a/server/src/models/Tappable.js b/server/src/models/Tappable.js new file mode 100644 index 000000000..b76001531 --- /dev/null +++ b/server/src/models/Tappable.js @@ -0,0 +1,161 @@ +// @ts-check +const { Model } = require('objection') +const config = require('@rm/config') + +const { getAreaSql } = require('../utils/getAreaSql') +const { applyManualIdFilter } = require('../utils/manualFilter') +const { getEpoch } = require('../utils/getClientTime') + +class Tappable extends Model { + static get tableName() { + return 'tappable' + } + + /** + * @param {import('@rm/types').Permissions} perms + * @param {{ + * filters: Record, + * minLat: number, + * maxLat: number, + * minLon: number, + * maxLon: number, + * }} args + * @param {import('@rm/types').DbContext} ctx + * @returns {Promise} + */ + static async getAll(perms, args, ctx) { + if (!perms?.tappables) return [] + + const { filters: filterArgs = {}, minLat, maxLat, minLon, maxLon } = args + + const { queryLimits = {} } = config.getSafe('api') + const timestamp = getEpoch() + + const query = this.query().select([ + 'id', + 'lat', + 'lon', + 'type', + 'fort_id', + 'item_id', + 'count', + 'expire_timestamp', + 'expire_timestamp_verified', + 'updated', + ]) + + applyManualIdFilter(query, { + manualId: filterArgs.onlyManualId, + latColumn: 'lat', + lonColumn: 'lon', + idColumn: 'id', + bounds: { minLat, maxLat, minLon, maxLon }, + }) + + const onlyAreas = filterArgs.onlyAreas || [] + if (!getAreaSql(query, perms.areaRestrictions, onlyAreas, ctx?.isMad)) { + return [] + } + + query.whereNull('pokemon_id').whereNotNull('item_id') + + query.andWhere('expire_timestamp', '>', timestamp) + + const itemIds = [] + Object.keys(filterArgs).forEach((key) => { + if (!key || key.startsWith('only')) return + switch (key.charAt(0)) { + case 'q': { + const itemId = Number.parseInt(key.slice(1), 10) + if (!Number.isNaN(itemId) && itemId !== 0) { + itemIds.push(itemId) + } + break + } + default: + break + } + }) + + if (itemIds.length) { + query.whereIn('item_id', itemIds) + } + + query.orderBy('updated', 'desc') + + const limit = queryLimits.tappables || queryLimits.pokestops || 5000 + const results = await query.limit(limit) + + return results.map((row) => ({ + ...row, + expire_timestamp_verified: !!row.expire_timestamp_verified, + })) + } + + /** + * Retrieve tappable metadata for a specific tappable id. + * @param {import('@rm/types').Permissions} perms + * @param {string | number} tappableId + * @param {import('@rm/types').DbContext} ctx + * @returns {Promise} + */ + static async getById(perms, tappableId, ctx) { + if (!perms?.tappables || !tappableId) { + return [] + } + + const query = this.query().select([ + 'id', + 'lat', + 'lon', + 'type', + 'fort_id', + 'item_id', + 'count', + 'expire_timestamp', + 'expire_timestamp_verified', + 'updated', + ]) + + if ( + !getAreaSql(query, perms.areaRestrictions, [], ctx?.isMad, 'tappable') + ) { + return [] + } + + query.where('id', tappableId).orderBy('updated', 'desc') + + const results = await query.limit(1) + + return results.map((row) => ({ + ...row, + expire_timestamp_verified: !!row.expire_timestamp_verified, + })) + } + + /** + * Returns filter keys available for tappables + * @returns {Promise<{ available: string[] }>} + */ + static async getAvailable() { + const rows = await this.query() + .select('item_id') + .count('id as total') + .whereNull('pokemon_id') + .whereNotNull('item_id') + .groupBy('item_id') + + const available = Array.from( + new Set( + rows + .map((row) => row.item_id) + .filter((itemId) => itemId !== null) + .map((itemId) => `q${itemId}`), + ), + ) + + return { available } + } +} + +module.exports = { Tappable } diff --git a/server/src/models/index.js b/server/src/models/index.js index 3dd967fae..4e0da69f9 100644 --- a/server/src/models/index.js +++ b/server/src/models/index.js @@ -14,6 +14,7 @@ const { ScanCell } = require('./ScanCell') const { Session } = require('./Session') const { Spawnpoint } = require('./Spawnpoint') const { Station } = require('./Station') +const { Tappable } = require('./Tappable') const { User } = require('./User') const { Weather } = require('./Weather') @@ -36,6 +37,7 @@ const scannerModels = { ScanCell, Spawnpoint, Station, + Tappable, Weather, } diff --git a/server/src/routes/api/v1/available.js b/server/src/routes/api/v1/available.js index e79d1dd89..c0cb7298f 100644 --- a/server/src/routes/api/v1/available.js +++ b/server/src/routes/api/v1/available.js @@ -9,6 +9,7 @@ const queryObj = /** @type {const} */ ({ quests: { model: 'Pokestop', category: 'pokestops' }, raids: { model: 'Gym', category: 'gyms' }, nests: { model: 'Nest', category: 'nests' }, + tappables: { model: 'Tappable', category: 'tappables' }, }) /** @param {string} category */ @@ -27,6 +28,9 @@ const resolveCategory = (category) => { case 'pokemon': case 'pokemons': return 'pokemon' + case 'tappable': + case 'tappables': + return 'tappables' default: return 'all' } @@ -40,12 +44,14 @@ const getAll = async (compare) => { state.db.getAvailable('Pokestop'), state.db.getAvailable('Gym'), state.db.getAvailable('Nest'), + state.db.getAvailable('Tappable'), ]) : [ state.event.available.pokemon, state.event.available.pokestops, state.event.available.gyms, state.event.available.nests, + state.event.available.tappables, ] return Object.fromEntries( Object.keys(queryObj).map((key, i) => [key, available[i]]), @@ -118,6 +124,7 @@ router.put('/:category', async (req, res) => { state.event.setAvailable('gyms', 'Gym', state.db), state.event.setAvailable('nests', 'Nest', state.db), state.event.setAvailable('stations', 'Station', state.db), + state.event.setAvailable('tappables', 'Tappable', state.db), ]) } res diff --git a/server/src/routes/rootRouter.js b/server/src/routes/rootRouter.js index a2feb1428..91d3a83bc 100644 --- a/server/src/routes/rootRouter.js +++ b/server/src/routes/rootRouter.js @@ -149,6 +149,7 @@ rootRouter.get('/api/settings', async (req, res, next) => { !scanner[key].discordRoles.length && !scanner[key].telegramGroups.length, ), + scannerCooldownBypass: [], } authentication.alwaysEnabledPerms.forEach((perm) => { if (authentication.perms[perm]) { @@ -180,6 +181,9 @@ rootRouter.get('/api/settings', async (req, res, next) => { req.session.save() } } + if (user.data !== undefined) { + req.user.data = user.data + } } } catch (e) { log.warn(TAGS.session, 'Issue finding user, User ID:', req?.user?.id, e) @@ -212,6 +216,9 @@ rootRouter.get('/api/settings', async (req, res, next) => { if (settings.user.perms.stations && api.queryOnSessionInit.stations) { state.event.setAvailable('stations', 'Station', state.db) } + if (settings.user.perms.tappables && api.queryOnSessionInit.tappables) { + state.event.setAvailable('tappables', 'Tappable', state.db) + } } res.status(200).json(settings) diff --git a/server/src/services/DbManager.js b/server/src/services/DbManager.js index ee1915074..e55ebe5f3 100644 --- a/server/src/services/DbManager.js +++ b/server/src/services/DbManager.js @@ -24,6 +24,7 @@ class DbManager extends Logger { 'ScanCell', 'Spawnpoint', 'Station', + 'Tappable', 'Weather', ]) @@ -130,14 +131,16 @@ class DbManager extends Logger { * @returns {Promise} */ static async schemaCheck(schema) { - const [isMad, pvpV2, hasSize, hasHeight] = await schema('pokemon') - .columnInfo() - .then((columns) => [ - 'cp_multiplier' in columns, - 'pvp' in columns, - 'size' in columns, - 'height' in columns, - ]) + const [isMad, pvpV2, hasSize, hasHeight, hasPokemonBackground] = + await schema('pokemon') + .columnInfo() + .then((columns) => [ + 'cp_multiplier' in columns, + 'pvp' in columns, + 'size' in columns, + 'height' in columns, + 'background' in columns, + ]) const [ hasRewardAmount, hasPowerUp, @@ -155,8 +158,11 @@ class DbManager extends Logger { 'showcase_pokemon_form_id' in columns, 'showcase_pokemon_type_id' in columns, ]) - const hasStationedGmax = - 'total_stationed_gmax' in (await schema('station').columnInfo()) + const stationColumns = await schema('station').columnInfo() + const hasStationedGmax = 'total_stationed_gmax' in stationColumns + const hasBattlePokemonStats = + 'battle_pokemon_stamina' in stationColumns && + 'battle_pokemon_cp_multiplier' in stationColumns const [hasLayerColumn] = isMad ? await schema('trs_quest') .columnInfo() @@ -213,8 +219,10 @@ class DbManager extends Logger { hasShowcaseForm, hasShowcaseType, hasStationedGmax, + hasBattlePokemonStats, hasShortcode, hasPokemonShinyStats, + hasPokemonBackground, } } @@ -232,10 +240,8 @@ class DbManager extends Logger { : { mem: this.endpoints[i].endpoint, secret: this.endpoints[i].secret, - // Add support for HTTP authentication httpAuth: this.endpoints[i].httpAuth, pvpV2: true, - hasPokemonShinyStats: false, } Object.entries(this.models).forEach(([category, sources]) => { diff --git a/server/src/services/DiscordClient.js b/server/src/services/DiscordClient.js index 036e986ad..4591cda1a 100644 --- a/server/src/services/DiscordClient.js +++ b/server/src/services/DiscordClient.js @@ -8,7 +8,7 @@ const config = require('@rm/config') const { logUserAuth } = require('./logUserAuth') const { areaPerms } = require('../utils/areaPerms') const { webhookPerms } = require('../utils/webhookPerms') -const { scannerPerms } = require('../utils/scannerPerms') +const { scannerPerms, scannerCooldownBypass } = require('../utils/scannerPerms') const { mergePerms } = require('../utils/mergePerms') const { AuthClient } = require('./AuthClient') const { state } = require('./state') @@ -128,6 +128,7 @@ class DiscordClient extends AuthClient { areaRestrictions: new Set(), webhooks: new Set(), scanner: new Set(), + scannerCooldownBypass: new Set(), blockedGuildNames: new Set(), } const scanner = config.getSafe('scanner') @@ -141,9 +142,12 @@ class DiscordClient extends AuthClient { Object.keys(this.perms).forEach((key) => (perms[key] = true)) perms.admin = true config.getSafe('webhooks').forEach((x) => permSets.webhooks.add(x.name)) - Object.keys(scanner).forEach( - (x) => scanner[x]?.enabled && permSets.scanner.add(x), - ) + Object.keys(scanner).forEach((x) => { + if (scanner[x]?.enabled) { + permSets.scanner.add(x) + permSets.scannerCooldownBypass.add(x) + } + }) this.log.debug( `User ${user.username} (${user.id}) in allowed users list, skipping guild and role check.`, ) @@ -197,6 +201,9 @@ class DiscordClient extends AuthClient { scannerPerms(userRoles, 'discordRoles', trialActive).forEach( (x) => permSets.scanner.add(x), ) + scannerCooldownBypass(userRoles, 'discordRoles').forEach((x) => + permSets.scannerCooldownBypass.add(x), + ) } }), ) diff --git a/server/src/services/EventManager.js b/server/src/services/EventManager.js index 0bd7f4ae4..1eb5091bb 100644 --- a/server/src/services/EventManager.js +++ b/server/src/services/EventManager.js @@ -30,6 +30,7 @@ class EventManager extends Logger { pokemon: [], nests: [], stations: [], + tappables: [], }) this.uicons = getCache('uicons.json', []) this.uaudio = getCache('uaudio.json', []) diff --git a/server/src/services/LocalClient.js b/server/src/services/LocalClient.js index d7e4e44c4..c8f5615b2 100644 --- a/server/src/services/LocalClient.js +++ b/server/src/services/LocalClient.js @@ -7,7 +7,7 @@ const config = require('@rm/config') const { areaPerms } = require('../utils/areaPerms') const { webhookPerms } = require('../utils/webhookPerms') -const { scannerPerms } = require('../utils/scannerPerms') +const { scannerPerms, scannerCooldownBypass } = require('../utils/scannerPerms') const { mergePerms } = require('../utils/mergePerms') const { AuthClient } = require('./AuthClient') const { state } = require('./state') @@ -47,6 +47,7 @@ class LocalClient extends AuthClient { areaRestrictions: areaPerms(localPerms), webhooks: [], scanner: [], + scannerCooldownBypass: [], }), rmStrategy: this.rmStrategy, } @@ -120,6 +121,9 @@ class LocalClient extends AuthClient { scannerPerms([user.status], 'local', trialActive).forEach((x) => user.perms.scanner.push(x), ) + scannerCooldownBypass([user.status], 'local').forEach((x) => + user.perms.scannerCooldownBypass.push(x), + ) this.log.info( user.username, `(${user.id})`, diff --git a/server/src/services/PvpWrapper.js b/server/src/services/PvpWrapper.js index 5d3de174d..021efe1af 100644 --- a/server/src/services/PvpWrapper.js +++ b/server/src/services/PvpWrapper.js @@ -13,6 +13,7 @@ class PvpWrapper extends Ohbem { cachingStrategy: Ohbem.cachingStrategies.memoryHeavy, }) this.rmCache = new NodeCache({ stdTTL: 60 * 60 * 1.5 }) + this._pokemonDataPromise = null } async fetchLatestPokemon() { @@ -20,6 +21,23 @@ class PvpWrapper extends Ohbem { this.updatePokemonData(data) } + async ensurePokemonData() { + if (this._pokemonData) return this._pokemonData + if (this._pokemonDataPromise) return this._pokemonDataPromise + this._pokemonDataPromise = Ohbem.fetchPokemonData() + .then((data) => { + this.updatePokemonData(data) + return this._pokemonData + }) + .catch((error) => { + throw error + }) + .finally(() => { + this._pokemonDataPromise = null + }) + return this._pokemonDataPromise + } + /** * @param {import("@rm/types").Pokemon} pokemon * @param {number} currentTs @@ -58,4 +76,16 @@ class PvpWrapper extends Ohbem { } } -module.exports = { PvpWrapper } +let sharedInstance = null + +/** + * @returns {PvpWrapper} + */ +function getSharedPvpWrapper() { + if (!sharedInstance) { + sharedInstance = new PvpWrapper() + } + return sharedInstance +} + +module.exports = { PvpWrapper, getSharedPvpWrapper } diff --git a/server/src/services/TelegramClient.js b/server/src/services/TelegramClient.js index ea951f48e..55d9eb8cb 100644 --- a/server/src/services/TelegramClient.js +++ b/server/src/services/TelegramClient.js @@ -8,7 +8,7 @@ const config = require('@rm/config') const { state } = require('./state') const { areaPerms } = require('../utils/areaPerms') const { webhookPerms } = require('../utils/webhookPerms') -const { scannerPerms } = require('../utils/scannerPerms') +const { scannerPerms, scannerCooldownBypass } = require('../utils/scannerPerms') const { mergePerms } = require('../utils/mergePerms') const { AuthClient } = require('./AuthClient') @@ -102,6 +102,7 @@ class TelegramClient extends AuthClient { areaRestrictions: areaPerms(groups), webhooks: webhookPerms(groups, 'telegramGroups', trialActive), scanner: scannerPerms(groups, 'telegramGroups', trialActive), + scannerCooldownBypass: scannerCooldownBypass(groups, 'telegramGroups'), }, } if (newUserObj.perms.trial) { diff --git a/server/src/services/state.js b/server/src/services/state.js index a1e34d87f..28c4c0544 100644 --- a/server/src/services/state.js +++ b/server/src/services/state.js @@ -7,14 +7,16 @@ const { log, TAGS } = require('@rm/logger') const { DbManager } = require('./DbManager') const { EventManager } = require('./EventManager') -const { PvpWrapper } = require('./PvpWrapper') +const { getSharedPvpWrapper } = require('./PvpWrapper') const { setCache } = require('./cache') const { migrate } = require('../db/migrate') const { Stats } = require('./Stats') const state = { db: new DbManager(), - pvp: config.getSafe('api.pvp.reactMapHandlesPvp') ? new PvpWrapper() : null, + pvp: config.getSafe('api.pvp.reactMapHandlesPvp') + ? getSharedPvpWrapper() + : null, event: new EventManager(), stats: new Stats(), startTimers() { @@ -105,6 +107,7 @@ const state = { this.event.setAvailable('pokemon', 'Pokemon', this.db), this.event.setAvailable('nests', 'Nest', this.db), this.event.setAvailable('stations', 'Station', this.db), + this.event.setAvailable('tappables', 'Tappable', this.db), ) } await Promise.all(promises) @@ -143,7 +146,7 @@ const state = { } if (reloadReport.pvp) { this.pvp = config.getSafe('api.pvp.reactMapHandlesPvp') - ? new PvpWrapper() + ? getSharedPvpWrapper() : null } if (reloadReport.strategies) { @@ -180,26 +183,55 @@ const state = { }, } -process.on('SIGINT', async (e) => { - await state.writeCache(e) - process.exit(0) -}) -process.on('SIGTERM', async (e) => { - await state.writeCache(e) - process.exit(0) -}) -process.on('SIGUSR1', async (e) => { - await state.writeCache(e) - process.exit(0) -}) -process.on('SIGUSR2', async (e) => { - await state.writeCache(e) - process.exit(0) -}) -process.on('uncaughtException', async (e) => { - log.error(TAGS.ReactMap, e) - await state.writeCache('SIGBREAK') - process.exit(99) +const shutdownState = { + initiated: false, + exitCode: 0, + reason: /** @type {NodeJS.Signals | 'SIGPIPE' | 'SIGBREAK' | null} */ (null), +} + +/** + * Flush caches once and terminate the process. + * @param {NodeJS.Signals | 'SIGPIPE' | 'SIGBREAK'} reason + * @param {number} [exitCode] + */ +const shutdown = (reason, exitCode = 0) => { + if (shutdownState.initiated) { + if (exitCode > shutdownState.exitCode) { + shutdownState.exitCode = exitCode + shutdownState.reason = reason + process.exitCode = exitCode + } + return + } + shutdownState.initiated = true + shutdownState.exitCode = exitCode + shutdownState.reason = reason + process.exitCode = exitCode + state + .writeCache(reason) + .catch((err) => log.error(TAGS.ReactMap, err)) + .finally(() => process.exit(shutdownState.exitCode)) +} + +;['SIGINT', 'SIGTERM', 'SIGUSR1', 'SIGUSR2', 'SIGHUP'].forEach( + /** @param {NodeJS.Signals} signal */ (signal) => { + process.on(signal, () => shutdown(signal)) + }, +) + +process.on('uncaughtException', (err) => { + log.error(TAGS.ReactMap, err) + shutdown('SIGBREAK', 99) }) +const handleStreamError = (err) => { + if (!err) return + if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') { + shutdown('SIGPIPE') + } +} + +process.stdout?.on('error', handleStreamError) +process.stderr?.on('error', handleStreamError) + module.exports = { state } diff --git a/server/src/ui/advMenus.js b/server/src/ui/advMenus.js index 908bb273f..4b2504353 100644 --- a/server/src/ui/advMenus.js +++ b/server/src/ui/advMenus.js @@ -20,6 +20,7 @@ const CATEGORIES = /** @type {const} */ ({ stations: ['pokemon'], pokemon: ['pokemon'], nests: ['pokemon'], + tappables: ['tappables'], }) /** @@ -110,6 +111,20 @@ function advMenus(perms) { } : {}, }, + tappables: { + categories: CATEGORIES.tappables, + filters: { + categories: Object.fromEntries( + CATEGORIES.tappables.map((item) => [item, false]), + ), + others: { + reverse: false, + selected: false, + unselected: false, + onlyAvailable: true, + }, + }, + }, pokemon: { categories: CATEGORIES.pokemon, filters: { diff --git a/server/src/ui/clientOptions.js b/server/src/ui/clientOptions.js index 638c62ed1..cf866c5a3 100644 --- a/server/src/ui/clientOptions.js +++ b/server/src/ui/clientOptions.js @@ -171,6 +171,51 @@ function clientOptions(perms) { ? { type: 'bool', perm: ['stations', 'dynamax'], category: 'popups' } : undefined, }, + tappables: { + clustering: { + type: 'bool', + perm: ['tappables'], + category: 'markers', + }, + tappableTimers: { + type: 'bool', + perm: ['tappables'], + category: 'tooltips', + }, + interactionRanges: { + type: 'bool', + perm: ['tappables'], + category: 'markers', + }, + spacialRendRange: { + type: 'bool', + perm: ['tappables'], + category: 'markers', + }, + tappablesOpacity: { + type: 'bool', + perm: ['tappables'], + category: 'dynamic_opacity', + }, + opacityTenMinutes: { + type: 'number', + perm: ['tappables'], + category: 'dynamic_opacity', + }, + opacityFiveMinutes: { + type: 'number', + perm: ['tappables'], + category: 'dynamic_opacity', + }, + opacityOneMinute: { + type: 'number', + perm: ['tappables'], + category: 'dynamic_opacity', + }, + enableTappablePopupCoords: map.misc.enableTappablePopupCoordsSelector + ? { type: 'bool', perm: ['tappables'], category: 'popups' } + : undefined, + }, pokemon: { clustering: { type: 'bool', perm: ['pokemon'], category: 'markers' }, linkGlobalAndAdvanced: { diff --git a/server/src/ui/drawer.js b/server/src/ui/drawer.js index 031773a78..3af7527df 100644 --- a/server/src/ui/drawer.js +++ b/server/src/ui/drawer.js @@ -63,12 +63,19 @@ function drawer(req, perms) { arEligible: perms.pokestops || BLOCKED, } : BLOCKED, + tappables: + perms.tappables && state.db.models.Tappable + ? { + enabled: perms.tappables || BLOCKED, + } + : BLOCKED, stations: (perms.stations || perms.dynamax) && state.db.models.Station ? { allStations: perms.stations || BLOCKED, maxBattles: perms.dynamax || BLOCKED, gmaxStationed: perms.dynamax || BLOCKED, + inactiveStations: perms.stations || BLOCKED, } : BLOCKED, pokemon: diff --git a/server/src/utils/manualFilter.js b/server/src/utils/manualFilter.js new file mode 100644 index 000000000..120b4807a --- /dev/null +++ b/server/src/utils/manualFilter.js @@ -0,0 +1,57 @@ +// @ts-check + +/** + * @param {unknown} manualId + * @returns {string | number | null} + */ +function normalizeManualId(manualId) { + if ( + manualId === undefined || + manualId === null || + manualId === '' || + (typeof manualId !== 'string' && typeof manualId !== 'number') + ) { + return null + } + return manualId +} + +/** + * @param {import('objection').QueryBuilder} query + * @param {{ + * manualId?: string | number | null, + * latColumn: string, + * lonColumn: string, + * idColumn: string, + * bounds: { minLat: number, maxLat: number, minLon: number, maxLon: number }, + * }} options + * @returns {string | number | null} + */ +function applyManualIdFilter(query, options) { + const { + manualId: rawManual, + latColumn, + lonColumn, + idColumn, + bounds, + } = options + + const manualId = normalizeManualId(rawManual) + + if (manualId !== null) { + query.where((builder) => { + builder + .whereBetween(latColumn, [bounds.minLat, bounds.maxLat]) + .andWhereBetween(lonColumn, [bounds.minLon, bounds.maxLon]) + .orWhere(idColumn, manualId) + }) + } else { + query + .whereBetween(latColumn, [bounds.minLat, bounds.maxLat]) + .andWhereBetween(lonColumn, [bounds.minLon, bounds.maxLon]) + } + + return manualId +} + +module.exports = { applyManualIdFilter, normalizeManualId } diff --git a/server/src/utils/mergePerms.js b/server/src/utils/mergePerms.js index c578593be..3e13abf31 100644 --- a/server/src/utils/mergePerms.js +++ b/server/src/utils/mergePerms.js @@ -6,14 +6,24 @@ * @param {import("@rm/types").Permissions} incomingPerms */ function mergePerms(existingPerms, incomingPerms) { + const keys = new Set([ + ...Object.keys(existingPerms), + ...Object.keys(incomingPerms), + ]) + return /** @type {import("@rm/types").Permissions} */ ( Object.fromEntries( - Object.keys(existingPerms).map((key) => [ - key, - Array.isArray(existingPerms[key]) - ? [...new Set([...existingPerms[key], ...incomingPerms[key]])] - : existingPerms[key] || incomingPerms[key], - ]), + [...keys].map((key) => { + const existingValue = existingPerms[key] + const incomingValue = incomingPerms[key] + + return [ + key, + Array.isArray(existingValue) || Array.isArray(incomingValue) + ? [...new Set([...(existingValue || []), ...(incomingValue || [])])] + : existingValue || incomingValue, + ] + }), ) ) } diff --git a/server/src/utils/scannerPerms.js b/server/src/utils/scannerPerms.js index bcfbdbaa0..c6582eb85 100644 --- a/server/src/utils/scannerPerms.js +++ b/server/src/utils/scannerPerms.js @@ -28,4 +28,30 @@ function scannerPerms(roles, provider, trialActive = false) { return [...new Set(perms)] } -module.exports = { scannerPerms } +/** + * Determine which scanner modes should bypass the cooldown for a given role set. + * + * @param {string[]} roles + * @param {'discordRoles' | 'telegramGroups' | 'local'} provider + * @returns {string[]} + */ +function scannerCooldownBypass(roles, provider) { + const scanner = config.getSafe('scanner') + + const bypass = [] + roles.forEach((role) => { + Object.keys(scanner).forEach((mode) => { + const bypassRoles = scanner[mode]?.cooldownBypass?.[provider] + if ( + scanner[mode]?.enabled && + Array.isArray(bypassRoles) && + bypassRoles.includes(role) + ) { + bypass.push(mode) + } + }) + }) + return [...new Set(bypass)] +} + +module.exports = { scannerPerms, scannerCooldownBypass } diff --git a/src/assets/css/main.css b/src/assets/css/main.css index 72c7badcd..f6d4a5c95 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -100,7 +100,7 @@ body { min-width: 18px; padding: 0 4px; border-radius: 10px; - background: #ff4b4d; + background: #2196f3; color: #fff; font-size: 11px; font-weight: 700; @@ -110,7 +110,7 @@ body { } .route-count-badge--destination { - background: #2196f3; + background: #ff4b4d; } .invasion-exists { @@ -315,6 +315,65 @@ img { font-weight: 700; } +.pokestop-marker__stack { + position: absolute; + display: flex; + flex-direction: column-reverse; + align-items: flex-start; + width: 100%; + gap: 0; +} + +.pokestop-marker__stack-item { + position: relative; + display: flex; + justify-content: center; + align-items: center; + transform: translateX(-50%); +} + +.pokestop-marker__amount { + position: absolute; + bottom: -4px; + left: 50%; + transform: translateX(-50%); + color: white; + text-shadow: + #000 0 0 1px, + #000 0 0 1px, + #000 0 0 1px, + #000 0 0 1px, + #000 0 0 1px, + #000 0 0 1px; + font-weight: 700; + white-space: nowrap; +} + +.pokestop-marker__decoration { + position: absolute; + bottom: calc(var(--marker-size, 30px) * -0.34); + left: 50%; + transform: translateX(-50%); +} + +.leaflet-container + .leaflet-marker-pane + .pokestop-marker__stack-item + img:not(.pokestop-marker__decoration) { + width: var(--marker-size, 30px) !important; + height: auto !important; + max-width: var(--marker-size, 30px) !important; + max-height: var(--marker-size, 30px) !important; + object-fit: contain; +} + +.leaflet-container + .leaflet-marker-pane + .pokestop-marker__stack-item[data-reward-type='12'] + img:not(.pokestop-marker__decoration) { + max-height: calc(var(--marker-size, 30px) * 1.5) !important; +} + .search-amount-holder { position: absolute; left: 50%; @@ -347,6 +406,66 @@ img { filter: invert(75%) sepia(100%) hue-rotate(151deg) saturate(3.1); } +.tappable-marker { + position: relative; + width: var(--tappable-size, 32px); + height: var(--tappable-size, 32px); + display: flex; + align-items: center; + justify-content: center; +} + +.tappable-marker__icon { + width: 100%; + height: 100%; + object-fit: contain; +} + +.tappable-marker--reward-primary { + position: relative; + width: var(--tappable-size, 32px); + height: var(--tappable-size, 32px); + display: flex; + align-items: center; + justify-content: center; +} + +.tappable-marker__reward-primary { + width: 100%; + height: 100%; + object-fit: contain; +} + +.tappable-marker__count { + position: absolute; + right: -4px; + bottom: -6px; + min-width: 22px; + padding: 0 4px; + border-radius: 10px; + background: rgba(32, 32, 32, 0.8); + color: #fff; + font-size: 12px; + font-weight: 600; + line-height: 1.4; + text-align: center; + pointer-events: none; +} + +.tappable-marker__bubble { + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + z-index: 1; +} + +.tappable-marker__bubble-svg { + display: block; + overflow: visible; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)); +} + .quest-popup-img { max-width: 30px; max-height: 30px; diff --git a/src/assets/theme.js b/src/assets/theme.js index 22c9f21c1..298e12a79 100644 --- a/src/assets/theme.js +++ b/src/assets/theme.js @@ -11,6 +11,12 @@ const VALID_COLOR = /** @type {import('@mui/material').Components>} */ const components = { + MuiTooltip: { + defaultProps: { + enterTouchDelay: 0, + leaveTouchDelay: 5000, + }, + }, MuiPaper: { styleOverrides: { root: { diff --git a/src/components/Global.jsx b/src/components/Global.jsx index 22abddd5a..648dbce70 100644 --- a/src/components/Global.jsx +++ b/src/components/Global.jsx @@ -60,6 +60,8 @@ function ApplyGlobal() { backgroundColor: theme.palette.background.paper, border: `${theme.palette.divider} solid 4px`, color: theme.palette.text.primary, + padding: 0, + overflow: 'hidden', }, '.leaflet-bar a': { backgroundColor: theme.palette.grey[grey], @@ -110,8 +112,7 @@ function ApplyGlobal() { borderRadius: '12px', padding: '3px', fontSize: '0.5rem !important', - backgroundColor: theme.palette.grey[grey], - color: theme.palette.text.primary, + backgroundColor: 'transparent', }, '.iv-badge': { color: theme.palette.text.primary, diff --git a/src/components/Menu.jsx b/src/components/Menu.jsx index ccf139208..6e06bea36 100644 --- a/src/components/Menu.jsx +++ b/src/components/Menu.jsx @@ -85,6 +85,7 @@ export function Menu({ { enabled: false }, category, useMemory.getState().advMenuFiltered[category], + false, ), icon: 'Clear', color: 'error', diff --git a/src/components/dialogs/Footer.jsx b/src/components/dialogs/Footer.jsx index c83752203..bbdbc48e8 100644 --- a/src/components/dialogs/Footer.jsx +++ b/src/components/dialogs/Footer.jsx @@ -69,13 +69,15 @@ export function Footer({ options, role }) { borderTop: `1px solid ${theme.palette.divider}`, })} > - {options.map((button) => { - const key = button.key || button.name + {options.map((button, index) => { + const baseName = typeof button.name === 'string' ? button.name : null + const key = + button.key ?? (baseName ? `${baseName}-${index}` : `option-${index}`) const actualSize = button.size || Math.floor(12 / options.length) if (button.component) { return ( diff --git a/src/components/filters/FilterMenu.jsx b/src/components/filters/FilterMenu.jsx index 2557fb036..6d204a730 100644 --- a/src/components/filters/FilterMenu.jsx +++ b/src/components/filters/FilterMenu.jsx @@ -2,6 +2,7 @@ import * as React from 'react' import { toggleDialog, useLayoutStore } from '@store/useLayoutStore' +import { useMemory } from '@store/useMemory' import { StandardItem } from '@components/virtual/StandardItem' import { Menu } from '@components/Menu' import { Header } from '@components/dialogs/Header' @@ -19,13 +20,13 @@ const EXTRA_BUTTONS = [ export function FilterMenu() { const { open, category, type } = useLayoutStore((s) => s.dialog) + const menuConfig = useMemory((s) => (category ? s.menus?.[category] : null)) - return (category === 'pokemon' || - category === 'gyms' || - category === 'pokestops' || - category === 'nests' || - category === 'stations') && - type === 'filters' ? ( + if (!category || !menuConfig || type !== 'filters') { + return null + } + + return ( } - ) : null + ) } diff --git a/src/components/popups/BackgroundCard.jsx b/src/components/popups/BackgroundCard.jsx new file mode 100644 index 000000000..febcebc9a --- /dev/null +++ b/src/components/popups/BackgroundCard.jsx @@ -0,0 +1,207 @@ +// @ts-check +import * as React from 'react' +import Tooltip from '@mui/material/Tooltip' +import Box from '@mui/material/Box' +import ThemeProvider from '@mui/material/styles/ThemeProvider' +import { createTheme, useTheme } from '@mui/material/styles' + +const DARK_PALETTE_OVERRIDES = (() => { + const dark = createTheme({ palette: { mode: 'dark' } }).palette + return Object.entries(dark).reduce((acc, [key, value]) => { + if (value && typeof value === 'object') { + acc[key] = { ...value } + } else { + acc[key] = value + } + return acc + }, /** @type {Record} */ ({ mode: 'dark' })) +})() + +/** + * Applies themed background visuals (and optional tooltip) around popup content. + * + * @param {{ + * visuals?: ReturnType> + * tooltip?: React.ReactNode + * tooltipProps?: import('@mui/material/Tooltip').TooltipProps + * wrapperProps?: React.HTMLAttributes + * wrapWhenNoTooltip?: boolean + * fullWidth?: boolean + * fullBleed?: boolean | number | { horizontal?: number, vertical?: number } + * surfaceStyle?: React.CSSProperties + * contentProps?: import('@mui/material/Box').BoxProps + * children: React.ReactNode + * }} props + */ +export function BackgroundCard({ + visuals, + tooltip, + tooltipProps, + wrapperProps, + wrapWhenNoTooltip = false, + fullWidth = false, + fullBleed, + surfaceStyle, + contentProps, + children, +}) { + const parentTheme = useTheme() + const hasBackground = Boolean(visuals?.hasBackground) + const tooltipTitle = + tooltip ?? (visuals?.backgroundMeta && visuals.backgroundMeta.tooltip) + const iconStyles = visuals?.styles?.icon || {} + const iconBaseColor = + iconStyles && typeof iconStyles.color === 'string' + ? iconStyles.color + : undefined + const primaryIconColor = hasBackground + ? iconBaseColor || visuals?.primaryColor || '#fff' + : iconBaseColor + const secondaryIconColor = + hasBackground && visuals?.styles?.secondaryText?.color + ? visuals.styles.secondaryText.color + : undefined + const primaryTextShadow = visuals?.primaryTextShadow + const borderColor = visuals?.borderColor + + const normalizedFullBleed = React.useMemo(() => { + if (!fullBleed) { + return undefined + } + if (fullBleed === true) { + return {} + } + if (typeof fullBleed === 'number') { + return { horizontal: fullBleed, vertical: 0 } + } + return fullBleed + }, [fullBleed]) + + const resolvedSurfaceStyle = React.useMemo(() => { + if (!hasBackground) { + return surfaceStyle + } + const base = { + ...(visuals?.styles?.surface || {}), + } + if (normalizedFullBleed) { + const horizontalPixels = Number(normalizedFullBleed.horizontal ?? 0) || 0 + const verticalPixels = Number(normalizedFullBleed.vertical ?? 0) || 0 + const widthAdjustment = horizontalPixels * 2 + base.margin = `${-verticalPixels}px ${-horizontalPixels}px` + base.padding = `${verticalPixels}px ${horizontalPixels}px` + base.width = widthAdjustment + ? `calc(100% + ${widthAdjustment}px)` + : '100%' + base.boxSizing = 'border-box' + } + if (surfaceStyle) { + Object.assign(base, surfaceStyle) + } + return base + }, [hasBackground, normalizedFullBleed, surfaceStyle, visuals]) + + const { sx: contentSx, ...restContentProps } = contentProps || {} + const combinedSx = React.useMemo(() => { + const baseSx = { + ...(resolvedSurfaceStyle || {}), + color: visuals?.primaryColor || parentTheme.palette.text.primary, + '& .MuiTypography-root': { + textShadow: primaryTextShadow || 'none', + }, + '& img[data-background-icon], & svg[data-background-icon]': { + ...(iconStyles || {}), + }, + ...(hasBackground && (primaryIconColor || secondaryIconColor) + ? { + '& img[data-background-icon="true"], & svg[data-background-icon="true"], & img[data-background-icon="primary"], & svg[data-background-icon="primary"]': + primaryIconColor ? { color: primaryIconColor } : undefined, + ...(secondaryIconColor + ? { + '& img[data-background-icon="secondary"], & svg[data-background-icon="secondary"]': + { color: secondaryIconColor }, + } + : {}), + } + : {}), + '& .MuiDivider-root': { + borderColor: borderColor || 'rgba(255, 255, 255, 0.2)', + backgroundColor: borderColor || 'rgba(255, 255, 255, 0.2)', + }, + '& .ar-task': { + borderColor: borderColor || 'rgba(255, 255, 255, 0.2)', + }, + } + if (contentSx) { + return Array.isArray(contentSx) + ? [baseSx, ...contentSx] + : [baseSx, contentSx] + } + return baseSx + }, [ + borderColor, + contentSx, + iconStyles, + primaryIconColor, + secondaryIconColor, + parentTheme.palette.text.primary, + primaryTextShadow, + resolvedSurfaceStyle, + visuals?.primaryColor, + hasBackground, + ]) + + const resolvedWrapperProps = React.useMemo(() => { + if (!wrapperProps && !fullWidth) { + return undefined + } + const base = wrapperProps ? { ...wrapperProps } : {} + const existingStyle = wrapperProps?.style + if (existingStyle || fullWidth) { + base.style = { + ...(existingStyle || {}), + ...(fullWidth ? { width: '100%' } : {}), + } + } + return base + }, [fullWidth, wrapperProps]) + + const themed = React.useMemo(() => { + if (!hasBackground) { + return parentTheme + } + return createTheme(parentTheme, { + palette: { + ...DARK_PALETTE_OVERRIDES, + }, + }) + }, [hasBackground, parentTheme]) + + let content = children + + if (hasBackground && visuals) { + content = ( + + + {children} + + + ) + } + + if (tooltipTitle) { + return ( + +
+ {content} +
+
+ ) + } + + if (wrapWhenNoTooltip && resolvedWrapperProps) { + return
{content}
+ } + + return content +} diff --git a/src/components/popups/ExtraInfo.jsx b/src/components/popups/ExtraInfo.jsx index a24a3bfd9..35dc7226b 100644 --- a/src/components/popups/ExtraInfo.jsx +++ b/src/components/popups/ExtraInfo.jsx @@ -6,14 +6,14 @@ import { useTranslation } from 'react-i18next' /** * - * @param {{ title?: string, data?: React.ReactNode, children?: React.ReactNode }} props + * @param {{ title?: string, data?: React.ReactNode, children?: React.ReactNode, xs?: number }} props * @returns */ -export const ExtraInfo = ({ title, data, children }) => { +export const ExtraInfo = ({ title, data, children, xs = 6 }) => { const { t } = useTranslation() return ( - + {title && ( {t(title)}: diff --git a/src/components/popups/GenderIcon.jsx b/src/components/popups/GenderIcon.jsx index 1d00b8783..8430128d5 100644 --- a/src/components/popups/GenderIcon.jsx +++ b/src/components/popups/GenderIcon.jsx @@ -1,7 +1,6 @@ // @ts-check import * as React from 'react' import SvgIcon from '@mui/material/SvgIcon' -import useTheme from '@mui/material/styles/useTheme' const PATH = { 0: '', @@ -12,11 +11,9 @@ const PATH = { /** @param {{ gender: keyof typeof PATH } & import('@mui/material').SvgIconProps} props */ export function GenderIcon({ gender, ...props }) { - const theme = useTheme() - return ( - + ) } diff --git a/src/components/popups/NameTT.jsx b/src/components/popups/NameTT.jsx index 7358175de..9beec3d4c 100644 --- a/src/components/popups/NameTT.jsx +++ b/src/components/popups/NameTT.jsx @@ -14,7 +14,6 @@ export function NameTT({ title, children }) { return ( { +export const TimeStamp = ({ time, children, xs = 6 }) => { const { i18n } = useTranslation() if (!time) return null @@ -23,7 +23,7 @@ export const TimeStamp = ({ time, children }) => { }) return ( - + diff --git a/src/components/virtual/VirtualGrid.jsx b/src/components/virtual/VirtualGrid.jsx index b8c9b24b1..599dc0bd0 100644 --- a/src/components/virtual/VirtualGrid.jsx +++ b/src/components/virtual/VirtualGrid.jsx @@ -13,7 +13,7 @@ export const SQUARE_ITEM = /** @type {import('@mui/material').SxProps} */ ({ outline: 'ButtonText 1px solid', }) -/** @typedef {Pick} SomeGridProps */ +/** @typedef {Pick} SomeGridProps */ /** @type {React.ComponentType} */ const Item = React.forwardRef(({ context, ...props }, ref) => ( @@ -21,8 +21,14 @@ const Item = React.forwardRef(({ context, ...props }, ref) => ( )) /** @type {React.ComponentType} */ -const List = React.forwardRef((props, ref) => ( - +const List = React.forwardRef(({ context, ...props }, ref) => ( + )) /** diff --git a/src/features/device/DevicePopup.jsx b/src/features/device/DevicePopup.jsx index 21ffb6dae..e50fa3f33 100644 --- a/src/features/device/DevicePopup.jsx +++ b/src/features/device/DevicePopup.jsx @@ -28,7 +28,7 @@ export function DevicePopup({ isOnline, ts, ...device }) { ? `${t('instance')}: ${device.instance_name}` : device.type} - + { +const Timer = ({ device, ts }) => { + const { t } = useTranslation() const { updated } = device const lastSeen = updated * 1000 const [since, setSince] = React.useState(getTimeUntil(lastSeen)) diff --git a/src/features/drawer/Extras.jsx b/src/features/drawer/Extras.jsx index 9ada71fcc..d427e17cc 100644 --- a/src/features/drawer/Extras.jsx +++ b/src/features/drawer/Extras.jsx @@ -9,6 +9,7 @@ import { WayfarerDrawer } from './Wayfarer' import { S2CellsDrawer } from './S2Cells' import { AdminDrawer } from './Admin' import { StationsDrawer } from './Stations' +import { TappablesDrawer } from './Tappables' function ExtrasComponent({ category, subItem }) { switch (category) { @@ -28,6 +29,8 @@ function ExtrasComponent({ category, subItem }) { return case 'stations': return subItem === 'maxBattles' && + case 'tappables': + return default: return null } diff --git a/src/features/drawer/Tappables.jsx b/src/features/drawer/Tappables.jsx new file mode 100644 index 000000000..0625db5a4 --- /dev/null +++ b/src/features/drawer/Tappables.jsx @@ -0,0 +1,19 @@ +// @ts-check +import * as React from 'react' + +import { useStorage } from '@store/useStorage' + +import { CollapsibleItem } from './components/CollapsibleItem' +import { SelectorListMemo } from './components/SelectorList' + +const BaseTappablesDrawer = () => { + const enabled = useStorage((s) => !!s.filters?.tappables?.enabled) + + return ( + + + + ) +} + +export const TappablesDrawer = React.memo(BaseTappablesDrawer) diff --git a/src/features/drawer/areas/AreaTable.jsx b/src/features/drawer/areas/AreaTable.jsx index 9bd65221d..ffdfcf83d 100644 --- a/src/features/drawer/areas/AreaTable.jsx +++ b/src/features/drawer/areas/AreaTable.jsx @@ -1,15 +1,24 @@ // @ts-check import * as React from 'react' import { useQuery } from '@apollo/client' +import { useTranslation } from 'react-i18next' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import CircularProgress from '@mui/material/CircularProgress' import Paper from '@mui/material/Paper' import Table from '@mui/material/Table' import TableBody from '@mui/material/TableBody' +import TableCell from '@mui/material/TableCell' import TableRow from '@mui/material/TableRow' import TableContainer from '@mui/material/TableContainer' +import Typography from '@mui/material/Typography' import { Query } from '@services/queries' import { useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' +import { useMapStore } from '@store/useMapStore' + +/** @typedef {{ id: string, name: string, lat: number, lon: number }} JumpResult */ import { AreaParent } from './Parent' import { AreaChild } from './Child' @@ -17,10 +26,26 @@ import { AreaChild } from './Child' export function ScanAreasTable() { /** @type {import('@apollo/client').QueryResult<{ scanAreasMenu: import('@rm/types').Config['areas']['scanAreasMenu'][string] }>} */ const { data, loading, error } = useQuery(Query.scanAreasMenu()) - const search = useStorage( - (s) => s.filters.scanAreas?.filter?.search?.toLowerCase() || '', + const { t, i18n } = useTranslation() + const rawSearch = useStorage((s) => s.filters.scanAreas?.filter?.search || '') + const search = React.useMemo(() => rawSearch.toLowerCase(), [rawSearch]) + const trimmedSearch = React.useMemo(() => rawSearch.trim(), [rawSearch]) + const { misc, general } = useMemory.getState().config + const jumpZoom = general?.scanAreasZoom || general?.startZoom || 12 + /** @type {[JumpResult[], React.Dispatch>]} */ + const [jumpResults, setJumpResults] = React.useState([]) + const [jumpLoading, setJumpLoading] = React.useState(false) + const [jumpError, setJumpError] = React.useState(false) + + const handleJump = React.useCallback( + (target) => { + const mapInstance = useMapStore.getState().map + if (mapInstance) { + mapInstance.flyTo([target.lat, target.lon], jumpZoom) + } + }, + [jumpZoom], ) - const { misc } = useMemory.getState().config /** @type {string[]} */ const allAreas = React.useMemo( @@ -64,6 +89,73 @@ export function ScanAreasTable() { [data, search], ) + const totalMatches = React.useMemo( + () => allRows.reduce((sum, area) => sum + area.children.length, 0), + [allRows], + ) + + const showJumpResults = + trimmedSearch.length >= 3 && totalMatches === 0 && !loading && !error + + React.useEffect(() => { + if (!showJumpResults) { + if (trimmedSearch.length < 3) { + setJumpResults([]) + } + setJumpLoading(false) + setJumpError(false) + return + } + + setJumpResults([]) + setJumpLoading(true) + const controller = new AbortController() + const timer = window.setTimeout(() => { + fetch( + `https://nominatim.openstreetmap.org/search?format=json&limit=5&q=${encodeURIComponent(trimmedSearch)}&accept-language=${encodeURIComponent(i18n.language || 'en')}`, + { signal: controller.signal, headers: { Accept: 'application/json' } }, + ) + .then((res) => { + if (!res.ok) throw new Error('Nominatim request failed') + return res.json() + }) + .then((json) => { + if (!Array.isArray(json)) { + setJumpResults([]) + return + } + setJumpResults( + json + .slice(0, 5) + .map((item) => ({ + id: String(item.place_id), + name: item.display_name, + lat: Number(item.lat), + lon: Number(item.lon), + })) + .filter( + (item) => + Number.isFinite(item.lat) && Number.isFinite(item.lon), + ), + ) + setJumpError(false) + }) + .catch((err) => { + if (err.name === 'AbortError') return + setJumpResults([]) + setJumpError(true) + }) + .finally(() => { + setJumpLoading(false) + }) + }, 400) + + return () => { + clearTimeout(timer) + controller.abort() + } + }, [showJumpResults, trimmedSearch, i18n.language]) + if (loading || error) return null return ( @@ -122,6 +214,61 @@ export function ScanAreasTable() { ) })} + {showJumpResults && ( + + + + {jumpLoading ? ( + + + + {t('searching')} + + + ) : jumpError ? ( + + {t('local_error')} + + ) : jumpResults.length ? ( + jumpResults.map((result) => ( + + )) + ) : ( + {t('no_options')} + )} + + {t('jump_to_areas_attribution')} + + + + + )} diff --git a/src/features/drawer/components/Section.jsx b/src/features/drawer/components/Section.jsx index e78274d48..749cd236e 100644 --- a/src/features/drawer/components/Section.jsx +++ b/src/features/drawer/components/Section.jsx @@ -28,6 +28,7 @@ const ADV_CATEGORIES = new Set([ 'pokestops', 'nests', 'stations', + 'tappables', ]) /** @param {{ category: keyof import('@rm/types').UIObject }} props */ diff --git a/src/features/drawer/components/SelectorList.jsx b/src/features/drawer/components/SelectorList.jsx index 2bf6addb6..1787ab510 100644 --- a/src/features/drawer/components/SelectorList.jsx +++ b/src/features/drawer/components/SelectorList.jsx @@ -56,6 +56,7 @@ function SelectorList({ category, subCategory, label, height = 400 }) { ) const easyMode = useStorage((s) => !!s.filters[category]?.easyMode) const search = useStorage((s) => s.searches[searchKey] || '') + const disableGutters = !['pokemon', 'tappables'].includes(category) const translated = React.useMemo( () => @@ -92,6 +93,8 @@ function SelectorList({ category, subCategory, label, height = 400 }) { switch (category) { case 'gyms': return key.startsWith('t') + case 'tappables': + return key.startsWith('q') && key !== 'q0' default: return Number.isInteger(Number(key.charAt(0))) } @@ -133,7 +136,7 @@ function SelectorList({ category, subCategory, label, height = 400 }) { return ( {translated.length > 10 && ( - + )} @@ -145,7 +148,7 @@ function SelectorList({ category, subCategory, label, height = 400 }) { /> )} {!!items.length && ( - + {t(search ? 'set_filtered' : 'set_all')} setAll('enable')}> diff --git a/src/features/gym/GymPopup.jsx b/src/features/gym/GymPopup.jsx index 937cbc752..a05b62b33 100644 --- a/src/features/gym/GymPopup.jsx +++ b/src/features/gym/GymPopup.jsx @@ -16,8 +16,8 @@ import FavoriteIcon from '@mui/icons-material/Favorite' import EmojiEventsIcon from '@mui/icons-material/EmojiEvents' import SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied' import RestaurantIcon from '@mui/icons-material/Restaurant' -import { useTheme } from '@mui/material/styles' import { useTranslation } from 'react-i18next' +import { useTheme } from '@mui/material/styles' import { useSyncData } from '@features/webhooks' import { useMemory } from '@store/useMemory' @@ -31,14 +31,17 @@ import { GenderIcon } from '@components/popups/GenderIcon' import { Navigation } from '@components/popups/Navigation' import { Coords } from '@components/popups/Coords' import { TimeStamp } from '@components/popups/TimeStamps' +import { BackgroundCard } from '@components/popups/BackgroundCard' import { useAnalytics } from '@hooks/useAnalytics' import { getTimeUntil } from '@utils/getTimeUntil' import { formatInterval } from '@utils/formatInterval' +import { usePokemonBackgroundVisuals } from '@hooks/usePokemonBackgroundVisuals' +import { getFormDisplay } from '@utils/getFormDisplay' import { useWebhook } from './useWebhook' /** - * Format deployed time as either "Xd Xh Xm" or "X:X:X" format + * Format deployed time as m:ss, h:mm:ss, or d:hh:mm:ss using the shortest layout * @param {number} intervalMs - Time interval in milliseconds * @returns {string} Formatted time string */ @@ -47,14 +50,162 @@ function formatDeployedTime(intervalMs) { const days = Math.floor(totalSeconds / 86400) const hours = Math.floor((totalSeconds % 86400) / 3600) const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + const pad = (value) => value.toString().padStart(2, '0') if (days > 0) { - // Format as "Xd Xh Xm" - return `${days}d ${hours}h ${minutes}m` + return `${days}:${pad(hours)}:${pad(minutes)}:${pad(seconds)}` } - // Format as "X:X:X" (HH:MM:SS) - const seconds = totalSeconds % 60 - return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` + if (hours > 0) { + return `${hours}:${pad(minutes)}:${pad(seconds)}` + } + return `${minutes}:${pad(seconds)}` +} + +/** + * Shared row layout between fallback and detailed defender displays. + * @param {{ + * borderColor: string | undefined + * tooltip?: string + * imageSrc?: string + * imageAlt?: string + * showBestBuddy?: boolean + * trailingContent?: React.ReactNode + * children: React.ReactNode + * }} props + */ +function DefenderRowLayout({ + borderColor, + tooltip, + imageSrc, + imageAlt, + showBestBuddy, + trailingContent, + children, +}) { + const { t } = useTranslation() + const Icons = useMemory((s) => s.Icons) + const bestBuddySrc = Icons?.getMisc('bestbuddy') + return ( +
+
+ {imageSrc ? ( + {imageAlt} + ) : null} + {showBestBuddy && bestBuddySrc ? ( + {t('best_buddy')} + ) : null} +
+
+ {children} +
+ {trailingContent || null} +
+ ) +} + +const mergeSxParts = (...parts) => { + const flattened = parts.reduce((acc, part) => { + if (!part) { + return acc + } + if (Array.isArray(part)) { + return acc.concat(part) + } + acc.push(part) + return acc + }, []) + if (!flattened.length) { + return undefined + } + return flattened.length === 1 ? flattened[0] : flattened +} + +function DefenderPrimaryText({ children, title, visualStyles, sx }) { + return ( + + {children} + + ) +} + +function DefenderSecondaryText({ children, visualStyles, sx }) { + return ( + + {children} + + ) } /** @@ -175,16 +326,72 @@ function DefendersModal({ gym, onClose }) { const { t, i18n } = useTranslation() const theme = useTheme() const Icons = useMemory((s) => s.Icons) + const resolvePokemonBackgroundVisual = usePokemonBackgroundVisuals() const numFormatter = new Intl.NumberFormat(i18n.language) + const fallbackBackground = gym.guarding_pokemon_display?.background ?? null const defenders = gym.defenders || [] const updatedMs = defenders.length && defenders[0].deployed_ms + defenders[0].deployed_time * 1000 - const now = Date.now() + const [now, setNow] = React.useState(Date.now()) + const fallbackVisuals = React.useMemo( + () => resolvePokemonBackgroundVisual(fallbackBackground), + [resolvePokemonBackgroundVisual, fallbackBackground], + ) + + React.useEffect(() => { + const interval = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(interval) + }, []) // Fallback to basic gym data when detailed defender info isn't available const useFallbackData = !defenders.length && gym.team_id > 0 && gym.guarding_pokemon_id + let fallbackRow = null + if (useFallbackData) { + const fallbackImageSrc = + Icons && + (gym.guarding_pokemon_display + ? Icons.getPokemonByDisplay( + gym.guarding_pokemon_id, + gym.guarding_pokemon_display, + ) + : Icons.getPokemon?.(gym.guarding_pokemon_id)) + + const fallbackHasBackground = fallbackVisuals.hasBackground + const fallbackBorderColor = fallbackVisuals.borderColor + const fallbackPrimaryText = fallbackVisuals.styles?.primaryText + const fallbackSecondaryText = fallbackVisuals.styles?.secondaryText + const rowContent = ( + + + {t(`poke_${gym.guarding_pokemon_id}`)} + + + + {gym.total_cp && + `${t('total_cp')}: ${numFormatter.format(gym.total_cp)}`} + + + ) + + fallbackRow = ( + + {rowContent} + + ) + } return ( -
-
- {t(`poke_${gym.guarding_pokemon_id}`)} - {gym.guarding_pokemon_display?.badge === 1 && ( - {t('best_buddy')} - )} -
-
-
- {gym.guarding_pokemon_display?.badge === 1 && ( - {t('best_buddy')} - )} - {t(`poke_${gym.guarding_pokemon_id}`)} -
- -
- {gym.total_cp && - `${t('total_cp')}: ${numFormatter.format(gym.total_cp)}`} -
-
-
+ {fallbackRow} -
- {t(`poke_${def.pokemon_id}`)} - {def.badge === 1 && ( - {t('best_buddy')} - )} -
-
- {/* First line: Pokemon name CP{currentCP}/{fullCP} */} -
- {t(`poke_${def.pokemon_id}`)} -
- + const visuals = resolvePokemonBackgroundVisual(def.background) + const { + borderColor, + primaryColor, + backgroundMeta, + hasBackground, + styles: defenderStyles = {}, + } = visuals + const primaryTextStyles = defenderStyles.primaryText + const secondaryTextStyles = defenderStyles.secondaryText + const rowKey = `${def.pokemon_id}-${def.deployed_ms}` + const rowContent = ( + -
- - {def.battles_won || 0} -
-
+ {/* Heart background */} + - - {def.battles_lost || 0} -
-
+ {/* Heart fill */} + + {/* Heart cracks for rounds */} + - - {def.times_fed || 0} -
+ {/* Show cracks based on health: */} + {predictedMotivation <= 2 / 3 && ( + // Always show top crack if predictedMotivation <= 2/3 + + )} + {predictedMotivation <= 1 / 3 && ( + // Show bottom crack only if predictedMotivation <= 1/3 + + )} +
+ } + > + {/* First line: Pokemon name CP{currentCP}/{fullCP} */} + + {t(`poke_${def.pokemon_id}`)} + -
- CP{currentCP}/{fullCP}{' '} - {formatDeployedTime(def.deployed_ms + now - updatedMs)} -
- -
- {/* Heart outline */} - - {/* Heart background */} - - {/* Heart fill */} - + + + {def.battles_won || 0} + + + - {/* Heart cracks for rounds */} - + + + {def.battles_lost || 0} + + + - {/* Show cracks based on health: */} - {predictedMotivation <= 2 / 3 && ( - // Always show top crack if predictedMotivation <= 2/3 - - )} - {predictedMotivation <= 1 / 3 && ( - // Show bottom crack only if predictedMotivation <= 1/3 - - )} - -
- + + + {def.times_fed || 0} + + + + + + CP{currentCP}/{fullCP}{' '} + {formatDeployedTime(def.deployed_ms + now - updatedMs)} + + + ) + + return ( + + + {rowContent} + + ) })}
@@ -767,6 +859,8 @@ const RaidImage = ({ raid_pokemon_id, raid_pokemon_form, raid_pokemon_gender, + raid_pokemon_costume, + raid_pokemon_alignment, }) => { const { t } = useTranslation() const Icons = useMemory((s) => s.Icons) @@ -785,10 +879,27 @@ const RaidImage = ({ return pokemon[id]?.types || [] } + const raidDisplayIcon = + raid_pokemon_id && Icons?.getPokemon + ? Icons.getPokemon( + raid_pokemon_id, + raid_pokemon_form, + 0, + raid_pokemon_gender, + raid_pokemon_costume, + raid_pokemon_alignment, + ) || raidIconUrl + : raidIconUrl + return ( - {raidIconUrl} + {raidDisplayIcon} @@ -919,19 +1030,6 @@ const RaidInfo = ({ return `${t('tier')} ${raidLevel}` } - const getRaidForm = (id, form, costume) => { - if (costume) { - return t(`costume_${costume}`, 'Unknown Costume') - } - if (form) { - const raidForm = t(`form_${form}`) - if (raidForm === t('form_29') || !raidForm) { - return '' - } - return `${raidForm} ${t('form')}` - } - } - return ( - {getRaidForm( + {getFormDisplay( raid_pokemon_id, raid_pokemon_form, raid_pokemon_costume, diff --git a/src/features/gym/GymTile.jsx b/src/features/gym/GymTile.jsx index 139731905..b7733e7e3 100644 --- a/src/features/gym/GymTile.jsx +++ b/src/features/gym/GymTile.jsx @@ -9,9 +9,11 @@ import { basicEqualFn, useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' import { useOpacity } from '@hooks/useOpacity' import { useForcePopup } from '@hooks/useForcePopup' +import { useManualPopupTracker } from '@hooks/useManualPopupTracker' import { sendNotification } from '@services/desktopNotification' import { TooltipWrapper } from '@components/ToolTipWrapper' import { getTimeUntil } from '@utils/getTimeUntil' +import { getFormDisplay } from '@utils/getFormDisplay' import { useRouteStore, resolveRoutePoiKey } from '@features/route' import { gymMarker } from './gymMarker' @@ -153,10 +155,22 @@ const BaseGymTile = (gym) => { ? gym.raid_end_timestamp : gym.raid_battle_timestamp + const rangePathOptions = React.useMemo( + () => ({ color: getColor(gym.team_id), weight: 0.5 }), + [gym.team_id], + ) + useForcePopup(gym.id, markerRef) useMarkerTimer(timerToDisplay, markerRef, () => setStateChange(!stateChange)) + const handlePopupOpen = useManualPopupTracker('gyms', gym.id) + const raidForm = getFormDisplay( + gym.raid_pokemon_id, + gym.raid_pokemon_form, + gym.raid_pokemon_costume, + ) if (hasRaid) { sendNotification(`${gym.id}-${hasHatched}`, gym.name, 'raids', { + manualId: gym.id, lat: gym.lat, lon: gym.lon, expire: timerToDisplay, @@ -164,7 +178,7 @@ const BaseGymTile = (gym) => { body: `${t(`${hasHatched ? `raid` : 'egg'}_${gym.raid_level}`)}\n${ gym.raid_pokemon_evolution ? t(`evo_${gym.raid_pokemon_evolution}`) : '' }${gym.raid_pokemon_id ? t(`poke_${gym.raid_pokemon_id}`) : ''}${ - gym.raid_pokemon_form ? t(`form_${gym.raid_pokemon_form}`) : '' + raidForm && gym.raid_pokemon_id ? ` ${raidForm}` : '' }${gym.raid_pokemon_id ? '\n' : ''}${ getTimeUntil(timerToDisplay * 1000, true).str }`, @@ -193,6 +207,7 @@ const BaseGymTile = (gym) => { selectPoi(gym.id, gym.lat, gym.lon) } }, + popupopen: handlePopupOpen, }} > @@ -210,16 +225,14 @@ const BaseGymTile = (gym) => { )} {!!customRange && ( )} diff --git a/src/features/nest/NestTile.jsx b/src/features/nest/NestTile.jsx index 69bc13f1b..a6ec1ee41 100644 --- a/src/features/nest/NestTile.jsx +++ b/src/features/nest/NestTile.jsx @@ -7,6 +7,7 @@ import { GeoJSON, Marker, Popup } from 'react-leaflet' import { basicEqualFn, useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' import { useForcePopup } from '@hooks/useForcePopup' +import { useManualPopupTracker } from '@hooks/useManualPopupTracker' import { nestMarker } from './nestMarker' import { NestPopup } from './NestPopup' @@ -19,6 +20,7 @@ import { NestPopup } from './NestPopup' const BaseNestTile = (nest) => { const recent = Date.now() / 1000 - nest.updated < 172800000 const internalId = `${nest.pokemon_id}-${nest.pokemon_form}` + const handlePopupOpen = useManualPopupTracker('nests', nest.id) const size = useStorage( (s) => s.filters.nests.filter[internalId]?.size || 'md', @@ -39,13 +41,17 @@ const BaseNestTile = (nest) => { iconSize={iconSize} recent={recent} nest={nest} + eventHandlers={{ popupopen: handlePopupOpen }} > )} - + @@ -107,10 +113,10 @@ const NestMarker = ({ /** * - * @param {{ polygon_path: string, children?: React.ReactNode }} props + * @param {{ polygon_path: string, children?: React.ReactNode, eventHandlers?: import('leaflet').LeafletEventHandlerFnMap }} props * @returns */ -const NestGeoJSON = ({ polygon_path, children }) => { +const NestGeoJSON = ({ polygon_path, eventHandlers, children }) => { const showPolygons = useStorage((s) => s.filters.nests.polygons) const geometry = React.useMemo(() => { @@ -126,7 +132,11 @@ const NestGeoJSON = ({ polygon_path, children }) => { if (!showPolygons) { return null } - return {children} + return ( + + {children} + + ) } export const NestTile = React.memo( diff --git a/src/features/pokemon/PokemonPopup.jsx b/src/features/pokemon/PokemonPopup.jsx index 140b4967d..da91124f6 100644 --- a/src/features/pokemon/PokemonPopup.jsx +++ b/src/features/pokemon/PokemonPopup.jsx @@ -29,6 +29,10 @@ import { getTimeUntil } from '@utils/getTimeUntil' import { StatusIcon } from '@components/StatusIcon' import { readableProbability } from '@utils/readableProbability' import { GET_POKEMON_SHINY_STATS } from '@services/queries/pokemon' +import { GET_TAPPABLE_BY_ID } from '@services/queries/tappable' +import { usePokemonBackgroundVisual } from '@hooks/usePokemonBackgroundVisuals' +import { BackgroundCard } from '@components/popups/BackgroundCard' +import { getFormDisplay } from '@utils/getFormDisplay' const rowClass = { width: 30, fontWeight: 'bold' } @@ -38,6 +42,28 @@ const leagueLookup = { master: '9000', } +const TAPPABLE_SEEN_TYPES = new Set([ + 'tappable_encounter', + 'tappable_lure_encounter', +]) + +const formatTappableType = (type, t, i18n) => { + if (!type) return '' + const cleaned = type + .replace(/^TAPPABLE_TYPE_/, '') + .toLowerCase() + .replace(/_/g, ' ') + const translationKey = `tappable_type_${cleaned.replace(/\s+/g, '_')}` + if (i18n?.exists?.(translationKey)) { + return t(translationKey) + } + return cleaned + .split(' ') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + /** @param {number} ivPercent */ const getColor = (ivPercent) => { switch (true) { @@ -62,7 +88,7 @@ export function PokemonPopup({ pokemon, iconUrl, isTutorial = false }) { const perms = useMemory((s) => s.auth.perms) const timeOfDay = useMemory((s) => s.timeOfDay) const metaData = useMemory((s) => s.masterfile.pokemon[pokemon_id]) - const Icons = useMemory((s) => s.Icons) + const backgroundVisuals = usePokemonBackgroundVisual(pokemon.background) const userSettings = useStorage((s) => s.userSettings.pokemon) const pokePerms = isTutorial @@ -71,7 +97,19 @@ export function PokemonPopup({ pokemon, iconUrl, isTutorial = false }) { iv: true, } : perms - const popups = useStorage((s) => s.popups) + const pokemonExtrasOpen = useStorage((s) => !!s.popups.pokemonExtras) + const pokemonPvpOpen = useStorage((s) => !!s.popups.pokemonPvp) + const handlePokemonSectionToggle = React.useCallback((section) => { + const currentKey = section === 'extras' ? 'pokemonExtras' : 'pokemonPvp' + const oppositeKey = section === 'extras' ? 'pokemonPvp' : 'pokemonExtras' + useStorage.setState((prev) => ({ + popups: { + ...prev.popups, + [currentKey]: !prev.popups[currentKey], + [oppositeKey]: false, + }, + })) + }, []) const hasLeagues = cleanPvp ? Object.keys(cleanPvp) : [] const hasStats = iv || cp @@ -84,6 +122,19 @@ export function PokemonPopup({ pokemon, iconUrl, isTutorial = false }) { const [shinyStats, setShinyStats] = React.useState(null) const pendingShinyKey = React.useRef(null) const [loadShinyStats] = useLazyQuery(GET_POKEMON_SHINY_STATS) + const canQueryTappable = !isTutorial && !!perms?.tappables + const showTappableSource = React.useMemo( + () => canQueryTappable && TAPPABLE_SEEN_TYPES.has(pokemon.seen_type), + [canQueryTappable, pokemon.seen_type], + ) + const [tappableSource, setTappableSource] = React.useState(null) + const tappableRequestToken = React.useRef(0) + const [loadTappableSource, { loading: tappableLoading }] = useLazyQuery( + GET_TAPPABLE_BY_ID, + { + fetchPolicy: 'cache-first', + }, + ) React.useEffect(() => { setShinyStats(null) @@ -97,6 +148,18 @@ export function PokemonPopup({ pokemon, iconUrl, isTutorial = false }) { } }, [supportsShinyStats]) + React.useEffect(() => { + setTappableSource(null) + tappableRequestToken.current += 1 + }, [pokemon.id]) + + React.useEffect(() => { + if (!showTappableSource) { + setTappableSource(null) + tappableRequestToken.current += 1 + } + }, [showTappableSource]) + React.useEffect(() => { if (!supportsShinyStats) { pendingShinyKey.current = null @@ -135,97 +198,149 @@ export function PokemonPopup({ pokemon, iconUrl, isTutorial = false }) { } }, [supportsShinyStats, shinyStats, shinyKey, loadShinyStats]) + React.useEffect(() => { + if (!showTappableSource || !canQueryTappable || !pokemon.id) { + return + } + const tappableId = pokemon.id + tappableRequestToken.current += 1 + const requestToken = tappableRequestToken.current + loadTappableSource({ + variables: { id: tappableId }, + }) + .then((response) => { + if (tappableRequestToken.current !== requestToken) { + return + } + setTappableSource(response?.data?.tappableById || null) + }) + .catch(() => { + if (tappableRequestToken.current !== requestToken) { + return + } + setTappableSource(null) + }) + return () => { + tappableRequestToken.current += 1 + } + }, [showTappableSource, canQueryTappable, pokemon.id, loadTappableSource]) + useAnalytics( 'Popup', `ID: ${pokemon.pokemon_id} IV: ${pokemon.iv}% PVP: #${pokemon.bestPvp}`, 'Pokemon', ) - return ( - - -
- {pokemon.seen_type !== 'encounter' && ( - - - {t(`seen_${pokemon.seen_type}`, '')} - - - )} - {pokemon.seen_type === 'nearby_cell' && ( - {t('pokemon_cell')} - )} - {!!pokemon.expire_timestamp && ( - - )} - {hasStats && pokePerms.iv && ( - <> - - - - )} - +
+ {pokemon.seen_type !== 'encounter' && ( + + + {t(`seen_${pokemon.seen_type}`, '')} + {showTappableSource && ( + + )} + + + )} + {pokemon.seen_type === 'nearby_cell' && ( + {t('pokemon_cell')} + )} + {!!pokemon.expire_timestamp && ( + + )} + {hasStats && pokePerms.iv && ( + <> + + + + )} + + +