diff --git a/biome.jsonc b/biome.jsonc index e1aac032597..2433ba52010 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.3/schema.json", + "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", "vcs": { "enabled": true, "clientKind": "git", @@ -98,7 +98,9 @@ "useTrimStartEnd": "error", "useReadonlyClassProperties": { "level": "info", // TODO: Graduate to error eventually - "options": { "checkAllProperties": true } + // NOTE: "checkAllProperties" has an immature implementation that + // causes many false positives across files. Enable if/when maturity improves + "options": { "checkAllProperties": false } }, "useConsistentObjectDefinitions": { "level": "error", @@ -209,11 +211,15 @@ "nursery": { "noUselessUndefined": "error", "useMaxParams": { - "level": "warn", // TODO: Change to "error"... eventually... - "options": { "max": 4 } // A lot of stuff has a few params, but + "level": "info", // TODO: Change to "error"... eventually... + "options": { "max": 7 } }, "noShadow": "warn", // TODO: refactor and make "error" - "noNonNullAssertedOptionalChain": "warn" // TODO: refactor and make "error" + "noNonNullAssertedOptionalChain": "warn", // TODO: refactor and make "error" + "noDuplicateDependencies": "error", + "noImportCycles": "error", + // TODO: Change to error once promises are used properly + "noMisusedPromises": "info" } } }, @@ -248,16 +254,9 @@ }, // Overrides to prevent unused import removal inside `overrides.ts`, enums & `.d.ts` files (for TSDoc linkcodes), - // as well as inside script boilerplate files. + // as well as inside script boilerplate files (whose imports will _presumably_ be used in the generated file). { - // TODO: Rename existing boilerplates in the folder and remove this last alias - "includes": [ - "**/src/overrides.ts", - "**/src/enums/**/*", - "**/*.d.ts", - "scripts/**/*.boilerplate.ts", - "**/boilerplates/*.ts" - ], + "includes": ["**/src/overrides.ts", "**/src/enums/**/*", "**/*.d.ts", "scripts/**/*.boilerplate.ts"], "linter": { "rules": { "correctness": { diff --git a/package.json b/package.json index ee5b001a589..ac8bca50f76 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "start:podman": "vite --mode development --host 0.0.0.0 --port $PORT", "build": "vite build", "build:beta": "vite build --mode beta", + "build:dev": "vite build --mode development", "preview": "vite preview", "test": "vitest run --no-isolate", "test:cov": "vitest run --coverage --no-isolate", @@ -32,7 +33,7 @@ "update-locales:remote": "git submodule update --progress --init --recursive --force --remote" }, "devDependencies": { - "@biomejs/biome": "2.2.3", + "@biomejs/biome": "2.2.4", "@ls-lint/ls-lint": "2.3.1", "@types/crypto-js": "^4.2.0", "@types/jsdom": "^21.1.7", @@ -47,12 +48,12 @@ "lefthook": "^1.12.2", "msw": "^2.10.4", "phaser3spectorjs": "^0.0.8", - "typedoc": "0.28.7", + "typedoc": "^0.28.13", "typedoc-github-theme": "^0.3.1", "typedoc-plugin-coverage": "^4.0.1", "typedoc-plugin-mdn-links": "^5.0.9", - "typescript": "^5.8.3", - "vite": "^7.0.6", + "typescript": "^5.9.2", + "vite": "^7.0.7", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4", "vitest-canvas-mock": "^0.3.3" @@ -73,5 +74,5 @@ "engines": { "node": ">=22.0.0" }, - "packageManager": "pnpm@10.14.0" + "packageManager": "pnpm@10.17.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e750095a4c5..50a8b17b366 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 4.2.0 i18next: specifier: ^24.2.3 - version: 24.2.3(typescript@5.8.3) + version: 24.2.3(typescript@5.9.2) i18next-browser-languagedetector: specifier: ^8.2.0 version: 8.2.0 @@ -28,7 +28,7 @@ importers: version: 3.0.2 i18next-korean-postposition-processor: specifier: ^1.0.0 - version: 1.0.0(i18next@24.2.3(typescript@5.8.3)) + version: 1.0.0(i18next@24.2.3(typescript@5.9.2)) json-stable-stringify: specifier: ^1.3.0 version: 1.3.0 @@ -43,8 +43,8 @@ importers: version: 1.80.16(graphology-types@0.24.8) devDependencies: '@biomejs/biome': - specifier: 2.2.3 - version: 2.2.3 + specifier: 2.2.4 + version: 2.2.4 '@ls-lint/ls-lint': specifier: 2.3.1 version: 2.3.1 @@ -59,7 +59,7 @@ importers: version: 22.16.5 '@vitest/coverage-istanbul': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@22.16.5)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.5)(typescript@5.8.3))(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/node@22.16.5)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.5)(typescript@5.9.2))(yaml@2.8.1)) '@vitest/expect': specifier: ^3.2.4 version: 3.2.4 @@ -83,37 +83,37 @@ importers: version: 1.12.2 msw: specifier: ^2.10.4 - version: 2.10.4(@types/node@22.16.5)(typescript@5.8.3) + version: 2.10.4(@types/node@22.16.5)(typescript@5.9.2) phaser3spectorjs: specifier: ^0.0.8 version: 0.0.8 typedoc: - specifier: 0.28.7 - version: 0.28.7(typescript@5.8.3) + specifier: ^0.28.13 + version: 0.28.13(typescript@5.9.2) typedoc-github-theme: specifier: ^0.3.1 - version: 0.3.1(typedoc@0.28.7(typescript@5.8.3)) + version: 0.3.1(typedoc@0.28.13(typescript@5.9.2)) typedoc-plugin-coverage: specifier: ^4.0.1 - version: 4.0.1(typedoc@0.28.7(typescript@5.8.3)) + version: 4.0.1(typedoc@0.28.13(typescript@5.9.2)) typedoc-plugin-mdn-links: specifier: ^5.0.9 - version: 5.0.9(typedoc@0.28.7(typescript@5.8.3)) + version: 5.0.9(typedoc@0.28.13(typescript@5.9.2)) typescript: - specifier: ^5.8.3 - version: 5.8.3 + specifier: ^5.9.2 + version: 5.9.2 vite: - specifier: ^7.0.6 - version: 7.0.6(@types/node@22.16.5)(yaml@2.8.1) + specifier: ^7.0.7 + version: 7.0.7(@types/node@22.16.5)(yaml@2.8.1) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.8.3)(vite@7.0.6(@types/node@22.16.5)(yaml@2.8.1)) + version: 5.1.4(typescript@5.9.2)(vite@7.0.7(@types/node@22.16.5)(yaml@2.8.1)) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.16.5)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.5)(typescript@5.8.3))(yaml@2.8.1) + version: 3.2.4(@types/node@22.16.5)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.5)(typescript@5.9.2))(yaml@2.8.1) vitest-canvas-mock: specifier: ^0.3.3 - version: 0.3.3(vitest@3.2.4(@types/node@22.16.5)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.5)(typescript@5.8.3))(yaml@2.8.1)) + version: 0.3.3(vitest@3.2.4(@types/node@22.16.5)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.5)(typescript@5.9.2))(yaml@2.8.1)) packages: @@ -195,55 +195,55 @@ packages: resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.2.3': - resolution: {integrity: sha512-9w0uMTvPrIdvUrxazZ42Ib7t8Y2yoGLKLdNne93RLICmaHw7mcLv4PPb5LvZLJF3141gQHiCColOh/v6VWlWmg==} + '@biomejs/biome@2.2.4': + resolution: {integrity: sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.2.3': - resolution: {integrity: sha512-OrqQVBpadB5eqzinXN4+Q6honBz+tTlKVCsbEuEpljK8ASSItzIRZUA02mTikl3H/1nO2BMPFiJ0nkEZNy3B1w==} + '@biomejs/cli-darwin-arm64@2.2.4': + resolution: {integrity: sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.2.3': - resolution: {integrity: sha512-OCdBpb1TmyfsTgBAM1kPMXyYKTohQ48WpiN9tkt9xvU6gKVKHY4oVwteBebiOqyfyzCNaSiuKIPjmHjUZ2ZNMg==} + '@biomejs/cli-darwin-x64@2.2.4': + resolution: {integrity: sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.2.3': - resolution: {integrity: sha512-q3w9jJ6JFPZPeqyvwwPeaiS/6NEszZ+pXKF+IczNo8Xj6fsii45a4gEEicKyKIytalV+s829ACZujQlXAiVLBQ==} + '@biomejs/cli-linux-arm64-musl@2.2.4': + resolution: {integrity: sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-arm64@2.2.3': - resolution: {integrity: sha512-g/Uta2DqYpECxG+vUmTAmUKlVhnGEcY7DXWgKP8ruLRa8Si1QHsWknPY3B/wCo0KgYiFIOAZ9hjsHfNb9L85+g==} + '@biomejs/cli-linux-arm64@2.2.4': + resolution: {integrity: sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-x64-musl@2.2.3': - resolution: {integrity: sha512-y76Dn4vkP1sMRGPFlNc+OTETBhGPJ90jY3il6jAfur8XWrYBQV3swZ1Jo0R2g+JpOeeoA0cOwM7mJG6svDz79w==} + '@biomejs/cli-linux-x64-musl@2.2.4': + resolution: {integrity: sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-linux-x64@2.2.3': - resolution: {integrity: sha512-LEtyYL1fJsvw35CxrbQ0gZoxOG3oZsAjzfRdvRBRHxOpQ91Q5doRVjvWW/wepgSdgk5hlaNzfeqpyGmfSD0Eyw==} + '@biomejs/cli-linux-x64@2.2.4': + resolution: {integrity: sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-win32-arm64@2.2.3': - resolution: {integrity: sha512-Ms9zFYzjcJK7LV+AOMYnjN3pV3xL8Prxf9aWdDVL74onLn5kcvZ1ZMQswE5XHtnd/r/0bnUd928Rpbs14BzVmA==} + '@biomejs/cli-win32-arm64@2.2.4': + resolution: {integrity: sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.2.3': - resolution: {integrity: sha512-gvCpewE7mBwBIpqk1YrUqNR4mCiyJm6UI3YWQQXkedSSEwzRdodRpaKhbdbHw1/hmTWOVXQ+Eih5Qctf4TCVOQ==} + '@biomejs/cli-win32-x64@2.2.4': + resolution: {integrity: sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -285,158 +285,158 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} - '@esbuild/aix-ppc64@0.25.8': - resolution: {integrity: sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==} + '@esbuild/aix-ppc64@0.25.9': + resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.8': - resolution: {integrity: sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==} + '@esbuild/android-arm64@0.25.9': + resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.8': - resolution: {integrity: sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==} + '@esbuild/android-arm@0.25.9': + resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.8': - resolution: {integrity: sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==} + '@esbuild/android-x64@0.25.9': + resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.8': - resolution: {integrity: sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==} + '@esbuild/darwin-arm64@0.25.9': + resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.8': - resolution: {integrity: sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==} + '@esbuild/darwin-x64@0.25.9': + resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.8': - resolution: {integrity: sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==} + '@esbuild/freebsd-arm64@0.25.9': + resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.8': - resolution: {integrity: sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==} + '@esbuild/freebsd-x64@0.25.9': + resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.8': - resolution: {integrity: sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==} + '@esbuild/linux-arm64@0.25.9': + resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.8': - resolution: {integrity: sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==} + '@esbuild/linux-arm@0.25.9': + resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.8': - resolution: {integrity: sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==} + '@esbuild/linux-ia32@0.25.9': + resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.8': - resolution: {integrity: sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==} + '@esbuild/linux-loong64@0.25.9': + resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.8': - resolution: {integrity: sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==} + '@esbuild/linux-mips64el@0.25.9': + resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.8': - resolution: {integrity: sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==} + '@esbuild/linux-ppc64@0.25.9': + resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.8': - resolution: {integrity: sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==} + '@esbuild/linux-riscv64@0.25.9': + resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.8': - resolution: {integrity: sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==} + '@esbuild/linux-s390x@0.25.9': + resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.8': - resolution: {integrity: sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==} + '@esbuild/linux-x64@0.25.9': + resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.8': - resolution: {integrity: sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==} + '@esbuild/netbsd-arm64@0.25.9': + resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.8': - resolution: {integrity: sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==} + '@esbuild/netbsd-x64@0.25.9': + resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.8': - resolution: {integrity: sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==} + '@esbuild/openbsd-arm64@0.25.9': + resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.8': - resolution: {integrity: sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==} + '@esbuild/openbsd-x64@0.25.9': + resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.8': - resolution: {integrity: sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==} + '@esbuild/openharmony-arm64@0.25.9': + resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.8': - resolution: {integrity: sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==} + '@esbuild/sunos-x64@0.25.9': + resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.8': - resolution: {integrity: sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==} + '@esbuild/win32-arm64@0.25.9': + resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.8': - resolution: {integrity: sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==} + '@esbuild/win32-ia32@0.25.9': + resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.8': - resolution: {integrity: sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==} + '@esbuild/win32-x64@0.25.9': + resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -612,117 +612,122 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@rollup/rollup-android-arm-eabi@4.46.1': - resolution: {integrity: sha512-oENme6QxtLCqjChRUUo3S6X8hjCXnWmJWnedD7VbGML5GUtaOtAyx+fEEXnBXVf0CBZApMQU0Idwi0FmyxzQhw==} + '@rollup/rollup-android-arm-eabi@4.50.1': + resolution: {integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.46.1': - resolution: {integrity: sha512-OikvNT3qYTl9+4qQ9Bpn6+XHM+ogtFadRLuT2EXiFQMiNkXFLQfNVppi5o28wvYdHL2s3fM0D/MZJ8UkNFZWsw==} + '@rollup/rollup-android-arm64@4.50.1': + resolution: {integrity: sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.46.1': - resolution: {integrity: sha512-EFYNNGij2WllnzljQDQnlFTXzSJw87cpAs4TVBAWLdkvic5Uh5tISrIL6NRcxoh/b2EFBG/TK8hgRrGx94zD4A==} + '@rollup/rollup-darwin-arm64@4.50.1': + resolution: {integrity: sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.46.1': - resolution: {integrity: sha512-ZaNH06O1KeTug9WI2+GRBE5Ujt9kZw4a1+OIwnBHal92I8PxSsl5KpsrPvthRynkhMck4XPdvY0z26Cym/b7oA==} + '@rollup/rollup-darwin-x64@4.50.1': + resolution: {integrity: sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.46.1': - resolution: {integrity: sha512-n4SLVebZP8uUlJ2r04+g2U/xFeiQlw09Me5UFqny8HGbARl503LNH5CqFTb5U5jNxTouhRjai6qPT0CR5c/Iig==} + '@rollup/rollup-freebsd-arm64@4.50.1': + resolution: {integrity: sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.46.1': - resolution: {integrity: sha512-8vu9c02F16heTqpvo3yeiu7Vi1REDEC/yES/dIfq3tSXe6mLndiwvYr3AAvd1tMNUqE9yeGYa5w7PRbI5QUV+w==} + '@rollup/rollup-freebsd-x64@4.50.1': + resolution: {integrity: sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.46.1': - resolution: {integrity: sha512-K4ncpWl7sQuyp6rWiGUvb6Q18ba8mzM0rjWJ5JgYKlIXAau1db7hZnR0ldJvqKWWJDxqzSLwGUhA4jp+KqgDtQ==} + '@rollup/rollup-linux-arm-gnueabihf@4.50.1': + resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.46.1': - resolution: {integrity: sha512-YykPnXsjUjmXE6j6k2QBBGAn1YsJUix7pYaPLK3RVE0bQL2jfdbfykPxfF8AgBlqtYbfEnYHmLXNa6QETjdOjQ==} + '@rollup/rollup-linux-arm-musleabihf@4.50.1': + resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.46.1': - resolution: {integrity: sha512-kKvqBGbZ8i9pCGW3a1FH3HNIVg49dXXTsChGFsHGXQaVJPLA4f/O+XmTxfklhccxdF5FefUn2hvkoGJH0ScWOA==} + '@rollup/rollup-linux-arm64-gnu@4.50.1': + resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.46.1': - resolution: {integrity: sha512-zzX5nTw1N1plmqC9RGC9vZHFuiM7ZP7oSWQGqpbmfjK7p947D518cVK1/MQudsBdcD84t6k70WNczJOct6+hdg==} + '@rollup/rollup-linux-arm64-musl@4.50.1': + resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loongarch64-gnu@4.46.1': - resolution: {integrity: sha512-O8CwgSBo6ewPpktFfSDgB6SJN9XDcPSvuwxfejiddbIC/hn9Tg6Ai0f0eYDf3XvB/+PIWzOQL+7+TZoB8p9Yuw==} + '@rollup/rollup-linux-loongarch64-gnu@4.50.1': + resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.46.1': - resolution: {integrity: sha512-JnCfFVEKeq6G3h3z8e60kAp8Rd7QVnWCtPm7cxx+5OtP80g/3nmPtfdCXbVl063e3KsRnGSKDHUQMydmzc/wBA==} + '@rollup/rollup-linux-ppc64-gnu@4.50.1': + resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.46.1': - resolution: {integrity: sha512-dVxuDqS237eQXkbYzQQfdf/njgeNw6LZuVyEdUaWwRpKHhsLI+y4H/NJV8xJGU19vnOJCVwaBFgr936FHOnJsQ==} + '@rollup/rollup-linux-riscv64-gnu@4.50.1': + resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.46.1': - resolution: {integrity: sha512-CvvgNl2hrZrTR9jXK1ye0Go0HQRT6ohQdDfWR47/KFKiLd5oN5T14jRdUVGF4tnsN8y9oSfMOqH6RuHh+ck8+w==} + '@rollup/rollup-linux-riscv64-musl@4.50.1': + resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.46.1': - resolution: {integrity: sha512-x7ANt2VOg2565oGHJ6rIuuAon+A8sfe1IeUx25IKqi49OjSr/K3awoNqr9gCwGEJo9OuXlOn+H2p1VJKx1psxA==} + '@rollup/rollup-linux-s390x-gnu@4.50.1': + resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.46.1': - resolution: {integrity: sha512-9OADZYryz/7E8/qt0vnaHQgmia2Y0wrjSSn1V/uL+zw/i7NUhxbX4cHXdEQ7dnJgzYDS81d8+tf6nbIdRFZQoQ==} + '@rollup/rollup-linux-x64-gnu@4.50.1': + resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.46.1': - resolution: {integrity: sha512-NuvSCbXEKY+NGWHyivzbjSVJi68Xfq1VnIvGmsuXs6TCtveeoDRKutI5vf2ntmNnVq64Q4zInet0UDQ+yMB6tA==} + '@rollup/rollup-linux-x64-musl@4.50.1': + resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==} cpu: [x64] os: [linux] - '@rollup/rollup-win32-arm64-msvc@4.46.1': - resolution: {integrity: sha512-mWz+6FSRb82xuUMMV1X3NGiaPFqbLN9aIueHleTZCc46cJvwTlvIh7reQLk4p97dv0nddyewBhwzryBHH7wtPw==} + '@rollup/rollup-openharmony-arm64@4.50.1': + resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.50.1': + resolution: {integrity: sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.46.1': - resolution: {integrity: sha512-7Thzy9TMXDw9AU4f4vsLNBxh7/VOKuXi73VH3d/kHGr0tZ3x/ewgL9uC7ojUKmH1/zvmZe2tLapYcZllk3SO8Q==} + '@rollup/rollup-win32-ia32-msvc@4.50.1': + resolution: {integrity: sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.46.1': - resolution: {integrity: sha512-7GVB4luhFmGUNXXJhH2jJwZCFB3pIOixv2E3s17GQHBFUOQaISlt7aGcQgqvCaDSxTZJUzlK/QJ1FN8S94MrzQ==} + '@rollup/rollup-win32-x64-msvc@4.50.1': + resolution: {integrity: sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==} cpu: [x64] os: [win32] - '@shikijs/engine-oniguruma@3.12.2': - resolution: {integrity: sha512-hozwnFHsLvujK4/CPVHNo3Bcg2EsnG8krI/ZQ2FlBlCRpPZW4XAEQmEwqegJsypsTAN9ehu2tEYe30lYKSZW/w==} + '@shikijs/engine-oniguruma@3.13.0': + resolution: {integrity: sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg==} - '@shikijs/langs@3.12.2': - resolution: {integrity: sha512-bVx5PfuZHDSHoBal+KzJZGheFuyH4qwwcwG/n+MsWno5cTlKmaNtTsGzJpHYQ8YPbB5BdEdKU1rga5/6JGY8ww==} + '@shikijs/langs@3.13.0': + resolution: {integrity: sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ==} - '@shikijs/themes@3.12.2': - resolution: {integrity: sha512-fTR3QAgnwYpfGczpIbzPjlRnxyONJOerguQv1iwpyQZ9QXX4qy/XFQqXlf17XTsorxnHoJGbH/LXBvwtqDsF5A==} + '@shikijs/themes@3.13.0': + resolution: {integrity: sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg==} - '@shikijs/types@3.12.2': - resolution: {integrity: sha512-K5UIBzxCyv0YoxN3LMrKB9zuhp1bV+LgewxuVwHdl4Gz5oePoUFrr9EfgJlGlDeXCU1b/yhdnXeuRvAnz8HN8Q==} + '@shikijs/types@3.13.0': + resolution: {integrity: sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw==} '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -1022,8 +1027,8 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - esbuild@0.25.8: - resolution: {integrity: sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==} + esbuild@0.25.9: + resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} engines: {node: '>=18'} hasBin: true @@ -1058,8 +1063,9 @@ packages: fast-uri@3.0.6: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} - fdir@6.4.6: - resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} peerDependencies: picomatch: ^3 || ^4 peerDependenciesMeta: @@ -1597,8 +1603,8 @@ packages: engines: {node: '>= 0.4'} hasBin: true - rollup@4.46.1: - resolution: {integrity: sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==} + rollup@4.50.1: + resolution: {integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -1734,6 +1740,10 @@ packages: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1818,15 +1828,15 @@ packages: peerDependencies: typedoc: 0.27.x || 0.28.x - typedoc@0.28.7: - resolution: {integrity: sha512-lpz0Oxl6aidFkmS90VQDQjk/Qf2iw0IUvFqirdONBdj7jPSN9mGXhy66BcGNDxx5ZMyKKiBVAREvPEzT6Uxipw==} + typedoc@0.28.13: + resolution: {integrity: sha512-dNWY8msnYB2a+7Audha+aTF1Pu3euiE7ySp53w8kEsXoYw7dMouV5A1UsTUY345aB152RHnmRMDiovuBi7BD+w==} engines: {node: '>= 18', pnpm: '>= 10'} hasBin: true peerDependencies: - typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x - typescript@5.8.3: - resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} hasBin: true @@ -1865,8 +1875,8 @@ packages: vite: optional: true - vite@7.0.6: - resolution: {integrity: sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==} + vite@7.0.7: + resolution: {integrity: sha512-hc6LujN/EkJHmxeiDJMs0qBontZ1cdBvvoCbWhVjzUFTU329VRyOC46gHNSA8NcOC5yzCeXpwI40tieI3DEZqg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2154,39 +2164,39 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@biomejs/biome@2.2.3': + '@biomejs/biome@2.2.4': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.2.3 - '@biomejs/cli-darwin-x64': 2.2.3 - '@biomejs/cli-linux-arm64': 2.2.3 - '@biomejs/cli-linux-arm64-musl': 2.2.3 - '@biomejs/cli-linux-x64': 2.2.3 - '@biomejs/cli-linux-x64-musl': 2.2.3 - '@biomejs/cli-win32-arm64': 2.2.3 - '@biomejs/cli-win32-x64': 2.2.3 + '@biomejs/cli-darwin-arm64': 2.2.4 + '@biomejs/cli-darwin-x64': 2.2.4 + '@biomejs/cli-linux-arm64': 2.2.4 + '@biomejs/cli-linux-arm64-musl': 2.2.4 + '@biomejs/cli-linux-x64': 2.2.4 + '@biomejs/cli-linux-x64-musl': 2.2.4 + '@biomejs/cli-win32-arm64': 2.2.4 + '@biomejs/cli-win32-x64': 2.2.4 - '@biomejs/cli-darwin-arm64@2.2.3': + '@biomejs/cli-darwin-arm64@2.2.4': optional: true - '@biomejs/cli-darwin-x64@2.2.3': + '@biomejs/cli-darwin-x64@2.2.4': optional: true - '@biomejs/cli-linux-arm64-musl@2.2.3': + '@biomejs/cli-linux-arm64-musl@2.2.4': optional: true - '@biomejs/cli-linux-arm64@2.2.3': + '@biomejs/cli-linux-arm64@2.2.4': optional: true - '@biomejs/cli-linux-x64-musl@2.2.3': + '@biomejs/cli-linux-x64-musl@2.2.4': optional: true - '@biomejs/cli-linux-x64@2.2.3': + '@biomejs/cli-linux-x64@2.2.4': optional: true - '@biomejs/cli-win32-arm64@2.2.3': + '@biomejs/cli-win32-arm64@2.2.4': optional: true - '@biomejs/cli-win32-x64@2.2.3': + '@biomejs/cli-win32-x64@2.2.4': optional: true '@bundled-es-modules/cookie@2.0.1': @@ -2222,90 +2232,90 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} - '@esbuild/aix-ppc64@0.25.8': + '@esbuild/aix-ppc64@0.25.9': optional: true - '@esbuild/android-arm64@0.25.8': + '@esbuild/android-arm64@0.25.9': optional: true - '@esbuild/android-arm@0.25.8': + '@esbuild/android-arm@0.25.9': optional: true - '@esbuild/android-x64@0.25.8': + '@esbuild/android-x64@0.25.9': optional: true - '@esbuild/darwin-arm64@0.25.8': + '@esbuild/darwin-arm64@0.25.9': optional: true - '@esbuild/darwin-x64@0.25.8': + '@esbuild/darwin-x64@0.25.9': optional: true - '@esbuild/freebsd-arm64@0.25.8': + '@esbuild/freebsd-arm64@0.25.9': optional: true - '@esbuild/freebsd-x64@0.25.8': + '@esbuild/freebsd-x64@0.25.9': optional: true - '@esbuild/linux-arm64@0.25.8': + '@esbuild/linux-arm64@0.25.9': optional: true - '@esbuild/linux-arm@0.25.8': + '@esbuild/linux-arm@0.25.9': optional: true - '@esbuild/linux-ia32@0.25.8': + '@esbuild/linux-ia32@0.25.9': optional: true - '@esbuild/linux-loong64@0.25.8': + '@esbuild/linux-loong64@0.25.9': optional: true - '@esbuild/linux-mips64el@0.25.8': + '@esbuild/linux-mips64el@0.25.9': optional: true - '@esbuild/linux-ppc64@0.25.8': + '@esbuild/linux-ppc64@0.25.9': optional: true - '@esbuild/linux-riscv64@0.25.8': + '@esbuild/linux-riscv64@0.25.9': optional: true - '@esbuild/linux-s390x@0.25.8': + '@esbuild/linux-s390x@0.25.9': optional: true - '@esbuild/linux-x64@0.25.8': + '@esbuild/linux-x64@0.25.9': optional: true - '@esbuild/netbsd-arm64@0.25.8': + '@esbuild/netbsd-arm64@0.25.9': optional: true - '@esbuild/netbsd-x64@0.25.8': + '@esbuild/netbsd-x64@0.25.9': optional: true - '@esbuild/openbsd-arm64@0.25.8': + '@esbuild/openbsd-arm64@0.25.9': optional: true - '@esbuild/openbsd-x64@0.25.8': + '@esbuild/openbsd-x64@0.25.9': optional: true - '@esbuild/openharmony-arm64@0.25.8': + '@esbuild/openharmony-arm64@0.25.9': optional: true - '@esbuild/sunos-x64@0.25.8': + '@esbuild/sunos-x64@0.25.9': optional: true - '@esbuild/win32-arm64@0.25.8': + '@esbuild/win32-arm64@0.25.9': optional: true - '@esbuild/win32-ia32@0.25.8': + '@esbuild/win32-ia32@0.25.9': optional: true - '@esbuild/win32-x64@0.25.8': + '@esbuild/win32-x64@0.25.9': optional: true '@gerrit0/mini-shiki@3.12.2': dependencies: - '@shikijs/engine-oniguruma': 3.12.2 - '@shikijs/langs': 3.12.2 - '@shikijs/themes': 3.12.2 - '@shikijs/types': 3.12.2 + '@shikijs/engine-oniguruma': 3.13.0 + '@shikijs/langs': 3.13.0 + '@shikijs/themes': 3.13.0 + '@shikijs/types': 3.13.0 '@shikijs/vscode-textmate': 10.0.2 '@inquirer/checkbox@4.2.0(@types/node@22.16.5)': @@ -2474,80 +2484,83 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@rollup/rollup-android-arm-eabi@4.46.1': + '@rollup/rollup-android-arm-eabi@4.50.1': optional: true - '@rollup/rollup-android-arm64@4.46.1': + '@rollup/rollup-android-arm64@4.50.1': optional: true - '@rollup/rollup-darwin-arm64@4.46.1': + '@rollup/rollup-darwin-arm64@4.50.1': optional: true - '@rollup/rollup-darwin-x64@4.46.1': + '@rollup/rollup-darwin-x64@4.50.1': optional: true - '@rollup/rollup-freebsd-arm64@4.46.1': + '@rollup/rollup-freebsd-arm64@4.50.1': optional: true - '@rollup/rollup-freebsd-x64@4.46.1': + '@rollup/rollup-freebsd-x64@4.50.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.46.1': + '@rollup/rollup-linux-arm-gnueabihf@4.50.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.46.1': + '@rollup/rollup-linux-arm-musleabihf@4.50.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.46.1': + '@rollup/rollup-linux-arm64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.46.1': + '@rollup/rollup-linux-arm64-musl@4.50.1': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.46.1': + '@rollup/rollup-linux-loongarch64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.46.1': + '@rollup/rollup-linux-ppc64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.46.1': + '@rollup/rollup-linux-riscv64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.46.1': + '@rollup/rollup-linux-riscv64-musl@4.50.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.46.1': + '@rollup/rollup-linux-s390x-gnu@4.50.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.46.1': + '@rollup/rollup-linux-x64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-x64-musl@4.46.1': + '@rollup/rollup-linux-x64-musl@4.50.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.46.1': + '@rollup/rollup-openharmony-arm64@4.50.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.46.1': + '@rollup/rollup-win32-arm64-msvc@4.50.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.46.1': + '@rollup/rollup-win32-ia32-msvc@4.50.1': optional: true - '@shikijs/engine-oniguruma@3.12.2': + '@rollup/rollup-win32-x64-msvc@4.50.1': + optional: true + + '@shikijs/engine-oniguruma@3.13.0': dependencies: - '@shikijs/types': 3.12.2 + '@shikijs/types': 3.13.0 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@3.12.2': + '@shikijs/langs@3.13.0': dependencies: - '@shikijs/types': 3.12.2 + '@shikijs/types': 3.13.0 - '@shikijs/themes@3.12.2': + '@shikijs/themes@3.13.0': dependencies: - '@shikijs/types': 3.12.2 + '@shikijs/types': 3.13.0 - '@shikijs/types@3.12.2': + '@shikijs/types@3.13.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 @@ -2586,7 +2599,7 @@ snapshots: '@types/unist@3.0.3': {} - '@vitest/coverage-istanbul@3.2.4(vitest@3.2.4(@types/node@22.16.5)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.5)(typescript@5.8.3))(yaml@2.8.1))': + '@vitest/coverage-istanbul@3.2.4(vitest@3.2.4(@types/node@22.16.5)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.5)(typescript@5.9.2))(yaml@2.8.1))': dependencies: '@istanbuljs/schema': 0.1.3 debug: 4.4.1 @@ -2598,7 +2611,7 @@ snapshots: magicast: 0.3.5 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@22.16.5)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.5)(typescript@5.8.3))(yaml@2.8.1) + vitest: 3.2.4(@types/node@22.16.5)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.5)(typescript@5.9.2))(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -2610,14 +2623,14 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(msw@2.10.4(@types/node@22.16.5)(typescript@5.8.3))(vite@7.0.6(@types/node@22.16.5)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(msw@2.10.4(@types/node@22.16.5)(typescript@5.9.2))(vite@7.0.7(@types/node@22.16.5)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - msw: 2.10.4(@types/node@22.16.5)(typescript@5.8.3) - vite: 7.0.6(@types/node@22.16.5)(yaml@2.8.1) + msw: 2.10.4(@types/node@22.16.5)(typescript@5.9.2) + vite: 7.0.7(@types/node@22.16.5)(yaml@2.8.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -2868,34 +2881,34 @@ snapshots: dependencies: es-errors: 1.3.0 - esbuild@0.25.8: + esbuild@0.25.9: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.8 - '@esbuild/android-arm': 0.25.8 - '@esbuild/android-arm64': 0.25.8 - '@esbuild/android-x64': 0.25.8 - '@esbuild/darwin-arm64': 0.25.8 - '@esbuild/darwin-x64': 0.25.8 - '@esbuild/freebsd-arm64': 0.25.8 - '@esbuild/freebsd-x64': 0.25.8 - '@esbuild/linux-arm': 0.25.8 - '@esbuild/linux-arm64': 0.25.8 - '@esbuild/linux-ia32': 0.25.8 - '@esbuild/linux-loong64': 0.25.8 - '@esbuild/linux-mips64el': 0.25.8 - '@esbuild/linux-ppc64': 0.25.8 - '@esbuild/linux-riscv64': 0.25.8 - '@esbuild/linux-s390x': 0.25.8 - '@esbuild/linux-x64': 0.25.8 - '@esbuild/netbsd-arm64': 0.25.8 - '@esbuild/netbsd-x64': 0.25.8 - '@esbuild/openbsd-arm64': 0.25.8 - '@esbuild/openbsd-x64': 0.25.8 - '@esbuild/openharmony-arm64': 0.25.8 - '@esbuild/sunos-x64': 0.25.8 - '@esbuild/win32-arm64': 0.25.8 - '@esbuild/win32-ia32': 0.25.8 - '@esbuild/win32-x64': 0.25.8 + '@esbuild/aix-ppc64': 0.25.9 + '@esbuild/android-arm': 0.25.9 + '@esbuild/android-arm64': 0.25.9 + '@esbuild/android-x64': 0.25.9 + '@esbuild/darwin-arm64': 0.25.9 + '@esbuild/darwin-x64': 0.25.9 + '@esbuild/freebsd-arm64': 0.25.9 + '@esbuild/freebsd-x64': 0.25.9 + '@esbuild/linux-arm': 0.25.9 + '@esbuild/linux-arm64': 0.25.9 + '@esbuild/linux-ia32': 0.25.9 + '@esbuild/linux-loong64': 0.25.9 + '@esbuild/linux-mips64el': 0.25.9 + '@esbuild/linux-ppc64': 0.25.9 + '@esbuild/linux-riscv64': 0.25.9 + '@esbuild/linux-s390x': 0.25.9 + '@esbuild/linux-x64': 0.25.9 + '@esbuild/netbsd-arm64': 0.25.9 + '@esbuild/netbsd-x64': 0.25.9 + '@esbuild/openbsd-arm64': 0.25.9 + '@esbuild/openbsd-x64': 0.25.9 + '@esbuild/openharmony-arm64': 0.25.9 + '@esbuild/sunos-x64': 0.25.9 + '@esbuild/win32-arm64': 0.25.9 + '@esbuild/win32-ia32': 0.25.9 + '@esbuild/win32-x64': 0.25.9 escalade@3.2.0: {} @@ -2921,7 +2934,7 @@ snapshots: fast-uri@3.0.6: {} - fdir@6.4.6(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -3040,19 +3053,19 @@ snapshots: transitivePeerDependencies: - encoding - i18next-korean-postposition-processor@1.0.0(i18next@24.2.3(typescript@5.8.3)): + i18next-korean-postposition-processor@1.0.0(i18next@24.2.3(typescript@5.9.2)): dependencies: - i18next: 24.2.3(typescript@5.8.3) + i18next: 24.2.3(typescript@5.9.2) i18next@22.5.1: dependencies: '@babel/runtime': 7.28.2 - i18next@24.2.3(typescript@5.8.3): + i18next@24.2.3(typescript@5.9.2): dependencies: '@babel/runtime': 7.28.2 optionalDependencies: - typescript: 5.8.3 + typescript: 5.9.2 iconv-lite@0.4.24: dependencies: @@ -3319,7 +3332,7 @@ snapshots: ms@2.1.3: {} - msw@2.10.4(@types/node@22.16.5)(typescript@5.8.3): + msw@2.10.4(@types/node@22.16.5)(typescript@5.9.2): dependencies: '@bundled-es-modules/cookie': 2.0.1 '@bundled-es-modules/statuses': 1.0.1 @@ -3340,7 +3353,7 @@ snapshots: type-fest: 4.41.0 yargs: 17.7.2 optionalDependencies: - typescript: 5.8.3 + typescript: 5.9.2 transitivePeerDependencies: - '@types/node' @@ -3467,30 +3480,31 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - rollup@4.46.1: + rollup@4.50.1: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.46.1 - '@rollup/rollup-android-arm64': 4.46.1 - '@rollup/rollup-darwin-arm64': 4.46.1 - '@rollup/rollup-darwin-x64': 4.46.1 - '@rollup/rollup-freebsd-arm64': 4.46.1 - '@rollup/rollup-freebsd-x64': 4.46.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.46.1 - '@rollup/rollup-linux-arm-musleabihf': 4.46.1 - '@rollup/rollup-linux-arm64-gnu': 4.46.1 - '@rollup/rollup-linux-arm64-musl': 4.46.1 - '@rollup/rollup-linux-loongarch64-gnu': 4.46.1 - '@rollup/rollup-linux-ppc64-gnu': 4.46.1 - '@rollup/rollup-linux-riscv64-gnu': 4.46.1 - '@rollup/rollup-linux-riscv64-musl': 4.46.1 - '@rollup/rollup-linux-s390x-gnu': 4.46.1 - '@rollup/rollup-linux-x64-gnu': 4.46.1 - '@rollup/rollup-linux-x64-musl': 4.46.1 - '@rollup/rollup-win32-arm64-msvc': 4.46.1 - '@rollup/rollup-win32-ia32-msvc': 4.46.1 - '@rollup/rollup-win32-x64-msvc': 4.46.1 + '@rollup/rollup-android-arm-eabi': 4.50.1 + '@rollup/rollup-android-arm64': 4.50.1 + '@rollup/rollup-darwin-arm64': 4.50.1 + '@rollup/rollup-darwin-x64': 4.50.1 + '@rollup/rollup-freebsd-arm64': 4.50.1 + '@rollup/rollup-freebsd-x64': 4.50.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.50.1 + '@rollup/rollup-linux-arm-musleabihf': 4.50.1 + '@rollup/rollup-linux-arm64-gnu': 4.50.1 + '@rollup/rollup-linux-arm64-musl': 4.50.1 + '@rollup/rollup-linux-loongarch64-gnu': 4.50.1 + '@rollup/rollup-linux-ppc64-gnu': 4.50.1 + '@rollup/rollup-linux-riscv64-gnu': 4.50.1 + '@rollup/rollup-linux-riscv64-musl': 4.50.1 + '@rollup/rollup-linux-s390x-gnu': 4.50.1 + '@rollup/rollup-linux-x64-gnu': 4.50.1 + '@rollup/rollup-linux-x64-musl': 4.50.1 + '@rollup/rollup-openharmony-arm64': 4.50.1 + '@rollup/rollup-win32-arm64-msvc': 4.50.1 + '@rollup/rollup-win32-ia32-msvc': 4.50.1 + '@rollup/rollup-win32-x64-msvc': 4.50.1 fsevents: 2.3.3 rrweb-cssom@0.8.0: {} @@ -3604,7 +3618,12 @@ snapshots: tinyglobby@0.2.14: dependencies: - fdir: 6.4.6(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 tinypool@1.1.1: {} @@ -3640,9 +3659,9 @@ snapshots: dependencies: punycode: 2.3.1 - tsconfck@3.1.6(typescript@5.8.3): + tsconfck@3.1.6(typescript@5.9.2): optionalDependencies: - typescript: 5.8.3 + typescript: 5.9.2 tsconfig-paths-webpack-plugin@4.2.0: dependencies: @@ -3663,28 +3682,28 @@ snapshots: type-fest@4.41.0: {} - typedoc-github-theme@0.3.1(typedoc@0.28.7(typescript@5.8.3)): + typedoc-github-theme@0.3.1(typedoc@0.28.13(typescript@5.9.2)): dependencies: - typedoc: 0.28.7(typescript@5.8.3) + typedoc: 0.28.13(typescript@5.9.2) - typedoc-plugin-coverage@4.0.1(typedoc@0.28.7(typescript@5.8.3)): + typedoc-plugin-coverage@4.0.1(typedoc@0.28.13(typescript@5.9.2)): dependencies: - typedoc: 0.28.7(typescript@5.8.3) + typedoc: 0.28.13(typescript@5.9.2) - typedoc-plugin-mdn-links@5.0.9(typedoc@0.28.7(typescript@5.8.3)): + typedoc-plugin-mdn-links@5.0.9(typedoc@0.28.13(typescript@5.9.2)): dependencies: - typedoc: 0.28.7(typescript@5.8.3) + typedoc: 0.28.13(typescript@5.9.2) - typedoc@0.28.7(typescript@5.8.3): + typedoc@0.28.13(typescript@5.9.2): dependencies: '@gerrit0/mini-shiki': 3.12.2 lunr: 2.3.9 markdown-it: 14.1.0 minimatch: 9.0.5 - typescript: 5.8.3 + typescript: 5.9.2 yaml: 2.8.1 - typescript@5.8.3: {} + typescript@5.9.2: {} uc.micro@2.1.0: {} @@ -3711,7 +3730,7 @@ snapshots: debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.0.6(@types/node@22.16.5)(yaml@2.8.1) + vite: 7.0.7(@types/node@22.16.5)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -3726,40 +3745,40 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@7.0.6(@types/node@22.16.5)(yaml@2.8.1)): + vite-tsconfig-paths@5.1.4(typescript@5.9.2)(vite@7.0.7(@types/node@22.16.5)(yaml@2.8.1)): dependencies: debug: 4.4.1 globrex: 0.1.2 - tsconfck: 3.1.6(typescript@5.8.3) + tsconfck: 3.1.6(typescript@5.9.2) optionalDependencies: - vite: 7.0.6(@types/node@22.16.5)(yaml@2.8.1) + vite: 7.0.7(@types/node@22.16.5)(yaml@2.8.1) transitivePeerDependencies: - supports-color - typescript - vite@7.0.6(@types/node@22.16.5)(yaml@2.8.1): + vite@7.0.7(@types/node@22.16.5)(yaml@2.8.1): dependencies: - esbuild: 0.25.8 - fdir: 6.4.6(picomatch@4.0.3) + esbuild: 0.25.9 + fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.46.1 - tinyglobby: 0.2.14 + rollup: 4.50.1 + tinyglobby: 0.2.15 optionalDependencies: '@types/node': 22.16.5 fsevents: 2.3.3 yaml: 2.8.1 - vitest-canvas-mock@0.3.3(vitest@3.2.4(@types/node@22.16.5)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.5)(typescript@5.8.3))(yaml@2.8.1)): + vitest-canvas-mock@0.3.3(vitest@3.2.4(@types/node@22.16.5)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.5)(typescript@5.9.2))(yaml@2.8.1)): dependencies: jest-canvas-mock: 2.5.2 - vitest: 3.2.4(@types/node@22.16.5)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.5)(typescript@5.8.3))(yaml@2.8.1) + vitest: 3.2.4(@types/node@22.16.5)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.5)(typescript@5.9.2))(yaml@2.8.1) - vitest@3.2.4(@types/node@22.16.5)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.5)(typescript@5.8.3))(yaml@2.8.1): + vitest@3.2.4(@types/node@22.16.5)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.16.5)(typescript@5.9.2))(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.10.4(@types/node@22.16.5)(typescript@5.8.3))(vite@7.0.6(@types/node@22.16.5)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(msw@2.10.4(@types/node@22.16.5)(typescript@5.9.2))(vite@7.0.7(@types/node@22.16.5)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -3777,7 +3796,7 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.0.6(@types/node@22.16.5)(yaml@2.8.1) + vite: 7.0.7(@types/node@22.16.5)(yaml@2.8.1) vite-node: 3.2.4(@types/node@22.16.5)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: diff --git a/public/images/ui/legacy/text_images/ca/summary/summary_dexnb_label_ca.png b/public/images/ui/legacy/text_images/ca/summary/summary_dexnb_label_ca.png index a457468d8d0..5477e3385a8 100644 Binary files a/public/images/ui/legacy/text_images/ca/summary/summary_dexnb_label_ca.png and b/public/images/ui/legacy/text_images/ca/summary/summary_dexnb_label_ca.png differ diff --git a/public/images/ui/legacy/text_images/ca/summary/summary_dexnb_label_overlay_shiny_ca.png b/public/images/ui/legacy/text_images/ca/summary/summary_dexnb_label_overlay_shiny_ca.png index 3277a28a59b..1640e46caa0 100644 Binary files a/public/images/ui/legacy/text_images/ca/summary/summary_dexnb_label_overlay_shiny_ca.png and b/public/images/ui/legacy/text_images/ca/summary/summary_dexnb_label_overlay_shiny_ca.png differ diff --git a/public/images/ui/legacy/text_images/ca/summary/summary_stats_expbar_title_ca.png b/public/images/ui/legacy/text_images/ca/summary/summary_stats_expbar_title_ca.png index e9dfb10e5d6..da999975932 100644 Binary files a/public/images/ui/legacy/text_images/ca/summary/summary_stats_expbar_title_ca.png and b/public/images/ui/legacy/text_images/ca/summary/summary_stats_expbar_title_ca.png differ diff --git a/public/images/ui/legacy/text_images/en/battle_ui/overlay_exp_label.png b/public/images/ui/legacy/text_images/en/battle_ui/overlay_exp_label.png index 40b5e8925a1..acb04a84a31 100644 Binary files a/public/images/ui/legacy/text_images/en/battle_ui/overlay_exp_label.png and b/public/images/ui/legacy/text_images/en/battle_ui/overlay_exp_label.png differ diff --git a/public/images/ui/legacy/text_images/en/summary/summary_dexnb_label.png b/public/images/ui/legacy/text_images/en/summary/summary_dexnb_label.png index eab90a91c7f..bf568c486aa 100644 Binary files a/public/images/ui/legacy/text_images/en/summary/summary_dexnb_label.png and b/public/images/ui/legacy/text_images/en/summary/summary_dexnb_label.png differ diff --git a/public/images/ui/legacy/text_images/en/summary/summary_moves_descriptions_title.png b/public/images/ui/legacy/text_images/en/summary/summary_moves_descriptions_title.png index 3d2b4d08376..e83e8cafbfc 100644 Binary files a/public/images/ui/legacy/text_images/en/summary/summary_moves_descriptions_title.png and b/public/images/ui/legacy/text_images/en/summary/summary_moves_descriptions_title.png differ diff --git a/public/images/ui/legacy/text_images/en/summary/summary_moves_effect_title.png b/public/images/ui/legacy/text_images/en/summary/summary_moves_effect_title.png index 55fb0efd832..55c4b545d98 100644 Binary files a/public/images/ui/legacy/text_images/en/summary/summary_moves_effect_title.png and b/public/images/ui/legacy/text_images/en/summary/summary_moves_effect_title.png differ diff --git a/public/images/ui/legacy/text_images/en/summary/summary_moves_moves_title.png b/public/images/ui/legacy/text_images/en/summary/summary_moves_moves_title.png index d869ab4e311..6bbb29c9c5f 100644 Binary files a/public/images/ui/legacy/text_images/en/summary/summary_moves_moves_title.png and b/public/images/ui/legacy/text_images/en/summary/summary_moves_moves_title.png differ diff --git a/public/images/ui/legacy/text_images/en/summary/summary_profile_ability.png b/public/images/ui/legacy/text_images/en/summary/summary_profile_ability.png index 6600db26802..a05c22b7d47 100644 Binary files a/public/images/ui/legacy/text_images/en/summary/summary_profile_ability.png and b/public/images/ui/legacy/text_images/en/summary/summary_profile_ability.png differ diff --git a/public/images/ui/legacy/text_images/en/summary/summary_profile_memo_title.png b/public/images/ui/legacy/text_images/en/summary/summary_profile_memo_title.png index 14cdf099044..3d69c20e57f 100644 Binary files a/public/images/ui/legacy/text_images/en/summary/summary_profile_memo_title.png and b/public/images/ui/legacy/text_images/en/summary/summary_profile_memo_title.png differ diff --git a/public/images/ui/legacy/text_images/en/summary/summary_profile_passive.png b/public/images/ui/legacy/text_images/en/summary/summary_profile_passive.png index 66f56ff435e..c026e87a215 100644 Binary files a/public/images/ui/legacy/text_images/en/summary/summary_profile_passive.png and b/public/images/ui/legacy/text_images/en/summary/summary_profile_passive.png differ diff --git a/public/images/ui/legacy/text_images/en/summary/summary_profile_profile_title.png b/public/images/ui/legacy/text_images/en/summary/summary_profile_profile_title.png index 8d4f82df3b3..4170dccf682 100644 Binary files a/public/images/ui/legacy/text_images/en/summary/summary_profile_profile_title.png and b/public/images/ui/legacy/text_images/en/summary/summary_profile_profile_title.png differ diff --git a/public/images/ui/legacy/text_images/en/summary/summary_stats_expbar_title.png b/public/images/ui/legacy/text_images/en/summary/summary_stats_expbar_title.png index e9dfb10e5d6..da999975932 100644 Binary files a/public/images/ui/legacy/text_images/en/summary/summary_stats_expbar_title.png and b/public/images/ui/legacy/text_images/en/summary/summary_stats_expbar_title.png differ diff --git a/public/images/ui/legacy/text_images/en/summary/summary_stats_item_title.png b/public/images/ui/legacy/text_images/en/summary/summary_stats_item_title.png index 5752b28288c..42e08b3e52a 100644 Binary files a/public/images/ui/legacy/text_images/en/summary/summary_stats_item_title.png and b/public/images/ui/legacy/text_images/en/summary/summary_stats_item_title.png differ diff --git a/public/images/ui/legacy/text_images/en/summary/summary_stats_stats_title.png b/public/images/ui/legacy/text_images/en/summary/summary_stats_stats_title.png index 5531819ef66..f602a43c39d 100644 Binary files a/public/images/ui/legacy/text_images/en/summary/summary_stats_stats_title.png and b/public/images/ui/legacy/text_images/en/summary/summary_stats_stats_title.png differ diff --git a/public/images/ui/legacy/text_images/es-ES/summary/summary_dexnb_label_es-ES.png b/public/images/ui/legacy/text_images/es-ES/summary/summary_dexnb_label_es-ES.png index a457468d8d0..5477e3385a8 100644 Binary files a/public/images/ui/legacy/text_images/es-ES/summary/summary_dexnb_label_es-ES.png and b/public/images/ui/legacy/text_images/es-ES/summary/summary_dexnb_label_es-ES.png differ diff --git a/public/images/ui/legacy/text_images/es-ES/summary/summary_dexnb_label_overlay_shiny_es-ES.png b/public/images/ui/legacy/text_images/es-ES/summary/summary_dexnb_label_overlay_shiny_es-ES.png index 3277a28a59b..1640e46caa0 100644 Binary files a/public/images/ui/legacy/text_images/es-ES/summary/summary_dexnb_label_overlay_shiny_es-ES.png and b/public/images/ui/legacy/text_images/es-ES/summary/summary_dexnb_label_overlay_shiny_es-ES.png differ diff --git a/public/images/ui/legacy/text_images/es-ES/summary/summary_moves_descriptions_title_es-ES.png b/public/images/ui/legacy/text_images/es-ES/summary/summary_moves_descriptions_title_es-ES.png index ffcae31894d..3a4e3c7c375 100644 Binary files a/public/images/ui/legacy/text_images/es-ES/summary/summary_moves_descriptions_title_es-ES.png and b/public/images/ui/legacy/text_images/es-ES/summary/summary_moves_descriptions_title_es-ES.png differ diff --git a/public/images/ui/legacy/text_images/es-ES/summary/summary_moves_effect_title_es-ES.png b/public/images/ui/legacy/text_images/es-ES/summary/summary_moves_effect_title_es-ES.png index 50ce2f51d6f..cf8d1309848 100644 Binary files a/public/images/ui/legacy/text_images/es-ES/summary/summary_moves_effect_title_es-ES.png and b/public/images/ui/legacy/text_images/es-ES/summary/summary_moves_effect_title_es-ES.png differ diff --git a/public/images/ui/legacy/text_images/es-ES/summary/summary_moves_moves_title_es-ES.png b/public/images/ui/legacy/text_images/es-ES/summary/summary_moves_moves_title_es-ES.png index ffca8bdfa10..a601ae79e4f 100644 Binary files a/public/images/ui/legacy/text_images/es-ES/summary/summary_moves_moves_title_es-ES.png and b/public/images/ui/legacy/text_images/es-ES/summary/summary_moves_moves_title_es-ES.png differ diff --git a/public/images/ui/legacy/text_images/es-ES/summary/summary_profile_ability_es-ES.png b/public/images/ui/legacy/text_images/es-ES/summary/summary_profile_ability_es-ES.png index b1b1a84ddcf..71bffe95cfe 100644 Binary files a/public/images/ui/legacy/text_images/es-ES/summary/summary_profile_ability_es-ES.png and b/public/images/ui/legacy/text_images/es-ES/summary/summary_profile_ability_es-ES.png differ diff --git a/public/images/ui/legacy/text_images/es-ES/summary/summary_profile_memo_title_es-ES.png b/public/images/ui/legacy/text_images/es-ES/summary/summary_profile_memo_title_es-ES.png index e837a58e4f9..b7ef7c91fc5 100644 Binary files a/public/images/ui/legacy/text_images/es-ES/summary/summary_profile_memo_title_es-ES.png and b/public/images/ui/legacy/text_images/es-ES/summary/summary_profile_memo_title_es-ES.png differ diff --git a/public/images/ui/legacy/text_images/es-ES/summary/summary_profile_passive_es-ES.png b/public/images/ui/legacy/text_images/es-ES/summary/summary_profile_passive_es-ES.png index 885453e3e98..a50e3cacf58 100644 Binary files a/public/images/ui/legacy/text_images/es-ES/summary/summary_profile_passive_es-ES.png and b/public/images/ui/legacy/text_images/es-ES/summary/summary_profile_passive_es-ES.png differ diff --git a/public/images/ui/legacy/text_images/es-ES/summary/summary_profile_profile_title_es-ES.png b/public/images/ui/legacy/text_images/es-ES/summary/summary_profile_profile_title_es-ES.png index 51ba9300dab..a5ed0e3e169 100644 Binary files a/public/images/ui/legacy/text_images/es-ES/summary/summary_profile_profile_title_es-ES.png and b/public/images/ui/legacy/text_images/es-ES/summary/summary_profile_profile_title_es-ES.png differ diff --git a/public/images/ui/legacy/text_images/es-ES/summary/summary_stats_expbar_title_es-ES.png b/public/images/ui/legacy/text_images/es-ES/summary/summary_stats_expbar_title_es-ES.png index e9dfb10e5d6..da999975932 100644 Binary files a/public/images/ui/legacy/text_images/es-ES/summary/summary_stats_expbar_title_es-ES.png and b/public/images/ui/legacy/text_images/es-ES/summary/summary_stats_expbar_title_es-ES.png differ diff --git a/public/images/ui/legacy/text_images/es-ES/summary/summary_stats_item_title_es-ES.png b/public/images/ui/legacy/text_images/es-ES/summary/summary_stats_item_title_es-ES.png index 15fdb806125..9236aaa1ff8 100644 Binary files a/public/images/ui/legacy/text_images/es-ES/summary/summary_stats_item_title_es-ES.png and b/public/images/ui/legacy/text_images/es-ES/summary/summary_stats_item_title_es-ES.png differ diff --git a/public/images/ui/legacy/text_images/es-ES/summary/summary_stats_stats_title_es-ES.png b/public/images/ui/legacy/text_images/es-ES/summary/summary_stats_stats_title_es-ES.png index 2233461522c..5b59c12984f 100644 Binary files a/public/images/ui/legacy/text_images/es-ES/summary/summary_stats_stats_title_es-ES.png and b/public/images/ui/legacy/text_images/es-ES/summary/summary_stats_stats_title_es-ES.png differ diff --git a/public/images/ui/legacy/text_images/es-MX/summary/summary_dexnb_label_es-MX.png b/public/images/ui/legacy/text_images/es-MX/summary/summary_dexnb_label_es-MX.png index a457468d8d0..5477e3385a8 100644 Binary files a/public/images/ui/legacy/text_images/es-MX/summary/summary_dexnb_label_es-MX.png and b/public/images/ui/legacy/text_images/es-MX/summary/summary_dexnb_label_es-MX.png differ diff --git a/public/images/ui/legacy/text_images/es-MX/summary/summary_dexnb_label_overlay_shiny_es-MX.png b/public/images/ui/legacy/text_images/es-MX/summary/summary_dexnb_label_overlay_shiny_es-MX.png index 3277a28a59b..1640e46caa0 100644 Binary files a/public/images/ui/legacy/text_images/es-MX/summary/summary_dexnb_label_overlay_shiny_es-MX.png and b/public/images/ui/legacy/text_images/es-MX/summary/summary_dexnb_label_overlay_shiny_es-MX.png differ diff --git a/public/images/ui/legacy/text_images/es-MX/summary/summary_moves_descriptions_title_es-MX.png b/public/images/ui/legacy/text_images/es-MX/summary/summary_moves_descriptions_title_es-MX.png index ffcae31894d..3a4e3c7c375 100644 Binary files a/public/images/ui/legacy/text_images/es-MX/summary/summary_moves_descriptions_title_es-MX.png and b/public/images/ui/legacy/text_images/es-MX/summary/summary_moves_descriptions_title_es-MX.png differ diff --git a/public/images/ui/legacy/text_images/es-MX/summary/summary_moves_effect_title_es-MX.png b/public/images/ui/legacy/text_images/es-MX/summary/summary_moves_effect_title_es-MX.png index 50ce2f51d6f..cf8d1309848 100644 Binary files a/public/images/ui/legacy/text_images/es-MX/summary/summary_moves_effect_title_es-MX.png and b/public/images/ui/legacy/text_images/es-MX/summary/summary_moves_effect_title_es-MX.png differ diff --git a/public/images/ui/legacy/text_images/es-MX/summary/summary_moves_moves_title_es-MX.png b/public/images/ui/legacy/text_images/es-MX/summary/summary_moves_moves_title_es-MX.png index ffca8bdfa10..a601ae79e4f 100644 Binary files a/public/images/ui/legacy/text_images/es-MX/summary/summary_moves_moves_title_es-MX.png and b/public/images/ui/legacy/text_images/es-MX/summary/summary_moves_moves_title_es-MX.png differ diff --git a/public/images/ui/legacy/text_images/es-MX/summary/summary_profile_ability_es-MX.png b/public/images/ui/legacy/text_images/es-MX/summary/summary_profile_ability_es-MX.png index b1b1a84ddcf..71bffe95cfe 100644 Binary files a/public/images/ui/legacy/text_images/es-MX/summary/summary_profile_ability_es-MX.png and b/public/images/ui/legacy/text_images/es-MX/summary/summary_profile_ability_es-MX.png differ diff --git a/public/images/ui/legacy/text_images/es-MX/summary/summary_profile_memo_title_es-MX.png b/public/images/ui/legacy/text_images/es-MX/summary/summary_profile_memo_title_es-MX.png index e837a58e4f9..b7ef7c91fc5 100644 Binary files a/public/images/ui/legacy/text_images/es-MX/summary/summary_profile_memo_title_es-MX.png and b/public/images/ui/legacy/text_images/es-MX/summary/summary_profile_memo_title_es-MX.png differ diff --git a/public/images/ui/legacy/text_images/es-MX/summary/summary_profile_passive_es-MX.png b/public/images/ui/legacy/text_images/es-MX/summary/summary_profile_passive_es-MX.png index 885453e3e98..a50e3cacf58 100644 Binary files a/public/images/ui/legacy/text_images/es-MX/summary/summary_profile_passive_es-MX.png and b/public/images/ui/legacy/text_images/es-MX/summary/summary_profile_passive_es-MX.png differ diff --git a/public/images/ui/legacy/text_images/es-MX/summary/summary_profile_profile_title_es-MX.png b/public/images/ui/legacy/text_images/es-MX/summary/summary_profile_profile_title_es-MX.png index 51ba9300dab..a5ed0e3e169 100644 Binary files a/public/images/ui/legacy/text_images/es-MX/summary/summary_profile_profile_title_es-MX.png and b/public/images/ui/legacy/text_images/es-MX/summary/summary_profile_profile_title_es-MX.png differ diff --git a/public/images/ui/legacy/text_images/es-MX/summary/summary_stats_expbar_title_es-MX.png b/public/images/ui/legacy/text_images/es-MX/summary/summary_stats_expbar_title_es-MX.png index e9dfb10e5d6..da999975932 100644 Binary files a/public/images/ui/legacy/text_images/es-MX/summary/summary_stats_expbar_title_es-MX.png and b/public/images/ui/legacy/text_images/es-MX/summary/summary_stats_expbar_title_es-MX.png differ diff --git a/public/images/ui/legacy/text_images/es-MX/summary/summary_stats_item_title_es-MX.png b/public/images/ui/legacy/text_images/es-MX/summary/summary_stats_item_title_es-MX.png index 15fdb806125..9236aaa1ff8 100644 Binary files a/public/images/ui/legacy/text_images/es-MX/summary/summary_stats_item_title_es-MX.png and b/public/images/ui/legacy/text_images/es-MX/summary/summary_stats_item_title_es-MX.png differ diff --git a/public/images/ui/legacy/text_images/es-MX/summary/summary_stats_stats_title_es-MX.png b/public/images/ui/legacy/text_images/es-MX/summary/summary_stats_stats_title_es-MX.png index 2233461522c..5b59c12984f 100644 Binary files a/public/images/ui/legacy/text_images/es-MX/summary/summary_stats_stats_title_es-MX.png and b/public/images/ui/legacy/text_images/es-MX/summary/summary_stats_stats_title_es-MX.png differ diff --git a/public/images/ui/legacy/text_images/fr/battle_ui/overlay_exp_label_fr.png b/public/images/ui/legacy/text_images/fr/battle_ui/overlay_exp_label_fr.png index 40b5e8925a1..acb04a84a31 100644 Binary files a/public/images/ui/legacy/text_images/fr/battle_ui/overlay_exp_label_fr.png and b/public/images/ui/legacy/text_images/fr/battle_ui/overlay_exp_label_fr.png differ diff --git a/public/images/ui/legacy/text_images/ja/battle_ui/overlay_exp_label_ja.png b/public/images/ui/legacy/text_images/ja/battle_ui/overlay_exp_label_ja.png index 40b5e8925a1..acb04a84a31 100644 Binary files a/public/images/ui/legacy/text_images/ja/battle_ui/overlay_exp_label_ja.png and b/public/images/ui/legacy/text_images/ja/battle_ui/overlay_exp_label_ja.png differ diff --git a/public/images/ui/legacy/text_images/ja/summary/summary_stats_expbar_title_ja.png b/public/images/ui/legacy/text_images/ja/summary/summary_stats_expbar_title_ja.png index e9dfb10e5d6..da999975932 100644 Binary files a/public/images/ui/legacy/text_images/ja/summary/summary_stats_expbar_title_ja.png and b/public/images/ui/legacy/text_images/ja/summary/summary_stats_expbar_title_ja.png differ diff --git a/public/images/ui/legacy/text_images/ko/battle_ui/overlay_exp_label_ko.png b/public/images/ui/legacy/text_images/ko/battle_ui/overlay_exp_label_ko.png index 40b5e8925a1..acb04a84a31 100644 Binary files a/public/images/ui/legacy/text_images/ko/battle_ui/overlay_exp_label_ko.png and b/public/images/ui/legacy/text_images/ko/battle_ui/overlay_exp_label_ko.png differ diff --git a/public/images/ui/legacy/text_images/ko/summary/summary_stats_expbar_title_ko.png b/public/images/ui/legacy/text_images/ko/summary/summary_stats_expbar_title_ko.png index e9dfb10e5d6..da999975932 100644 Binary files a/public/images/ui/legacy/text_images/ko/summary/summary_stats_expbar_title_ko.png and b/public/images/ui/legacy/text_images/ko/summary/summary_stats_expbar_title_ko.png differ diff --git a/public/images/ui/legacy/text_images/pt-BR/summary/summary_dexnb_label_overlay_shiny_pt-BR.png b/public/images/ui/legacy/text_images/pt-BR/summary/summary_dexnb_label_overlay_shiny_pt-BR.png index 3277a28a59b..1640e46caa0 100644 Binary files a/public/images/ui/legacy/text_images/pt-BR/summary/summary_dexnb_label_overlay_shiny_pt-BR.png and b/public/images/ui/legacy/text_images/pt-BR/summary/summary_dexnb_label_overlay_shiny_pt-BR.png differ diff --git a/public/images/ui/legacy/text_images/pt-BR/summary/summary_stats_expbar_title_pt-BR.png b/public/images/ui/legacy/text_images/pt-BR/summary/summary_stats_expbar_title_pt-BR.png index e9dfb10e5d6..da999975932 100644 Binary files a/public/images/ui/legacy/text_images/pt-BR/summary/summary_stats_expbar_title_pt-BR.png and b/public/images/ui/legacy/text_images/pt-BR/summary/summary_stats_expbar_title_pt-BR.png differ diff --git a/public/images/ui/legacy/text_images/ro/battle_ui/overlay_exp_label_ro.png b/public/images/ui/legacy/text_images/ro/battle_ui/overlay_exp_label_ro.png index 40b5e8925a1..acb04a84a31 100644 Binary files a/public/images/ui/legacy/text_images/ro/battle_ui/overlay_exp_label_ro.png and b/public/images/ui/legacy/text_images/ro/battle_ui/overlay_exp_label_ro.png differ diff --git a/public/images/ui/legacy/text_images/ro/summary/summary_stats_expbar_title_ro.png b/public/images/ui/legacy/text_images/ro/summary/summary_stats_expbar_title_ro.png index e9dfb10e5d6..da999975932 100644 Binary files a/public/images/ui/legacy/text_images/ro/summary/summary_stats_expbar_title_ro.png and b/public/images/ui/legacy/text_images/ro/summary/summary_stats_expbar_title_ro.png differ diff --git a/public/images/ui/legacy/text_images/tl/battle_ui/overlay_exp_label_tl.png b/public/images/ui/legacy/text_images/tl/battle_ui/overlay_exp_label_tl.png index 40b5e8925a1..acb04a84a31 100644 Binary files a/public/images/ui/legacy/text_images/tl/battle_ui/overlay_exp_label_tl.png and b/public/images/ui/legacy/text_images/tl/battle_ui/overlay_exp_label_tl.png differ diff --git a/public/images/ui/legacy/text_images/tl/summary/summary_stats_expbar_title.png b/public/images/ui/legacy/text_images/tl/summary/summary_stats_expbar_title.png index e9dfb10e5d6..da999975932 100644 Binary files a/public/images/ui/legacy/text_images/tl/summary/summary_stats_expbar_title.png and b/public/images/ui/legacy/text_images/tl/summary/summary_stats_expbar_title.png differ diff --git a/public/images/ui/legacy/text_images/tr/summary/summary_stats_expbar_title_tr.png b/public/images/ui/legacy/text_images/tr/summary/summary_stats_expbar_title_tr.png index e9dfb10e5d6..da999975932 100644 Binary files a/public/images/ui/legacy/text_images/tr/summary/summary_stats_expbar_title_tr.png and b/public/images/ui/legacy/text_images/tr/summary/summary_stats_expbar_title_tr.png differ diff --git a/public/images/ui/legacy/text_images/zh-CN/battle_ui/overlay_exp_label_zh-CN.png b/public/images/ui/legacy/text_images/zh-CN/battle_ui/overlay_exp_label_zh-CN.png index 40b5e8925a1..acb04a84a31 100644 Binary files a/public/images/ui/legacy/text_images/zh-CN/battle_ui/overlay_exp_label_zh-CN.png and b/public/images/ui/legacy/text_images/zh-CN/battle_ui/overlay_exp_label_zh-CN.png differ diff --git a/public/images/ui/legacy/text_images/zh-CN/summary/summary_dexnb_label_zh-CN.png b/public/images/ui/legacy/text_images/zh-CN/summary/summary_dexnb_label_zh-CN.png index eab90a91c7f..bf568c486aa 100644 Binary files a/public/images/ui/legacy/text_images/zh-CN/summary/summary_dexnb_label_zh-CN.png and b/public/images/ui/legacy/text_images/zh-CN/summary/summary_dexnb_label_zh-CN.png differ diff --git a/public/images/ui/legacy/text_images/zh-CN/summary/summary_moves_descriptions_title_zh-CN.png b/public/images/ui/legacy/text_images/zh-CN/summary/summary_moves_descriptions_title_zh-CN.png index 3d2b4d08376..e83e8cafbfc 100644 Binary files a/public/images/ui/legacy/text_images/zh-CN/summary/summary_moves_descriptions_title_zh-CN.png and b/public/images/ui/legacy/text_images/zh-CN/summary/summary_moves_descriptions_title_zh-CN.png differ diff --git a/public/images/ui/legacy/text_images/zh-CN/summary/summary_moves_effect_title_zh-CN.png b/public/images/ui/legacy/text_images/zh-CN/summary/summary_moves_effect_title_zh-CN.png index 55fb0efd832..fbbaac0b260 100644 Binary files a/public/images/ui/legacy/text_images/zh-CN/summary/summary_moves_effect_title_zh-CN.png and b/public/images/ui/legacy/text_images/zh-CN/summary/summary_moves_effect_title_zh-CN.png differ diff --git a/public/images/ui/legacy/text_images/zh-CN/summary/summary_moves_moves_title_zh-CN.png b/public/images/ui/legacy/text_images/zh-CN/summary/summary_moves_moves_title_zh-CN.png index d869ab4e311..6bbb29c9c5f 100644 Binary files a/public/images/ui/legacy/text_images/zh-CN/summary/summary_moves_moves_title_zh-CN.png and b/public/images/ui/legacy/text_images/zh-CN/summary/summary_moves_moves_title_zh-CN.png differ diff --git a/public/images/ui/legacy/text_images/zh-CN/summary/summary_profile_ability_zh-CN.png b/public/images/ui/legacy/text_images/zh-CN/summary/summary_profile_ability_zh-CN.png index 6600db26802..a05c22b7d47 100644 Binary files a/public/images/ui/legacy/text_images/zh-CN/summary/summary_profile_ability_zh-CN.png and b/public/images/ui/legacy/text_images/zh-CN/summary/summary_profile_ability_zh-CN.png differ diff --git a/public/images/ui/legacy/text_images/zh-CN/summary/summary_profile_memo_title_zh-CN.png b/public/images/ui/legacy/text_images/zh-CN/summary/summary_profile_memo_title_zh-CN.png index 14cdf099044..3d69c20e57f 100644 Binary files a/public/images/ui/legacy/text_images/zh-CN/summary/summary_profile_memo_title_zh-CN.png and b/public/images/ui/legacy/text_images/zh-CN/summary/summary_profile_memo_title_zh-CN.png differ diff --git a/public/images/ui/legacy/text_images/zh-CN/summary/summary_profile_passive_zh-CN.png b/public/images/ui/legacy/text_images/zh-CN/summary/summary_profile_passive_zh-CN.png index 66f56ff435e..c026e87a215 100644 Binary files a/public/images/ui/legacy/text_images/zh-CN/summary/summary_profile_passive_zh-CN.png and b/public/images/ui/legacy/text_images/zh-CN/summary/summary_profile_passive_zh-CN.png differ diff --git a/public/images/ui/legacy/text_images/zh-CN/summary/summary_profile_profile_title_zh-CN.png b/public/images/ui/legacy/text_images/zh-CN/summary/summary_profile_profile_title_zh-CN.png index 8d4f82df3b3..4170dccf682 100644 Binary files a/public/images/ui/legacy/text_images/zh-CN/summary/summary_profile_profile_title_zh-CN.png and b/public/images/ui/legacy/text_images/zh-CN/summary/summary_profile_profile_title_zh-CN.png differ diff --git a/public/images/ui/legacy/text_images/zh-CN/summary/summary_stats_expbar_title_zh-CN.png b/public/images/ui/legacy/text_images/zh-CN/summary/summary_stats_expbar_title_zh-CN.png index e9dfb10e5d6..da999975932 100644 Binary files a/public/images/ui/legacy/text_images/zh-CN/summary/summary_stats_expbar_title_zh-CN.png and b/public/images/ui/legacy/text_images/zh-CN/summary/summary_stats_expbar_title_zh-CN.png differ diff --git a/public/images/ui/legacy/text_images/zh-CN/summary/summary_stats_item_title_zh-CN.png b/public/images/ui/legacy/text_images/zh-CN/summary/summary_stats_item_title_zh-CN.png index 5752b28288c..42e08b3e52a 100644 Binary files a/public/images/ui/legacy/text_images/zh-CN/summary/summary_stats_item_title_zh-CN.png and b/public/images/ui/legacy/text_images/zh-CN/summary/summary_stats_item_title_zh-CN.png differ diff --git a/public/images/ui/legacy/text_images/zh-CN/summary/summary_stats_stats_title_zh-CN.png b/public/images/ui/legacy/text_images/zh-CN/summary/summary_stats_stats_title_zh-CN.png index 5531819ef66..f602a43c39d 100644 Binary files a/public/images/ui/legacy/text_images/zh-CN/summary/summary_stats_stats_title_zh-CN.png and b/public/images/ui/legacy/text_images/zh-CN/summary/summary_stats_stats_title_zh-CN.png differ diff --git a/public/images/ui/legacy/text_images/zh-TW/battle_ui/overlay_exp_label_zh-TW.png b/public/images/ui/legacy/text_images/zh-TW/battle_ui/overlay_exp_label_zh-TW.png index 40b5e8925a1..acb04a84a31 100644 Binary files a/public/images/ui/legacy/text_images/zh-TW/battle_ui/overlay_exp_label_zh-TW.png and b/public/images/ui/legacy/text_images/zh-TW/battle_ui/overlay_exp_label_zh-TW.png differ diff --git a/public/images/ui/legacy/text_images/zh-TW/summary/summary_dexnb_label_zh-TW.png b/public/images/ui/legacy/text_images/zh-TW/summary/summary_dexnb_label_zh-TW.png index eab90a91c7f..bf568c486aa 100644 Binary files a/public/images/ui/legacy/text_images/zh-TW/summary/summary_dexnb_label_zh-TW.png and b/public/images/ui/legacy/text_images/zh-TW/summary/summary_dexnb_label_zh-TW.png differ diff --git a/public/images/ui/legacy/text_images/zh-TW/summary/summary_moves_descriptions_title_zh-TW.png b/public/images/ui/legacy/text_images/zh-TW/summary/summary_moves_descriptions_title_zh-TW.png index 3d2b4d08376..e83e8cafbfc 100644 Binary files a/public/images/ui/legacy/text_images/zh-TW/summary/summary_moves_descriptions_title_zh-TW.png and b/public/images/ui/legacy/text_images/zh-TW/summary/summary_moves_descriptions_title_zh-TW.png differ diff --git a/public/images/ui/legacy/text_images/zh-TW/summary/summary_moves_effect_title_zh-TW.png b/public/images/ui/legacy/text_images/zh-TW/summary/summary_moves_effect_title_zh-TW.png index 55fb0efd832..fbbaac0b260 100644 Binary files a/public/images/ui/legacy/text_images/zh-TW/summary/summary_moves_effect_title_zh-TW.png and b/public/images/ui/legacy/text_images/zh-TW/summary/summary_moves_effect_title_zh-TW.png differ diff --git a/public/images/ui/legacy/text_images/zh-TW/summary/summary_moves_moves_title_zh-TW.png b/public/images/ui/legacy/text_images/zh-TW/summary/summary_moves_moves_title_zh-TW.png index d869ab4e311..6bbb29c9c5f 100644 Binary files a/public/images/ui/legacy/text_images/zh-TW/summary/summary_moves_moves_title_zh-TW.png and b/public/images/ui/legacy/text_images/zh-TW/summary/summary_moves_moves_title_zh-TW.png differ diff --git a/public/images/ui/legacy/text_images/zh-TW/summary/summary_profile_ability_zh-TW.png b/public/images/ui/legacy/text_images/zh-TW/summary/summary_profile_ability_zh-TW.png index 6600db26802..a05c22b7d47 100644 Binary files a/public/images/ui/legacy/text_images/zh-TW/summary/summary_profile_ability_zh-TW.png and b/public/images/ui/legacy/text_images/zh-TW/summary/summary_profile_ability_zh-TW.png differ diff --git a/public/images/ui/legacy/text_images/zh-TW/summary/summary_profile_memo_title_zh-TW.png b/public/images/ui/legacy/text_images/zh-TW/summary/summary_profile_memo_title_zh-TW.png index 14cdf099044..3d69c20e57f 100644 Binary files a/public/images/ui/legacy/text_images/zh-TW/summary/summary_profile_memo_title_zh-TW.png and b/public/images/ui/legacy/text_images/zh-TW/summary/summary_profile_memo_title_zh-TW.png differ diff --git a/public/images/ui/legacy/text_images/zh-TW/summary/summary_profile_passive_zh-TW.png b/public/images/ui/legacy/text_images/zh-TW/summary/summary_profile_passive_zh-TW.png index 66f56ff435e..c026e87a215 100644 Binary files a/public/images/ui/legacy/text_images/zh-TW/summary/summary_profile_passive_zh-TW.png and b/public/images/ui/legacy/text_images/zh-TW/summary/summary_profile_passive_zh-TW.png differ diff --git a/public/images/ui/legacy/text_images/zh-TW/summary/summary_profile_profile_title_zh-TW.png b/public/images/ui/legacy/text_images/zh-TW/summary/summary_profile_profile_title_zh-TW.png index 8d4f82df3b3..4170dccf682 100644 Binary files a/public/images/ui/legacy/text_images/zh-TW/summary/summary_profile_profile_title_zh-TW.png and b/public/images/ui/legacy/text_images/zh-TW/summary/summary_profile_profile_title_zh-TW.png differ diff --git a/public/images/ui/legacy/text_images/zh-TW/summary/summary_stats_expbar_title_zh-TW.png b/public/images/ui/legacy/text_images/zh-TW/summary/summary_stats_expbar_title_zh-TW.png index e9dfb10e5d6..da999975932 100644 Binary files a/public/images/ui/legacy/text_images/zh-TW/summary/summary_stats_expbar_title_zh-TW.png and b/public/images/ui/legacy/text_images/zh-TW/summary/summary_stats_expbar_title_zh-TW.png differ diff --git a/public/images/ui/legacy/text_images/zh-TW/summary/summary_stats_item_title_zh-TW.png b/public/images/ui/legacy/text_images/zh-TW/summary/summary_stats_item_title_zh-TW.png index 5752b28288c..42e08b3e52a 100644 Binary files a/public/images/ui/legacy/text_images/zh-TW/summary/summary_stats_item_title_zh-TW.png and b/public/images/ui/legacy/text_images/zh-TW/summary/summary_stats_item_title_zh-TW.png differ diff --git a/public/images/ui/legacy/text_images/zh-TW/summary/summary_stats_stats_title_zh-TW.png b/public/images/ui/legacy/text_images/zh-TW/summary/summary_stats_stats_title_zh-TW.png index 5531819ef66..f602a43c39d 100644 Binary files a/public/images/ui/legacy/text_images/zh-TW/summary/summary_stats_stats_title_zh-TW.png and b/public/images/ui/legacy/text_images/zh-TW/summary/summary_stats_stats_title_zh-TW.png differ diff --git a/scripts/create-test/boilerplates/default.ts b/scripts/create-test/boilerplates/default.boilerplate.ts similarity index 94% rename from scripts/create-test/boilerplates/default.ts rename to scripts/create-test/boilerplates/default.boilerplate.ts index e644e740594..7b633cf8276 100644 --- a/scripts/create-test/boilerplates/default.ts +++ b/scripts/create-test/boilerplates/default.boilerplate.ts @@ -47,6 +47,6 @@ describe("{{description}}", () => { await game.toEndOfTurn(); expect(feebas).toHaveUsedMove({ move: MoveId.SPLASH, result: MoveResult.SUCCESS }); - expect(game.textInterceptor.logs).toContain(i18next.t("moveTriggers:splash")); + expect(game).toHaveShownMessage(i18next.t("moveTriggers:splash")); }); }); diff --git a/scripts/create-test/create-test.js b/scripts/create-test/create-test.js index 5e395783da7..df065657346 100644 --- a/scripts/create-test/create-test.js +++ b/scripts/create-test/create-test.js @@ -102,9 +102,9 @@ async function promptFileName(selectedType) { function getBoilerplatePath(choiceType) { switch (choiceType) { // case "Reward": - // return path.join(__dirname, "boilerplates/reward.ts"); + // return path.join(__dirname, "boilerplates/reward.boilerplate.ts"); default: - return path.join(__dirname, "boilerplates/default.ts"); + return path.join(__dirname, "boilerplates/default.boilerplate.ts"); } } diff --git a/src/@types/battler-tags.ts b/src/@types/battler-tags.ts index e47b4f8cfc1..ec72c811447 100644 --- a/src/@types/battler-tags.ts +++ b/src/@types/battler-tags.ts @@ -89,7 +89,8 @@ export type AbilityBattlerTagType = | BattlerTagType.QUARK_DRIVE | BattlerTagType.UNBURDEN | BattlerTagType.SLOW_START - | BattlerTagType.TRUANT; + | BattlerTagType.TRUANT + | BattlerTagType.SUPREME_OVERLORD; /** Subset of {@linkcode BattlerTagType}s that provide type boosts */ export type TypeBoostTagType = BattlerTagType.FIRE_BOOST | BattlerTagType.CHARGED; diff --git a/src/@types/damage-params.ts b/src/@types/damage-params.ts new file mode 100644 index 00000000000..b656c60f0ab --- /dev/null +++ b/src/@types/damage-params.ts @@ -0,0 +1,44 @@ +import type { MoveCategory } from "#enums/move-category"; +import type { Pokemon } from "#field/pokemon"; +import type { Move } from "#types/move-types"; + +/** + * Collection of types for methods like {@linkcode Pokemon#getBaseDamage} and {@linkcode Pokemon#getAttackDamage}. + * @module + */ + +/** Base type for damage parameter methods, used for DRY */ +export interface damageParams { + /** The attacking {@linkcode Pokemon} */ + source: Pokemon; + /** The move used in the attack */ + move: Move; + /** The move's {@linkcode MoveCategory} after variable-category effects are applied */ + moveCategory: MoveCategory; + /** If `true`, ignores this Pokemon's defensive ability effects */ + ignoreAbility?: boolean; + /** If `true`, ignores the attacking Pokemon's ability effects */ + ignoreSourceAbility?: boolean; + /** If `true`, ignores the ally Pokemon's ability effects */ + ignoreAllyAbility?: boolean; + /** If `true`, ignores the ability effects of the attacking pokemon's ally */ + ignoreSourceAllyAbility?: boolean; + /** If `true`, calculates damage for a critical hit */ + isCritical?: boolean; + /** If `true`, suppresses changes to game state during the calculation */ + simulated?: boolean; + /** If defined, used in place of calculated effectiveness values */ + effectiveness?: number; +} + +/** + * Type for the parameters of {@linkcode Pokemon#getBaseDamage | getBaseDamage} + * @interface + */ +export type getBaseDamageParams = Omit; + +/** + * Type for the parameters of {@linkcode Pokemon#getAttackDamage | getAttackDamage} + * @interface + */ +export type getAttackDamageParams = Omit; diff --git a/src/@types/phase-types.ts b/src/@types/phase-types.ts index 91673053747..2324c927e3a 100644 --- a/src/@types/phase-types.ts +++ b/src/@types/phase-types.ts @@ -1,26 +1,27 @@ +import type { Pokemon } from "#app/field/pokemon"; +import type { Phase } from "#app/phase"; import type { PhaseConstructorMap } from "#app/phase-manager"; import type { ObjectValues } from "#types/type-helpers"; -// Intentionally export the types of everything in phase-manager, as this file is meant to be +// Intentionally [re-]export the types of everything in phase-manager, as this file is meant to be // the centralized place for type definitions for the phase system. export type * from "#app/phase-manager"; -// This file includes helpful types for the phase system. -// It intentionally imports the phase constructor map from the phase manager (and re-exports it) - -/** - * Map of phase names to constructors for said phase - */ +/** Map of phase names to constructors for said phase */ export type PhaseMap = { [K in keyof PhaseConstructorMap]: InstanceType; }; -/** - * Union type of all phase constructors. - */ +/** Union type of all phase constructors. */ export type PhaseClass = ObjectValues; -/** - * Union type of all phase names as strings. - */ +/** Union type of all phase names as strings. */ export type PhaseString = keyof PhaseMap; + +/** Type for predicate functions operating on a specific type of {@linkcode Phase}. */ +export type PhaseConditionFunc = (phase: PhaseMap[T]) => boolean; + +/** Interface type representing the assumption that all phases with pokemon associated are dynamic */ +export interface DynamicPhase extends Phase { + getPokemon(): Pokemon; +} diff --git a/src/@types/save-data.ts b/src/@types/save-data.ts index 4c20d63da53..ae359c20949 100644 --- a/src/@types/save-data.ts +++ b/src/@types/save-data.ts @@ -4,8 +4,10 @@ import type { BattleType } from "#enums/battle-type"; import type { GameModes } from "#enums/game-modes"; import type { MoveId } from "#enums/move-id"; import type { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import type { Nature } from "#enums/nature"; import type { PlayerGender } from "#enums/player-gender"; import type { PokemonType } from "#enums/pokemon-type"; +import type { SpeciesId } from "#enums/species-id"; import type { MysteryEncounterSaveData } from "#mystery-encounters/mystery-encounter-save-data"; import type { Variant } from "#sprites/variant"; import type { ArenaData } from "#system/arena-data"; @@ -108,6 +110,22 @@ export interface DexAttrProps { formIndex: number; } +export interface Starter { + speciesId: SpeciesId; + shiny: boolean; + variant: Variant; + formIndex: number; + female?: boolean; + abilityIndex: number; + passive: boolean; + nature: Nature; + moveset?: StarterMoveset; + pokerus: boolean; + nickname?: string; + teraType?: PokemonType; + ivs: number[]; +} + export type RunHistoryData = Record; export interface RunEntry { diff --git a/src/ai/ai-moveset-gen.ts b/src/ai/ai-moveset-gen.ts new file mode 100644 index 00000000000..f392ca46d3f --- /dev/null +++ b/src/ai/ai-moveset-gen.ts @@ -0,0 +1,770 @@ +import { globalScene } from "#app/global-scene"; +import { speciesEggMoves } from "#balance/egg-moves"; +import { + BASE_LEVEL_WEIGHT_OFFSET, + BASE_WEIGHT_MULTIPLIER, + BOSS_EXTRA_WEIGHT_MULTIPLIER, + COMMON_TIER_TM_LEVEL_REQUIREMENT, + COMMON_TM_MOVESET_WEIGHT, + EGG_MOVE_LEVEL_REQUIREMENT, + EGG_MOVE_TO_LEVEL_WEIGHT, + EGG_MOVE_WEIGHT_MAX, + EVOLUTION_MOVE_WEIGHT, + GREAT_TIER_TM_LEVEL_REQUIREMENT, + GREAT_TM_MOVESET_WEIGHT, + getMaxEggMoveCount, + getMaxTmCount, + RARE_EGG_MOVE_LEVEL_REQUIREMENT, + STAB_BLACKLIST, + ULTRA_TIER_TM_LEVEL_REQUIREMENT, + ULTRA_TM_MOVESET_WEIGHT, +} from "#balance/moveset-generation"; +import { EVOLVE_MOVE, RELEARN_MOVE } from "#balance/pokemon-level-moves"; +import { speciesTmMoves, tmPoolTiers } from "#balance/tms"; +import { allMoves } from "#data/data-lists"; +import { ModifierTier } from "#enums/modifier-tier"; +import { MoveCategory } from "#enums/move-category"; +import type { MoveId } from "#enums/move-id"; +import { PokemonType } from "#enums/pokemon-type"; +import type { SpeciesId } from "#enums/species-id"; +import { Stat } from "#enums/stat"; +import type { EnemyPokemon, Pokemon } from "#field/pokemon"; +import { PokemonMove } from "#moves/pokemon-move"; +import { NumberHolder, randSeedInt } from "#utils/common"; +import { isBeta } from "#utils/utility-vars"; + +/** + * Compute and assign a weight to the level-up moves currently available to the Pokémon + * + * @param pokemon - The Pokémon to generate a level-based move pool for + * @returns A map of move IDs to their computed weights + * + * @remarks + * A move's weight is determined by its level, as follows: + * 1. If the level is an {@linkcode EVOLVE_MOVE} move, weight is 60 + * 2. If it is level 1 with 80+ BP, it is considered a "move reminder" move and + * weight is 60 + * 3. If the Pokémon has a trainer and the move is a {@linkcode RELEARN_MOVE}, + * weight is 60 + * 4. Otherwise, weight is the earliest level the move can be learned + 20 + */ +function getAndWeightLevelMoves(pokemon: Pokemon): Map { + const movePool = new Map(); + let allLevelMoves: [number, MoveId][]; + // TODO: Investigate why there needs to be error handling here + try { + allLevelMoves = pokemon.getLevelMoves(1, true, true, pokemon.hasTrainer()); + } catch (e) { + console.warn("Error encountered trying to generate moveset for %s: %s", pokemon.species.name, e); + return movePool; + } + + const level = pokemon.level; + const hasTrainer = pokemon.hasTrainer(); + + for (const levelMove of allLevelMoves) { + const [learnLevel, id] = levelMove; + if (level < learnLevel) { + break; + } + const move = allMoves[id]; + // Skip unimplemented moves or moves that are already in the pool + if (move.name.endsWith(" (N)") || movePool.has(id)) { + continue; + } + + let weight = learnLevel + BASE_LEVEL_WEIGHT_OFFSET; + switch (learnLevel) { + case EVOLVE_MOVE: + weight = EVOLUTION_MOVE_WEIGHT; + break; + // Assume level 1 moves with 80+ BP are "move reminder" moves and bump their weight. Trainers use actual relearn moves. + case 1: + if (move.power >= 80) { + weight = 60; + } + break; + case RELEARN_MOVE: + if (hasTrainer) { + weight = 60; + } + } + + movePool.set(id, weight); + } + + return movePool; +} + +/** + * Determine which TM tiers a Pokémon can learn based on its level + * @param level - The level of the Pokémon + * @returns A tuple indicating whether the Pokémon can learn common, great, and ultra tier TMs + */ +function getAllowedTmTiers(level: number): [common: boolean, great: boolean, ultra: boolean] { + return [ + level >= COMMON_TIER_TM_LEVEL_REQUIREMENT, + level >= GREAT_TIER_TM_LEVEL_REQUIREMENT, + level >= ULTRA_TIER_TM_LEVEL_REQUIREMENT, + ]; +} + +/** + * Get the TMs that a species can learn based on its ID and formKey + * @param speciesId - The species ID of the Pokémon + * @param level - The level of the Pokémon + * @param formKey - The form key of the Pokémon + * @param levelPool - The current level-based move pool, to avoid duplicates + * @param tmPool - The TM move pool to add to, which will be modified in place + * @param allowedTiers - The tiers of TMs the Pokémon is allowed to learn + * + * @privateRemarks + * Split out from `getAndWeightTmMoves` to allow fusion species to add their TMs + * without duplicating code. + */ +function getTmPoolForSpecies( + speciesId: number, + level: number, + formKey: string, + levelPool: ReadonlyMap, + eggPool: ReadonlyMap, + tmPool: Map, + allowedTiers = getAllowedTmTiers(level), +): void { + const [allowCommon, allowGreat, allowUltra] = allowedTiers; + const tms = speciesTmMoves[speciesId]; + // Species with no learnable TMs (e.g. Ditto) don't have entries in the `speciesTmMoves` object, + // so this is needed to avoid iterating over `undefined` + if (tms == null) { + return; + } + + let moveId: MoveId; + for (const tm of tms) { + if (Array.isArray(tm)) { + if (tm[0] !== formKey) { + continue; + } + moveId = tm[1]; + } else { + moveId = tm; + } + + if (levelPool.has(moveId) || eggPool.has(moveId) || tmPool.has(moveId)) { + continue; + } + switch (tmPoolTiers[moveId]) { + case ModifierTier.COMMON: + allowCommon && tmPool.set(moveId, COMMON_TM_MOVESET_WEIGHT); + break; + case ModifierTier.GREAT: + allowGreat && tmPool.set(moveId, GREAT_TM_MOVESET_WEIGHT); + break; + case ModifierTier.ULTRA: + allowUltra && tmPool.set(moveId, ULTRA_TM_MOVESET_WEIGHT); + break; + } + } +} + +/** + * Compute and assign a weight to the TM moves currently available to the Pokémon + * @param pokemon - The Pokémon to generate a TM-based move pool for + * @param currentSet - The current movepool, to avoid duplicates + * @param tmPool - The TM move pool to add to, which will be modified in place + * @returns A map of move IDs to their computed weights + * + * @remarks + * Only trainer pokemon can learn TM moves, and there are restrictions + * as to how many and which TMs are available based on the level of the Pokémon. + * 1. Before level 25, no TM moves are available + * 2. Between levels 25 and 40, only COMMON tier TMs are available, + */ +function getAndWeightTmMoves( + pokemon: Pokemon, + currentPool: ReadonlyMap, + eggPool: ReadonlyMap, + tmPool: Map, +): void { + const level = pokemon.level; + const allowedTiers = getAllowedTmTiers(level); + if (!allowedTiers.includes(true)) { + return; + } + + const form = pokemon.species.forms[pokemon.formIndex]?.formKey ?? ""; + getTmPoolForSpecies(pokemon.species.speciesId, level, form, currentPool, eggPool, tmPool, allowedTiers); + const fusionFormKey = pokemon.getFusionFormKey(); + const fusionSpecies = pokemon.fusionSpecies?.speciesId; + if (fusionSpecies != null && fusionFormKey != null && fusionFormKey !== "") { + getTmPoolForSpecies(fusionSpecies, level, fusionFormKey, currentPool, eggPool, tmPool, allowedTiers); + } +} + +/** + * Get the weight multiplier for an egg move + * @param levelPool - Map of level up moves to their weights + * @param level - The level of the Pokémon + * @param forRare - Whether this is for a rare egg move + * @param isBoss - Whether the Pokémon having the egg move generated is a boss Pokémon + */ +export function getEggMoveWeight( + // biome-ignore-start lint/correctness/noUnusedFunctionParameters: Saved to allow this algorithm to be tweaked easily without adjusting signatures + levelPool: ReadonlyMap, + level: number, + forRare: boolean, + isBoss: boolean, + // biome-ignore-end lint/correctness/noUnusedFunctionParameters: Endrange +): number { + const levelUpWeightedEggMoveWeight = Math.round(Math.max(...levelPool.values()) * EGG_MOVE_TO_LEVEL_WEIGHT); + // Rare egg moves are always weighted at 5/6 the weight of normal egg moves + return Math.min(levelUpWeightedEggMoveWeight, EGG_MOVE_WEIGHT_MAX) * (forRare ? 5 / 6 : 1); +} + +/** + * Submethod of {@linkcode getAndWeightEggMoves} that adds egg moves for a specific species to the egg move pool + * + * @param rootSpeciesId - The ID of the root species for which to generate the egg move pool. + * @param levelPool - A readonly map of move IDs to their levels, representing moves already learned by leveling up. + * @param eggPool - A map to be populated with egg move IDs and their corresponding weights. + * @param eggMoveWeight - The default weight to assign to regular egg moves. + * @param excludeRare - If true, excludes rare egg moves + * @param rareEggMoveWeight - The weight to assign to rare egg moves; default 0 + * + * @privateRemarks + * Split from `getAndWeightEggMoves` to allow fusion species to add their egg moves without duplicating code. + * + * @remarks + * - Moves present in `levelPool` are excluded from the egg pool. + * - If `excludeRare` is true, rare egg moves (at index 3) are skipped. + * - Rare egg moves are assigned `rareEggMoveWeight`, while others receive `eggMoveWeight`. + */ +function getEggPoolForSpecies( + rootSpeciesId: SpeciesId, + levelPool: ReadonlyMap, + eggPool: Map, + eggMoveWeight: number, + excludeRare: boolean, + rareEggMoveWeight = 0, +): void { + const eggMoves = speciesEggMoves[rootSpeciesId]; + if (eggMoves == null) { + return; + } + for (const [idx, moveId] of eggMoves.entries()) { + if (levelPool.has(moveId) || (idx === 3 && excludeRare)) { + continue; + } + eggPool.set(Math.max(moveId, eggPool.get(moveId) ?? 0), idx === 3 ? rareEggMoveWeight : eggMoveWeight); + } +} + +/** + * Compute and assign a weight to the egg moves currently available to the Pokémon + * @param pokemon - The Pokémon to generate egg moves for + * @param levelPool - The map of level-based moves to their weights + * @param eggPool - A map of move IDs to their weights for egg moves that will be modified in place + * + * @remarks + * This function checks if the Pokémon meets the requirements to learn egg moves, + * and if allowed, calculates the weights for regular and rare egg moves using the provided pools. + */ +function getAndWeightEggMoves( + pokemon: Pokemon, + levelPool: ReadonlyMap, + eggPool: Map, +): void { + const level = pokemon.level; + if (level < EGG_MOVE_LEVEL_REQUIREMENT || !globalScene.currentBattle?.trainer?.config.allowEggMoves) { + return; + } + const isBoss = pokemon.isBoss(); + const excludeRare = isBoss || level < RARE_EGG_MOVE_LEVEL_REQUIREMENT; + const eggMoveWeight = getEggMoveWeight(levelPool, level, false, isBoss); + let rareEggMoveWeight: number | undefined; + if (!excludeRare) { + rareEggMoveWeight = getEggMoveWeight(levelPool, level, true, isBoss); + } + getEggPoolForSpecies( + pokemon.species.getRootSpeciesId(), + levelPool, + eggPool, + eggMoveWeight, + excludeRare, + rareEggMoveWeight, + ); + + const fusionSpecies = pokemon.fusionSpecies?.getRootSpeciesId(); + if (fusionSpecies != null) { + getEggPoolForSpecies(fusionSpecies, levelPool, eggPool, eggMoveWeight, excludeRare, rareEggMoveWeight); + } +} + +/** + * Filter a move pool, removing moves that are not allowed based on conditions + * @param pool - The move pool to filter + * @param isBoss - Whether the Pokémon is a boss + * @param hasTrainer - Whether the Pokémon has a trainer + */ +function filterMovePool(pool: Map, isBoss: boolean, hasTrainer: boolean): void { + for (const [moveId, weight] of pool) { + if (weight <= 0) { + pool.delete(moveId); + continue; + } + const move = allMoves[moveId]; + // Forbid unimplemented moves + if (move.name.endsWith(" (N)")) { + pool.delete(moveId); + continue; + } + // Bosses never get self ko moves or Pain Split + if (isBoss && (move.hasAttr("SacrificialAttr") || move.hasAttr("HpSplitAttr"))) { + pool.delete(moveId); + } + + // No one gets Memento or Final Gambit + if (move.hasAttr("SacrificialAttrOnHit")) { + pool.delete(moveId); + continue; + } + + // Trainers never get OHKO moves + if (hasTrainer && move.hasAttr("OneHitKOAttr")) { + pool.delete(moveId); + } + } +} + +/** + * Perform Trainer-specific adjustments to move weights in a move pool + * @param pool - The move pool to adjust + */ +function adjustWeightsForTrainer(pool: Map): void { + for (const [moveId, weight] of pool.entries()) { + const move = allMoves[moveId]; + let adjustedWeight = weight; + // Half the weight of self KO moves on trainers + adjustedWeight *= move.hasAttr("SacrificialAttr") ? 0.5 : 1; + + // Trainers get a weight bump to stat buffing moves + adjustedWeight *= move.getAttrs("StatStageChangeAttr").some(a => a.stages > 1 && a.selfTarget) ? 1.25 : 1; + + // Trainers get a weight decrease to multiturn moves + adjustedWeight *= !!move.isChargingMove() || !!move.hasAttr("RechargeAttr") ? 0.7 : 1; + if (adjustedWeight !== weight) { + pool.set(moveId, adjustedWeight); + } + } +} + +/** + * Adjust weights of damaging moves in a move pool based on their power and category + * + * @param pool - The move pool to adjust + * @param pokemon - The Pokémon for which the moveset is being generated + * @param willTera - Whether the Pokémon is expected to Tera (i.e., has instant Tera on a Trainer Pokémon); default `false` + * @remarks + * Caps max power at 90 to avoid something like hyper beam ruining the stats. + * pokemon is a pretty soft weighting factor, although it is scaled with the weight multiplier. + */ +function adjustDamageMoveWeights(pool: Map, pokemon: Pokemon, willTera = false): void { + // begin max power at 40 to avoid inflating weights too much when there are only low power moves + let maxPower = 40; + for (const moveId of pool.keys()) { + const move = allMoves[moveId]; + maxPower = Math.max(maxPower, move.calculateEffectivePower()); + if (maxPower >= 90) { + maxPower = 90; + break; + } + } + + const atk = pokemon.getStat(Stat.ATK); + const spAtk = pokemon.getStat(Stat.SPATK); + const lowerStat = Math.min(atk, spAtk); + const higherStat = Math.max(atk, spAtk); + const worseCategory = atk > spAtk ? MoveCategory.SPECIAL : MoveCategory.PHYSICAL; + const statRatio = lowerStat / higherStat; + const adjustmentRatio = Math.min(Math.pow(statRatio, 3) * 1.3, 1); + + for (const [moveId, weight] of pool) { + const move = allMoves[moveId]; + let adjustedWeight = weight; + if (move.category === MoveCategory.STATUS) { + continue; + } + // Scale weight based on their ratio to the highest power move, capping at 50% reduction + adjustedWeight *= Math.max(Math.min(move.calculateEffectivePower() / maxPower, 1), 0.5); + + // Scale weight based the stat it uses to deal damage, based on the ratio between said stat + // and the higher stat + if (move.hasAttr("DefAtkAttr")) { + const def = pokemon.getStat(Stat.DEF); + const defRatio = def / higherStat; + const defAdjustRatio = Math.min(Math.pow(defRatio, 3) * 1.3, 1.1); + adjustedWeight *= defAdjustRatio; + } else if ( + move.category === worseCategory + && !move.hasAttr("PhotonGeyserCategoryAttr") + && !move.hasAttr("ShellSideArmCategoryAttr") + && !(move.hasAttr("TeraMoveCategoryAttr") && willTera) + ) { + // Raw multiply each move's category by the stat it uses to deal damage + // moves that always use the higher offensive stat are left unadjusted + adjustedWeight *= adjustmentRatio; + } + + if (adjustedWeight !== weight) { + pool.set(moveId, adjustedWeight); + } + } +} + +/** + * Calculate the total weight of all moves in a move pool + * @param pool - The move pool to calculate the total weight for + * @returns The total weight of all moves in the pool + */ +function calculateTotalPoolWeight(pool: Map): number { + let totalWeight = 0; + for (const weight of pool.values()) { + totalWeight += weight; + } + return totalWeight; +} + +/** + * Filter a pool and return a new array of moves that pass the predicate + * @param pool - The move pool to filter + * @param predicate - The predicate function to determine if a move should be included + * @param totalWeight - An output parameter to hold the total weight of the filtered pool. Its value is reset to 0 if provided. + * @returns An array of move ID and weight tuples that pass the predicate + */ +function filterPool( + pool: ReadonlyMap, + predicate: (moveId: MoveId) => boolean, + totalWeight?: NumberHolder, +): [id: MoveId, weight: number][] { + let hasTotalWeight = false; + if (totalWeight != null) { + totalWeight.value = 0; + hasTotalWeight = true; + } + const newPool: [id: MoveId, weight: number][] = []; + for (const [moveId, weight] of pool) { + if (predicate(moveId)) { + newPool.push([moveId, weight]); + if (hasTotalWeight) { + // Bang is safe here because we set `hasTotalWeight` in the if check above + totalWeight!.value += weight; + } + } + } + + return newPool; +} + +/** + * Forcibly add a STAB move to the Pokémon's moveset from the provided pools + * + * @remarks + * If no STAB move is available, add any damaging move. + * If no damaging move is available, no move is added + * @param pool - The master move pool + * @param tmPool - The TM move pool + * @param eggPool - The egg move pool + * @param pokemon - The Pokémon for which the moveset is being generated + * @param tmCount - A holder for the count of TM moves selected + * @param eggMoveCount - A holder for the count of egg moves selected + * @param willTera - Whether the Pokémon is expected to Tera (i.e., has instant Tera on a Trainer Pokémon); default `false` + * @param forceAnyDamageIfNoStab - If true, will force any damaging move if no STAB move is available + */ +// biome-ignore lint/nursery/useMaxParams: This is a complex function that needs all these parameters +function forceStabMove( + pool: Map, + tmPool: Map, + eggPool: Map, + pokemon: Pokemon, + tmCount: NumberHolder, + eggMoveCount: NumberHolder, + willTera = false, + forceAnyDamageIfNoStab = false, +): void { + // All Pokemon force a STAB move first + const totalWeight = new NumberHolder(0); + const stabMovePool = filterPool( + pool, + moveId => { + const move = allMoves[moveId]; + return ( + move.category !== MoveCategory.STATUS + && (pokemon.isOfType(move.type) + || (willTera && move.hasAttr("TeraBlastTypeAttr") && pokemon.getTeraType() !== PokemonType.STELLAR)) + && !STAB_BLACKLIST.has(moveId) + ); + }, + totalWeight, + ); + + const chosenPool = + stabMovePool.length > 0 || !forceAnyDamageIfNoStab + ? stabMovePool + : filterPool( + pool, + m => allMoves[m[0]].category !== MoveCategory.STATUS && !STAB_BLACKLIST.has(m[0]), + totalWeight, + ); + + if (chosenPool.length > 0) { + let rand = randSeedInt(totalWeight.value); + let index = 0; + while (rand > chosenPool[index][1]) { + rand -= chosenPool[index++][1]; + } + const selectedId = chosenPool[index][0]; + pool.delete(selectedId); + if (tmPool.has(selectedId)) { + tmPool.delete(selectedId); + tmCount.value++; + } else if (eggPool.has(selectedId)) { + eggPool.delete(selectedId); + eggMoveCount.value++; + } + pokemon.moveset.push(new PokemonMove(selectedId)); + } +} + +/** + * Adjust weights in the remaining move pool based on existing moves in the Pokémon's moveset + * + * @remarks + * Submethod for step 5 of moveset generation + * @param pool - The move pool to filter + * @param pokemon - The Pokémon for which the moveset is being generated + */ +function filterRemainingTrainerMovePool(pool: [id: MoveId, weight: number][], pokemon: Pokemon) { + // Sqrt the weight of any damaging moves with overlapping types. pokemon is about a 0.05 - 0.1 multiplier. + // Other damaging moves 2x weight if 0-1 damaging moves, 0.5x if 2, 0.125x if 3. These weights get 20x if STAB. + // Status moves remain unchanged on weight, pokemon encourages 1-2 + for (const [idx, [moveId, weight]] of pool.entries()) { + let ret: number; + if ( + pokemon.moveset.some( + mo => mo.getMove().category !== MoveCategory.STATUS && mo.getMove().type === allMoves[moveId].type, + ) + ) { + ret = Math.ceil(Math.sqrt(weight)); + } else if (allMoves[moveId].category !== MoveCategory.STATUS) { + ret = Math.ceil( + (weight / Math.max(Math.pow(4, pokemon.moveset.filter(mo => (mo.getMove().power ?? 0) > 1).length) / 8, 0.5)) + * (pokemon.isOfType(allMoves[moveId].type) && !STAB_BLACKLIST.has(moveId) ? 20 : 1), + ); + } else { + ret = weight; + } + pool[idx] = [moveId, ret]; + } +} + +/** + * Fill in the remaining slots in the Pokémon's moveset from the provided pools + * @param pokemon - The Pokémon for which the moveset is being generated + * @param tmPool - The TM move pool + * @param eggMovePool - The egg move pool + * @param tmCount - A holder for the count of moves that have been added to the moveset from TMs + * @param eggMoveCount - A holder for the count of moves that have been added to the moveset from egg moves + * @param baseWeights - The base weights of all moves in the master pool + * @param remainingPool - The remaining move pool to select from + */ +function fillInRemainingMovesetSlots( + pokemon: Pokemon, + tmPool: Map, + eggMovePool: Map, + tmCount: NumberHolder, + eggMoveCount: NumberHolder, + baseWeights: Map, + remainingPool: [id: MoveId, weight: number][], +): void { + const tmCap = getMaxTmCount(pokemon.level); + const eggCap = getMaxEggMoveCount(pokemon.level); + const remainingPoolWeight = new NumberHolder(0); + while (remainingPool.length > pokemon.moveset.length && pokemon.moveset.length < 4) { + const nonLevelMoveCount = tmCount.value + eggMoveCount.value; + remainingPool = filterPool( + baseWeights, + (m: MoveId) => + !pokemon.moveset.some( + mo => + m === mo.moveId || (allMoves[m]?.hasAttr("SacrificialAttr") && mo.getMove()?.hasAttr("SacrificialAttr")), // Only one self-KO move allowed + ) + && (nonLevelMoveCount < tmCap || !tmPool.has(m)) + && (nonLevelMoveCount < eggCap || !eggMovePool.has(m)), + remainingPoolWeight, + ); + if (pokemon.hasTrainer()) { + filterRemainingTrainerMovePool(remainingPool, pokemon); + } + const totalWeight = remainingPool.reduce((v, m) => v + m[1], 0); + let rand = randSeedInt(totalWeight); + let index = 0; + while (rand > remainingPool[index][1]) { + rand -= remainingPool[index++][1]; + } + const selectedMoveId = remainingPool[index][0]; + baseWeights.delete(selectedMoveId); + if (tmPool.has(selectedMoveId)) { + tmCount.value++; + tmPool.delete(selectedMoveId); + } else if (eggMovePool.has(selectedMoveId)) { + eggMoveCount.value++; + eggMovePool.delete(selectedMoveId); + } + pokemon.moveset.push(new PokemonMove(selectedMoveId)); + } +} + +/** + * Debugging function to log computed move weights for a Pokémon + * @param pokemon - The Pokémon for which the move weights were computed + * @param pool - The move pool containing move IDs and their weights + * @param note - Short note to include in the log for context + */ +function debugMoveWeights(pokemon: Pokemon, pool: Map, note: string): void { + if ((isBeta || import.meta.env.DEV) && import.meta.env.NODE_ENV !== "test") { + const moveNameToWeightMap = new Map(); + const sortedByValue = Array.from(pool.entries()).sort((a, b) => b[1] - a[1]); + for (const [moveId, weight] of sortedByValue) { + moveNameToWeightMap.set(allMoves[moveId].name, weight); + } + console.log("%cComputed move weights [%s] for %s", "color: blue", note, pokemon.name, moveNameToWeightMap); + } +} + +/** + * Generate a moveset for a given Pokémon based on its level, types, stats, and whether it is wild or a trainer's Pokémon. + * @param pokemon - The Pokémon to generate a moveset for + * @returns A reference to the Pokémon's moveset array + */ +export function generateMoveset(pokemon: Pokemon): void { + pokemon.moveset = []; + // Step 1: Generate the pools from various sources: level up, egg moves, and TMs + const learnPool = getAndWeightLevelMoves(pokemon); + debugMoveWeights(pokemon, learnPool, "Initial Level Moves"); + const hasTrainer = pokemon.hasTrainer(); + const tmPool = new Map(); + const eggMovePool = new Map(); + + if (hasTrainer) { + getAndWeightEggMoves(pokemon, learnPool, eggMovePool); + eggMovePool.size > 0 && debugMoveWeights(pokemon, eggMovePool, "Initial Egg Moves"); + getAndWeightTmMoves(pokemon, learnPool, eggMovePool, tmPool); + tmPool.size > 0 && debugMoveWeights(pokemon, tmPool, "Initial Tm Moves"); + } + + // Now, combine pools into one master pool. + // The pools are kept around so we know where the move was sourced from + const movePool = new Map([...tmPool.entries(), ...eggMovePool.entries(), ...learnPool.entries()]); + + // Step 2: Filter out forbidden moves + const isBoss = pokemon.isBoss(); + filterMovePool(movePool, isBoss, hasTrainer); + + // Step 3: Adjust weights for trainers + if (hasTrainer) { + adjustWeightsForTrainer(movePool); + } + + /** Determine whether this pokemon will instantly tera */ + const willTera = + hasTrainer + && globalScene.currentBattle?.trainer?.config.trainerAI.instantTeras.includes( + // The cast to EnemyPokemon is safe; includes will just return false if the property doesn't exist + (pokemon as EnemyPokemon).initialTeamIndex, + ); + + adjustDamageMoveWeights(movePool, pokemon, willTera); + + /** The higher this is, the greater the impact of weight. At `0` all moves are equal weight. */ + let weightMultiplier = BASE_WEIGHT_MULTIPLIER; + if (isBoss) { + weightMultiplier += BOSS_EXTRA_WEIGHT_MULTIPLIER; + } + + const baseWeights = new Map(movePool); + for (const [moveId, weight] of baseWeights) { + if (weight <= 0) { + baseWeights.delete(moveId); + continue; + } + baseWeights.set(moveId, Math.ceil(Math.pow(weight, weightMultiplier) * 100)); + } + + const tmCount = new NumberHolder(0); + const eggMoveCount = new NumberHolder(0); + + debugMoveWeights(pokemon, baseWeights, "Pre STAB Move"); + + // Step 4: Force a STAB move if possible + forceStabMove(movePool, tmPool, eggMovePool, pokemon, tmCount, eggMoveCount, willTera); + // Note: To force a secondary stab, call this a second time, and pass `false` for the last parameter + // Would also tweak the function to not consider moves already in the moveset + // e.g. forceStabMove(..., false); + + // Step 5: Fill in remaining slots + fillInRemainingMovesetSlots( + pokemon, + tmPool, + eggMovePool, + tmCount, + eggMoveCount, + baseWeights, + filterPool(baseWeights, (m: MoveId) => !pokemon.moveset.some(mo => m[0] === mo.moveId)), + ); +} + +/** + * Exports for internal testing purposes. + * ⚠️ These *must not* be used outside of tests, as they will not be defined. + * @internal + */ +export const __INTERNAL_TEST_EXPORTS: { + getAndWeightLevelMoves: typeof getAndWeightLevelMoves; + getAllowedTmTiers: typeof getAllowedTmTiers; + getTmPoolForSpecies: typeof getTmPoolForSpecies; + getAndWeightTmMoves: typeof getAndWeightTmMoves; + getEggMoveWeight: typeof getEggMoveWeight; + getEggPoolForSpecies: typeof getEggPoolForSpecies; + getAndWeightEggMoves: typeof getAndWeightEggMoves; + filterMovePool: typeof filterMovePool; + adjustWeightsForTrainer: typeof adjustWeightsForTrainer; + adjustDamageMoveWeights: typeof adjustDamageMoveWeights; + calculateTotalPoolWeight: typeof calculateTotalPoolWeight; + filterPool: typeof filterPool; + forceStabMove: typeof forceStabMove; + filterRemainingTrainerMovePool: typeof filterRemainingTrainerMovePool; + fillInRemainingMovesetSlots: typeof fillInRemainingMovesetSlots; +} = {} as any; + +// We can't use `import.meta.vitest` here, because this would not be set +// until the tests themselves begin to run, which is after imports +// So we rely on NODE_ENV being test instead +if (import.meta.env.NODE_ENV === "test") { + Object.assign(__INTERNAL_TEST_EXPORTS, { + getAndWeightLevelMoves, + getAllowedTmTiers, + getTmPoolForSpecies, + getAndWeightTmMoves, + getEggMoveWeight, + getEggPoolForSpecies, + getAndWeightEggMoves, + filterMovePool, + adjustWeightsForTrainer, + adjustDamageMoveWeights, + calculateTotalPoolWeight, + filterPool, + forceStabMove, + filterRemainingTrainerMovePool, + fillInRemainingMovesetSlots, + }); +} diff --git a/src/battle-scene.ts b/src/battle-scene.ts index d87b88f2f93..df2a9575e8b 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -105,7 +105,6 @@ import { import { MysteryEncounter } from "#mystery-encounters/mystery-encounter"; import { MysteryEncounterSaveData } from "#mystery-encounters/mystery-encounter-save-data"; import { allMysteryEncounters, mysteryEncountersByBiome } from "#mystery-encounters/mystery-encounters"; -import type { MovePhase } from "#phases/move-phase"; import { expSpriteKeys } from "#sprites/sprite-keys"; import { hasExpSprite } from "#sprites/sprite-utils"; import type { Variant } from "#sprites/variant"; @@ -140,7 +139,6 @@ import { formatMoney, getIvsFromId, isBetween, - isNullOrUndefined, NumberHolder, randomString, randSeedInt, @@ -784,12 +782,14 @@ export class BattleScene extends SceneBase { /** * Returns an array of EnemyPokemon of length 1 or 2 depending on if in a double battle or not. - * Does not actually check if the pokemon are on the field or not. + * @param active - (Default `false`) Whether to consider only {@linkcode Pokemon.isActive | active} on-field pokemon * @returns array of {@linkcode EnemyPokemon} */ - public getEnemyField(): EnemyPokemon[] { + public getEnemyField(active = false): EnemyPokemon[] { const party = this.getEnemyParty(); - return party.slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1)); + return party + .slice(0, Math.min(party.length, this.currentBattle?.double ? 2 : 1)) + .filter(p => !active || p.isActive()); } /** @@ -814,25 +814,7 @@ export class BattleScene extends SceneBase { * @param allyPokemon - The {@linkcode Pokemon} allied with the removed Pokemon; will have moves redirected to it */ redirectPokemonMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void { - // failsafe: if not a double battle just return - if (this.currentBattle.double === false) { - return; - } - if (allyPokemon?.isActive(true)) { - let targetingMovePhase: MovePhase; - do { - targetingMovePhase = this.phaseManager.findPhase( - mp => - mp.is("MovePhase") - && mp.targets.length === 1 - && mp.targets[0] === removedPokemon.getBattlerIndex() - && mp.pokemon.isPlayer() !== allyPokemon.isPlayer(), - ) as MovePhase; - if (targetingMovePhase && targetingMovePhase.targets[0] !== allyPokemon.getBattlerIndex()) { - targetingMovePhase.targets[0] = allyPokemon.getBattlerIndex(); - } - } while (targetingMovePhase); - } + this.phaseManager.redirectMoves(removedPokemon, allyPokemon); } /** @@ -855,20 +837,21 @@ export class BattleScene extends SceneBase { } /** - * Return the {@linkcode Pokemon} associated with a given ID. - * @param pokemonId - The ID whose Pokemon will be retrieved. - * @returns The {@linkcode Pokemon} associated with the given id. - * Returns `null` if the ID is `undefined` or not present in either party. - * @todo Change the `null` to `undefined` and update callers' signatures - - * this is weird and causes a lot of random jank + * Return the {@linkcode Pokemon} associated with the given ID. + * @param pokemonId - The PID whose Pokemon will be retrieved + * @returns The `Pokemon` associated with the given ID, + * or `undefined` if none is found in either team's party. + * @see {@linkcode Pokemon.id} + * @todo `pokemonId` should not allow `undefined` */ - getPokemonById(pokemonId: number | undefined): Pokemon | null { - if (isNullOrUndefined(pokemonId)) { - return null; + public getPokemonById(pokemonId: number | undefined): Pokemon | undefined { + if (pokemonId == null) { + // biome-ignore lint/nursery/noUselessUndefined: More explicit + return undefined; } const party = (this.getPlayerParty() as Pokemon[]).concat(this.getEnemyParty()); - return party.find(p => p.id === pokemonId) ?? null; + return party.find(p => p.id === pokemonId); } addPlayerPokemon( @@ -1315,7 +1298,7 @@ export class BattleScene extends SceneBase { if ( !this.gameMode.hasTrainers || Overrides.BATTLE_TYPE_OVERRIDE === BattleType.WILD - || (Overrides.DISABLE_STANDARD_TRAINERS_OVERRIDE && isNullOrUndefined(trainerData)) + || (Overrides.DISABLE_STANDARD_TRAINERS_OVERRIDE && trainerData == null) ) { newBattleType = BattleType.WILD; } else { @@ -1328,13 +1311,12 @@ export class BattleScene extends SceneBase { if (newBattleType === BattleType.TRAINER) { const trainerType = Overrides.RANDOM_TRAINER_OVERRIDE?.trainerType ?? this.arena.randomTrainerType(newWaveIndex); + const hasDouble = trainerConfigs[trainerType].hasDouble; let doubleTrainer = false; if (trainerConfigs[trainerType].doubleOnly) { doubleTrainer = true; - } else if (trainerConfigs[trainerType].hasDouble) { - doubleTrainer = - Overrides.RANDOM_TRAINER_OVERRIDE?.alwaysDouble - || !randSeedInt(this.getDoubleBattleChance(newWaveIndex, playerField)); + } else if (hasDouble) { + doubleTrainer = !randSeedInt(this.getDoubleBattleChance(newWaveIndex, playerField)); // Add a check that special trainers can't be double except for tate and liza - they should use the normal double chance if ( trainerConfigs[trainerType].trainerTypeDouble @@ -1343,11 +1325,19 @@ export class BattleScene extends SceneBase { doubleTrainer = false; } } - const variant = doubleTrainer - ? TrainerVariant.DOUBLE - : randSeedInt(2) - ? TrainerVariant.FEMALE - : TrainerVariant.DEFAULT; + + // Forcing a double battle on wave 1 causes a bug where only one enemy is sent out, + // making it impossible to complete the fight without a reload + const overrideVariant = + Overrides.RANDOM_TRAINER_OVERRIDE?.trainerVariant === TrainerVariant.DOUBLE + && (!hasDouble || newWaveIndex <= 1) + ? TrainerVariant.DEFAULT + : Overrides.RANDOM_TRAINER_OVERRIDE?.trainerVariant; + + const variant = + overrideVariant + ?? (doubleTrainer ? TrainerVariant.DOUBLE : randSeedInt(2) ? TrainerVariant.FEMALE : TrainerVariant.DEFAULT); + newTrainer = trainerData !== undefined ? trainerData.toTrainer() : new Trainer(trainerType, variant); this.field.add(newTrainer); } @@ -1379,7 +1369,7 @@ export class BattleScene extends SceneBase { newDouble = false; } - if (!isNullOrUndefined(Overrides.BATTLE_STYLE_OVERRIDE)) { + if (Overrides.BATTLE_STYLE_OVERRIDE != null) { let doubleOverrideForWave: "single" | "double" | null = null; switch (Overrides.BATTLE_STYLE_OVERRIDE) { @@ -1422,7 +1412,7 @@ export class BattleScene extends SceneBase { } if (lastBattle?.double && !newDouble) { - this.phaseManager.tryRemovePhase((p: Phase) => p.is("SwitchPhase")); + this.phaseManager.tryRemovePhase("SwitchPhase"); for (const p of this.getPlayerField()) { p.lapseTag(BattlerTagType.COMMANDED); } @@ -1568,7 +1558,7 @@ export class BattleScene extends SceneBase { // Give trainers with specialty types an appropriately-typed form for Wormadam, Rotom, Arceus, Oricorio, Silvally, or Paldean Tauros. !isEggPhase && this.currentBattle?.battleType === BattleType.TRAINER - && !isNullOrUndefined(this.currentBattle.trainer) + && this.currentBattle.trainer != null && this.currentBattle.trainer.config.hasSpecialtyType() ) { if (species.speciesId === SpeciesId.WORMADAM) { @@ -2688,7 +2678,7 @@ export class BattleScene extends SceneBase { } } else if (modifier instanceof FusePokemonModifier) { args.push(this.getPokemonById(modifier.fusePokemonId) as PlayerPokemon); - } else if (modifier instanceof RememberMoveModifier && !isNullOrUndefined(cost)) { + } else if (modifier instanceof RememberMoveModifier && cost != null) { args.push(cost); } @@ -3003,7 +2993,7 @@ export class BattleScene extends SceneBase { } if ( modifier instanceof PokemonHeldItemModifier - && !isNullOrUndefined(modifier.getSpecies()) + && modifier.getSpecies() != null && !this.getPokemonById(modifier.pokemonId)?.hasSpecies(modifier.getSpecies()!) ) { modifiers.splice(m--, 1); @@ -3569,7 +3559,7 @@ export class BattleScene extends SceneBase { // Loading override or session encounter let encounter: MysteryEncounter | null; if ( - !isNullOrUndefined(Overrides.MYSTERY_ENCOUNTER_OVERRIDE) + Overrides.MYSTERY_ENCOUNTER_OVERRIDE != null && allMysteryEncounters.hasOwnProperty(Overrides.MYSTERY_ENCOUNTER_OVERRIDE) ) { encounter = allMysteryEncounters[Overrides.MYSTERY_ENCOUNTER_OVERRIDE]; @@ -3580,7 +3570,7 @@ export class BattleScene extends SceneBase { encounter = allMysteryEncounters[encounterType ?? -1]; return encounter; } else { - encounter = !isNullOrUndefined(encounterType) ? allMysteryEncounters[encounterType] : null; + encounter = encounterType != null ? allMysteryEncounters[encounterType] : null; } // Check for queued encounters first @@ -3639,7 +3629,7 @@ export class BattleScene extends SceneBase { ? MysteryEncounterTier.ULTRA : MysteryEncounterTier.ROGUE; - if (!isNullOrUndefined(Overrides.MYSTERY_ENCOUNTER_TIER_OVERRIDE)) { + if (Overrides.MYSTERY_ENCOUNTER_TIER_OVERRIDE != null) { tier = Overrides.MYSTERY_ENCOUNTER_TIER_OVERRIDE; } diff --git a/src/constants/colors.ts b/src/constants/colors.ts index 717c5fa5f0d..a2400ef5f90 100644 --- a/src/constants/colors.ts +++ b/src/constants/colors.ts @@ -1,7 +1,8 @@ /** - * @module + * * A big file storing colors used in logging. * Minified by Terser during production builds, so has no overhead. + * @module */ // Colors used in prod diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 99cf2b80cd5..79619ec395f 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -33,6 +33,7 @@ import { CommonAnim } from "#enums/move-anims-common"; import { MoveCategory } from "#enums/move-category"; import { MoveFlags } from "#enums/move-flags"; import { MoveId } from "#enums/move-id"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; import { MoveResult } from "#enums/move-result"; import { MoveTarget } from "#enums/move-target"; import { MoveUseMode } from "#enums/move-use-mode"; @@ -66,7 +67,6 @@ import type { Constructor } from "#utils/common"; import { BooleanHolder, coerceArray, - isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, @@ -1039,7 +1039,7 @@ export class PostDefendStatStageChangeAbAttr extends PostDefendAbAttr { if (this.allOthers) { const ally = pokemon.getAlly(); - const otherPokemon = !isNullOrUndefined(ally) ? pokemon.getOpponents().concat([ally]) : pokemon.getOpponents(); + const otherPokemon = ally != null ? pokemon.getOpponents().concat([ally]) : pokemon.getOpponents(); for (const other of otherPokemon) { globalScene.phaseManager.unshiftNew( "StatStageChangePhase", @@ -1472,7 +1472,7 @@ export class PostDefendMoveDisableAbAttr extends PostDefendAbAttr { override canApply({ move, opponent: attacker, pokemon }: PostMoveInteractionAbAttrParams): boolean { return ( - isNullOrUndefined(attacker.getTag(BattlerTagType.DISABLED)) + attacker.getTag(BattlerTagType.DISABLED) == null && move.doesFlagEffectApply({ flag: MoveFlags.MAKES_CONTACT, user: attacker, target: pokemon }) && (this.chance === -1 || pokemon.randBattleSeedInt(100) < this.chance) ); @@ -2555,7 +2555,7 @@ export class PostIntimidateStatStageChangeAbAttr extends AbAttr { override apply({ pokemon, simulated, cancelled }: AbAttrParamsWithCancel): void { if (!simulated) { - globalScene.phaseManager.pushNew( + globalScene.phaseManager.unshiftNew( "StatStageChangePhase", pokemon.getBattlerIndex(), false, @@ -2809,7 +2809,7 @@ export class PostSummonAllyHealAbAttr extends PostSummonAbAttr { override apply({ pokemon, simulated }: AbAttrBaseParams): void { const target = pokemon.getAlly(); - if (!simulated && !isNullOrUndefined(target)) { + if (!simulated && target != null) { globalScene.phaseManager.unshiftNew( "PokemonHealPhase", target.getBattlerIndex(), @@ -2840,7 +2840,7 @@ export class PostSummonClearAllyStatStagesAbAttr extends PostSummonAbAttr { override apply({ pokemon, simulated }: AbAttrBaseParams): void { const target = pokemon.getAlly(); - if (!simulated && !isNullOrUndefined(target)) { + if (!simulated && target != null) { for (const s of BATTLE_STATS) { target.setStatStage(s, 0); } @@ -2959,13 +2959,13 @@ export class PostSummonHealStatusAbAttr extends PostSummonRemoveEffectAbAttr { public override canApply({ pokemon }: AbAttrBaseParams): boolean { const status = pokemon.status?.effect; - return !isNullOrUndefined(status) && (this.immuneEffects.length === 0 || this.immuneEffects.includes(status)); + return status != null && (this.immuneEffects.length === 0 || this.immuneEffects.includes(status)); } public override apply({ pokemon }: AbAttrBaseParams): void { // TODO: should probably check against simulated... const status = pokemon.status?.effect; - if (!isNullOrUndefined(status)) { + if (status != null) { this.statusHealed = status; pokemon.resetStatus(false); pokemon.updateInfo(); @@ -3101,7 +3101,7 @@ export class PostSummonCopyAllyStatsAbAttr extends PostSummonAbAttr { } const ally = pokemon.getAlly(); - return !(isNullOrUndefined(ally) || ally.getStatStages().every(s => s === 0)); + return !(ally == null || ally.getStatStages().every(s => s === 0)); } override apply({ pokemon, simulated }: AbAttrBaseParams): void { @@ -3109,7 +3109,7 @@ export class PostSummonCopyAllyStatsAbAttr extends PostSummonAbAttr { return; } const ally = pokemon.getAlly(); - if (!isNullOrUndefined(ally)) { + if (ally != null) { for (const s of BATTLE_STATS) { pokemon.setStatStage(s, ally.getStatStage(s)); } @@ -3239,7 +3239,8 @@ export class CommanderAbAttr extends AbAttr { const ally = pokemon.getAlly(); return ( globalScene.currentBattle?.double - && !isNullOrUndefined(ally) + && ally != null + && ally.isActive(true) && ally.species.speciesId === SpeciesId.DONDOZO && !(ally.isFainted() || ally.getTag(BattlerTagType.COMMANDED)) ); @@ -3254,7 +3255,7 @@ export class CommanderAbAttr extends AbAttr { // Apply boosts from this effect to the ally Dondozo pokemon.getAlly()?.addTag(BattlerTagType.COMMANDED, 0, MoveId.NONE, pokemon.id); // Cancel the source Pokemon's next move (if a move is queued) - globalScene.phaseManager.tryRemovePhase(phase => phase.is("MovePhase") && phase.pokemon === pokemon); + globalScene.phaseManager.tryRemovePhase("MovePhase", phase => phase.pokemon === pokemon); } } } @@ -3283,7 +3284,7 @@ export class PreSwitchOutResetStatusAbAttr extends PreSwitchOutAbAttr { } override canApply({ pokemon }: AbAttrBaseParams): boolean { - return !isNullOrUndefined(pokemon.status); + return pokemon.status != null; } override apply({ pokemon, simulated }: AbAttrBaseParams): void { @@ -3563,7 +3564,7 @@ export class ProtectStatAbAttr extends PreStatStageChangeAbAttr { } override canApply({ stat, cancelled }: PreStatStageChangeAbAttrParams): boolean { - return !cancelled.value && (isNullOrUndefined(this.protectedStat) || stat === this.protectedStat); + return !cancelled.value && (this.protectedStat == null || stat === this.protectedStat); } /** @@ -3799,11 +3800,7 @@ export class ConditionalUserFieldProtectStatAbAttr extends PreStatStageChangeAbA if (!target) { return false; } - return ( - !cancelled.value - && (isNullOrUndefined(this.protectedStat) || stat === this.protectedStat) - && this.condition(target) - ); + return !cancelled.value && (this.protectedStat == null || stat === this.protectedStat) && this.condition(target); } /** @@ -4560,7 +4557,7 @@ export class PostTurnStatusHealAbAttr extends PostTurnAbAttr { } override canApply({ pokemon }: AbAttrBaseParams): boolean { - return !isNullOrUndefined(pokemon.status) && this.effects.includes(pokemon.status.effect) && !pokemon.isFullHp(); + return pokemon.status != null && this.effects.includes(pokemon.status.effect) && !pokemon.isFullHp(); } override apply({ simulated, passive, pokemon }: AbAttrBaseParams): void { @@ -4893,7 +4890,7 @@ export class PostTurnHurtIfSleepingAbAttr extends PostTurnAbAttr { */ export class FetchBallAbAttr extends PostTurnAbAttr { override canApply({ simulated, pokemon }: AbAttrBaseParams): boolean { - return !simulated && !isNullOrUndefined(globalScene.currentBattle.lastUsedPokeball) && !!pokemon.isPlayer; + return !simulated && globalScene.currentBattle.lastUsedPokeball != null && !!pokemon.isPlayer; } /** @@ -5006,7 +5003,14 @@ export class PostDancingMoveAbAttr extends PostMoveUsedAbAttr { // If the move is an AttackMove or a StatusMove the Dancer must replicate the move on the source of the Dance if (move.getMove().is("AttackMove") || move.getMove().is("StatusMove")) { const target = this.getTarget(pokemon, source, targets); - globalScene.phaseManager.unshiftNew("MovePhase", pokemon, target, move, MoveUseMode.INDIRECT); + globalScene.phaseManager.unshiftNew( + "MovePhase", + pokemon, + target, + move, + MoveUseMode.INDIRECT, + MovePhaseTimingModifier.FIRST, + ); } else if (move.getMove().is("SelfStatusMove")) { // If the move is a SelfStatusMove (ie. Swords Dance) the Dancer should replicate it on itself globalScene.phaseManager.unshiftNew( @@ -5015,6 +5019,7 @@ export class PostDancingMoveAbAttr extends PostMoveUsedAbAttr { [pokemon.getBattlerIndex()], move, MoveUseMode.INDIRECT, + MovePhaseTimingModifier.FIRST, ); } } @@ -6030,11 +6035,6 @@ export class IllusionPostBattleAbAttr extends PostBattleAbAttr { } } -export interface BypassSpeedChanceAbAttrParams extends AbAttrBaseParams { - /** Holds whether the speed check is bypassed after ability application */ - bypass: BooleanHolder; -} - /** * If a Pokémon with this Ability selects a damaging move, it has a 30% chance of going first in its priority bracket. If the Ability activates, this is announced at the start of the turn (after move selection). * @sealed @@ -6050,26 +6050,28 @@ export class BypassSpeedChanceAbAttr extends AbAttr { this.chance = chance; } - override canApply({ bypass, simulated, pokemon }: BypassSpeedChanceAbAttrParams): boolean { + override canApply({ simulated, pokemon }: AbAttrBaseParams): boolean { // TODO: Consider whether we can move the simulated check to the `apply` method // May be difficult as we likely do not want to modify the randBattleSeed const turnCommand = globalScene.currentBattle.turnCommands[pokemon.getBattlerIndex()]; - const isCommandFight = turnCommand?.command === Command.FIGHT; const move = turnCommand?.move?.move ? allMoves[turnCommand.move.move] : null; const isDamageMove = move?.category === MoveCategory.PHYSICAL || move?.category === MoveCategory.SPECIAL; return ( - !simulated && !bypass.value && pokemon.randBattleSeedInt(100) < this.chance && isCommandFight && isDamageMove + !simulated + && pokemon.randBattleSeedInt(100) < this.chance + && isDamageMove + && pokemon.canAddTag(BattlerTagType.BYPASS_SPEED) ); } /** * bypass move order in their priority bracket when pokemon choose damaging move */ - override apply({ bypass }: BypassSpeedChanceAbAttrParams): void { - bypass.value = true; + override apply({ pokemon }: AbAttrBaseParams): void { + pokemon.addTag(BattlerTagType.BYPASS_SPEED); } - override getTriggerMessage({ pokemon }: BypassSpeedChanceAbAttrParams, _abilityName: string): string { + override getTriggerMessage({ pokemon }: AbAttrBaseParams, _abilityName: string): string { return i18next.t("abilityTriggers:quickDraw", { pokemonName: getPokemonNameWithAffix(pokemon) }); } } @@ -6077,8 +6079,6 @@ export class BypassSpeedChanceAbAttr extends AbAttr { export interface PreventBypassSpeedChanceAbAttrParams extends AbAttrBaseParams { /** Holds whether the speed check is bypassed after ability application */ bypass: BooleanHolder; - /** Holds whether the Pokemon can check held items for Quick Claw's effects */ - canCheckHeldItems: BooleanHolder; } /** @@ -6105,9 +6105,8 @@ export class PreventBypassSpeedChanceAbAttr extends AbAttr { return isCommandFight && this.condition(pokemon, move!); } - override apply({ bypass, canCheckHeldItems }: PreventBypassSpeedChanceAbAttrParams): void { + override apply({ bypass }: PreventBypassSpeedChanceAbAttrParams): void { bypass.value = false; - canCheckHeldItems.value = false; } } @@ -6205,8 +6204,7 @@ class ForceSwitchOutHelper { if (switchOutTarget.hp > 0) { switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); - globalScene.phaseManager.prependNewToPhase( - "MoveEndPhase", + globalScene.phaseManager.queueDeferred( "SwitchPhase", this.switchType, switchOutTarget.getFieldIndex(), @@ -6228,8 +6226,7 @@ class ForceSwitchOutHelper { const summonIndex = globalScene.currentBattle.trainer ? globalScene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot) : 0; - globalScene.phaseManager.prependNewToPhase( - "MoveEndPhase", + globalScene.phaseManager.queueDeferred( "SwitchSummonPhase", this.switchType, switchOutTarget.getFieldIndex(), @@ -6258,7 +6255,7 @@ class ForceSwitchOutHelper { true, 500, ); - if (globalScene.currentBattle.double && !isNullOrUndefined(allyPokemon)) { + if (globalScene.currentBattle.double && allyPokemon != null) { globalScene.redirectPokemonMoves(switchOutTarget, allyPokemon); } } @@ -6951,7 +6948,7 @@ export function initAbilities() { .attr(TypeImmunityStatStageChangeAbAttr, PokemonType.ELECTRIC, Stat.SPD, 1) .ignorable(), new Ability(AbilityId.RIVALRY, 4) - .attr(MovePowerBoostAbAttr, (user, target, _move) => user?.gender !== Gender.GENDERLESS && target?.gender !== Gender.GENDERLESS && user?.gender === target?.gender, 1.25, true) + .attr(MovePowerBoostAbAttr, (user, target, _move) => user?.gender !== Gender.GENDERLESS && target?.gender !== Gender.GENDERLESS && user?.gender === target?.gender, 1.25) .attr(MovePowerBoostAbAttr, (user, target, _move) => user?.gender !== Gender.GENDERLESS && target?.gender !== Gender.GENDERLESS && user?.gender !== target?.gender, 0.75), new Ability(AbilityId.STEADFAST, 4) .attr(FlinchStatStageChangeAbAttr, [ Stat.SPD ], 1), @@ -7110,7 +7107,7 @@ export function initAbilities() { .attr(PostDefendMoveDisableAbAttr, 30) .bypassFaint(), new Ability(AbilityId.HEALER, 5) - .conditionalAttr(pokemon => !isNullOrUndefined(pokemon.getAlly()) && randSeedInt(10) < 3, PostTurnResetStatusAbAttr, true), + .conditionalAttr(pokemon => pokemon.getAlly() != null && randSeedInt(10) < 3, PostTurnResetStatusAbAttr, true), new Ability(AbilityId.FRIEND_GUARD, 5) .attr(AlliedFieldDamageReductionAbAttr, 0.75) .ignorable(), @@ -7163,7 +7160,7 @@ export function initAbilities() { new Ability(AbilityId.ANALYTIC, 5) .attr(MovePowerBoostAbAttr, (user) => // Boost power if all other Pokemon have already moved (no other moves are slated to execute) - !globalScene.phaseManager.findPhase((phase) => phase.is("MovePhase") && phase.pokemon.id !== user?.id), + !globalScene.phaseManager.hasPhaseOfType("MovePhase", phase => phase.pokemon.id !== user?.id), 1.3), new Ability(AbilityId.ILLUSION, 5) // The Pokemon generate an illusion if it's available @@ -7744,8 +7741,8 @@ export function initAbilities() { new Ability(AbilityId.SHARPNESS, 9) .attr(MovePowerBoostAbAttr, (_user, _target, move) => move.hasFlag(MoveFlags.SLICING_MOVE), 1.5), new Ability(AbilityId.SUPREME_OVERLORD, 9) - .attr(VariableMovePowerBoostAbAttr, (user, _target, _move) => 1 + 0.1 * Math.min(user.isPlayer() ? globalScene.arena.playerFaints : globalScene.currentBattle.enemyFaints, 5)) - .partial(), // Should only boost once, on summon + .conditionalAttr((p) => (p.isPlayer() ? globalScene.arena.playerFaints : globalScene.currentBattle.enemyFaints) > 0, PostSummonAddBattlerTagAbAttr, BattlerTagType.SUPREME_OVERLORD, 0, true) + .edgeCase(), // Tag is not tied to ability, so suppression/removal etc will not function until a structure to allow this is implemented new Ability(AbilityId.COSTAR, 9, -2) .attr(PostSummonCopyAllyStatsAbAttr), new Ability(AbilityId.TOXIC_DEBRIS, 9) diff --git a/src/data/abilities/apply-ab-attrs.ts b/src/data/abilities/apply-ab-attrs.ts index 58f63c5924a..23b16a4cac7 100644 --- a/src/data/abilities/apply-ab-attrs.ts +++ b/src/data/abilities/apply-ab-attrs.ts @@ -74,7 +74,6 @@ function applyAbAttrsInternal( for (const passive of [false, true]) { params.passive = passive; applySingleAbAttrs(attrType, params, gainedMidTurn, messages); - globalScene.phaseManager.clearPhaseQueueSplice(); } // We need to restore passive to its original state in the case that it was undefined on entry // this is necessary in case this method is called with an object that is reused. diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 22955e0a9ac..fd64e271758 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -1,39 +1,4 @@ -/** biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports */ -import type { BattlerTag } from "#app/data/battler-tags"; -/** biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports */ - -import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs"; -import { globalScene } from "#app/global-scene"; -import { getPokemonNameWithAffix } from "#app/messages"; -import { CommonBattleAnim } from "#data/battle-anims"; -import { allMoves } from "#data/data-lists"; -import { AbilityId } from "#enums/ability-id"; -import { ArenaTagSide } from "#enums/arena-tag-side"; -import { ArenaTagType } from "#enums/arena-tag-type"; -import { BattlerTagType } from "#enums/battler-tag-type"; -import { HitResult } from "#enums/hit-result"; -import { CommonAnim } from "#enums/move-anims-common"; -import { MoveCategory } from "#enums/move-category"; -import { MoveId } from "#enums/move-id"; -import { MoveTarget } from "#enums/move-target"; -import { PokemonType } from "#enums/pokemon-type"; -import { Stat } from "#enums/stat"; -import { StatusEffect } from "#enums/status-effect"; -import type { Arena } from "#field/arena"; -import type { Pokemon } from "#field/pokemon"; -import type { - ArenaScreenTagType, - ArenaTagData, - EntryHazardTagType, - RoomArenaTagType, - SerializableArenaTagType, -} from "#types/arena-tags"; -import type { Mutable } from "#types/type-helpers"; -import { BooleanHolder, type NumberHolder, toDmgValue } from "#utils/common"; -import i18next from "i18next"; - /** - * @module * ArenaTags are are meant for effects that are tied to the arena (as opposed to a specific pokemon). * Examples include (but are not limited to) * - Cross-turn effects that persist even if the user/target switches out, such as Happy Hour @@ -76,14 +41,54 @@ import i18next from "i18next"; * ``` * Notes * - If the class has any subclasses, then the second form of `loadTag` *must* be used. + * @module */ +// biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports +import type { BattlerTag } from "#app/data/battler-tags"; +// biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports + +import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs"; +import { globalScene } from "#app/global-scene"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { CommonBattleAnim } from "#data/battle-anims"; +import { allMoves } from "#data/data-lists"; +import { AbilityId } from "#enums/ability-id"; +import { ArenaTagSide } from "#enums/arena-tag-side"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import type { BattlerIndex } from "#enums/battler-index"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { HitResult } from "#enums/hit-result"; +import { CommonAnim } from "#enums/move-anims-common"; +import { MoveCategory } from "#enums/move-category"; +import { MoveId } from "#enums/move-id"; +import { MoveTarget } from "#enums/move-target"; +import { PokemonType } from "#enums/pokemon-type"; +import { Stat } from "#enums/stat"; +import { StatusEffect } from "#enums/status-effect"; +import type { Arena } from "#field/arena"; +import type { Pokemon } from "#field/pokemon"; +import type { + ArenaScreenTagType, + ArenaTagData, + EntryHazardTagType, + RoomArenaTagType, + SerializableArenaTagType, +} from "#types/arena-tags"; +import type { Mutable } from "#types/type-helpers"; +import { BooleanHolder, type NumberHolder, toDmgValue } from "#utils/common"; +import i18next from "i18next"; + /** Interface containing the serializable fields of ArenaTagData. */ interface BaseArenaTag { /** * The tag's remaining duration. Setting to any number `<=0` will make the tag's duration effectively infinite. */ turnCount: number; + /** + * The tag's max duration. + */ + maxDuration: number; /** * The {@linkcode MoveId} that created this tag, or `undefined` if not set by a move. */ @@ -110,12 +115,14 @@ export abstract class ArenaTag implements BaseArenaTag { /** The type of the arena tag */ public abstract readonly tagType: ArenaTagType; public turnCount: number; + public maxDuration: number; public sourceMove?: MoveId; public sourceId: number | undefined; public side: ArenaTagSide; constructor(turnCount: number, sourceMove?: MoveId, sourceId?: number, side: ArenaTagSide = ArenaTagSide.BOTH) { this.turnCount = turnCount; + this.maxDuration = turnCount; this.sourceMove = sourceMove; this.sourceId = sourceId; this.side = side; @@ -138,7 +145,7 @@ export abstract class ArenaTag implements BaseArenaTag { } } - onOverlap(_arena: Arena, _source: Pokemon | null): void {} + onOverlap(_arena: Arena, _source: Pokemon | undefined): void {} /** * Trigger this {@linkcode ArenaTag}'s effect, reducing its duration as applicable. @@ -164,6 +171,7 @@ export abstract class ArenaTag implements BaseArenaTag { */ loadTag(source: BaseArenaTag & Pick): void { this.turnCount = source.turnCount; + this.maxDuration = source.maxDuration; this.sourceMove = source.sourceMove; this.sourceId = source.sourceId; this.side = source.side; @@ -172,9 +180,8 @@ export abstract class ArenaTag implements BaseArenaTag { /** * Helper function that retrieves the source Pokemon * @returns - The source {@linkcode Pokemon} for this tag. - * Returns `null` if `this.sourceId` is `undefined` */ - public getSourcePokemon(): Pokemon | null { + public getSourcePokemon(): Pokemon | undefined { return globalScene.getPokemonById(this.sourceId); } @@ -617,7 +624,7 @@ export class NoCritTag extends SerializableArenaTag { globalScene.phaseManager.queueMessage( i18next.t("arenaTag:noCritOnRemove", { - pokemonNameWithAffix: getPokemonNameWithAffix(source ?? undefined), + pokemonNameWithAffix: getPokemonNameWithAffix(source), moveName: this.getMoveName(), }), ); @@ -1537,7 +1544,7 @@ export class SuppressAbilitiesTag extends SerializableArenaTag { } } - public override onOverlap(_arena: Arena, source: Pokemon | null): void { + public override onOverlap(_arena: Arena, source: Pokemon | undefined): void { (this as Mutable).sourceCount++; this.playActivationMessage(source); } @@ -1580,7 +1587,7 @@ export class SuppressAbilitiesTag extends SerializableArenaTag { return this.sourceCount > 1; } - private playActivationMessage(pokemon: Pokemon | null) { + private playActivationMessage(pokemon: Pokemon | undefined) { if (pokemon) { globalScene.phaseManager.queueMessage( i18next.t("arenaTag:neutralizingGasOnAdd", { @@ -1591,6 +1598,145 @@ export class SuppressAbilitiesTag extends SerializableArenaTag { } } +/** + * Interface containing data related to a queued healing effect from + * {@link https://bulbapedia.bulbagarden.net/wiki/Healing_Wish_(move) | Healing Wish} + * or {@link https://bulbapedia.bulbagarden.net/wiki/Lunar_Dance_(move) | Lunar Dance}. + */ +interface PendingHealEffect { + /** The {@linkcode Pokemon.id | PID} of the {@linkcode Pokemon} that created the effect. */ + readonly sourceId: number; + /** The {@linkcode MoveId} of the move that created the effect. */ + readonly moveId: MoveId; + /** If `true`, also restores the target's PP when the effect activates. */ + readonly restorePP: boolean; + /** The message to display when the effect activates */ + readonly healMessage: string; +} + +/** + * Arena tag to contain stored healing effects, namely from + * {@link https://bulbapedia.bulbagarden.net/wiki/Healing_Wish_(move) | Healing Wish} + * and {@link https://bulbapedia.bulbagarden.net/wiki/Lunar_Dance_(move) | Lunar Dance}. + * When a damaged Pokemon first enters the effect's {@linkcode BattlerIndex | field position}, + * their HP is fully restored, and they are cured of any non-volatile status condition. + * If the effect is from Lunar Dance, their PP is also restored. + */ +export class PendingHealTag extends SerializableArenaTag { + public readonly tagType = ArenaTagType.PENDING_HEAL; + /** All pending healing effects, organized by {@linkcode BattlerIndex} */ + public readonly pendingHeals: Partial> = {}; + + constructor() { + super(0); + } + + /** + * Adds a pending healing effect to the field. Effects under the same move *and* + * target index as an existing effect are ignored. + * @param targetIndex - The {@linkcode BattlerIndex} under which the effect applies + * @param healEffect - The {@linkcode PendingHealEffect | data} for the pending heal effect + */ + public queueHeal(targetIndex: BattlerIndex, healEffect: PendingHealEffect): void { + const existingHealEffects = this.pendingHeals[targetIndex]; + if (existingHealEffects) { + if (!existingHealEffects.some(he => he.moveId === healEffect.moveId)) { + existingHealEffects.push(healEffect); + } + } else { + this.pendingHeals[targetIndex] = [healEffect]; + } + } + + /** Removes default on-remove message */ + override onRemove(_arena: Arena): void {} + + /** This arena tag is removed at the end of the turn if no pending healing effects are on the field */ + override lapse(_arena: Arena): boolean { + for (const key in this.pendingHeals) { + if (this.pendingHeals[key].length > 0) { + return true; + } + } + return false; + } + + /** + * Applies a pending healing effect on the given target index. If an effect is found for + * the index, the Pokemon at that index is healed to full HP, is cured of any non-volatile status, + * and has its PP fully restored (if the effect is from Lunar Dance). + * @param arena - The {@linkcode Arena} containing this tag + * @param simulated - If `true`, suppresses changes to game state + * @param pokemon - The {@linkcode Pokemon} receiving the healing effect + * @returns `true` if the target Pokemon was healed by this effect + * @todo This should also be called when a Pokemon moves into a new position via Ally Switch + */ + override apply(arena: Arena, simulated: boolean, pokemon: Pokemon): boolean { + const targetIndex = pokemon.getBattlerIndex(); + const targetEffects = this.pendingHeals[targetIndex]; + + if (targetEffects == null || targetEffects.length === 0) { + return false; + } + + const healEffect = targetEffects.find(effect => this.canApply(effect, pokemon)); + + if (healEffect == null) { + return false; + } + + if (simulated) { + return true; + } + + const { sourceId, moveId, restorePP, healMessage } = healEffect; + const sourcePokemon = globalScene.getPokemonById(sourceId); + if (!sourcePokemon) { + console.warn(`Source of pending ${allMoves[moveId].name} effect is undefined!`); + targetEffects.splice(targetEffects.indexOf(healEffect), 1); + // Re-evaluate after the invalid heal effect is removed + return this.apply(arena, simulated, pokemon); + } + + globalScene.phaseManager.unshiftNew( + "PokemonHealPhase", + targetIndex, + pokemon.getMaxHp(), + healMessage, + true, + false, + false, + true, + false, + restorePP, + ); + + targetEffects.splice(targetEffects.indexOf(healEffect), 1); + + return healEffect != null; + } + + /** + * Determines if the given {@linkcode PendingHealEffect} can immediately heal + * the given target {@linkcode Pokemon}. + * @param healEffect - The {@linkcode PendingHealEffect} to evaluate + * @param pokemon - The {@linkcode Pokemon} to evaluate against + * @returns `true` if the Pokemon can be healed by the effect + */ + private canApply(healEffect: PendingHealEffect, pokemon: Pokemon): boolean { + return ( + !pokemon.isFullHp() + || pokemon.status != null + || (healEffect.restorePP && pokemon.getMoveset().some(mv => mv.ppUsed > 0)) + ); + } + + override loadTag(source: BaseArenaTag & Pick): void { + super.loadTag(source); + (this as Mutable).pendingHeals = source.pendingHeals; + } +} + // TODO: swap `sourceMove` and `sourceId` and make `sourceMove` an optional parameter export function getArenaTag( tagType: ArenaTagType, @@ -1654,6 +1800,8 @@ export function getArenaTag( return new FairyLockTag(turnCount, sourceId); case ArenaTagType.NEUTRALIZING_GAS: return new SuppressAbilitiesTag(sourceId); + case ArenaTagType.PENDING_HEAL: + return new PendingHealTag(); default: return null; } @@ -1702,5 +1850,6 @@ export type ArenaTagTypeMap = { [ArenaTagType.GRASS_WATER_PLEDGE]: GrassWaterPledgeTag; [ArenaTagType.FAIRY_LOCK]: FairyLockTag; [ArenaTagType.NEUTRALIZING_GAS]: SuppressAbilitiesTag; + [ArenaTagType.PENDING_HEAL]: PendingHealTag; [ArenaTagType.NONE]: NoneTag; }; diff --git a/src/data/balance/biomes.ts b/src/data/balance/biomes.ts index b253b0ded6e..9af2dbe221c 100644 --- a/src/data/balance/biomes.ts +++ b/src/data/balance/biomes.ts @@ -1119,7 +1119,7 @@ export const biomePokemonPools: BiomePokemonPools = { }, [BiomePoolTier.RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ SpeciesId.HITMONLEE, SpeciesId.HITMONCHAN, SpeciesId.LUCARIO, SpeciesId.THROH, SpeciesId.SAWK, { 1: [ SpeciesId.PANCHAM ], 52: [ SpeciesId.PANGORO ] } ] }, [BiomePoolTier.SUPER_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ SpeciesId.HITMONTOP, SpeciesId.GALLADE, SpeciesId.GALAR_FARFETCHD ] }, - [BiomePoolTier.ULTRA_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ SpeciesId.TERRAKION, { 1: [ SpeciesId.KUBFU ], 60: [ SpeciesId.URSHIFU] }, SpeciesId.GALAR_ZAPDOS ] }, + [BiomePoolTier.ULTRA_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ SpeciesId.TERRAKION, { 1: [ SpeciesId.KUBFU ], 60: [ SpeciesId.URSHIFU ] }, SpeciesId.GALAR_ZAPDOS ] }, [BiomePoolTier.BOSS]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], @@ -1128,7 +1128,7 @@ export const biomePokemonPools: BiomePokemonPools = { [TimeOfDay.ALL]: [ SpeciesId.HITMONLEE, SpeciesId.HITMONCHAN, SpeciesId.HARIYAMA, SpeciesId.MEDICHAM, SpeciesId.LUCARIO, SpeciesId.TOXICROAK, SpeciesId.THROH, SpeciesId.SAWK, SpeciesId.SCRAFTY, SpeciesId.MIENSHAO, SpeciesId.BEWEAR, SpeciesId.GRAPPLOCT, SpeciesId.ANNIHILAPE ] }, [BiomePoolTier.BOSS_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ SpeciesId.HITMONTOP, SpeciesId.GALLADE, SpeciesId.PANGORO, SpeciesId.SIRFETCHD, SpeciesId.HISUI_DECIDUEYE ] }, - [BiomePoolTier.BOSS_SUPER_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ SpeciesId.TERRAKION, { 1: [ SpeciesId.KUBFU ], 60: [ SpeciesId.URSHIFU] } ] }, + [BiomePoolTier.BOSS_SUPER_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ SpeciesId.TERRAKION, { 1: [ SpeciesId.KUBFU ], 60: [ SpeciesId.URSHIFU ] } ] }, [BiomePoolTier.BOSS_ULTRA_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ SpeciesId.ZAMAZENTA, SpeciesId.GALAR_ZAPDOS ] } }, [BiomeId.FACTORY]: { @@ -1597,10 +1597,10 @@ export const biomePokemonPools: BiomePokemonPools = { [BiomePoolTier.UNCOMMON]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ { 1: [ SpeciesId.SOLOSIS ], 32: [ SpeciesId.DUOSION ], 41: [ SpeciesId.REUNICLUS ] } ] }, [BiomePoolTier.RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ SpeciesId.DITTO, { 1: [ SpeciesId.PORYGON ], 30: [ SpeciesId.PORYGON2 ] } ] }, [BiomePoolTier.SUPER_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ SpeciesId.ROTOM ] }, - [BiomePoolTier.ULTRA_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ { 1: [SpeciesId.TYPE_NULL], 60: [ SpeciesId.SILVALLY ] } ] }, + [BiomePoolTier.ULTRA_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ { 1: [ SpeciesId.TYPE_NULL ], 60: [ SpeciesId.SILVALLY ] } ] }, [BiomePoolTier.BOSS]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ SpeciesId.MUK, SpeciesId.ELECTRODE, SpeciesId.BRONZONG, SpeciesId.MAGNEZONE, SpeciesId.PORYGON_Z, SpeciesId.REUNICLUS, SpeciesId.KLINKLANG ] }, [BiomePoolTier.BOSS_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [] }, - [BiomePoolTier.BOSS_SUPER_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ SpeciesId.ROTOM, SpeciesId.ZYGARDE, { 1: [SpeciesId.TYPE_NULL], 60: [ SpeciesId.SILVALLY ] } ] }, + [BiomePoolTier.BOSS_SUPER_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ SpeciesId.ROTOM, SpeciesId.ZYGARDE, { 1: [ SpeciesId.TYPE_NULL ], 60: [ SpeciesId.SILVALLY ] } ] }, [BiomePoolTier.BOSS_ULTRA_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ SpeciesId.MEWTWO, SpeciesId.MIRAIDON ] } }, [BiomeId.END]: { @@ -5627,10 +5627,12 @@ export function initBiomes() { ] ], [ SpeciesId.TYPE_NULL, PokemonType.NORMAL, -1, [ - [ BiomeId.LABORATORY, BiomePoolTier.ULTRA_RARE ] + [ BiomeId.LABORATORY, BiomePoolTier.ULTRA_RARE ], + [ BiomeId.LABORATORY, BiomePoolTier.BOSS_SUPER_RARE ] ] ], [ SpeciesId.SILVALLY, PokemonType.NORMAL, -1, [ + [ BiomeId.LABORATORY, BiomePoolTier.ULTRA_RARE ], [ BiomeId.LABORATORY, BiomePoolTier.BOSS_SUPER_RARE ] ] ], @@ -5773,10 +5775,12 @@ export function initBiomes() { ] ], [ SpeciesId.POIPOLE, PokemonType.POISON, -1, [ - [ BiomeId.SWAMP, BiomePoolTier.ULTRA_RARE ] + [ BiomeId.SWAMP, BiomePoolTier.ULTRA_RARE ], + [ BiomeId.SWAMP, BiomePoolTier.BOSS_SUPER_RARE ] ] ], [ SpeciesId.NAGANADEL, PokemonType.POISON, PokemonType.DRAGON, [ + [ BiomeId.SWAMP, BiomePoolTier.ULTRA_RARE ], [ BiomeId.SWAMP, BiomePoolTier.BOSS_SUPER_RARE ] ] ], @@ -6165,10 +6169,12 @@ export function initBiomes() { ] ], [ SpeciesId.KUBFU, PokemonType.FIGHTING, -1, [ - [ BiomeId.DOJO, BiomePoolTier.ULTRA_RARE ] + [ BiomeId.DOJO, BiomePoolTier.ULTRA_RARE ], + [ BiomeId.DOJO, BiomePoolTier.BOSS_SUPER_RARE ] ] ], [ SpeciesId.URSHIFU, PokemonType.FIGHTING, PokemonType.DARK, [ + [ BiomeId.DOJO, BiomePoolTier.ULTRA_RARE ], [ BiomeId.DOJO, BiomePoolTier.BOSS_SUPER_RARE ] ] ], @@ -7209,7 +7215,8 @@ export function initBiomes() { ], [ TrainerType.SCIENTIST, [ [ BiomeId.DESERT, BiomePoolTier.COMMON ], - [ BiomeId.RUINS, BiomePoolTier.COMMON ] + [ BiomeId.RUINS, BiomePoolTier.COMMON ], + [ BiomeId.LABORATORY, BiomePoolTier.COMMON ] ] ], [ TrainerType.SMASHER, []], @@ -7224,7 +7231,8 @@ export function initBiomes() { ] ], [ TrainerType.SWIMMER, [ - [ BiomeId.SEA, BiomePoolTier.COMMON ] + [ BiomeId.SEA, BiomePoolTier.COMMON ], + [ BiomeId.SEABED, BiomePoolTier.COMMON ] ] ], [ TrainerType.TWINS, [ @@ -7590,11 +7598,13 @@ export function initBiomes() { [ TrainerType.ALDER, []], [ TrainerType.IRIS, []], [ TrainerType.DIANTHA, []], + [ TrainerType.KUKUI, []], [ TrainerType.HAU, []], + [ TrainerType.LEON, []], + [ TrainerType.MUSTARD, []], [ TrainerType.GEETA, []], [ TrainerType.NEMONA, []], [ TrainerType.KIERAN, []], - [ TrainerType.LEON, []], [ TrainerType.RIVAL, []] ]; diff --git a/src/data/balance/moveset-generation.ts b/src/data/balance/moveset-generation.ts new file mode 100644 index 00000000000..90a602ca97e --- /dev/null +++ b/src/data/balance/moveset-generation.ts @@ -0,0 +1,235 @@ +/* + * SPDX-Copyright-Text: 2025 Pagefault Games + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +/** + * # Balance: Moveset Generation Configuration + * + * This module contains configuration constants and functions that control + * the limitations and rules around moveset generation for generated Pokémon. + * + * + * ### Move Weights + * + * The various move weight constants in this module control how likely + * certain categories of moves are to appear in a generated Pokémon's + * moveset. Higher weights make a move more likely to be chosen. + * The constants here specify the *base* weight for a move when first computed. + * These weights are post-processed (and then scaled up such that weights have a larger impact, + * for instance, on boss Pokémon) before being used in the actual moveset generation. + * + * Post Processing of weights includes, but is not limited to: + * - Adjusting weights of status moves + * - Adjusting weights based on the move's power relative to the highest power available + * - Adjusting weights based on the stat the move uses to calculate damage relative to the higher stat + * + * + * All weights go through additional post-processing based on + * their expected power (accuracy * damage * expected number of hits) + * + * @module + */ + +import { MoveId } from "#enums/move-id"; + + +//#region Constants +/** + * The minimum level for a Pokémon to generate with a move it can only learn + * from a common tier TM + */ +export const COMMON_TIER_TM_LEVEL_REQUIREMENT = 25; +/** + * The minimum level for a Pokémon to generate with a move it can only learn + * from a great tier TM + */ +export const GREAT_TIER_TM_LEVEL_REQUIREMENT = 40; +/** + * The minimum level for a Pokémon to generate with a move it can only learn + * from an ultra tier TM + */ +export const ULTRA_TIER_TM_LEVEL_REQUIREMENT = 55; + +/** Below this level, Pokémon will be unable to generate with any egg moves */ +export const EGG_MOVE_LEVEL_REQUIREMENT = 60; +/** Below this level, Pokémon will be unable to generate with rare egg moves */ +export const RARE_EGG_MOVE_LEVEL_REQUIREMENT = 170; + +// Note: Not exported, only for use with `getMaxTmCount +/** Below this level, Pokémon will be unable to generate with any TMs */ +const ONE_TM_THRESHOLD = 25; +/** Below this level, Pokémon will generate with at most 1 TM */ +const TWO_TM_THRESHOLD = 41; +/** Below this level, Pokémon will generate with at most two TMs */ +const THREE_TM_THRESHOLD = 71; +/** Below this level, Pokémon will generate with at most three TMs */ +const FOUR_TM_THRESHOLD = 101; + +/** Below this level, Pokémon will be unable to generate any egg moves */ +const ONE_EGG_MOVE_THRESHOLD = 80; +/** Below this level, Pokémon will generate with at most 1 egg moves */ +const TWO_EGG_MOVE_THRESHOLD = 121; +/** Below this level, Pokémon will generate with at most 2 egg moves */ +const THREE_EGG_MOVE_THRESHOLD = 161; +/** Above this level, Pokémon will generate with at most 3 egg moves */ +const FOUR_EGG_MOVE_THRESHOLD = 201; + + +/** The weight given to TMs in the common tier during moveset generation */ +export const COMMON_TM_MOVESET_WEIGHT = 12; +/** The weight given to TMs in the great tier during moveset generation */ +export const GREAT_TM_MOVESET_WEIGHT = 14; +/** The weight given to TMs in the ultra tier during moveset generation */ +export const ULTRA_TM_MOVESET_WEIGHT = 18; + +/** + * The base weight offset for level moves + * + * @remarks + * The relative likelihood of moves learned at different levels is determined by + * the ratio of their weights, + * or, the formula: + * `(levelB + BASE_LEVEL_WEIGHT_OFFSET) / (levelA + BASE_LEVEL_WEIGHT_OFFSET)` + * + * For example, consider move A and B that are learned at levels 1 and 60, respectively, + * but have no other differences (same power, accuracy, category, etc). + * The following table demonstrates the likelihood of move B being chosen over move A. + * + * | Offset | Likelihood | + * |--------|------------| + * | 0 | 60x | + * | 1 | 30x | + * | 5 | 10.8x | + * | 20 | 3.8x | + * | 60 | 2x | + * + * Note that increasing this without adjusting the other weights will decrease the likelihood of non-level moves + * + * For a complete picture, see {@link https://www.desmos.com/calculator/wgln4dxigl} + */ +export const BASE_LEVEL_WEIGHT_OFFSET = 20; + +/** + * The maximum weight an egg move can ever have + * @remarks + * Egg moves have their weights adjusted based on the maximum weight of the Pokémon's + * level-up moves. Rare Egg moves are always 5/6th of the computed egg move weight. + * Boss pokemon are not allowed to spawn with rare egg moves. + * @see {@linkcode EGG_MOVE_TO_LEVEL_WEIGHT} + */ +export const EGG_MOVE_WEIGHT_MAX = 60; +/** + * The percentage of the Pokémon's highest weighted level move to the weight an + * egg move can generate with + */ +export const EGG_MOVE_TO_LEVEL_WEIGHT = 0.85; +/** The weight given to evolution moves */ +export const EVOLUTION_MOVE_WEIGHT = 70; +/** The weight given to relearn moves */ +export const RELEARN_MOVE_WEIGHT = 60; + +/** The base weight multiplier to use + * + * The higher the number, the more impact weights have on the final move selection. + * i.e. if set to 0, all moves have equal chance of being selected regardless of their weight. + */ +export const BASE_WEIGHT_MULTIPLIER = 1.6; + +/** The additional weight added onto {@linkcode BASE_WEIGHT_MULTIPLIER} for boss Pokémon */ +export const BOSS_EXTRA_WEIGHT_MULTIPLIER = 0.4; + + + +/** + * Set of moves that should be blacklisted from the forced STAB during moveset generation + * + * @remarks + * During moveset generation, trainer pokemon attempt to force their pokemon to generate with STAB + * moves in their movesets. Moves in this list not be considered to be "STAB" moves for this purpose. + * This does *not* prevent them from appearing in the moveset, but they will never + * be selected as a forced STAB move. + */ +export const STAB_BLACKLIST: ReadonlySet = new Set([ + MoveId.BEAT_UP, + MoveId.BELCH, + MoveId.BIDE, + MoveId.COMEUPPANCE, + MoveId.COUNTER, + MoveId.DOOM_DESIRE, + MoveId.DRAGON_RAGE, + MoveId.DREAM_EATER, + MoveId.ENDEAVOR, + MoveId.EXPLOSION, + MoveId.FAKE_OUT, + MoveId.FIRST_IMPRESSION, + MoveId.FISSURE, + MoveId.FLING, + MoveId.FOCUS_PUNCH, + MoveId.FUTURE_SIGHT, + MoveId.GUILLOTINE, + MoveId.HOLD_BACK, + MoveId.HORN_DRILL, + MoveId.LAST_RESORT, + MoveId.METAL_BURST, + MoveId.MIRROR_COAT, + MoveId.MISTY_EXPLOSION, + MoveId.NATURAL_GIFT, + MoveId.NATURES_MADNESS, + MoveId.NIGHT_SHADE, + MoveId.PSYWAVE, + MoveId.RUINATION, + MoveId.SELF_DESTRUCT, + MoveId.SHEER_COLD, + MoveId.SHELL_TRAP, + MoveId.SKY_DROP, + MoveId.SNORE, + MoveId.SONIC_BOOM, + MoveId.SPIT_UP, + MoveId.STEEL_BEAM, + MoveId.STEEL_ROLLER, + MoveId.SUPER_FANG, + MoveId.SYNCHRONOISE, + MoveId.UPPER_HAND, +]); + +//#endregion Constants + +/** + * Get the maximum number of TMs a Pokémon is allowed to learn based on + * its level + * @param level - The level of the Pokémon + * @returns The number of TMs the Pokémon can learn at this level + */ +export function getMaxTmCount(level: number) { + if (level < ONE_TM_THRESHOLD) { + return 0; + } + if (level < TWO_TM_THRESHOLD) { + return 1; + } + if (level < THREE_TM_THRESHOLD) { + return 2; + } + if (level < FOUR_TM_THRESHOLD) { + return 3; + } + return 4; +} + + +export function getMaxEggMoveCount(level: number): number { + if (level < ONE_EGG_MOVE_THRESHOLD) { + return 0; + } + if (level < TWO_EGG_MOVE_THRESHOLD) { + return 1; + } + if (level < THREE_EGG_MOVE_THRESHOLD) { + return 2; + } + if (level < FOUR_EGG_MOVE_THRESHOLD) { + return 3; + } + return 4; +} diff --git a/src/data/balance/pokemon-evolutions.ts b/src/data/balance/pokemon-evolutions.ts index d364dc036b1..0c2fa4e78fa 100644 --- a/src/data/balance/pokemon-evolutions.ts +++ b/src/data/balance/pokemon-evolutions.ts @@ -14,7 +14,7 @@ import { TimeOfDay } from "#enums/time-of-day"; import { WeatherType } from "#enums/weather-type"; import type { Pokemon } from "#field/pokemon"; import type { SpeciesStatBoosterItem, SpeciesStatBoosterModifierType } from "#modifiers/modifier-type"; -import { coerceArray, isNullOrUndefined, randSeedInt } from "#utils/common"; +import { coerceArray, randSeedInt } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import { toCamelCase } from "#utils/strings"; import i18next from "i18next"; @@ -53,6 +53,7 @@ export enum EvolutionItem { PRISM_SCALE, RAZOR_CLAW, RAZOR_FANG, + OVAL_STONE, REAPER_CLOTH, ELECTIRIZER, MAGMARIZER, @@ -128,7 +129,7 @@ export class SpeciesEvolutionCondition { } public get description(): string[] { - if (!isNullOrUndefined(this.desc)) { + if (this.desc != null) { return this.desc; } this.desc = this.data.map(cond => { @@ -161,11 +162,11 @@ export class SpeciesEvolutionCondition { case EvoCondKey.HELD_ITEM: return i18next.t(`pokemonEvolutions:heldItem.${toCamelCase(cond.itemKey)}`); } - }).filter(s => !isNullOrUndefined(s)); // Filter out stringless conditions + }).filter(s => s != null); // Filter out stringless conditions return this.desc; } - public conditionsFulfilled(pokemon: Pokemon): boolean { + public conditionsFulfilled(pokemon: Pokemon, forFusion = false): boolean { console.log(this.data); return this.data.every(cond => { switch (cond.key) { @@ -185,7 +186,7 @@ export class SpeciesEvolutionCondition { m.getStackCount() + pokemon.getPersistentTreasureCount() >= cond.value ); case EvoCondKey.GENDER: - return pokemon.gender === cond.gender; + return cond.gender === (forFusion ? pokemon.fusionGender : pokemon.gender); case EvoCondKey.SHEDINJA: // Shedinja cannot be evolved into directly return false; case EvoCondKey.BIOME: @@ -233,7 +234,7 @@ export class SpeciesFormEvolution { this.evoFormKey = evoFormKey; this.level = level; this.item = item || EvolutionItem.NONE; - if (!isNullOrUndefined(condition)) { + if (condition != null) { this.condition = new SpeciesEvolutionCondition(...coerceArray(condition)); } this.wildDelay = wildDelay ?? SpeciesWildEvolutionDelay.NONE; @@ -291,8 +292,8 @@ export class SpeciesFormEvolution { return ( pokemon.level >= this.level && // Check form key, using the fusion's form key if we're checking the fusion - (isNullOrUndefined(this.preFormKey) || (forFusion ? pokemon.getFusionFormKey() : pokemon.getFormKey()) === this.preFormKey) && - (isNullOrUndefined(this.condition) || this.condition.conditionsFulfilled(pokemon)) && + (this.preFormKey == null || (forFusion ? pokemon.getFusionFormKey() : pokemon.getFormKey()) === this.preFormKey) && + (this.condition == null || this.condition.conditionsFulfilled(pokemon, forFusion)) && ((item ?? EvolutionItem.NONE) === (this.item ?? EvolutionItem.NONE)) ); } @@ -305,11 +306,11 @@ export class SpeciesFormEvolution { */ public isValidItemEvolution(pokemon: Pokemon, forFusion = false): boolean { return ( - !isNullOrUndefined(this.item) && + this.item != null && pokemon.level >= this.level && // Check form key, using the fusion's form key if we're checking the fusion - (isNullOrUndefined(this.preFormKey) || (forFusion ? pokemon.getFusionFormKey() : pokemon.getFormKey()) === this.preFormKey) && - (isNullOrUndefined(this.condition) || this.condition.conditionsFulfilled(pokemon)) + (this.preFormKey == null || (forFusion ? pokemon.getFusionFormKey() : pokemon.getFormKey()) === this.preFormKey) && + (this.condition == null || this.condition.conditionsFulfilled(pokemon)) ); } @@ -1496,10 +1497,13 @@ export const pokemonEvolutions: PokemonEvolutions = { new SpeciesFormEvolution(SpeciesId.DUDUNSPARCE, "", "two-segment", 32, null, {key: EvoCondKey.MOVE, move: MoveId.HYPER_DRILL}, SpeciesWildEvolutionDelay.LONG) ], [SpeciesId.GLIGAR]: [ - new SpeciesEvolution(SpeciesId.GLISCOR, 1, EvolutionItem.RAZOR_FANG, {key: EvoCondKey.TIME, time: [TimeOfDay.DUSK, TimeOfDay.NIGHT]} /* Razor fang at night*/, SpeciesWildEvolutionDelay.VERY_LONG) + new SpeciesEvolution(SpeciesId.GLISCOR, 1, EvolutionItem.RAZOR_FANG, {key: EvoCondKey.TIME, time: [TimeOfDay.DUSK, TimeOfDay.NIGHT]}, SpeciesWildEvolutionDelay.VERY_LONG) ], [SpeciesId.SNEASEL]: [ - new SpeciesEvolution(SpeciesId.WEAVILE, 1, EvolutionItem.RAZOR_CLAW, {key: EvoCondKey.TIME, time: [TimeOfDay.DUSK, TimeOfDay.NIGHT]} /* Razor claw at night*/, SpeciesWildEvolutionDelay.VERY_LONG) + new SpeciesEvolution(SpeciesId.WEAVILE, 1, EvolutionItem.RAZOR_CLAW, {key: EvoCondKey.TIME, time: [TimeOfDay.DUSK, TimeOfDay.NIGHT]}, SpeciesWildEvolutionDelay.VERY_LONG) + ], + [SpeciesId.HAPPINY]: [ + new SpeciesEvolution(SpeciesId.CHANSEY, 1, EvolutionItem.OVAL_STONE, {key: EvoCondKey.TIME, time: [TimeOfDay.DAWN, TimeOfDay.DAY]}, SpeciesWildEvolutionDelay.SHORT) ], [SpeciesId.URSARING]: [ new SpeciesEvolution(SpeciesId.URSALUNA, 1, EvolutionItem.PEAT_BLOCK, null, SpeciesWildEvolutionDelay.VERY_LONG) //Ursaring does not evolve into Bloodmoon Ursaluna @@ -1760,7 +1764,7 @@ export const pokemonEvolutions: PokemonEvolutions = { new SpeciesEvolution(SpeciesId.CROBAT, 1, null, {key: EvoCondKey.FRIENDSHIP, value: 120}, SpeciesWildEvolutionDelay.VERY_LONG) ], [SpeciesId.CHANSEY]: [ - new SpeciesEvolution(SpeciesId.BLISSEY, 1, null, {key: EvoCondKey.FRIENDSHIP, value: 200}, SpeciesWildEvolutionDelay.LONG) + new SpeciesEvolution(SpeciesId.BLISSEY, 1, null, {key: EvoCondKey.FRIENDSHIP, value: 180}, SpeciesWildEvolutionDelay.LONG) ], [SpeciesId.PICHU]: [ new SpeciesFormEvolution(SpeciesId.PIKACHU, "spiky", "partner", 1, null, {key: EvoCondKey.FRIENDSHIP, value: 90}, SpeciesWildEvolutionDelay.SHORT), @@ -1787,9 +1791,6 @@ export const pokemonEvolutions: PokemonEvolutions = { [SpeciesId.CHINGLING]: [ new SpeciesEvolution(SpeciesId.CHIMECHO, 1, null, [{key: EvoCondKey.FRIENDSHIP, value: 90}, {key: EvoCondKey.TIME, time: [TimeOfDay.DUSK, TimeOfDay.NIGHT]}], SpeciesWildEvolutionDelay.MEDIUM) ], - [SpeciesId.HAPPINY]: [ - new SpeciesEvolution(SpeciesId.CHANSEY, 1, null, {key: EvoCondKey.FRIENDSHIP, value: 160}, SpeciesWildEvolutionDelay.SHORT) - ], [SpeciesId.MUNCHLAX]: [ new SpeciesEvolution(SpeciesId.SNORLAX, 1, null, {key: EvoCondKey.FRIENDSHIP, value: 120}, SpeciesWildEvolutionDelay.LONG) ], diff --git a/src/data/battle-anims.ts b/src/data/battle-anims.ts index 1dcb7d7eebf..573a1730796 100644 --- a/src/data/battle-anims.ts +++ b/src/data/battle-anims.ts @@ -7,7 +7,7 @@ import { AnimBlendType, AnimFocus, AnimFrameTarget, ChargeAnim, CommonAnim } fro import { MoveFlags } from "#enums/move-flags"; import { MoveId } from "#enums/move-id"; import type { Pokemon } from "#field/pokemon"; -import { coerceArray, getFrameMs, isNullOrUndefined, type nil } from "#utils/common"; +import { coerceArray, getFrameMs, type nil } from "#utils/common"; import { getEnumKeys, getEnumValues } from "#utils/enums"; import { toKebabCase } from "#utils/strings"; import Phaser from "phaser"; @@ -388,7 +388,7 @@ class AnimTimedAddBgEvent extends AnimTimedBgEvent { moveAnim.bgSprite.setAlpha(this.opacity / 255); globalScene.field.add(moveAnim.bgSprite); const fieldPokemon = globalScene.getEnemyPokemon(false) ?? globalScene.getPlayerPokemon(false); - if (!isNullOrUndefined(priority)) { + if (priority != null) { globalScene.field.moveTo(moveAnim.bgSprite as Phaser.GameObjects.GameObject, priority); } else if (fieldPokemon?.isOnField()) { globalScene.field.moveBelow(moveAnim.bgSprite as Phaser.GameObjects.GameObject, fieldPokemon); @@ -524,7 +524,7 @@ export async function initEncounterAnims(encounterAnim: EncounterAnim | Encounte const encounterAnimNames = getEnumKeys(EncounterAnim); const encounterAnimFetches: Promise>[] = []; for (const anim of anims) { - if (encounterAnims.has(anim) && !isNullOrUndefined(encounterAnims.get(anim))) { + if (encounterAnims.has(anim) && encounterAnims.get(anim) != null) { continue; } encounterAnimFetches.push( @@ -1240,7 +1240,7 @@ export abstract class BattleAnim { const graphicIndex = graphicFrameCount++; const moveSprite = sprites[graphicIndex]; - if (!isNullOrUndefined(frame.priority)) { + if (frame.priority != null) { const setSpritePriority = (priority: number) => { if (existingFieldSprites.length > priority) { // Move to specified priority index diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index c495cdaa604..8abd98f4683 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1,3 +1,42 @@ +/** + * BattlerTags are used to represent semi-persistent effects that can be attached to a Pokemon. + * Note that before serialization, a new tag object is created, and then `loadTag` is called on the + * tag with the object that was serialized. + * + * This means it is straightforward to avoid serializing fields. + * Fields that are not set in the constructor and not set in `loadTag` will thus not be serialized. + * + * Any battler tag that can persist across sessions must extend SerializableBattlerTag in its class definition signature. + * Only tags that persist across waves (meaning their effect can last >1 turn) should be considered + * serializable. + * + * Serializable battler tags have strict requirements for their fields. + * Properties that are not necessary to reconstruct the tag must not be serialized. This can be avoided + * by using a private property. If access to the property is needed outside of the class, then + * a getter (and potentially, a setter) should be used instead. + * + * If a property that is intended to be private must be serialized, then it should instead + * be declared as a public readonly propety. Then, in the `loadTag` method (or any method inside the class that needs to adjust the property) + * use `(this as Mutable).propertyName = value;` + * These rules ensure that Typescript is aware of the shape of the serialized version of the class. + * + * If any new serializable fields *are* added, then the class *must* override the + * `loadTag` method to set the new fields. Its signature *must* match the example below: + * ``` + * class ExampleTag extends SerializableBattlerTag { + * // Example, if we add 2 new fields that should be serialized: + * public a: string; + * public b: number; + * // Then we must also define a loadTag method with one of the following signatures + * public override loadTag(source: BaseBattlerTag & Pick(source: BaseBattlerTag & Pick): void; + * } + * ``` + * Notes + * - If the class has any subclasses, then the second form of `loadTag` *must* be used. + * @module + */ + import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; @@ -49,48 +88,9 @@ import type { TypeBoostTagType, } from "#types/battler-tags"; import type { Mutable } from "#types/type-helpers"; -import { BooleanHolder, coerceArray, getFrameMs, isNullOrUndefined, NumberHolder, toDmgValue } from "#utils/common"; +import { BooleanHolder, coerceArray, getFrameMs, NumberHolder, toDmgValue } from "#utils/common"; import { toCamelCase } from "#utils/strings"; -/** - * @module - * BattlerTags are used to represent semi-persistent effects that can be attached to a Pokemon. - * Note that before serialization, a new tag object is created, and then `loadTag` is called on the - * tag with the object that was serialized. - * - * This means it is straightforward to avoid serializing fields. - * Fields that are not set in the constructor and not set in `loadTag` will thus not be serialized. - * - * Any battler tag that can persist across sessions must extend SerializableBattlerTag in its class definition signature. - * Only tags that persist across waves (meaning their effect can last >1 turn) should be considered - * serializable. - * - * Serializable battler tags have strict requirements for their fields. - * Properties that are not necessary to reconstruct the tag must not be serialized. This can be avoided - * by using a private property. If access to the property is needed outside of the class, then - * a getter (and potentially, a setter) should be used instead. - * - * If a property that is intended to be private must be serialized, then it should instead - * be declared as a public readonly propety. Then, in the `loadTag` method (or any method inside the class that needs to adjust the property) - * use `(this as Mutable).propertyName = value;` - * These rules ensure that Typescript is aware of the shape of the serialized version of the class. - * - * If any new serializable fields *are* added, then the class *must* override the - * `loadTag` method to set the new fields. Its signature *must* match the example below: - * ``` - * class ExampleTag extends SerializableBattlerTag { - * // Example, if we add 2 new fields that should be serialized: - * public a: string; - * public b: number; - * // Then we must also define a loadTag method with one of the following signatures - * public override loadTag(source: BaseBattlerTag & Pick(source: BaseBattlerTag & Pick): void; - * } - * ``` - * Notes - * - If the class has any subclasses, then the second form of `loadTag` *must* be used. - */ - /** Interface containing the serializable fields of BattlerTag */ interface BaseBattlerTag { /** The tag's remaining duration */ @@ -198,7 +198,7 @@ export class BattlerTag implements BaseBattlerTag { * Helper function that retrieves the source Pokemon object * @returns The source {@linkcode Pokemon}, or `null` if none is found */ - public getSourcePokemon(): Pokemon | null { + public getSourcePokemon(): Pokemon | undefined { return globalScene.getPokemonById(this.sourceId); } } @@ -378,7 +378,7 @@ export class DisabledTag extends MoveRestrictionBattlerTag { // Disable fails against struggle or an empty move history // TODO: Confirm if this is redundant given Disable/Cursed Body's disable conditions const move = pokemon.getLastNonVirtualMove(); - if (isNullOrUndefined(move) || move.move === MoveId.STRUGGLE) { + if (move == null || move.move === MoveId.STRUGGLE) { return; } @@ -451,7 +451,7 @@ export class GorillaTacticsTag extends MoveRestrictionBattlerTag { override canAdd(pokemon: Pokemon): boolean { // Choice items ignore struggle, so Gorilla Tactics should too const lastSelectedMove = pokemon.getLastNonVirtualMove(); - return !isNullOrUndefined(lastSelectedMove) && lastSelectedMove.move !== MoveId.STRUGGLE; + return lastSelectedMove != null && lastSelectedMove.move !== MoveId.STRUGGLE; } /** @@ -606,17 +606,7 @@ export class ShellTrapTag extends BattlerTag { // Trap should only be triggered by opponent's Physical moves if (phaseData?.move.category === MoveCategory.PHYSICAL && pokemon.isOpponent(phaseData.attacker)) { - const shellTrapPhaseIndex = globalScene.phaseManager.phaseQueue.findIndex( - phase => phase.is("MovePhase") && phase.pokemon === pokemon, - ); - const firstMovePhaseIndex = globalScene.phaseManager.phaseQueue.findIndex(phase => phase.is("MovePhase")); - - // Only shift MovePhase timing if it's not already next up - if (shellTrapPhaseIndex !== -1 && shellTrapPhaseIndex !== firstMovePhaseIndex) { - const shellTrapMovePhase = globalScene.phaseManager.phaseQueue.splice(shellTrapPhaseIndex, 1)[0]; - globalScene.phaseManager.prependToPhase(shellTrapMovePhase, "MovePhase"); - } - + globalScene.phaseManager.forceMoveNext((phase: MovePhase) => phase.pokemon === pokemon); this.activated = true; } @@ -968,7 +958,7 @@ export class InfatuatedTag extends SerializableBattlerTag { phaseManager.queueMessage( i18next.t("battlerTags:infatuatedLapse", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - sourcePokemonName: getPokemonNameWithAffix(globalScene.getPokemonById(this.sourceId!) ?? undefined), // TODO: is that bang correct? + sourcePokemonName: getPokemonNameWithAffix(this.getSourcePokemon()), }), ); phaseManager.unshiftNew("CommonAnimPhase", pokemon.getBattlerIndex(), undefined, CommonAnim.ATTRACT); @@ -1279,22 +1269,9 @@ export class EncoreTag extends MoveRestrictionBattlerTag { }), ); - const movePhase = globalScene.phaseManager.findPhase(m => m.is("MovePhase") && m.pokemon === pokemon); - if (movePhase) { - const movesetMove = pokemon.getMoveset().find(m => m.moveId === this.moveId); - if (movesetMove) { - const lastMove = pokemon.getLastXMoves(1)[0]; - globalScene.phaseManager.tryReplacePhase( - m => m.is("MovePhase") && m.pokemon === pokemon, - globalScene.phaseManager.create( - "MovePhase", - pokemon, - lastMove.targets ?? [], - movesetMove, - MoveUseMode.NORMAL, - ), - ); - } + const movesetMove = pokemon.getMoveset().find(m => m.moveId === this.moveId); + if (movesetMove) { + globalScene.phaseManager.changePhaseMove((phase: MovePhase) => phase.pokemon === pokemon, movesetMove); } } @@ -1305,7 +1282,7 @@ export class EncoreTag extends MoveRestrictionBattlerTag { override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { if (lapseType === BattlerTagLapseType.CUSTOM) { const encoredMove = pokemon.getMoveset().find(m => m.moveId === this.moveId); - return !isNullOrUndefined(encoredMove) && encoredMove.getPpRatio() > 0; + return encoredMove != null && encoredMove.getPpRatio() > 0; } return super.lapse(pokemon, lapseType); } @@ -3578,6 +3555,25 @@ export class GrudgeTag extends SerializableBattlerTag { } } +/** + * Tag to allow the affected Pokemon's move to go first in its priority bracket. + * Used for {@link https://bulbapedia.bulbagarden.net/wiki/Quick_Draw_(Ability) | Quick Draw} + * and {@link https://bulbapedia.bulbagarden.net/wiki/Quick_Claw | Quick Claw}. + */ +export class BypassSpeedTag extends BattlerTag { + public override readonly tagType = BattlerTagType.BYPASS_SPEED; + + constructor() { + super(BattlerTagType.BYPASS_SPEED, BattlerTagLapseType.TURN_END, 1); + } + + override canAdd(pokemon: Pokemon): boolean { + const bypass = new BooleanHolder(true); + applyAbAttrs("PreventBypassSpeedChanceAbAttr", { pokemon, bypass }); + return bypass.value; + } +} + /** * Tag used to heal the user of Psycho Shift of its status effect if Psycho Shift succeeds in transferring its status effect to the target Pokemon */ @@ -3626,6 +3622,41 @@ export class MagicCoatTag extends BattlerTag { } } +/** + * Tag associated with {@linkcode AbilityId.SUPREME_OVERLORD} + */ +export class SupremeOverlordTag extends AbilityBattlerTag { + public override readonly tagType = BattlerTagType.SUPREME_OVERLORD; + /** The number of faints at the time the user was sent out */ + public readonly faintCount: number; + constructor() { + super(BattlerTagType.SUPREME_OVERLORD, AbilityId.SUPREME_OVERLORD, BattlerTagLapseType.FAINT, 0); + } + + public override onAdd(pokemon: Pokemon): boolean { + (this as Mutable).faintCount = Math.min( + pokemon.isPlayer() ? globalScene.arena.playerFaints : globalScene.currentBattle.enemyFaints, + 5, + ); + globalScene.phaseManager.queueMessage( + i18next.t("battlerTags:supremeOverlordOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }), + ); + return true; + } + + /** + * @returns The damage multiplier for Supreme Overlord + */ + public getBoost(): number { + return 1 + 0.1 * this.faintCount; + } + + public override loadTag(source: BaseBattlerTag & Pick): void { + super.loadTag(source); + (this as Mutable).faintCount = source.faintCount; + } +} + /** * Retrieves a {@linkcode BattlerTag} based on the provided tag type, turn count, source move, and source ID. * @param sourceId - The ID of the pokemon adding the tag @@ -3826,6 +3857,10 @@ export function getBattlerTag( return new PsychoShiftTag(); case BattlerTagType.MAGIC_COAT: return new MagicCoatTag(); + case BattlerTagType.SUPREME_OVERLORD: + return new SupremeOverlordTag(); + case BattlerTagType.BYPASS_SPEED: + return new BypassSpeedTag(); } } @@ -3960,4 +3995,6 @@ export type BattlerTagTypeMap = { [BattlerTagType.GRUDGE]: GrudgeTag; [BattlerTagType.PSYCHO_SHIFT]: PsychoShiftTag; [BattlerTagType.MAGIC_COAT]: MagicCoatTag; + [BattlerTagType.SUPREME_OVERLORD]: SupremeOverlordTag; + [BattlerTagType.BYPASS_SPEED]: BypassSpeedTag; }; diff --git a/src/data/daily-run.ts b/src/data/daily-run.ts index addaebdd238..776dff1bf46 100644 --- a/src/data/daily-run.ts +++ b/src/data/daily-run.ts @@ -6,8 +6,8 @@ import { PokemonSpecies } from "#data/pokemon-species"; import { BiomeId } from "#enums/biome-id"; import { PartyMemberStrength } from "#enums/party-member-strength"; import { SpeciesId } from "#enums/species-id"; -import type { Starter } from "#ui/starter-select-ui-handler"; -import { isNullOrUndefined, randSeedGauss, randSeedInt, randSeedItem } from "#utils/common"; +import type { Starter } from "#types/save-data"; +import { randSeedGauss, randSeedInt, randSeedItem } from "#utils/common"; import { getEnumValues } from "#utils/enums"; import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils"; @@ -32,7 +32,7 @@ export function getDailyRunStarters(seed: string): Starter[] { const startingLevel = globalScene.gameMode.getStartingLevel(); const eventStarters = getDailyEventSeedStarters(seed); - if (!isNullOrUndefined(eventStarters)) { + if (eventStarters != null) { starters.push(...eventStarters); return; } @@ -66,8 +66,11 @@ function getDailyRunStarter(starterSpeciesForm: PokemonSpeciesForm, startingLeve const formIndex = starterSpeciesForm instanceof PokemonSpecies ? undefined : starterSpeciesForm.formIndex; const pokemon = globalScene.addPlayerPokemon(starterSpecies, startingLevel, undefined, formIndex); const starter: Starter = { - species: starterSpecies, - dexAttr: pokemon.getDexAttr(), + speciesId: starterSpecies.speciesId, + shiny: pokemon.shiny, + variant: pokemon.variant, + formIndex: pokemon.formIndex, + ivs: pokemon.ivs, abilityIndex: pokemon.abilityIndex, passive: false, nature: pokemon.getNature(), @@ -127,7 +130,7 @@ const dailyBiomeWeights: BiomeWeights = { export function getDailyStartingBiome(): BiomeId { const eventBiome = getDailyEventSeedBiome(globalScene.seed); - if (!isNullOrUndefined(eventBiome)) { + if (eventBiome != null) { return eventBiome; } diff --git a/src/data/moves/move-utils.ts b/src/data/moves/move-utils.ts index eedeea53087..1fe0880317b 100644 --- a/src/data/moves/move-utils.ts +++ b/src/data/moves/move-utils.ts @@ -7,7 +7,7 @@ import { PokemonType } from "#enums/pokemon-type"; import type { Pokemon } from "#field/pokemon"; import { applyMoveAttrs } from "#moves/apply-attrs"; import type { Move, MoveTargetSet, UserMoveConditionFunc } from "#moves/move"; -import { isNullOrUndefined, NumberHolder } from "#utils/common"; +import { NumberHolder } from "#utils/common"; /** * Return whether the move targets the field @@ -78,7 +78,7 @@ export function getMoveTargets(user: Pokemon, move: MoveId, replaceTarget?: Move case MoveTarget.OTHER: case MoveTarget.ALL_NEAR_OTHERS: case MoveTarget.ALL_OTHERS: - set = !isNullOrUndefined(ally) ? opponents.concat([ally]) : opponents; + set = ally != null ? opponents.concat([ally]) : opponents; multiple = moveTarget === MoveTarget.ALL_NEAR_OTHERS || moveTarget === MoveTarget.ALL_OTHERS; break; case MoveTarget.NEAR_ENEMY: @@ -95,22 +95,22 @@ export function getMoveTargets(user: Pokemon, move: MoveId, replaceTarget?: Move return { targets: [-1 as BattlerIndex], multiple: false }; case MoveTarget.NEAR_ALLY: case MoveTarget.ALLY: - set = !isNullOrUndefined(ally) ? [ally] : []; + set = ally != null ? [ally] : []; break; case MoveTarget.USER_OR_NEAR_ALLY: case MoveTarget.USER_AND_ALLIES: case MoveTarget.USER_SIDE: - set = !isNullOrUndefined(ally) ? [user, ally] : [user]; + set = ally != null ? [user, ally] : [user]; multiple = moveTarget !== MoveTarget.USER_OR_NEAR_ALLY; break; case MoveTarget.ALL: case MoveTarget.BOTH_SIDES: - set = (!isNullOrUndefined(ally) ? [user, ally] : [user]).concat(opponents); + set = (ally != null ? [user, ally] : [user]).concat(opponents); multiple = true; break; case MoveTarget.CURSE: { - const extraTargets = !isNullOrUndefined(ally) ? [ally] : []; + const extraTargets = ally != null ? [ally] : []; set = user.getTypes(true).includes(PokemonType.GHOST) ? opponents.concat(extraTargets) : [user]; } break; diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index f0daa9c63ed..bd742db8a29 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -6,7 +6,7 @@ import { loggedInUser } from "#app/account"; import type { GameMode } from "#app/game-mode"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; -import type { EntryHazardTag } from "#data/arena-tag"; +import type { EntryHazardTag, PendingHealTag } from "#data/arena-tag"; import { WeakenMoveTypeTag } from "#data/arena-tag"; import { MoveChargeAnim } from "#data/battle-anims"; import { @@ -18,6 +18,7 @@ import { ShellTrapTag, StockpilingTag, SubstituteTag, + SupremeOverlordTag, TrappedTag, TypeBoostTag, } from "#data/battler-tags"; @@ -80,19 +81,18 @@ import { applyMoveAttrs } from "#moves/apply-attrs"; import { invalidAssistMoves, invalidCopycatMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves } from "#moves/invalid-moves"; import { frenzyMissFunc, getMoveTargets } from "#moves/move-utils"; import { PokemonMove } from "#moves/pokemon-move"; -import { MoveEndPhase } from "#phases/move-end-phase"; import { MovePhase } from "#phases/move-phase"; import { PokemonHealPhase } from "#phases/pokemon-heal-phase"; -import { SwitchSummonPhase } from "#phases/switch-summon-phase"; import type { AttackMoveResult } from "#types/attack-move-result"; import type { Localizable } from "#types/locales"; import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString, MoveMessageFunc } from "#types/move-types"; import type { TurnMove } from "#types/turn-move"; -import { BooleanHolder, coerceArray, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common"; +import { BooleanHolder, coerceArray, type Constructor, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common"; import { getEnumValues } from "#utils/enums"; import { toCamelCase, toTitleCase } from "#utils/strings"; import i18next from "i18next"; import { applyChallenges } from "#utils/challenge-utils"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; import type { AbstractConstructor } from "#types/type-helpers"; /** @@ -835,7 +835,7 @@ export abstract class Move implements Localizable { applyAbAttrs("VariableMovePowerAbAttr", abAttrParams); const ally = source.getAlly(); - if (!isNullOrUndefined(ally)) { + if (ally != null) { applyAbAttrs("AllyMoveCategoryPowerBoostAbAttr", {...abAttrParams, pokemon: ally}); } @@ -879,6 +879,8 @@ export abstract class Move implements Localizable { power.value *= 1.5; } + power.value *= (source.getTag(BattlerTagType.SUPREME_OVERLORD) as SupremeOverlordTag | undefined)?.getBoost() ?? 1; + return power.value; } @@ -888,6 +890,10 @@ export abstract class Move implements Localizable { applyMoveAttrs("IncrementMovePriorityAttr", user, null, this, priority); applyAbAttrs("ChangeMovePriorityAbAttr", {pokemon: user, simulated, move: this, priority}); + if (user.getTag(BattlerTagType.BYPASS_SPEED)) { + priority.value += 0.2; + } + return priority.value; } @@ -965,7 +971,7 @@ export abstract class Move implements Localizable { // ...and cannot enhance Pollen Puff when targeting an ally. const ally = user.getAlly(); - const exceptPollenPuffAlly: boolean = this.id === MoveId.POLLEN_PUFF && !isNullOrUndefined(ally) && targets.includes(ally.getBattlerIndex()) + const exceptPollenPuffAlly: boolean = this.id === MoveId.POLLEN_PUFF && ally != null && targets.includes(ally.getBattlerIndex()) return (!restrictSpread || !isMultiTarget) && !this.isChargingMove() @@ -2114,7 +2120,7 @@ export class FlameBurstAttr extends MoveEffectAttr { const targetAlly = target.getAlly(); const cancelled = new BooleanHolder(false); - if (!isNullOrUndefined(targetAlly)) { + if (targetAlly != null) { applyAbAttrs("BlockNonDirectDamageAbAttr", {pokemon: targetAlly, cancelled}); } @@ -2127,7 +2133,7 @@ export class FlameBurstAttr extends MoveEffectAttr { } getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number { - return !isNullOrUndefined(target.getAlly()) ? -5 : 0; + return target.getAlly() != null ? -5 : 0; } } @@ -2147,24 +2153,15 @@ export class SacrificialFullRestoreAttr extends SacrificialAttr { return false; } - // We don't know which party member will be chosen, so pick the highest max HP in the party - const party = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); - const maxPartyMemberHp = party.map(p => p.getMaxHp()).reduce((maxHp: number, hp: number) => Math.max(hp, maxHp), 0); - - const pm = globalScene.phaseManager; - - pm.pushPhase( - pm.create("PokemonHealPhase", - user.getBattlerIndex(), - maxPartyMemberHp, - i18next.t(this.moveMessage, { pokemonName: getPokemonNameWithAffix(user) }), - true, - false, - false, - true, - false, - this.restorePP), - true); + // Add a tag to the field if it doesn't already exist, then queue a delayed healing effect in the user's current slot. + globalScene.arena.addTag(ArenaTagType.PENDING_HEAL, 0, move.id, user.id); // Arguments after first go completely unused + const tag = globalScene.arena.getTag(ArenaTagType.PENDING_HEAL) as PendingHealTag; + tag.queueHeal(user.getBattlerIndex(), { + sourceId: user.id, + moveId: move.id, + restorePP: this.restorePP, + healMessage: i18next.t(this.moveMessage, { pokemonName: getPokemonNameWithAffix(user) }), + }); return true; } @@ -3156,7 +3153,7 @@ export class WeatherInstantChargeAttr extends InstantChargeAttr { super((user, move) => { const currentWeather = globalScene.arena.weather; - if (isNullOrUndefined(currentWeather?.weatherType)) { + if (currentWeather?.weatherType == null) { return false; } else { return !currentWeather?.isEffectSuppressed() @@ -3304,7 +3301,7 @@ export class AwaitCombinedPledgeAttr extends OverrideMoveEffectAttr { const overridden = args[0] as BooleanHolder; - const allyMovePhase = globalScene.phaseManager.findPhase((phase) => phase.is("MovePhase") && phase.pokemon.isPlayer() === user.isPlayer()); + const allyMovePhase = globalScene.phaseManager.getMovePhase((phase) => phase.pokemon.isPlayer() === user.isPlayer()); if (allyMovePhase) { const allyMove = allyMovePhase.move.getMove(); if (allyMove !== move && allyMove.hasAttr("AwaitCombinedPledgeAttr")) { @@ -3317,11 +3314,7 @@ export class AwaitCombinedPledgeAttr extends OverrideMoveEffectAttr { })); // Move the ally's MovePhase (if needed) so that the ally moves next - const allyMovePhaseIndex = globalScene.phaseManager.phaseQueue.indexOf(allyMovePhase); - const firstMovePhaseIndex = globalScene.phaseManager.phaseQueue.findIndex((phase) => phase.is("MovePhase")); - if (allyMovePhaseIndex !== firstMovePhaseIndex) { - globalScene.phaseManager.prependToPhase(globalScene.phaseManager.phaseQueue.splice(allyMovePhaseIndex, 1)[0], "MovePhase"); - } + globalScene.phaseManager.forceMoveNext((phase: MovePhase) => phase.pokemon === user.getAlly()); overridden.value = true; return true; @@ -4556,28 +4549,7 @@ export class LastMoveDoublePowerAttr extends VariablePowerAttr { */ apply(user: Pokemon, _target: Pokemon, _move: Move, args: any[]): boolean { const power = args[0] as NumberHolder; - const enemy = user.getOpponent(0); - const pokemonActed: Pokemon[] = []; - - if (enemy?.turnData.acted) { - pokemonActed.push(enemy); - } - - if (globalScene.currentBattle.double) { - const userAlly = user.getAlly(); - const enemyAlly = enemy?.getAlly(); - - if (userAlly?.turnData.acted) { - pokemonActed.push(userAlly); - } - if (enemyAlly?.turnData.acted) { - pokemonActed.push(enemyAlly); - } - } - - pokemonActed.sort((a, b) => b.turnData.order - a.turnData.order); - - for (const p of pokemonActed) { + for (const p of globalScene.phaseManager.dynamicQueueManager.getLastTurnOrder().slice(0, -1).reverse()) { const [ lastMove ] = p.getLastXMoves(1); if (lastMove.result !== MoveResult.FAIL) { if ((lastMove.result === MoveResult.SUCCESS) && (lastMove.move === this.move)) { @@ -4659,20 +4631,13 @@ export class CueNextRoundAttr extends MoveEffectAttr { } override apply(user: Pokemon, target: Pokemon, move: Move, args?: any[]): boolean { - const nextRoundPhase = globalScene.phaseManager.findPhase(phase => - phase.is("MovePhase") && phase.move.moveId === MoveId.ROUND - ); + const nextRoundPhase = globalScene.phaseManager.getMovePhase(phase => phase.move.moveId === MoveId.ROUND); if (!nextRoundPhase) { return false; } - // Update the phase queue so that the next Pokemon using Round moves next - const nextRoundIndex = globalScene.phaseManager.phaseQueue.indexOf(nextRoundPhase); - const nextMoveIndex = globalScene.phaseManager.phaseQueue.findIndex(phase => phase.is("MovePhase")); - if (nextRoundIndex !== nextMoveIndex) { - globalScene.phaseManager.prependToPhase(globalScene.phaseManager.phaseQueue.splice(nextRoundIndex, 1)[0], "MovePhase"); - } + globalScene.phaseManager.forceMoveNext(phase => phase.move.moveId === MoveId.ROUND); // Mark the corresponding Pokemon as having "joined the Round" (for doubling power later) nextRoundPhase.pokemon.turnData.joinedRound = true; @@ -6293,15 +6258,15 @@ export class RevivalBlessingAttr extends MoveEffectAttr { pokemon.heal(Math.min(toDmgValue(0.5 * pokemon.getMaxHp()), pokemon.getMaxHp())); globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:revivalBlessing", { pokemonName: getPokemonNameWithAffix(pokemon) }), 0, true); const allyPokemon = user.getAlly(); - if (globalScene.currentBattle.double && globalScene.getEnemyParty().length > 1 && !isNullOrUndefined(allyPokemon)) { + if (globalScene.currentBattle.double && globalScene.getEnemyParty().length > 1 && allyPokemon != null) { // Handle cases where revived pokemon needs to get switched in on same turn if (allyPokemon.isFainted() || allyPokemon === pokemon) { // Enemy switch phase should be removed and replaced with the revived pkmn switching in - globalScene.phaseManager.tryRemovePhase((phase: SwitchSummonPhase) => phase.is("SwitchSummonPhase") && phase.getPokemon() === pokemon); + globalScene.phaseManager.tryRemovePhase("SwitchSummonPhase", phase => phase.getFieldIndex() === slotIndex); // If the pokemon being revived was alive earlier in the turn, cancel its move // (revived pokemon can't move in the turn they're brought back) // TODO: might make sense to move this to `FaintPhase` after checking for Rev Seed (rather than handling it in the move) - globalScene.phaseManager.findPhase((phase: MovePhase) => phase.pokemon === pokemon)?.cancel(); + globalScene.phaseManager.getMovePhase((phase: MovePhase) => phase.pokemon === pokemon)?.cancel(); if (user.fieldPosition === FieldPosition.CENTER) { user.setFieldPosition(FieldPosition.LEFT); } @@ -6382,8 +6347,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { if (this.switchType === SwitchType.FORCE_SWITCH) { switchOutTarget.leaveField(true); const slotIndex = eligibleNewIndices[user.randBattleSeedInt(eligibleNewIndices.length)]; - globalScene.phaseManager.prependNewToPhase( - "MoveEndPhase", + globalScene.phaseManager.queueDeferred( "SwitchSummonPhase", this.switchType, switchOutTarget.getFieldIndex(), @@ -6393,7 +6357,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { ); } else { switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); - globalScene.phaseManager.prependNewToPhase("MoveEndPhase", + globalScene.phaseManager.queueDeferred( "SwitchPhase", this.switchType, switchOutTarget.getFieldIndex(), @@ -6422,7 +6386,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { if (this.switchType === SwitchType.FORCE_SWITCH) { switchOutTarget.leaveField(true); const slotIndex = eligibleNewIndices[user.randBattleSeedInt(eligibleNewIndices.length)]; - globalScene.phaseManager.prependNewToPhase("MoveEndPhase", + globalScene.phaseManager.queueDeferred( "SwitchSummonPhase", this.switchType, switchOutTarget.getFieldIndex(), @@ -6432,7 +6396,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { ); } else { switchOutTarget.leaveField(this.switchType === SwitchType.SWITCH); - globalScene.phaseManager.prependNewToPhase("MoveEndPhase", + globalScene.phaseManager.queueDeferred( "SwitchSummonPhase", this.switchType, switchOutTarget.getFieldIndex(), @@ -6462,7 +6426,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:fled", { pokemonName: getPokemonNameWithAffix(switchOutTarget) }), null, true, 500); // in double battles redirect potential moves off fled pokemon - if (globalScene.currentBattle.double && !isNullOrUndefined(allyPokemon)) { + if (globalScene.currentBattle.double && allyPokemon != null) { globalScene.redirectPokemonMoves(switchOutTarget, allyPokemon); } } @@ -6863,7 +6827,7 @@ class CallMoveAttr extends OverrideMoveEffectAttr { : moveTargets.targets[user.randBattleSeedInt(moveTargets.targets.length)]]; globalScene.phaseManager.unshiftNew("LoadMoveAnimPhase", move.id); - globalScene.phaseManager.unshiftNew("MovePhase", user, targets, new PokemonMove(move.id), MoveUseMode.FOLLOW_UP); + globalScene.phaseManager.unshiftNew("MovePhase", user, targets, new PokemonMove(move.id), MoveUseMode.FOLLOW_UP, MovePhaseTimingModifier.FIRST); return true; } } @@ -7095,7 +7059,7 @@ export class NaturePowerAttr extends OverrideMoveEffectAttr { // Load the move's animation if we didn't already and unshift a new usage phase globalScene.phaseManager.unshiftNew("LoadMoveAnimPhase", moveId); - globalScene.phaseManager.unshiftNew("MovePhase", user, [ target.getBattlerIndex() ], new PokemonMove(moveId), MoveUseMode.FOLLOW_UP); + globalScene.phaseManager.unshiftNew("MovePhase", user, [ target.getBattlerIndex() ], new PokemonMove(moveId), MoveUseMode.FOLLOW_UP, MovePhaseTimingModifier.FIRST); return true; } } @@ -7122,7 +7086,7 @@ export class CopyMoveAttr extends CallMoveAttr { getCondition(): MoveConditionFunc { return (_user, target, _move) => { const lastMove = this.mirrorMove ? target.getLastNonVirtualMove(false, false)?.move : globalScene.currentBattle.lastMove; - return !isNullOrUndefined(lastMove) && !this.invalidMoves.has(lastMove); + return lastMove != null && !this.invalidMoves.has(lastMove); }; } } @@ -7169,7 +7133,7 @@ export class RepeatMoveAttr extends MoveEffectAttr { && firstTarget !== target.getAlly() ) { const ally = firstTarget.getAlly(); - if (!isNullOrUndefined(ally) && ally.isActive()) { + if (ally != null && ally.isActive()) { moveTargets = [ ally.getBattlerIndex() ]; } } @@ -7179,7 +7143,7 @@ export class RepeatMoveAttr extends MoveEffectAttr { targetPokemonName: getPokemonNameWithAffix(target) })); target.turnData.extraTurns++; - globalScene.phaseManager.appendNewToPhase("MoveEndPhase", "MovePhase", target, moveTargets, movesetMove, MoveUseMode.NORMAL); + globalScene.phaseManager.unshiftNew("MovePhase", target, moveTargets, movesetMove, MoveUseMode.NORMAL, MovePhaseTimingModifier.FIRST); return true; } @@ -7478,7 +7442,7 @@ export class SketchAttr extends MoveEffectAttr { } const targetMove = target.getLastNonVirtualMove(); - return !isNullOrUndefined(targetMove) + return targetMove != null && !invalidSketchMoves.has(targetMove.move) && user.getMoveset().every(m => m.moveId !== targetMove.move) }; @@ -7535,7 +7499,7 @@ export class AbilityCopyAttr extends MoveEffectAttr { user.setTempAbility(target.getAbility()); const ally = user.getAlly(); - if (this.copyToPartner && globalScene.currentBattle?.double && !isNullOrUndefined(ally) && ally.hp) { // TODO is this the best way to check that the ally is active? + if (this.copyToPartner && globalScene.currentBattle?.double && ally != null && ally.hp) { // TODO is this the best way to check that the ally is active? globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:copiedTargetAbility", { pokemonName: getPokemonNameWithAffix(ally), targetName: getPokemonNameWithAffix(target), abilityName: allAbilities[target.getAbility().id].name })); ally.setTempAbility(target.getAbility()); } @@ -7954,12 +7918,7 @@ export class AfterYouAttr extends MoveEffectAttr { */ override apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean { globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:afterYou", { targetName: getPokemonNameWithAffix(target) })); - - // Will find next acting phase of the targeted pokémon, delete it and queue it right after us. - const targetNextPhase = globalScene.phaseManager.findPhase(phase => phase.pokemon === target); - if (targetNextPhase && globalScene.phaseManager.tryRemovePhase((phase: MovePhase) => phase.pokemon === target)) { - globalScene.phaseManager.prependToPhase(targetNextPhase, "MovePhase"); - } + globalScene.phaseManager.forceMoveNext((phase: MovePhase) => phase.pokemon === target); return true; } @@ -7982,45 +7941,11 @@ export class ForceLastAttr extends MoveEffectAttr { override apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean { globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:forceLast", { targetPokemonName: getPokemonNameWithAffix(target) })); - // TODO: Refactor this to be more readable and less janky - const targetMovePhase = globalScene.phaseManager.findPhase((phase) => phase.pokemon === target); - if (targetMovePhase && !targetMovePhase.isForcedLast() && globalScene.phaseManager.tryRemovePhase((phase: MovePhase) => phase.pokemon === target)) { - // Finding the phase to insert the move in front of - - // Either the end of the turn or in front of another, slower move which has also been forced last - const prependPhase = globalScene.phaseManager.findPhase((phase) => - [ MovePhase, MoveEndPhase ].every(cls => !(phase instanceof cls)) - || (phase.is("MovePhase")) && phaseForcedSlower(phase, target, !!globalScene.arena.getTag(ArenaTagType.TRICK_ROOM)) - ); - if (prependPhase) { - globalScene.phaseManager.phaseQueue.splice( - globalScene.phaseManager.phaseQueue.indexOf(prependPhase), - 0, - globalScene.phaseManager.create("MovePhase", target, [ ...targetMovePhase.targets ], targetMovePhase.move, targetMovePhase.useMode, true) - ); - } - } + globalScene.phaseManager.forceMoveLast((phase: MovePhase) => phase.pokemon === target); return true; } } -/** - * Returns whether a {@linkcode MovePhase} has been forced last and the corresponding pokemon is slower than {@linkcode target}. - - * TODO: - - Make this a class method - - Make this look at speed order from TurnStartPhase -*/ -const phaseForcedSlower = (phase: MovePhase, target: Pokemon, trickRoom: boolean): boolean => { - let slower: boolean; - // quashed pokemon still have speed ties - if (phase.pokemon.getEffectiveStat(Stat.SPD) === target.getEffectiveStat(Stat.SPD)) { - slower = !!target.randBattleSeedInt(2); - } else { - slower = !trickRoom ? phase.pokemon.getEffectiveStat(Stat.SPD) < target.getEffectiveStat(Stat.SPD) : phase.pokemon.getEffectiveStat(Stat.SPD) > target.getEffectiveStat(Stat.SPD); - } - return phase.isForcedLast() && slower; -}; - const failOnGravityCondition: MoveConditionFunc = (user, target, move) => !globalScene.arena.getTag(ArenaTagType.GRAVITY); const failOnBossCondition: MoveConditionFunc = (user, target, move) => !target.isBossImmune(); @@ -8044,7 +7969,7 @@ const userSleptOrComatoseCondition: MoveConditionFunc = (user) => user.status?.e const targetSleptOrComatoseCondition: MoveConditionFunc = (_user: Pokemon, target: Pokemon, _move: Move) => target.status?.effect === StatusEffect.SLEEP || target.hasAbility(AbilityId.COMATOSE); -const failIfLastCondition: MoveConditionFunc = () => globalScene.phaseManager.findPhase(phase => phase.is("MovePhase")) !== undefined; +const failIfLastCondition: MoveConditionFunc = () => globalScene.phaseManager.hasPhaseOfType("MovePhase"); const failIfLastInPartyCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => { const party: Pokemon[] = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); @@ -8056,7 +7981,7 @@ const failIfGhostTypeCondition: MoveConditionFunc = (user: Pokemon, target: Poke const failIfNoTargetHeldItemsCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => target.getHeldItems().filter(i => i.isTransferable)?.length > 0; const attackedByItemMessageFunc = (user: Pokemon, target: Pokemon, move: Move) => { - if (isNullOrUndefined(target)) { // Fix bug when used against targets that have both fainted + if (target == null) { // Fix bug when used against targets that have both fainted return ""; } const heldItems = target.getHeldItems().filter(i => i.isTransferable); @@ -8613,7 +8538,7 @@ export function initMoves() { .attr(AddBattlerTagAttr, BattlerTagType.DISABLED, false, true) .condition((_user, target, _move) => { const lastNonVirtualMove = target.getLastNonVirtualMove(); - return !isNullOrUndefined(lastNonVirtualMove) && lastNonVirtualMove.move !== MoveId.STRUGGLE; + return lastNonVirtualMove != null && lastNonVirtualMove.move !== MoveId.STRUGGLE; }) .ignoresSubstitute() .reflectable(), @@ -9957,7 +9882,7 @@ export function initMoves() { .condition(failOnGravityCondition) .condition((_user, target, _move) => ![ SpeciesId.DIGLETT, SpeciesId.DUGTRIO, SpeciesId.ALOLA_DIGLETT, SpeciesId.ALOLA_DUGTRIO, SpeciesId.SANDYGAST, SpeciesId.PALOSSAND, SpeciesId.WIGLETT, SpeciesId.WUGTRIO ].includes(target.species.speciesId)) .condition((_user, target, _move) => !(target.species.speciesId === SpeciesId.GENGAR && target.getFormKey() === "mega")) - .condition((_user, target, _move) => isNullOrUndefined(target.getTag(BattlerTagType.INGRAIN)) && isNullOrUndefined(target.getTag(BattlerTagType.IGNORE_FLYING))) + .condition((_user, target, _move) => target.getTag(BattlerTagType.INGRAIN) == null && target.getTag(BattlerTagType.IGNORE_FLYING) == null) .attr(AddBattlerTagAttr, BattlerTagType.TELEKINESIS, false, true, 3) .attr(AddBattlerTagAttr, BattlerTagType.FLOATING, false, true, 3) .reflectable(), diff --git a/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts b/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts index cb34190a584..00e98048ada 100644 --- a/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts +++ b/src/data/mystery-encounters/encounters/bug-type-superfan-encounter.ts @@ -48,7 +48,7 @@ import { getRandomPartyMemberFunc, trainerConfigs } from "#trainers/trainer-conf import { TrainerPartyCompoundTemplate, TrainerPartyTemplate } from "#trainers/trainer-party-template"; import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler"; import { MoveInfoOverlay } from "#ui/move-info-overlay"; -import { isNullOrUndefined, randSeedInt, randSeedShuffle } from "#utils/common"; +import { randSeedInt, randSeedShuffle } from "#utils/common"; import i18next from "i18next"; /** the i18n namespace for the encounter */ @@ -571,7 +571,7 @@ function getTrainerConfigForWave(waveIndex: number) { .setPartyMemberFunc( 4, getRandomPartyMemberFunc([pool3Mon.species], TrainerSlot.TRAINER, true, p => { - if (!isNullOrUndefined(pool3Mon.formIndex)) { + if (pool3Mon.formIndex != null) { p.formIndex = pool3Mon.formIndex; p.generateAndPopulateMoveset(); p.generateName(); @@ -603,7 +603,7 @@ function getTrainerConfigForWave(waveIndex: number) { .setPartyMemberFunc( 3, getRandomPartyMemberFunc([pool3Mon.species], TrainerSlot.TRAINER, true, p => { - if (!isNullOrUndefined(pool3Mon.formIndex)) { + if (pool3Mon.formIndex != null) { p.formIndex = pool3Mon.formIndex; p.generateAndPopulateMoveset(); p.generateName(); @@ -613,7 +613,7 @@ function getTrainerConfigForWave(waveIndex: number) { .setPartyMemberFunc( 4, getRandomPartyMemberFunc([pool3Mon2.species], TrainerSlot.TRAINER, true, p => { - if (!isNullOrUndefined(pool3Mon2.formIndex)) { + if (pool3Mon2.formIndex != null) { p.formIndex = pool3Mon2.formIndex; p.generateAndPopulateMoveset(); p.generateName(); @@ -648,7 +648,7 @@ function getTrainerConfigForWave(waveIndex: number) { .setPartyMemberFunc( 3, getRandomPartyMemberFunc([pool3Mon.species], TrainerSlot.TRAINER, true, p => { - if (!isNullOrUndefined(pool3Mon.formIndex)) { + if (pool3Mon.formIndex != null) { p.formIndex = pool3Mon.formIndex; p.generateAndPopulateMoveset(); p.generateName(); @@ -687,7 +687,7 @@ function getTrainerConfigForWave(waveIndex: number) { .setPartyMemberFunc( 2, getRandomPartyMemberFunc([pool3Mon.species], TrainerSlot.TRAINER, true, p => { - if (!isNullOrUndefined(pool3Mon.formIndex)) { + if (pool3Mon.formIndex != null) { p.formIndex = pool3Mon.formIndex; p.generateAndPopulateMoveset(); p.generateName(); @@ -697,7 +697,7 @@ function getTrainerConfigForWave(waveIndex: number) { .setPartyMemberFunc( 3, getRandomPartyMemberFunc([pool3Mon2.species], TrainerSlot.TRAINER, true, p => { - if (!isNullOrUndefined(pool3Mon2.formIndex)) { + if (pool3Mon2.formIndex != null) { p.formIndex = pool3Mon2.formIndex; p.generateAndPopulateMoveset(); p.generateName(); diff --git a/src/data/mystery-encounters/encounters/dark-deal-encounter.ts b/src/data/mystery-encounters/encounters/dark-deal-encounter.ts index 65d22bfc6de..426eafb5e67 100644 --- a/src/data/mystery-encounters/encounters/dark-deal-encounter.ts +++ b/src/data/mystery-encounters/encounters/dark-deal-encounter.ts @@ -15,7 +15,7 @@ import { getRandomPlayerPokemon, getRandomSpeciesByStarterCost } from "#mystery- import type { MysteryEncounter } from "#mystery-encounters/mystery-encounter"; import { MysteryEncounterBuilder } from "#mystery-encounters/mystery-encounter"; import { MysteryEncounterOptionBuilder } from "#mystery-encounters/mystery-encounter-option"; -import { isNullOrUndefined, randSeedInt } from "#utils/common"; +import { randSeedInt } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; /** i18n namespace for encounter */ @@ -192,7 +192,7 @@ export const DarkDealEncounter: MysteryEncounter = MysteryEncounterBuilder.withE }; }), }; - if (!isNullOrUndefined(bossSpecies.forms) && bossSpecies.forms.length > 0) { + if (bossSpecies.forms != null && bossSpecies.forms.length > 0) { pokemonConfig.formIndex = 0; } const config: EnemyPartyConfig = { diff --git a/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts b/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts index 1cc31eaa21f..0f37a1fae94 100644 --- a/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts +++ b/src/data/mystery-encounters/encounters/fiery-fallout-encounter.ts @@ -45,7 +45,7 @@ import { TypeRequirement, } from "#mystery-encounters/mystery-encounter-requirements"; import { FIRE_RESISTANT_ABILITIES } from "#mystery-encounters/requirement-groups"; -import { isNullOrUndefined, randSeedInt } from "#utils/common"; +import { randSeedInt } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; /** the i18n namespace for the encounter */ @@ -238,7 +238,7 @@ export const FieryFalloutEncounter: MysteryEncounter = MysteryEncounterBuilder.w // Burn random member const burnable = nonFireTypes.filter( - p => isNullOrUndefined(p.status) || isNullOrUndefined(p.status.effect) || p.status.effect === StatusEffect.NONE, + p => p.status == null || p.status.effect == null || p.status.effect === StatusEffect.NONE, ); if (burnable?.length > 0) { const roll = randSeedInt(burnable.length); diff --git a/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts b/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts index d883fdbb567..f2363ade500 100644 --- a/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts +++ b/src/data/mystery-encounters/encounters/fun-and-games-encounter.ts @@ -414,7 +414,7 @@ function summonPlayerPokemonAnimation(pokemon: PlayerPokemon): Promise { pokemon.resetTurnData(); globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true); - globalScene.phaseManager.pushNew("PostSummonPhase", pokemon.getBattlerIndex()); + globalScene.phaseManager.unshiftNew("PostSummonPhase", pokemon.getBattlerIndex()); resolve(); }); }, diff --git a/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts b/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts index 823f016029e..e2166e99f6a 100644 --- a/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts +++ b/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts @@ -43,7 +43,7 @@ import { PartySizeRequirement } from "#mystery-encounters/mystery-encounter-requ import { PokemonData } from "#system/pokemon-data"; import { MusicPreference } from "#system/settings"; import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler"; -import { isNullOrUndefined, NumberHolder, randInt, randSeedInt, randSeedItem, randSeedShuffle } from "#utils/common"; +import { NumberHolder, randInt, randSeedInt, randSeedItem, randSeedShuffle } from "#utils/common"; import { getEnumKeys } from "#utils/enums"; import { getRandomLocaleEntry } from "#utils/i18n"; import { getPokemonSpecies } from "#utils/pokemon-utils"; @@ -537,7 +537,7 @@ function generateTradeOption(alreadyUsedSpecies: PokemonSpecies[], originalBst?: bstCap = originalBst + 100; bstMin = originalBst - 100; } - while (isNullOrUndefined(newSpecies)) { + while (newSpecies == null) { // Get all non-legendary species that fall within the Bst range requirements let validSpecies = allSpecies.filter(s => { const isLegendaryOrMythical = s.legendary || s.subLegendary || s.mythical; @@ -550,7 +550,7 @@ function generateTradeOption(alreadyUsedSpecies: PokemonSpecies[], originalBst?: if (validSpecies?.length > 20) { validSpecies = randSeedShuffle(validSpecies); newSpecies = validSpecies.pop(); - while (isNullOrUndefined(newSpecies) || alreadyUsedSpecies.includes(newSpecies)) { + while (newSpecies == null || alreadyUsedSpecies.includes(newSpecies)) { newSpecies = validSpecies.pop(); } } else { diff --git a/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts b/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts index b5084743613..67e778d8c4b 100644 --- a/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts +++ b/src/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter.ts @@ -669,7 +669,6 @@ function onGameOver() { // Clear any leftover battle phases globalScene.phaseManager.clearPhaseQueue(); - globalScene.phaseManager.clearPhaseQueueSplice(); // Return enemy Pokemon const pokemon = globalScene.getEnemyPokemon(); diff --git a/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts b/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts index 292c866c0ee..51efa0c7586 100644 --- a/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts +++ b/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts @@ -28,7 +28,7 @@ import { MysteryEncounterBuilder } from "#mystery-encounters/mystery-encounter"; import { MysteryEncounterOptionBuilder } from "#mystery-encounters/mystery-encounter-option"; import { MoneyRequirement } from "#mystery-encounters/mystery-encounter-requirements"; import { PokemonData } from "#system/pokemon-data"; -import { isNullOrUndefined, randSeedInt, randSeedItem } from "#utils/common"; +import { randSeedInt, randSeedItem } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; /** the i18n namespace for this encounter */ @@ -81,7 +81,7 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui let tries = 0; // Reroll any species that don't have HAs - while ((isNullOrUndefined(species.abilityHidden) || species.abilityHidden === AbilityId.NONE) && tries < 5) { + while ((species.abilityHidden == null || species.abilityHidden === AbilityId.NONE) && tries < 5) { species = getSalesmanSpeciesOffer(); tries++; } @@ -110,7 +110,7 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui */ if ( r === 0 - || ((isNullOrUndefined(species.abilityHidden) || species.abilityHidden === AbilityId.NONE) + || ((species.abilityHidden == null || species.abilityHidden === AbilityId.NONE) && validEventEncounters.length === 0) ) { // If you roll 1%, give shiny Magikarp with random variant @@ -118,7 +118,7 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui pokemon = new PlayerPokemon(species, 5, 2, undefined, undefined, true); } else if ( validEventEncounters.length > 0 - && (r <= EVENT_THRESHOLD || isNullOrUndefined(species.abilityHidden) || species.abilityHidden === AbilityId.NONE) + && (r <= EVENT_THRESHOLD || species.abilityHidden == null || species.abilityHidden === AbilityId.NONE) ) { tries = 0; do { diff --git a/src/data/mystery-encounters/encounters/training-session-encounter.ts b/src/data/mystery-encounters/encounters/training-session-encounter.ts index 033a54cc5f5..1f3778a5d2c 100644 --- a/src/data/mystery-encounters/encounters/training-session-encounter.ts +++ b/src/data/mystery-encounters/encounters/training-session-encounter.ts @@ -28,7 +28,7 @@ import { MysteryEncounterOptionBuilder } from "#mystery-encounters/mystery-encou import { PokemonData } from "#system/pokemon-data"; import type { HeldModifierConfig } from "#types/held-modifier-config"; import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler"; -import { isNullOrUndefined, randSeedShuffle } from "#utils/common"; +import { randSeedShuffle } from "#utils/common"; import { getEnumValues } from "#utils/enums"; import i18next from "i18next"; @@ -324,7 +324,7 @@ export const TrainingSessionEncounter: MysteryEncounter = MysteryEncounterBuilde // Only update the fusion's dex data if the Pokemon is already caught in dex (ignore rentals) const rootFusionSpecies = playerPokemon.fusionSpecies?.getRootSpeciesId(); if ( - !isNullOrUndefined(rootFusionSpecies) + rootFusionSpecies != null && speciesStarterCosts.hasOwnProperty(rootFusionSpecies) && !!globalScene.gameData.dexData[rootFusionSpecies].caughtAttr ) { diff --git a/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts b/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts index 7bbc4a57757..cd61a6852f7 100644 --- a/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts +++ b/src/data/mystery-encounters/encounters/uncommon-breed-encounter.ts @@ -32,7 +32,7 @@ import { MysteryEncounterOptionBuilder } from "#mystery-encounters/mystery-encou import { MoveRequirement, PersistentModifierRequirement } from "#mystery-encounters/mystery-encounter-requirements"; import { CHARMING_MOVES } from "#mystery-encounters/requirement-groups"; import { PokemonData } from "#system/pokemon-data"; -import { isNullOrUndefined, randSeedInt } from "#utils/common"; +import { randSeedInt } from "#utils/common"; /** the i18n namespace for the encounter */ const namespace = "mysteryEncounters/uncommonBreed"; @@ -167,7 +167,7 @@ export const UncommonBreedEncounter: MysteryEncounter = MysteryEncounterBuilder. const encounter = globalScene.currentBattle.mysteryEncounter!; const eggMove = encounter.misc.eggMove; - if (!isNullOrUndefined(eggMove)) { + if (eggMove != null) { // Check what type of move the egg move is to determine target const pokemonMove = new PokemonMove(eggMove); const move = pokemonMove.getMove(); diff --git a/src/data/mystery-encounters/encounters/weird-dream-encounter.ts b/src/data/mystery-encounters/encounters/weird-dream-encounter.ts index 1fcbd2961d1..abd81fb92ea 100644 --- a/src/data/mystery-encounters/encounters/weird-dream-encounter.ts +++ b/src/data/mystery-encounters/encounters/weird-dream-encounter.ts @@ -41,7 +41,7 @@ import { PokemonData } from "#system/pokemon-data"; import { trainerConfigs } from "#trainers/trainer-config"; import { TrainerPartyTemplate } from "#trainers/trainer-party-template"; import type { HeldModifierConfig } from "#types/held-modifier-config"; -import { isNullOrUndefined, NumberHolder, randSeedInt, randSeedShuffle } from "#utils/common"; +import { NumberHolder, randSeedInt, randSeedShuffle } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; /** i18n namespace for encounter */ @@ -634,7 +634,7 @@ function getTransformedSpecies( alreadyUsedSpecies: PokemonSpecies[], ): PokemonSpecies { let newSpecies: PokemonSpecies | undefined; - while (isNullOrUndefined(newSpecies)) { + while (newSpecies == null) { const bstCap = originalBst + bstSearchRange[1]; const bstMin = Math.max(originalBst + bstSearchRange[0], 0); @@ -655,7 +655,7 @@ function getTransformedSpecies( if (validSpecies?.length > 20) { validSpecies = randSeedShuffle(validSpecies); newSpecies = validSpecies.pop(); - while (isNullOrUndefined(newSpecies) || alreadyUsedSpecies.includes(newSpecies)) { + while (newSpecies == null || alreadyUsedSpecies.includes(newSpecies)) { newSpecies = validSpecies.pop(); } } else { @@ -771,12 +771,12 @@ async function addEggMoveToNewPokemonMoveset( if (eggMoves) { const eggMoveIndices = randSeedShuffle([0, 1, 2, 3]); let randomEggMoveIndex = eggMoveIndices.pop(); - let randomEggMove = !isNullOrUndefined(randomEggMoveIndex) ? eggMoves[randomEggMoveIndex] : null; + let randomEggMove = randomEggMoveIndex != null ? eggMoves[randomEggMoveIndex] : null; let retries = 0; while (retries < 3 && (!randomEggMove || newPokemon.moveset.some(m => m.moveId === randomEggMove))) { // If Pokemon already knows this move, roll for another egg move randomEggMoveIndex = eggMoveIndices.pop(); - randomEggMove = !isNullOrUndefined(randomEggMoveIndex) ? eggMoves[randomEggMoveIndex] : null; + randomEggMove = randomEggMoveIndex != null ? eggMoves[randomEggMoveIndex] : null; retries++; } @@ -791,11 +791,7 @@ async function addEggMoveToNewPokemonMoveset( } // For pokemon that the player owns (including ones just caught), unlock the egg move - if ( - !forBattle - && !isNullOrUndefined(randomEggMoveIndex) - && !!globalScene.gameData.dexData[speciesRootForm].caughtAttr - ) { + if (!forBattle && randomEggMoveIndex != null && !!globalScene.gameData.dexData[speciesRootForm].caughtAttr) { await globalScene.gameData.setEggMoveUnlocked(getPokemonSpecies(speciesRootForm), randomEggMoveIndex, true); } } diff --git a/src/data/mystery-encounters/mystery-encounter-option.ts b/src/data/mystery-encounters/mystery-encounter-option.ts index fc7bb15d343..1b3b260414d 100644 --- a/src/data/mystery-encounters/mystery-encounter-option.ts +++ b/src/data/mystery-encounters/mystery-encounter-option.ts @@ -12,7 +12,7 @@ import { MoneyRequirement, TypeRequirement, } from "#mystery-encounters/mystery-encounter-requirements"; -import { isNullOrUndefined, randSeedInt } from "#utils/common"; +import { randSeedInt } from "#utils/common"; // biome-ignore lint/suspicious/noConfusingVoidType: void unions in callbacks are OK export type OptionPhaseCallback = () => Promise; @@ -62,7 +62,7 @@ export class MysteryEncounterOption implements IMysteryEncounterOption { onPostOptionPhase?: OptionPhaseCallback; constructor(option: IMysteryEncounterOption | null) { - if (!isNullOrUndefined(option)) { + if (option != null) { Object.assign(this, option); } this.hasDexProgress = this.hasDexProgress ?? false; diff --git a/src/data/mystery-encounters/mystery-encounter-requirements.ts b/src/data/mystery-encounters/mystery-encounter-requirements.ts index f20d513419e..85906044b77 100644 --- a/src/data/mystery-encounters/mystery-encounter-requirements.ts +++ b/src/data/mystery-encounters/mystery-encounter-requirements.ts @@ -15,7 +15,7 @@ import { WeatherType } from "#enums/weather-type"; import type { PlayerPokemon } from "#field/pokemon"; import { AttackTypeBoosterModifier } from "#modifiers/modifier"; import type { AttackTypeBoosterModifierType } from "#modifiers/modifier-type"; -import { coerceArray, isNullOrUndefined } from "#utils/common"; +import { coerceArray } from "#utils/common"; export interface EncounterRequirement { meetsRequirement(): boolean; // Boolean to see if a requirement is met @@ -219,7 +219,7 @@ export class WaveRangeRequirement extends EncounterSceneRequirement { } override meetsRequirement(): boolean { - if (!isNullOrUndefined(this.waveRange) && this.waveRange[0] <= this.waveRange[1]) { + if (this.waveRange != null && this.waveRange[0] <= this.waveRange[1]) { const waveIndex = globalScene.currentBattle.waveIndex; if ( (waveIndex >= 0 && this.waveRange[0] >= 0 && this.waveRange[0] > waveIndex) @@ -275,11 +275,7 @@ export class TimeOfDayRequirement extends EncounterSceneRequirement { override meetsRequirement(): boolean { const timeOfDay = globalScene.arena?.getTimeOfDay(); - return !( - !isNullOrUndefined(timeOfDay) - && this.requiredTimeOfDay?.length > 0 - && !this.requiredTimeOfDay.includes(timeOfDay) - ); + return !(timeOfDay != null && this.requiredTimeOfDay?.length > 0 && !this.requiredTimeOfDay.includes(timeOfDay)); } override getDialogueToken(_pokemon?: PlayerPokemon): [string, string] { @@ -298,7 +294,7 @@ export class WeatherRequirement extends EncounterSceneRequirement { override meetsRequirement(): boolean { const currentWeather = globalScene.arena.weather?.weatherType; return !( - !isNullOrUndefined(currentWeather) + currentWeather != null && this.requiredWeather?.length > 0 && !this.requiredWeather.includes(currentWeather!) ); @@ -307,7 +303,7 @@ export class WeatherRequirement extends EncounterSceneRequirement { override getDialogueToken(_pokemon?: PlayerPokemon): [string, string] { const currentWeather = globalScene.arena.weather?.weatherType; let token = ""; - if (!isNullOrUndefined(currentWeather)) { + if (currentWeather != null) { token = WeatherType[currentWeather].replace("_", " ").toLocaleLowerCase(); } return ["weather", token]; @@ -331,7 +327,7 @@ export class PartySizeRequirement extends EncounterSceneRequirement { } override meetsRequirement(): boolean { - if (!isNullOrUndefined(this.partySizeRange) && this.partySizeRange[0] <= this.partySizeRange[1]) { + if (this.partySizeRange != null && this.partySizeRange[0] <= this.partySizeRange[1]) { const partySize = this.excludeDisallowedPokemon ? globalScene.getPokemonAllowedInBattle().length : globalScene.getPlayerParty().length; @@ -363,7 +359,7 @@ export class PersistentModifierRequirement extends EncounterSceneRequirement { override meetsRequirement(): boolean { const partyPokemon = globalScene.getPlayerParty(); - if (isNullOrUndefined(partyPokemon) || this.requiredHeldItemModifiers?.length < 0) { + if (partyPokemon == null || this.requiredHeldItemModifiers?.length < 0) { return false; } let modifierCount = 0; @@ -396,7 +392,7 @@ export class MoneyRequirement extends EncounterSceneRequirement { override meetsRequirement(): boolean { const money = globalScene.money; - if (isNullOrUndefined(money)) { + if (money == null) { return false; } @@ -429,7 +425,7 @@ export class SpeciesRequirement extends EncounterPokemonRequirement { override meetsRequirement(): boolean { const partyPokemon = globalScene.getPlayerParty(); - if (isNullOrUndefined(partyPokemon) || this.requiredSpecies?.length < 0) { + if (partyPokemon == null || this.requiredSpecies?.length < 0) { return false; } return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; @@ -469,7 +465,7 @@ export class NatureRequirement extends EncounterPokemonRequirement { override meetsRequirement(): boolean { const partyPokemon = globalScene.getPlayerParty(); - if (isNullOrUndefined(partyPokemon) || this.requiredNature?.length < 0) { + if (partyPokemon == null || this.requiredNature?.length < 0) { return false; } return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; @@ -484,7 +480,7 @@ export class NatureRequirement extends EncounterPokemonRequirement { } override getDialogueToken(pokemon?: PlayerPokemon): [string, string] { - if (!isNullOrUndefined(pokemon?.nature) && this.requiredNature.includes(pokemon.nature)) { + if (pokemon?.nature != null && this.requiredNature.includes(pokemon.nature)) { return ["nature", Nature[pokemon.nature]]; } return ["nature", ""]; @@ -508,7 +504,7 @@ export class TypeRequirement extends EncounterPokemonRequirement { override meetsRequirement(): boolean { let partyPokemon = globalScene.getPlayerParty(); - if (isNullOrUndefined(partyPokemon)) { + if (partyPokemon == null) { return false; } @@ -561,7 +557,7 @@ export class MoveRequirement extends EncounterPokemonRequirement { override meetsRequirement(): boolean { const partyPokemon = globalScene.getPlayerParty(); - if (isNullOrUndefined(partyPokemon) || this.requiredMoves?.length < 0) { + if (partyPokemon == null || this.requiredMoves?.length < 0) { return false; } return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; @@ -612,7 +608,7 @@ export class CompatibleMoveRequirement extends EncounterPokemonRequirement { override meetsRequirement(): boolean { const partyPokemon = globalScene.getPlayerParty(); - if (isNullOrUndefined(partyPokemon) || this.requiredMoves?.length < 0) { + if (partyPokemon == null || this.requiredMoves?.length < 0) { return false; } return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; @@ -668,7 +664,7 @@ export class AbilityRequirement extends EncounterPokemonRequirement { override meetsRequirement(): boolean { const partyPokemon = globalScene.getPlayerParty(); - if (isNullOrUndefined(partyPokemon) || this.requiredAbilities?.length < 0) { + if (partyPokemon == null || this.requiredAbilities?.length < 0) { return false; } return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; @@ -692,7 +688,7 @@ export class AbilityRequirement extends EncounterPokemonRequirement { override getDialogueToken(pokemon?: PlayerPokemon): [string, string] { const matchingAbility = this.requiredAbilities.find(a => pokemon?.hasAbility(a, false)); - if (!isNullOrUndefined(matchingAbility)) { + if (matchingAbility != null) { return ["ability", allAbilities[matchingAbility].name]; } return ["ability", ""]; @@ -713,7 +709,7 @@ export class StatusEffectRequirement extends EncounterPokemonRequirement { override meetsRequirement(): boolean { const partyPokemon = globalScene.getPlayerParty(); - if (isNullOrUndefined(partyPokemon) || this.requiredStatusEffect?.length < 0) { + if (partyPokemon == null || this.requiredStatusEffect?.length < 0) { return false; } const x = this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; @@ -727,11 +723,7 @@ export class StatusEffectRequirement extends EncounterPokemonRequirement { return this.requiredStatusEffect.some(statusEffect => { if (statusEffect === StatusEffect.NONE) { // StatusEffect.NONE also checks for null or undefined status - return ( - isNullOrUndefined(pokemon.status) - || isNullOrUndefined(pokemon.status.effect) - || pokemon.status.effect === statusEffect - ); + return pokemon.status == null || pokemon.status.effect == null || pokemon.status.effect === statusEffect; } return pokemon.status?.effect === statusEffect; }); @@ -742,11 +734,7 @@ export class StatusEffectRequirement extends EncounterPokemonRequirement { return !this.requiredStatusEffect.some(statusEffect => { if (statusEffect === StatusEffect.NONE) { // StatusEffect.NONE also checks for null or undefined status - return ( - isNullOrUndefined(pokemon.status) - || isNullOrUndefined(pokemon.status.effect) - || pokemon.status.effect === statusEffect - ); + return pokemon.status == null || pokemon.status.effect == null || pokemon.status.effect === statusEffect; } return pokemon.status?.effect === statusEffect; }); @@ -756,9 +744,7 @@ export class StatusEffectRequirement extends EncounterPokemonRequirement { override getDialogueToken(pokemon?: PlayerPokemon): [string, string] { const reqStatus = this.requiredStatusEffect.filter(a => { if (a === StatusEffect.NONE) { - return ( - isNullOrUndefined(pokemon?.status) || isNullOrUndefined(pokemon.status.effect) || pokemon.status.effect === a - ); + return pokemon?.status == null || pokemon.status.effect == null || pokemon.status.effect === a; } return pokemon!.status?.effect === a; }); @@ -788,7 +774,7 @@ export class CanFormChangeWithItemRequirement extends EncounterPokemonRequiremen override meetsRequirement(): boolean { const partyPokemon = globalScene.getPlayerParty(); - if (isNullOrUndefined(partyPokemon) || this.requiredFormChangeItem?.length < 0) { + if (partyPokemon == null || this.requiredFormChangeItem?.length < 0) { return false; } return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; @@ -847,7 +833,7 @@ export class HeldItemRequirement extends EncounterPokemonRequirement { override meetsRequirement(): boolean { const partyPokemon = globalScene.getPlayerParty(); - if (isNullOrUndefined(partyPokemon)) { + if (partyPokemon == null) { return false; } return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; @@ -911,7 +897,7 @@ export class AttackTypeBoosterHeldItemTypeRequirement extends EncounterPokemonRe override meetsRequirement(): boolean { const partyPokemon = globalScene.getPlayerParty(); - if (isNullOrUndefined(partyPokemon)) { + if (partyPokemon == null) { return false; } return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; @@ -978,7 +964,7 @@ export class LevelRequirement extends EncounterPokemonRequirement { override meetsRequirement(): boolean { // Party Pokemon inside required level range - if (!isNullOrUndefined(this.requiredLevelRange) && this.requiredLevelRange[0] <= this.requiredLevelRange[1]) { + if (this.requiredLevelRange != null && this.requiredLevelRange[0] <= this.requiredLevelRange[1]) { const partyPokemon = globalScene.getPlayerParty(); const pokemonInRange = this.queryParty(partyPokemon); if (pokemonInRange.length < this.minNumberOfPokemon) { @@ -1019,10 +1005,7 @@ export class FriendshipRequirement extends EncounterPokemonRequirement { override meetsRequirement(): boolean { // Party Pokemon inside required friendship range - if ( - !isNullOrUndefined(this.requiredFriendshipRange) - && this.requiredFriendshipRange[0] <= this.requiredFriendshipRange[1] - ) { + if (this.requiredFriendshipRange != null && this.requiredFriendshipRange[0] <= this.requiredFriendshipRange[1]) { const partyPokemon = globalScene.getPlayerParty(); const pokemonInRange = this.queryParty(partyPokemon); if (pokemonInRange.length < this.minNumberOfPokemon) { @@ -1071,7 +1054,7 @@ export class HealthRatioRequirement extends EncounterPokemonRequirement { override meetsRequirement(): boolean { // Party Pokemon's health inside required health range - if (!isNullOrUndefined(this.requiredHealthRange) && this.requiredHealthRange[0] <= this.requiredHealthRange[1]) { + if (this.requiredHealthRange != null && this.requiredHealthRange[0] <= this.requiredHealthRange[1]) { const partyPokemon = globalScene.getPlayerParty(); const pokemonInRange = this.queryParty(partyPokemon); if (pokemonInRange.length < this.minNumberOfPokemon) { @@ -1098,7 +1081,7 @@ export class HealthRatioRequirement extends EncounterPokemonRequirement { override getDialogueToken(pokemon?: PlayerPokemon): [string, string] { const hpRatio = pokemon?.getHpRatio(); - if (!isNullOrUndefined(hpRatio)) { + if (hpRatio != null) { return ["healthRatio", Math.floor(hpRatio * 100).toString() + "%"]; } return ["healthRatio", ""]; @@ -1119,7 +1102,7 @@ export class WeightRequirement extends EncounterPokemonRequirement { override meetsRequirement(): boolean { // Party Pokemon's weight inside required weight range - if (!isNullOrUndefined(this.requiredWeightRange) && this.requiredWeightRange[0] <= this.requiredWeightRange[1]) { + if (this.requiredWeightRange != null && this.requiredWeightRange[0] <= this.requiredWeightRange[1]) { const partyPokemon = globalScene.getPlayerParty(); const pokemonInRange = this.queryParty(partyPokemon); if (pokemonInRange.length < this.minNumberOfPokemon) { diff --git a/src/data/mystery-encounters/mystery-encounter-save-data.ts b/src/data/mystery-encounters/mystery-encounter-save-data.ts index f04abccba5f..71cd2517a95 100644 --- a/src/data/mystery-encounters/mystery-encounter-save-data.ts +++ b/src/data/mystery-encounters/mystery-encounter-save-data.ts @@ -1,7 +1,6 @@ import { BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT } from "#app/constants"; import type { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import type { MysteryEncounterType } from "#enums/mystery-encounter-type"; -import { isNullOrUndefined } from "#utils/common"; export class SeenEncounterData { type: MysteryEncounterType; @@ -28,7 +27,7 @@ export class MysteryEncounterSaveData { queuedEncounters: QueuedEncounter[] = []; constructor(data?: MysteryEncounterSaveData) { - if (!isNullOrUndefined(data)) { + if (data != null) { Object.assign(this, data); } diff --git a/src/data/mystery-encounters/mystery-encounter.ts b/src/data/mystery-encounters/mystery-encounter.ts index 273e14248e6..f18660b5d71 100644 --- a/src/data/mystery-encounters/mystery-encounter.ts +++ b/src/data/mystery-encounters/mystery-encounter.ts @@ -25,7 +25,7 @@ import { StatusEffectRequirement, WaveRangeRequirement, } from "#mystery-encounters/mystery-encounter-requirements"; -import { coerceArray, isNullOrUndefined, randSeedInt } from "#utils/common"; +import { coerceArray, randSeedInt } from "#utils/common"; import { capitalizeFirstLetter } from "#utils/strings"; export interface EncounterStartOfBattleEffect { @@ -275,7 +275,7 @@ export class MysteryEncounter implements IMysteryEncounter { private seedOffset?: any; constructor(encounter: IMysteryEncounter | null) { - if (!isNullOrUndefined(encounter)) { + if (encounter != null) { Object.assign(this, encounter); } this.encounterTier = this.encounterTier ?? MysteryEncounterTier.COMMON; diff --git a/src/data/mystery-encounters/requirements/can-learn-move-requirement.ts b/src/data/mystery-encounters/requirements/can-learn-move-requirement.ts index 26602b8ae31..a5810406ef9 100644 --- a/src/data/mystery-encounters/requirements/can-learn-move-requirement.ts +++ b/src/data/mystery-encounters/requirements/can-learn-move-requirement.ts @@ -3,7 +3,7 @@ import type { MoveId } from "#enums/move-id"; import type { PlayerPokemon } from "#field/pokemon"; import { PokemonMove } from "#moves/pokemon-move"; import { EncounterPokemonRequirement } from "#mystery-encounters/mystery-encounter-requirements"; -import { coerceArray, isNullOrUndefined } from "#utils/common"; +import { coerceArray } from "#utils/common"; /** * {@linkcode CanLearnMoveRequirement} options @@ -44,7 +44,7 @@ export class CanLearnMoveRequirement extends EncounterPokemonRequirement { .getPlayerParty() .filter(pkm => (this.includeFainted ? pkm.isAllowedInChallenge() : pkm.isAllowedInBattle())); - if (isNullOrUndefined(partyPokemon) || this.requiredMoves?.length < 0) { + if (partyPokemon == null || this.requiredMoves?.length < 0) { return false; } diff --git a/src/data/mystery-encounters/utils/encounter-dialogue-utils.ts b/src/data/mystery-encounters/utils/encounter-dialogue-utils.ts index 1a9b008f9e9..be681f731e8 100644 --- a/src/data/mystery-encounters/utils/encounter-dialogue-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-dialogue-utils.ts @@ -1,7 +1,6 @@ import { globalScene } from "#app/global-scene"; import type { TextStyle } from "#enums/text-style"; import { getTextWithColors } from "#ui/text"; -import { isNullOrUndefined } from "#utils/common"; import i18next from "i18next"; /** @@ -11,7 +10,7 @@ import i18next from "i18next"; * @param primaryStyle Can define a text style to be applied to the entire string. Must be defined for BBCodeText styles to be applied correctly */ export function getEncounterText(keyOrString?: string, primaryStyle?: TextStyle): string | null { - if (isNullOrUndefined(keyOrString)) { + if (keyOrString == null) { return null; } diff --git a/src/data/mystery-encounters/utils/encounter-phase-utils.ts b/src/data/mystery-encounters/utils/encounter-phase-utils.ts index aa569f11aca..0ba0dec896a 100644 --- a/src/data/mystery-encounters/utils/encounter-phase-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-phase-utils.ts @@ -49,7 +49,7 @@ import type { HeldModifierConfig } from "#types/held-modifier-config"; import type { OptionSelectConfig, OptionSelectItem } from "#ui/abstract-option-select-ui-handler"; import type { PartyOption, PokemonSelectFilter } from "#ui/party-ui-handler"; import { PartyUiMode } from "#ui/party-ui-handler"; -import { coerceArray, isNullOrUndefined, randomString, randSeedInt, randSeedItem } from "#utils/common"; +import { coerceArray, randomString, randSeedInt, randSeedItem } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import i18next from "i18next"; @@ -143,7 +143,7 @@ export async function initBattleWithEnemyConfig(partyConfig: EnemyPartyConfig): const trainerType = partyConfig?.trainerType; const partyTrainerConfig = partyConfig?.trainerConfig; let trainerConfig: TrainerConfig; - if (!isNullOrUndefined(trainerType) || partyTrainerConfig) { + if (trainerType != null || partyTrainerConfig) { globalScene.currentBattle.mysteryEncounter!.encounterMode = MysteryEncounterMode.TRAINER_BATTLE; if (globalScene.currentBattle.trainer) { globalScene.currentBattle.trainer.setVisible(false); @@ -154,7 +154,7 @@ export async function initBattleWithEnemyConfig(partyConfig: EnemyPartyConfig): const doubleTrainer = trainerConfig.doubleOnly || (trainerConfig.hasDouble && !!partyConfig.doubleBattle); doubleBattle = doubleTrainer; - const trainerFemale = isNullOrUndefined(partyConfig.female) ? !!randSeedInt(2) : partyConfig.female; + const trainerFemale = partyConfig.female == null ? !!randSeedInt(2) : partyConfig.female; const newTrainer = new Trainer( trainerConfig.trainerType, doubleTrainer ? TrainerVariant.DOUBLE : trainerFemale ? TrainerVariant.FEMALE : TrainerVariant.DEFAULT, @@ -202,7 +202,7 @@ export async function initBattleWithEnemyConfig(partyConfig: EnemyPartyConfig): let dataSource: PokemonData | undefined; let isBoss = false; if (!loaded) { - if ((!isNullOrUndefined(trainerType) || trainerConfig) && battle.trainer) { + if ((trainerType != null || trainerConfig) && battle.trainer) { // Allows overriding a trainer's pokemon to use specific species/data if (partyConfig?.pokemonConfigs && e < partyConfig.pokemonConfigs.length) { const config = partyConfig.pokemonConfigs[e]; @@ -258,7 +258,7 @@ export async function initBattleWithEnemyConfig(partyConfig: EnemyPartyConfig): enemyPokemon.resetSummonData(); } - if ((!loaded && isNullOrUndefined(partyConfig.countAsSeen)) || partyConfig.countAsSeen) { + if ((!loaded && partyConfig.countAsSeen == null) || partyConfig.countAsSeen) { globalScene.gameData.setPokemonSeen(enemyPokemon, true, !!(trainerType || trainerConfig)); } @@ -266,7 +266,7 @@ export async function initBattleWithEnemyConfig(partyConfig: EnemyPartyConfig): const config = partyConfig.pokemonConfigs[e]; // Set form - if (!isNullOrUndefined(config.nickname)) { + if (config.nickname != null) { enemyPokemon.nickname = btoa(unescape(encodeURIComponent(config.nickname))); } @@ -276,22 +276,22 @@ export async function initBattleWithEnemyConfig(partyConfig: EnemyPartyConfig): } // Set form - if (!isNullOrUndefined(config.formIndex)) { + if (config.formIndex != null) { enemyPokemon.formIndex = config.formIndex; } // Set shiny - if (!isNullOrUndefined(config.shiny)) { + if (config.shiny != null) { enemyPokemon.shiny = config.shiny; } // Set Variant - if (enemyPokemon.shiny && !isNullOrUndefined(config.variant)) { + if (enemyPokemon.shiny && config.variant != null) { enemyPokemon.variant = config.variant; } // Set custom mystery encounter data fields (such as sprite scale, custom abilities, types, etc.) - if (!isNullOrUndefined(config.customPokemonData)) { + if (config.customPokemonData != null) { enemyPokemon.customPokemonData = config.customPokemonData; } @@ -300,7 +300,7 @@ export async function initBattleWithEnemyConfig(partyConfig: EnemyPartyConfig): let segments = config.bossSegments ?? globalScene.getEncounterBossSegments(globalScene.currentBattle.waveIndex, level, enemySpecies, true); - if (!isNullOrUndefined(config.bossSegmentModifier)) { + if (config.bossSegmentModifier != null) { segments += config.bossSegmentModifier; } enemyPokemon.setBoss(true, segments); @@ -335,18 +335,18 @@ export async function initBattleWithEnemyConfig(partyConfig: EnemyPartyConfig): } // Set ability - if (!isNullOrUndefined(config.abilityIndex)) { + if (config.abilityIndex != null) { enemyPokemon.abilityIndex = config.abilityIndex; } // Set gender - if (!isNullOrUndefined(config.gender)) { + if (config.gender != null) { enemyPokemon.gender = config.gender!; enemyPokemon.summonData.gender = config.gender; } // Set AI type - if (!isNullOrUndefined(config.aiType)) { + if (config.aiType != null) { enemyPokemon.aiType = config.aiType; } @@ -738,7 +738,7 @@ export function setEncounterRewards( if (customShopRewards) { globalScene.phaseManager.unshiftNew("SelectModifierPhase", 0, undefined, customShopRewards); } else { - globalScene.phaseManager.tryRemovePhase(p => p.is("MysteryEncounterRewardsPhase")); + globalScene.phaseManager.removeAllPhasesOfType("MysteryEncounterRewardsPhase"); } if (eggRewards) { @@ -812,8 +812,7 @@ export function leaveEncounterWithoutBattle( encounterMode: MysteryEncounterMode = MysteryEncounterMode.NO_BATTLE, ) { globalScene.currentBattle.mysteryEncounter!.encounterMode = encounterMode; - globalScene.phaseManager.clearPhaseQueue(); - globalScene.phaseManager.clearPhaseQueueSplice(); + globalScene.phaseManager.clearPhaseQueue(true); handleMysteryEncounterVictory(addHealPhase); } @@ -826,7 +825,7 @@ export function handleMysteryEncounterVictory(addHealPhase = false, doNotContinu const allowedPkm = globalScene.getPlayerParty().filter(pkm => pkm.isAllowedInBattle()); if (allowedPkm.length === 0) { - globalScene.phaseManager.clearPhaseQueue(); + globalScene.phaseManager.clearPhaseQueue(true); globalScene.phaseManager.unshiftNew("GameOverPhase"); return; } @@ -869,7 +868,7 @@ export function handleMysteryEncounterBattleFailed(addHealPhase = false, doNotCo const allowedPkm = globalScene.getPlayerParty().filter(pkm => pkm.isAllowedInBattle()); if (allowedPkm.length === 0) { - globalScene.phaseManager.clearPhaseQueue(); + globalScene.phaseManager.clearPhaseQueue(true); globalScene.phaseManager.unshiftNew("GameOverPhase"); return; } diff --git a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts index 8f6c78fab9c..01d4659d379 100644 --- a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts @@ -35,7 +35,7 @@ import type { PartyOption } from "#ui/party-ui-handler"; import { PartyUiMode } from "#ui/party-ui-handler"; import { SummaryUiMode } from "#ui/summary-ui-handler"; import { applyChallenges } from "#utils/challenge-utils"; -import { BooleanHolder, isNullOrUndefined, randSeedInt } from "#utils/common"; +import { BooleanHolder, randSeedInt } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import i18next from "i18next"; @@ -276,7 +276,7 @@ export function getRandomSpeciesByStarterCost( if (types && types.length > 0) { filteredSpecies = filteredSpecies.filter( - s => types.includes(s[0].type1) || (!isNullOrUndefined(s[0].type2) && types.includes(s[0].type2)), + s => types.includes(s[0].type1) || (s[0].type2 != null && types.includes(s[0].type2)), ); } diff --git a/src/data/phase-priority-queue.ts b/src/data/phase-priority-queue.ts deleted file mode 100644 index 2c83348cc7b..00000000000 --- a/src/data/phase-priority-queue.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { globalScene } from "#app/global-scene"; -import type { Phase } from "#app/phase"; -import { TrickRoomTag } from "#data/arena-tag"; -import { DynamicPhaseType } from "#enums/dynamic-phase-type"; -import { Stat } from "#enums/stat"; -import { ActivatePriorityQueuePhase } from "#phases/activate-priority-queue-phase"; -import { PostSummonActivateAbilityPhase } from "#phases/post-summon-activate-ability-phase"; -import type { PostSummonPhase } from "#phases/post-summon-phase"; -import { BooleanHolder } from "#utils/common"; - -/** - * Stores a list of {@linkcode Phase}s - * - * Dynamically updates ordering to always pop the highest "priority", based on implementation of {@linkcode reorder} - */ -export abstract class PhasePriorityQueue { - protected abstract queue: Phase[]; - - /** - * Sorts the elements in the queue - */ - public abstract reorder(): void; - - /** - * Calls {@linkcode reorder} and shifts the queue - * @returns The front element of the queue after sorting - */ - public pop(): Phase | undefined { - this.reorder(); - return this.queue.shift(); - } - - /** - * Adds a phase to the queue - * @param phase The phase to add - */ - public push(phase: Phase): void { - this.queue.push(phase); - } - - /** - * Removes all phases from the queue - */ - public clear(): void { - this.queue.splice(0, this.queue.length); - } - - /** - * Attempt to remove one or more Phases from the current queue. - * @param phaseFilter - The function to select phases for removal - * @param removeCount - The maximum number of phases to remove, or `all` to remove all matching phases; - * default `1` - * @returns The number of successfully removed phases - * @todo Remove this eventually once the patchwork bug this is used for is fixed - */ - public tryRemovePhase(phaseFilter: (phase: Phase) => boolean, removeCount: number | "all" = 1): number { - if (removeCount === "all") { - removeCount = this.queue.length; - } else if (removeCount < 1) { - return 0; - } - let numRemoved = 0; - - do { - const phaseIndex = this.queue.findIndex(phaseFilter); - if (phaseIndex === -1) { - break; - } - this.queue.splice(phaseIndex, 1); - numRemoved++; - } while (numRemoved < removeCount && this.queue.length > 0); - - return numRemoved; - } -} - -/** - * Priority Queue for {@linkcode PostSummonPhase} and {@linkcode PostSummonActivateAbilityPhase} - * - * Orders phases first by ability priority, then by the {@linkcode Pokemon}'s effective speed - */ -export class PostSummonPhasePriorityQueue extends PhasePriorityQueue { - protected override queue: PostSummonPhase[] = []; - - public override reorder(): void { - this.queue.sort((phaseA: PostSummonPhase, phaseB: PostSummonPhase) => { - if (phaseA.getPriority() === phaseB.getPriority()) { - return ( - (phaseB.getPokemon().getEffectiveStat(Stat.SPD) - phaseA.getPokemon().getEffectiveStat(Stat.SPD)) - * (isTrickRoom() ? -1 : 1) - ); - } - - return phaseB.getPriority() - phaseA.getPriority(); - }); - } - - public override push(phase: PostSummonPhase): void { - super.push(phase); - this.queueAbilityPhase(phase); - } - - /** - * Queues all necessary {@linkcode PostSummonActivateAbilityPhase}s for each pushed {@linkcode PostSummonPhase} - * @param phase The {@linkcode PostSummonPhase} that was pushed onto the queue - */ - private queueAbilityPhase(phase: PostSummonPhase): void { - const phasePokemon = phase.getPokemon(); - - phasePokemon.getAbilityPriorities().forEach((priority, idx) => { - this.queue.push(new PostSummonActivateAbilityPhase(phasePokemon.getBattlerIndex(), priority, !!idx)); - globalScene.phaseManager.appendToPhase( - new ActivatePriorityQueuePhase(DynamicPhaseType.POST_SUMMON), - "ActivatePriorityQueuePhase", - (p: ActivatePriorityQueuePhase) => p.getType() === DynamicPhaseType.POST_SUMMON, - ); - }); - } -} - -function isTrickRoom(): boolean { - const speedReversed = new BooleanHolder(false); - globalScene.arena.applyTags(TrickRoomTag, false, speedReversed); - return speedReversed.value; -} diff --git a/src/data/pokemon-species.ts b/src/data/pokemon-species.ts index 2d76c2c0400..7c00bf5dff7 100644 --- a/src/data/pokemon-species.ts +++ b/src/data/pokemon-species.ts @@ -28,7 +28,7 @@ import type { Variant, VariantSet } from "#sprites/variant"; import { populateVariantColorCache, variantColorCache, variantData } from "#sprites/variant"; import type { Localizable } from "#types/locales"; import type { StarterMoveset } from "#types/save-data"; -import { isNullOrUndefined, randSeedFloat, randSeedGauss, randSeedInt } from "#utils/common"; +import { randSeedFloat, randSeedGauss, randSeedInt } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import { toCamelCase, toPascalCase } from "#utils/strings"; import { argbFromRgba, QuantizerCelebi, rgbaFromArgb } from "@material/material-color-utilities"; @@ -197,7 +197,7 @@ export abstract class PokemonSpeciesForm { * @returns The id of the ability */ getPassiveAbility(formIndex?: number): AbilityId { - if (isNullOrUndefined(formIndex)) { + if (formIndex == null) { formIndex = this.formIndex; } let starterSpeciesId = this.speciesId; @@ -551,7 +551,7 @@ export abstract class PokemonSpeciesForm { const spriteKey = this.getSpriteKey(female, formIndex, shiny, variant, back); globalScene.loadPokemonAtlas(spriteKey, this.getSpriteAtlasPath(female, formIndex, shiny, variant, back)); globalScene.load.audio(this.getCryKey(formIndex), `audio/${this.getCryKey(formIndex)}.m4a`); - if (!isNullOrUndefined(variant)) { + if (variant != null) { await this.loadVariantColors(spriteKey, female, variant, back, formIndex); } return new Promise(resolve => { @@ -579,7 +579,7 @@ export abstract class PokemonSpeciesForm { const spritePath = this.getSpriteAtlasPath(female, formIndex, shiny, variant, back) .replace("variant/", "") .replace(/_[1-3]$/, ""); - if (!isNullOrUndefined(variant)) { + if (variant != null) { loadPokemonVariantAssets(spriteKey, spritePath, variant).then(() => resolve()); } }); @@ -791,7 +791,7 @@ export class PokemonSpecies extends PokemonSpeciesForm implements Localizable { * @returns A randomly rolled gender based on this Species' {@linkcode malePercent}. */ generateGender(): Gender { - if (isNullOrUndefined(this.malePercent)) { + if (this.malePercent == null) { return Gender.GENDERLESS; } diff --git a/src/data/pokemon/pokemon-data.ts b/src/data/pokemon/pokemon-data.ts index 87ffbbab4cd..4fbb70bccb2 100644 --- a/src/data/pokemon/pokemon-data.ts +++ b/src/data/pokemon/pokemon-data.ts @@ -16,7 +16,6 @@ import type { AttackMoveResult } from "#types/attack-move-result"; import type { IllusionData } from "#types/illusion-data"; import type { TurnMove } from "#types/turn-move"; import type { CoerceNullPropertiesToUndefined } from "#types/type-helpers"; -import { isNullOrUndefined } from "#utils/common"; import { getPokemonSpeciesForm } from "#utils/pokemon-utils"; /** @@ -64,14 +63,14 @@ function deserializePokemonSpeciesForm(value: SerializedSpeciesForm | PokemonSpe // @ts-expect-error: We may be deserializing a PokemonSpeciesForm, but we catch later on let { id, formIdx } = value; - if (isNullOrUndefined(id) || isNullOrUndefined(formIdx)) { + if (id == null || formIdx == null) { // @ts-expect-error: Typescript doesn't know that in block, `value` must be a PokemonSpeciesForm id = value.speciesId; // @ts-expect-error: Same as above (plus we are accessing a protected property) formIdx = value._formIndex; } // If for some reason either of these fields are null/undefined, we cannot reconstruct the species form - if (isNullOrUndefined(id) || isNullOrUndefined(formIdx)) { + if (id == null || formIdx == null) { return null; } return getPokemonSpeciesForm(id, formIdx); @@ -151,13 +150,13 @@ export class PokemonSummonData { public moveHistory: TurnMove[] = []; constructor(source?: PokemonSummonData | SerializedPokemonSummonData) { - if (isNullOrUndefined(source)) { + if (source == null) { return; } // TODO: Rework this into an actual generic function for use elsewhere for (const [key, value] of Object.entries(source)) { - if (isNullOrUndefined(value) && this.hasOwnProperty(key)) { + if (value == null && this.hasOwnProperty(key)) { continue; } @@ -171,7 +170,7 @@ export class PokemonSummonData { const illusionData = { ...value, }; - if (!isNullOrUndefined(illusionData.fusionSpecies)) { + if (illusionData.fusionSpecies != null) { switch (typeof illusionData.fusionSpecies) { case "object": illusionData.fusionSpecies = allSpecies[illusionData.fusionSpecies.speciesId]; @@ -224,18 +223,18 @@ export class PokemonSummonData { CoerceNullPropertiesToUndefined, "speciesForm" | "fusionSpeciesForm" | "illusion" >), - speciesForm: isNullOrUndefined(speciesForm) - ? undefined - : { id: speciesForm.speciesId, formIdx: speciesForm.formIndex }, - fusionSpeciesForm: isNullOrUndefined(fusionSpeciesForm) - ? undefined - : { id: fusionSpeciesForm.speciesId, formIdx: fusionSpeciesForm.formIndex }, - illusion: isNullOrUndefined(illusion) - ? undefined - : { - ...(this.illusion as Omit), - fusionSpecies: illusionSpeciesForm?.speciesId, - }, + speciesForm: speciesForm == null ? undefined : { id: speciesForm.speciesId, formIdx: speciesForm.formIndex }, + fusionSpeciesForm: + fusionSpeciesForm == null + ? undefined + : { id: fusionSpeciesForm.speciesId, formIdx: fusionSpeciesForm.formIndex }, + illusion: + illusion == null + ? undefined + : { + ...(this.illusion as Omit), + fusionSpecies: illusionSpeciesForm?.speciesId, + }, }; // Replace `null` with `undefined`, as `undefined` never gets serialized for (const [key, value] of Object.entries(t)) { @@ -278,7 +277,7 @@ export class PokemonBattleData { public berriesEaten: BerryType[] = []; constructor(source?: PokemonBattleData | Partial) { - if (!isNullOrUndefined(source)) { + if (source != null) { this.hitCount = source.hitCount ?? 0; this.hasEatenBerry = source.hasEatenBerry ?? false; this.berriesEaten = source.berriesEaten ?? []; diff --git a/src/data/terrain.ts b/src/data/terrain.ts index 139230605bf..315ed919e03 100644 --- a/src/data/terrain.ts +++ b/src/data/terrain.ts @@ -22,10 +22,12 @@ export interface SerializedTerrain { export class Terrain { public terrainType: TerrainType; public turnsLeft: number; + public maxDuration: number; - constructor(terrainType: TerrainType, turnsLeft?: number) { + constructor(terrainType: TerrainType, turnsLeft = 0, maxDuration: number = turnsLeft) { this.terrainType = terrainType; - this.turnsLeft = turnsLeft || 0; + this.turnsLeft = turnsLeft; + this.maxDuration = maxDuration; } lapse(): boolean { diff --git a/src/data/trainers/trainer-config.ts b/src/data/trainers/trainer-config.ts index 9d891444829..b5786d1f0a2 100644 --- a/src/data/trainers/trainer-config.ts +++ b/src/data/trainers/trainer-config.ts @@ -3,6 +3,8 @@ import { globalScene } from "#app/global-scene"; import { pokemonEvolutions, pokemonPrevolutions } from "#balance/pokemon-evolutions"; import { signatureSpecies } from "#balance/signature-species"; import { tmSpecies } from "#balance/tms"; +// biome-ignore lint/correctness/noUnusedImports: Used in a tsdoc comment +import type { RARE_EGG_MOVE_LEVEL_REQUIREMENT } from "#data/balance/moveset-generation"; import { modifierTypes } from "#data/data-lists"; import { doubleBattleDialogue } from "#data/double-battle-dialogue"; import { Gender } from "#data/gender"; @@ -41,7 +43,8 @@ import type { TrainerConfigs, TrainerTierPools, } from "#types/trainer-funcs"; -import { coerceArray, isNullOrUndefined, randSeedInt, randSeedIntRange, randSeedItem } from "#utils/common"; +import type { Mutable } from "#types/type-helpers"; +import { coerceArray, randSeedInt, randSeedIntRange, randSeedItem } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import { toCamelCase, toTitleCase } from "#utils/strings"; import i18next from "i18next"; @@ -119,6 +122,15 @@ export class TrainerConfig { public hasVoucher = false; public trainerAI: TrainerAI; + /** + * Whether this trainer's Pokémon are allowed to generate with egg moves + * @defaultValue `false` + * + * @see {@linkcode setEggMovesAllowed} + * @see {@linkcode RARE_EGG_MOVE_LEVEL_THRESHOLD} + */ + public readonly allowEggMoves: boolean = false; + public encounterMessages: string[] = []; public victoryMessages: string[] = []; public defeatMessages: string[] = []; @@ -387,8 +399,27 @@ export class TrainerConfig { return this; } - setBoss(): TrainerConfig { + /** + * Allow this trainer's Pokémon to have egg moves when generating their movesets. + * + * @remarks + * It is redundant to call this if {@linkcode setBoss} is also called on the configuration. + * @returns `this` for method chaining + * @see {@linkcode allowEggMoves} + */ + public setEggMovesAllowed(): this { + (this as Mutable).allowEggMoves = true; + return this; + } + + /** + * Set this trainer as a boss trainer + * @returns `this` for method chaining + * @see {@linkcode isBoss} + */ + public setBoss(): TrainerConfig { this.isBoss = true; + (this as Mutable).allowEggMoves = true; return this; } @@ -474,7 +505,7 @@ export class TrainerConfig { .fill(null) .map((_, i) => i) .filter(i => shedinjaCanTera || party[i].species.speciesId !== SpeciesId.SHEDINJA); // Shedinja can only Tera on Bug specialty type (or no specialty type) - const setPartySlot = !isNullOrUndefined(slot) ? Phaser.Math.Wrap(slot, 0, party.length) : -1; // If we have a tera slot defined, wrap it to party size. + const setPartySlot = slot != null ? Phaser.Math.Wrap(slot, 0, party.length) : -1; // If we have a tera slot defined, wrap it to party size. for (let t = 0; t < Math.min(count(), party.length); t++) { const randomIndex = partyMemberIndexes.indexOf(setPartySlot) > -1 ? setPartySlot : randSeedItem(partyMemberIndexes); @@ -537,7 +568,7 @@ export class TrainerConfig { initI18n(); } - if (!isNullOrUndefined(specialtyType)) { + if (specialtyType != null) { this.setSpecialtyType(specialtyType); } @@ -612,7 +643,7 @@ export class TrainerConfig { signatureSpecies.forEach((speciesPool, s) => { this.setPartyMemberFunc(-(s + 1), getRandomPartyMemberFunc(coerceArray(speciesPool))); }); - if (!isNullOrUndefined(specialtyType)) { + if (specialtyType != null) { this.setSpeciesFilter(p => p.isOfType(specialtyType)); this.setSpecialtyType(specialtyType); } @@ -717,7 +748,7 @@ export class TrainerConfig { }); // Set species filter and specialty type if provided, otherwise filter by base total. - if (!isNullOrUndefined(specialtyType)) { + if (specialtyType != null) { this.setSpeciesFilter(p => p.isOfType(specialtyType) && p.baseTotal >= ELITE_FOUR_MINIMUM_BST); this.setSpecialtyType(specialtyType); } else { @@ -895,7 +926,7 @@ export class TrainerConfig { * @returns `true` if `specialtyType` is defined and not {@link PokemonType.UNKNOWN} */ hasSpecialtyType(): boolean { - return !isNullOrUndefined(this.specialtyType) && this.specialtyType !== PokemonType.UNKNOWN; + return this.specialtyType != null && this.specialtyType !== PokemonType.UNKNOWN; } /** @@ -2942,7 +2973,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.SLOWBRO, SpeciesId.GALAR_SLOWBRO], TrainerSlot.TRAINER, true, p => { // Tera Ice Slowbro/G-Slowbro p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.ICE_BEAM)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.ICE_BEAM)) { // Check if Ice Beam is in the moveset, if not, replace the third move with Ice Beam. p.moveset[2] = new PokemonMove(MoveId.ICE_BEAM); } @@ -2967,7 +2998,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.STEELIX], TrainerSlot.TRAINER, true, p => { // Tera Fighting Steelix p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.BODY_PRESS)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.BODY_PRESS)) { // Check if Body Press is in the moveset, if not, replace the third move with Body Press. p.moveset[2] = new PokemonMove(MoveId.BODY_PRESS); } @@ -2992,7 +3023,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.ARBOK, SpeciesId.WEEZING], TrainerSlot.TRAINER, true, p => { // Tera Ghost Arbok/Weezing p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.TERA_BLAST)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.TERA_BLAST)) { // Check if Tera Blast is in the moveset, if not, replace the third move with Tera Blast. p.moveset[2] = new PokemonMove(MoveId.TERA_BLAST); } @@ -3018,7 +3049,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.GYARADOS, SpeciesId.AERODACTYL], TrainerSlot.TRAINER, true, p => { // Tera Dragon Gyarados/Aerodactyl p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.TERA_BLAST)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.TERA_BLAST)) { // Check if Tera Blast is in the moveset, if not, replace the third move with Tera Blast. p.moveset[2] = new PokemonMove(MoveId.TERA_BLAST); } @@ -3079,7 +3110,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.GENGAR], TrainerSlot.TRAINER, true, p => { // Tera Dark Gengar p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.DARK_PULSE)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.DARK_PULSE)) { // Check if Dark Pulse is in the moveset, if not, replace the third move with Dark Pulse. p.moveset[2] = new PokemonMove(MoveId.DARK_PULSE); } @@ -3163,7 +3194,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.DHELMISE], TrainerSlot.TRAINER, true, p => { // Tera Dragon Dhelmise p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.TERA_BLAST)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.TERA_BLAST)) { // Check if Tera Blast is in the moveset, if not, replace the third move with Tera Blast. p.moveset[2] = new PokemonMove(MoveId.TERA_BLAST); } @@ -3193,7 +3224,7 @@ export const trainerConfigs: TrainerConfigs = { p.setBoss(true, 2); p.abilityIndex = 1; // Sniper p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.X_SCISSOR)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.X_SCISSOR)) { // Check if X-Scissor is in the moveset, if not, replace the third move with X-Scissor. p.moveset[2] = new PokemonMove(MoveId.X_SCISSOR); } @@ -3232,7 +3263,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.STEELIX, SpeciesId.LOPUNNY], TrainerSlot.TRAINER, true, p => { // Tera Fire Steelix/Lopunny p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.TERA_BLAST)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.TERA_BLAST)) { // Check if Tera Blast is in the moveset, if not, replace the third move with Tera Blast. p.moveset[2] = new PokemonMove(MoveId.TERA_BLAST); } @@ -3375,7 +3406,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.CERULEDGE], TrainerSlot.TRAINER, true, p => { // Tera Steel Ceruledge p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.IRON_HEAD)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.IRON_HEAD)) { // Check if Iron Head is in the moveset, if not, replace the third move with Iron Head. p.moveset[2] = new PokemonMove(MoveId.IRON_HEAD); } @@ -3413,7 +3444,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.INCINEROAR], TrainerSlot.TRAINER, true, p => { // Tera Fighting Incineroar p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.CROSS_CHOP)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.CROSS_CHOP)) { // Check if Cross Chop is in the moveset, if not, replace the third move with Cross Chop. p.moveset[2] = new PokemonMove(MoveId.CROSS_CHOP); } @@ -3486,7 +3517,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.DECIDUEYE], TrainerSlot.TRAINER, true, p => { // Tera Flying Decidueye p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.BRAVE_BIRD)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.BRAVE_BIRD)) { // Check if Brave Bird is in the moveset, if not, replace the third move with Brave Bird. p.moveset[2] = new PokemonMove(MoveId.BRAVE_BIRD); } @@ -3511,7 +3542,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.TOXICROAK], TrainerSlot.TRAINER, true, p => { // Tera Dark Toxicroak p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.SUCKER_PUNCH)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.SUCKER_PUNCH)) { // Check if Sucker Punch is in the moveset, if not, replace the third move with Sucker Punch. p.moveset[2] = new PokemonMove(MoveId.SUCKER_PUNCH); } @@ -3536,7 +3567,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.EISCUE], TrainerSlot.TRAINER, true, p => { // Tera Water Eiscue p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.LIQUIDATION)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.LIQUIDATION)) { // Check if Liquidation is in the moveset, if not, replace the third move with Liquidation. p.moveset[2] = new PokemonMove(MoveId.LIQUIDATION); } @@ -3598,7 +3629,7 @@ export const trainerConfigs: TrainerConfigs = { // Tera Dragon Torkoal p.abilityIndex = 1; // Drought p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.TERA_BLAST)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.TERA_BLAST)) { // Check if Tera Blast is in the moveset, if not, replace the third move with Tera Blast. p.moveset[2] = new PokemonMove(MoveId.TERA_BLAST); } @@ -3695,7 +3726,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.EXEGGUTOR], TrainerSlot.TRAINER, true, p => { // Tera Fire Exeggutor p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.TERA_BLAST)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.TERA_BLAST)) { // Check if Tera Blast is in the moveset, if not, replace the third move with Tera Blast. p.moveset[2] = new PokemonMove(MoveId.TERA_BLAST); } @@ -3705,7 +3736,7 @@ export const trainerConfigs: TrainerConfigs = { 3, getRandomPartyMemberFunc([SpeciesId.TALONFLAME], TrainerSlot.TRAINER, true, p => { p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.SUNNY_DAY)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.SUNNY_DAY)) { // Check if Sunny Day is in the moveset, if not, replace the third move with Sunny Day. p.moveset[2] = new PokemonMove(MoveId.SUNNY_DAY); } @@ -3728,7 +3759,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.REUNICLUS], TrainerSlot.TRAINER, true, p => { // Tera Steel Reuniclus p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.FLASH_CANNON)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.FLASH_CANNON)) { // Check if Flash Cannon is in the moveset, if not, replace the third move with Flash Cannon. p.moveset[2] = new PokemonMove(MoveId.FLASH_CANNON); } @@ -3756,7 +3787,7 @@ export const trainerConfigs: TrainerConfigs = { // Tera Fairy Excadrill p.setBoss(true, 2); p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.TERA_BLAST)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.TERA_BLAST)) { // Check if Tera Blast is in the moveset, if not, replace the third move with Tera Blast. p.moveset[2] = new PokemonMove(MoveId.TERA_BLAST); } @@ -3771,7 +3802,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.SCEPTILE], TrainerSlot.TRAINER, true, p => { // Tera Dragon Sceptile p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.DUAL_CHOP)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.DUAL_CHOP)) { // Check if Dual Chop is in the moveset, if not, replace the third move with Dual Chop. p.moveset[2] = new PokemonMove(MoveId.DUAL_CHOP); } @@ -3841,7 +3872,7 @@ export const trainerConfigs: TrainerConfigs = { p.formIndex = 1; // Partner Pikachu p.gender = Gender.MALE; p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.VOLT_TACKLE)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.VOLT_TACKLE)) { // Check if Volt Tackle is in the moveset, if not, replace the first move with Volt Tackle. p.moveset[0] = new PokemonMove(MoveId.VOLT_TACKLE); } @@ -4072,7 +4103,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.KELDEO], TrainerSlot.TRAINER, true, p => { p.pokeball = PokeballType.ROGUE_BALL; p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.SECRET_SWORD)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.SECRET_SWORD)) { // Check if Secret Sword is in the moveset, if not, replace the third move with Secret Sword. p.moveset[2] = new PokemonMove(MoveId.SECRET_SWORD); } @@ -4401,7 +4432,7 @@ export const trainerConfigs: TrainerConfigs = { 5, getRandomPartyMemberFunc([SpeciesId.KINGAMBIT], TrainerSlot.TRAINER, true, p => { p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.TERA_BLAST)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.TERA_BLAST)) { // Check if Tera Blast is in the moveset, if not, replace the third move with Tera Blast. p.moveset[2] = new PokemonMove(MoveId.TERA_BLAST); } @@ -4480,7 +4511,7 @@ export const trainerConfigs: TrainerConfigs = { 4, getRandomPartyMemberFunc([SpeciesId.TERAPAGOS], TrainerSlot.TRAINER, true, p => { p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.TERA_STARSTORM)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.TERA_STARSTORM)) { // Check if Tera Starstorm is in the moveset, if not, replace the first move with Tera Starstorm. p.moveset[0] = new PokemonMove(MoveId.TERA_STARSTORM); } @@ -4494,7 +4525,7 @@ export const trainerConfigs: TrainerConfigs = { p.setBoss(true, 2); p.teraType = PokemonType.FIGHTING; p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.TERA_BLAST)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.TERA_BLAST)) { // Check if Tera Blast is in the moveset, if not, replace the third move with Tera Blast. p.moveset[2] = new PokemonMove(MoveId.TERA_BLAST); } @@ -5054,7 +5085,7 @@ export const trainerConfigs: TrainerConfigs = { 2, getRandomPartyMemberFunc([SpeciesId.HONCHKROW], TrainerSlot.TRAINER, true, p => { p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.SUCKER_PUNCH)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.SUCKER_PUNCH)) { // Check if Sucker Punch is in the moveset, if not, replace the third move with Sucker Punch. p.moveset[2] = new PokemonMove(MoveId.SUCKER_PUNCH); } @@ -5517,7 +5548,7 @@ export const trainerConfigs: TrainerConfigs = { p.formIndex = randSeedInt(18); // Random Silvally Form p.generateAndPopulateMoveset(); p.pokeball = PokeballType.ROGUE_BALL; - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.MULTI_ATTACK)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.MULTI_ATTACK)) { // Check if Multi Attack is in the moveset, if not, replace the first move with Multi Attack. p.moveset[0] = new PokemonMove(MoveId.MULTI_ATTACK); } @@ -5590,7 +5621,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.GOLISOPOD], TrainerSlot.TRAINER, true, p => { p.setBoss(true, 2); p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.FIRST_IMPRESSION)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.FIRST_IMPRESSION)) { // Check if First Impression is in the moveset, if not, replace the third move with First Impression. p.moveset[2] = new PokemonMove(MoveId.FIRST_IMPRESSION); p.gender = Gender.MALE; @@ -5607,7 +5638,7 @@ export const trainerConfigs: TrainerConfigs = { getRandomPartyMemberFunc([SpeciesId.GOLISOPOD], TrainerSlot.TRAINER, true, p => { p.setBoss(true, 2); p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.FIRST_IMPRESSION)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.FIRST_IMPRESSION)) { // Check if First Impression is in the moveset, if not, replace the third move with First Impression. p.moveset[2] = new PokemonMove(MoveId.FIRST_IMPRESSION); p.abilityIndex = 2; // Anticipation @@ -5643,7 +5674,7 @@ export const trainerConfigs: TrainerConfigs = { p.generateAndPopulateMoveset(); p.pokeball = PokeballType.ROGUE_BALL; p.formIndex = randSeedInt(4, 1); // Shock, Burn, Chill, or Douse Drive - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.TECHNO_BLAST)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.TECHNO_BLAST)) { // Check if Techno Blast is in the moveset, if not, replace the third move with Techno Blast. p.moveset[2] = new PokemonMove(MoveId.TECHNO_BLAST); } @@ -5778,7 +5809,7 @@ export const trainerConfigs: TrainerConfigs = { p.setBoss(true, 2); p.abilityIndex = 2; // Pixilate p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.HYPER_VOICE)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.HYPER_VOICE)) { // Check if Hyper Voice is in the moveset, if not, replace the second move with Hyper Voice. p.moveset[1] = new PokemonMove(MoveId.HYPER_VOICE); p.gender = Gender.FEMALE; @@ -5807,7 +5838,7 @@ export const trainerConfigs: TrainerConfigs = { p.setBoss(true, 2); p.abilityIndex = 2; // Pixilate p.generateAndPopulateMoveset(); - if (!p.moveset.some(move => !isNullOrUndefined(move) && move.moveId === MoveId.HYPER_VOICE)) { + if (!p.moveset.some(move => move != null && move.moveId === MoveId.HYPER_VOICE)) { // Check if Hyper Voice is in the moveset, if not, replace the second move with Hyper Voice. p.moveset[1] = new PokemonMove(MoveId.HYPER_VOICE); p.gender = Gender.FEMALE; diff --git a/src/data/weather.ts b/src/data/weather.ts index 84a5e1ba4f8..49af505dc62 100644 --- a/src/data/weather.ts +++ b/src/data/weather.ts @@ -19,10 +19,12 @@ export interface SerializedWeather { export class Weather { public weatherType: WeatherType; public turnsLeft: number; + public maxDuration: number; - constructor(weatherType: WeatherType, turnsLeft?: number) { + constructor(weatherType: WeatherType, turnsLeft = 0, maxDuration: number = turnsLeft) { this.weatherType = weatherType; - this.turnsLeft = !this.isImmutable() ? turnsLeft || 0 : 0; + this.turnsLeft = this.isImmutable() ? 0 : turnsLeft; + this.maxDuration = this.isImmutable() ? 0 : maxDuration; } lapse(): boolean { diff --git a/src/dynamic-queue-manager.ts b/src/dynamic-queue-manager.ts new file mode 100644 index 00000000000..7c65a79d743 --- /dev/null +++ b/src/dynamic-queue-manager.ts @@ -0,0 +1,187 @@ +import type { DynamicPhase, PhaseConditionFunc, PhaseString } from "#app/@types/phase-types"; +import type { PokemonMove } from "#app/data/moves/pokemon-move"; +import type { Pokemon } from "#app/field/pokemon"; +import type { Phase } from "#app/phase"; +import type { MovePhase } from "#app/phases/move-phase"; +import { MovePhasePriorityQueue } from "#app/queues/move-phase-priority-queue"; +import { PokemonPhasePriorityQueue } from "#app/queues/pokemon-phase-priority-queue"; +import { PostSummonPhasePriorityQueue } from "#app/queues/post-summon-phase-priority-queue"; +import type { PriorityQueue } from "#app/queues/priority-queue"; +import type { BattlerIndex } from "#enums/battler-index"; +import type { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; + +// TODO: might be easier to define which phases should be dynamic instead +/** All phases which have defined a `getPokemon` method but should not be sorted dynamically */ +const nonDynamicPokemonPhases: readonly PhaseString[] = [ + "SummonPhase", + "CommandPhase", + "LearnMovePhase", + "MoveEffectPhase", + "MoveEndPhase", + "FaintPhase", + "DamageAnimPhase", + "VictoryPhase", + "PokemonHealPhase", + "WeatherEffectPhase", + "ShowAbilityPhase", + "HideAbilityPhase", + "ExpPhase", + "ShowPartyExpBarPhase", + "HidePartyExpBarPhase", +] as const; + +/** + * The dynamic queue manager holds priority queues for phases which are queued as dynamic. + * + * Dynamic phases are generally those which hold a pokemon and are unshifted, not pushed. \ + * Queues work by sorting their entries in speed order (and possibly with more complex ordering) before each time a phase is popped. + * + * As the holder, this structure is also used to access and modify queued phases. + * This is mostly used in redirection, cancellation, etc. of {@linkcode MovePhase}s. + */ +export class DynamicQueueManager { + /** Maps phase types to their corresponding queues */ + private readonly dynamicPhaseMap: Map>; + + constructor() { + this.dynamicPhaseMap = new Map(); + // PostSummon and Move phases have specialized queues + this.dynamicPhaseMap.set("PostSummonPhase", new PostSummonPhasePriorityQueue()); + this.dynamicPhaseMap.set("MovePhase", new MovePhasePriorityQueue()); + } + + /** Removes all phases from the manager */ + public clearQueues(): void { + for (const queue of this.dynamicPhaseMap.values()) { + queue.clear(); + } + } + + /** + * Adds a new phase to the manager and creates the priority queue for it if one does not exist. + * @param phase - The {@linkcode Phase} to add + * @returns `true` if the phase was added, or `false` if it is not dynamic + */ + public queueDynamicPhase(phase: T): boolean { + if (!this.isDynamicPhase(phase)) { + return false; + } + + if (!this.dynamicPhaseMap.has(phase.phaseName)) { + // TS can't figure out that T is dynamic at this point, but it does know that `typeof phase` is + this.dynamicPhaseMap.set(phase.phaseName, new PokemonPhasePriorityQueue()); + } + this.dynamicPhaseMap.get(phase.phaseName)?.push(phase); + return true; + } + + /** + * Returns the highest-priority (generally by speed) {@linkcode Phase} of the specified type + * @param type - The {@linkcode PhaseString | type} to pop + * @returns The popped {@linkcode Phase}, or `undefined` if none of the specified type exist + */ + public popNextPhase(type: PhaseString): Phase | undefined { + return this.dynamicPhaseMap.get(type)?.pop(); + } + + /** + * Determines if there is a queued dynamic {@linkcode Phase} meeting the conditions + * @param type - The {@linkcode PhaseString | type} of phase to search for + * @param condition - An optional {@linkcode PhaseConditionFunc} to add conditions to the search + * @returns Whether a matching phase exists + */ + public exists(type: T, condition?: PhaseConditionFunc): boolean { + return !!this.dynamicPhaseMap.get(type)?.has(condition); + } + + /** + * Finds and removes a single queued {@linkcode Phase} + * @param type - The {@linkcode PhaseString | type} of phase to search for + * @param phaseFilter - A {@linkcode PhaseConditionFunc} to specify conditions for the phase + * @returns Whether a removal occurred + */ + public removePhase(type: T, condition?: PhaseConditionFunc): boolean { + return !!this.dynamicPhaseMap.get(type)?.remove(condition); + } + + /** + * Sets the timing modifier of a move (i.e. to force it first or last) + * @param condition - A {@linkcode PhaseConditionFunc} to specify conditions for the move + * @param modifier - The {@linkcode MovePhaseTimingModifier} to switch the move to + */ + public setMoveTimingModifier(condition: PhaseConditionFunc<"MovePhase">, modifier: MovePhaseTimingModifier): void { + this.getMovePhaseQueue().setTimingModifier(condition, modifier); + } + + /** + * Finds the {@linkcode MovePhase} meeting the condition and changes its move + * @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function + * @param move - The {@linkcode PokemonMove | move} to use in replacement + */ + public setMoveForPhase(condition: PhaseConditionFunc<"MovePhase">, move: PokemonMove): void { + this.getMovePhaseQueue().setMoveForPhase(condition, move); + } + + /** + * Redirects moves which were targeted at a {@linkcode Pokemon} that has been removed + * @param removedPokemon - The removed {@linkcode Pokemon} + * @param allyPokemon - The ally of the removed pokemon + */ + public redirectMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void { + this.getMovePhaseQueue().redirectMoves(removedPokemon, allyPokemon); + } + + /** + * Finds a {@linkcode MovePhase} meeting the condition + * @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function + * @returns The MovePhase, or `undefined` if it does not exist + */ + public getMovePhase(condition: PhaseConditionFunc<"MovePhase">): MovePhase | undefined { + return this.getMovePhaseQueue().find(condition); + } + + /** + * Finds and cancels a {@linkcode MovePhase} meeting the condition + * @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function + */ + public cancelMovePhase(condition: PhaseConditionFunc<"MovePhase">): void { + this.getMovePhaseQueue().cancelMove(condition); + } + + /** + * Sets the move order to a static array rather than a dynamic queue + * @param order - The order of {@linkcode BattlerIndex}s + */ + public setMoveOrder(order: BattlerIndex[]): void { + this.getMovePhaseQueue().setMoveOrder(order); + } + + /** + * @returns An in-order array of {@linkcode Pokemon}, representing the turn order as played out in the most recent turn + */ + public getLastTurnOrder(): Pokemon[] { + return this.getMovePhaseQueue().getTurnOrder(); + } + + /** Clears the stored `Move` turn order */ + public clearLastTurnOrder(): void { + this.getMovePhaseQueue().clearTurnOrder(); + } + + /** Internal helper to get the {@linkcode MovePhasePriorityQueue} */ + private getMovePhaseQueue(): MovePhasePriorityQueue { + return this.dynamicPhaseMap.get("MovePhase") as MovePhasePriorityQueue; + } + + /** + * Internal helper to determine if a phase is dynamic. + * @param phase - The {@linkcode Phase} to check + * @returns Whether `phase` is dynamic + * @privateRemarks + * Currently, this checks that `phase` has a `getPokemon` method + * and is not blacklisted in `nonDynamicPokemonPhases`. + */ + private isDynamicPhase(phase: Phase): phase is DynamicPhase { + return typeof (phase as any).getPokemon === "function" && !nonDynamicPokemonPhases.includes(phase.phaseName); + } +} diff --git a/src/enums/arena-tag-side.ts b/src/enums/arena-tag-side.ts index 5f25a74ab36..50741751fbb 100644 --- a/src/enums/arena-tag-side.ts +++ b/src/enums/arena-tag-side.ts @@ -1,3 +1,4 @@ +// TODO: rename to something else (this isn't used only for arena tags) export enum ArenaTagSide { BOTH, PLAYER, diff --git a/src/enums/arena-tag-type.ts b/src/enums/arena-tag-type.ts index 30f053b98bd..717845cf2d9 100644 --- a/src/enums/arena-tag-type.ts +++ b/src/enums/arena-tag-type.ts @@ -34,4 +34,5 @@ export enum ArenaTagType { GRASS_WATER_PLEDGE = "GRASS_WATER_PLEDGE", FAIRY_LOCK = "FAIRY_LOCK", NEUTRALIZING_GAS = "NEUTRALIZING_GAS", + PENDING_HEAL = "PENDING_HEAL", } diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index 6d9d2dd4a92..4f0ac491e8b 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -94,4 +94,6 @@ export enum BattlerTagType { ENDURE_TOKEN = "ENDURE_TOKEN", POWDER = "POWDER", MAGIC_COAT = "MAGIC_COAT", + SUPREME_OVERLORD = "SUPREME_OVERLORD", + BYPASS_SPEED = "BYPASS_SPEED", } diff --git a/src/enums/dynamic-phase-type.ts b/src/enums/dynamic-phase-type.ts deleted file mode 100644 index 3146b136dac..00000000000 --- a/src/enums/dynamic-phase-type.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Enum representation of the phase types held by implementations of {@linkcode PhasePriorityQueue}. - */ -// TODO: We currently assume these are in order -export enum DynamicPhaseType { - POST_SUMMON, -} diff --git a/src/enums/move-phase-timing-modifier.ts b/src/enums/move-phase-timing-modifier.ts new file mode 100644 index 00000000000..a452d37e7ff --- /dev/null +++ b/src/enums/move-phase-timing-modifier.ts @@ -0,0 +1,16 @@ +import type { ObjectValues } from "#types/type-helpers"; + +/** + * Enum representing modifiers for the timing of MovePhases. + * + * @remarks + * This system is entirely independent of and takes precedence over move priority + */ +export const MovePhaseTimingModifier = Object.freeze({ + /** Used when moves go last regardless of speed and priority (i.e. Quash) */ + LAST: 0, + NORMAL: 1, + /** Used to trigger moves immediately (i.e. ones that were called through Instruct). */ + FIRST: 2, +}); +export type MovePhaseTimingModifier = ObjectValues; diff --git a/src/events/arena.ts b/src/events/arena.ts index cf287de3176..9f818a36c89 100644 --- a/src/events/arena.ts +++ b/src/events/arena.ts @@ -20,10 +20,13 @@ export enum ArenaEventType { export class ArenaEvent extends Event { /** The total duration of the {@linkcode ArenaEventType} */ public duration: number; - constructor(eventType: ArenaEventType, duration: number) { + /** The maximum duration of the {@linkcode ArenaEventType} */ + public maxDuration: number; + constructor(eventType: ArenaEventType, duration: number, maxDuration: number = duration) { super(eventType); this.duration = duration; + this.maxDuration = maxDuration; } } /** Container class for {@linkcode ArenaEventType.WEATHER_CHANGED} events */ @@ -32,8 +35,8 @@ export class WeatherChangedEvent extends ArenaEvent { public oldWeatherType: WeatherType; /** The {@linkcode WeatherType} being set */ public newWeatherType: WeatherType; - constructor(oldWeatherType: WeatherType, newWeatherType: WeatherType, duration: number) { - super(ArenaEventType.WEATHER_CHANGED, duration); + constructor(oldWeatherType: WeatherType, newWeatherType: WeatherType, duration: number, maxDuration?: number) { + super(ArenaEventType.WEATHER_CHANGED, duration, maxDuration); this.oldWeatherType = oldWeatherType; this.newWeatherType = newWeatherType; @@ -45,8 +48,8 @@ export class TerrainChangedEvent extends ArenaEvent { public oldTerrainType: TerrainType; /** The {@linkcode TerrainType} being set */ public newTerrainType: TerrainType; - constructor(oldTerrainType: TerrainType, newTerrainType: TerrainType, duration: number) { - super(ArenaEventType.TERRAIN_CHANGED, duration); + constructor(oldTerrainType: TerrainType, newTerrainType: TerrainType, duration: number, maxDuration?: number) { + super(ArenaEventType.TERRAIN_CHANGED, duration, maxDuration); this.oldTerrainType = oldTerrainType; this.newTerrainType = newTerrainType; @@ -68,10 +71,11 @@ export class TagAddedEvent extends ArenaEvent { arenaTagType: ArenaTagType, arenaTagSide: ArenaTagSide, duration: number, + maxDuration?: number, arenaTagLayers?: number, arenaTagMaxLayers?: number, ) { - super(ArenaEventType.TAG_ADDED, duration); + super(ArenaEventType.TAG_ADDED, duration, maxDuration); this.arenaTagType = arenaTagType; this.arenaTagSide = arenaTagSide; diff --git a/src/field/arena.ts b/src/field/arena.ts index ff7379b2a4a..3e214ff1ea7 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -36,7 +36,7 @@ import type { Pokemon } from "#field/pokemon"; import { FieldEffectModifier } from "#modifiers/modifier"; import type { Move } from "#moves/move"; import type { AbstractConstructor } from "#types/type-helpers"; -import { type Constructor, isNullOrUndefined, NumberHolder, randSeedInt } from "#utils/common"; +import { type Constructor, NumberHolder, randSeedInt } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; export class Arena { @@ -339,12 +339,12 @@ export class Arena { const weatherDuration = new NumberHolder(0); - if (!isNullOrUndefined(user)) { + if (user != null) { weatherDuration.value = 5; globalScene.applyModifier(FieldEffectModifier, user.isPlayer(), user, weatherDuration); } - this.weather = weather ? new Weather(weather, weatherDuration.value) : null; + this.weather = weather ? new Weather(weather, weatherDuration.value, weatherDuration.value) : null; this.eventTarget.dispatchEvent( new WeatherChangedEvent(oldWeatherType, this.weather?.weatherType!, this.weather?.turnsLeft!), ); // TODO: is this bang correct? @@ -371,9 +371,15 @@ export class Arena { /** * Function to trigger all weather based form changes + * @param source - The Pokemon causing the changes by removing itself from the field */ - triggerWeatherBasedFormChanges(): void { + triggerWeatherBasedFormChanges(source?: Pokemon): void { globalScene.getField(true).forEach(p => { + // TODO - This is a bandaid. Abilities leaving the field needs a better approach than + // calling this method for every switch out that happens + if (p === source) { + return; + } const isCastformWithForecast = p.hasAbility(AbilityId.FORECAST) && p.species.speciesId === SpeciesId.CASTFORM; const isCherrimWithFlowerGift = p.hasAbility(AbilityId.FLOWER_GIFT) && p.species.speciesId === SpeciesId.CHERRIM; @@ -420,12 +426,12 @@ export class Arena { const terrainDuration = new NumberHolder(0); - if (!isNullOrUndefined(user)) { + if (user != null) { terrainDuration.value = 5; globalScene.applyModifier(FieldEffectModifier, user.isPlayer(), user, terrainDuration); } - this.terrain = terrain ? new Terrain(terrain, terrainDuration.value) : null; + this.terrain = terrain ? new Terrain(terrain, terrainDuration.value, terrainDuration.value) : null; this.eventTarget.dispatchEvent( new TerrainChangedEvent(oldTerrainType, this.terrain?.terrainType!, this.terrain?.turnsLeft!), @@ -705,8 +711,8 @@ export class Arena { existingTag.onOverlap(this, globalScene.getPokemonById(sourceId)); if (existingTag instanceof EntryHazardTag) { - const { tagType, side, turnCount, layers, maxLayers } = existingTag as EntryHazardTag; - this.eventTarget.dispatchEvent(new TagAddedEvent(tagType, side, turnCount, layers, maxLayers)); + const { tagType, side, turnCount, maxDuration, layers, maxLayers } = existingTag as EntryHazardTag; + this.eventTarget.dispatchEvent(new TagAddedEvent(tagType, side, turnCount, maxDuration, layers, maxLayers)); } return false; @@ -721,7 +727,7 @@ export class Arena { const { layers = 0, maxLayers = 0 } = newTag instanceof EntryHazardTag ? newTag : {}; this.eventTarget.dispatchEvent( - new TagAddedEvent(newTag.tagType, newTag.side, newTag.turnCount, layers, maxLayers), + new TagAddedEvent(newTag.tagType, newTag.side, newTag.turnCount, newTag.maxDuration, layers, maxLayers), ); } diff --git a/src/field/mystery-encounter-intro.ts b/src/field/mystery-encounter-intro.ts index cf0a0f30529..4b4a234251a 100644 --- a/src/field/mystery-encounter-intro.ts +++ b/src/field/mystery-encounter-intro.ts @@ -5,7 +5,6 @@ import { getSpriteKeysFromSpecies } from "#mystery-encounters/encounter-pokemon- import type { MysteryEncounter } from "#mystery-encounters/mystery-encounter"; import { loadPokemonVariantAssets } from "#sprites/pokemon-sprite"; import type { Variant } from "#sprites/variant"; -import { isNullOrUndefined } from "#utils/common"; import type { GameObjects } from "phaser"; type PlayAnimationConfig = Phaser.Types.Animations.PlayAnimationConfig; @@ -98,7 +97,7 @@ export class MysteryEncounterIntroVisuals extends Phaser.GameObjects.Container { ...config, }; - if (!isNullOrUndefined(result.species)) { + if (result.species != null) { const keys = getSpriteKeysFromSpecies(result.species, undefined, undefined, result.isShiny, result.variant); result.spriteKey = keys.spriteKey; result.fileRoot = keys.fileRoot; @@ -205,12 +204,12 @@ export class MysteryEncounterIntroVisuals extends Phaser.GameObjects.Container { n++; } - if (!isNullOrUndefined(pokemonShinySparkle)) { + if (pokemonShinySparkle != null) { // Offset the sparkle to match the Pokemon's position pokemonShinySparkle.setPosition(sprite.x, sprite.y); } - if (!isNullOrUndefined(alpha)) { + if (alpha != null) { sprite.setAlpha(alpha); tintSprite.setAlpha(alpha); } @@ -234,7 +233,7 @@ export class MysteryEncounterIntroVisuals extends Phaser.GameObjects.Container { this.spriteConfigs.forEach(config => { if (config.isPokemon) { globalScene.loadPokemonAtlas(config.spriteKey, config.fileRoot); - if (config.isShiny && !isNullOrUndefined(config.variant)) { + if (config.isShiny && config.variant != null) { shinyPromises.push(loadPokemonVariantAssets(config.spriteKey, config.fileRoot, config.variant)); } } else if (config.isItem) { diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 6355416d76f..f962a6bf8d1 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1,5 +1,6 @@ import type { Ability, PreAttackModifyDamageAbAttrParams } from "#abilities/ability"; import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs"; +import { generateMoveset } from "#app/ai/ai-moveset-gen"; import type { AnySound, BattleScene } from "#app/battle-scene"; import { PLAYER_PARTY_MAX_SIZE, RARE_CANDY_FRIENDSHIP_CAP } from "#app/constants"; import { timedEventManager } from "#app/global-event-manager"; @@ -18,7 +19,7 @@ import type { LevelMoves } from "#balance/pokemon-level-moves"; import { EVOLVE_MOVE, RELEARN_MOVE } from "#balance/pokemon-level-moves"; import { BASE_HIDDEN_ABILITY_CHANCE, BASE_SHINY_CHANCE, SHINY_EPIC_CHANCE, SHINY_VARIANT_CHANCE } from "#balance/rates"; import { getStarterValueFriendshipCap, speciesStarterCosts } from "#balance/starters"; -import { reverseCompatibleTms, tmPoolTiers, tmSpecies } from "#balance/tms"; +import { reverseCompatibleTms, tmSpecies } from "#balance/tms"; import type { SuppressAbilitiesTag } from "#data/arena-tag"; import { NoCritTag, WeakenMoveScreenTag } from "#data/arena-tag"; import { @@ -81,7 +82,6 @@ import { DexAttr } from "#enums/dex-attr"; import { FieldPosition } from "#enums/field-position"; import { HitResult } from "#enums/hit-result"; import { LearnMoveSituation } from "#enums/learn-move-situation"; -import { ModifierTier } from "#enums/modifier-tier"; import { MoveCategory } from "#enums/move-category"; import { MoveFlags } from "#enums/move-flags"; import { MoveId } from "#enums/move-id"; @@ -142,6 +142,7 @@ import type { PokemonData } from "#system/pokemon-data"; import { RibbonData } from "#system/ribbons/ribbon-data"; import { awardRibbonsToSpeciesLine } from "#system/ribbons/ribbon-methods"; import type { AbAttrMap, AbAttrString, TypeMultiplierAbAttrParams } from "#types/ability-types"; +import type { getAttackDamageParams, getBaseDamageParams } from "#types/damage-params"; import type { DamageCalculationResult, DamageResult } from "#types/damage-result"; import type { IllusionData } from "#types/illusion-data"; import type { StarterDataEntry, StarterMoveset } from "#types/save-data"; @@ -160,7 +161,6 @@ import { fixedInt, getIvsFromId, isBetween, - isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, @@ -178,36 +178,6 @@ import i18next from "i18next"; import Phaser from "phaser"; import SoundFade from "phaser3-rex-plugins/plugins/soundfade"; -/** Base typeclass for damage parameter methods, used for DRY */ -type damageParams = { - /** The attacking {@linkcode Pokemon} */ - source: Pokemon; - /** The move used in the attack */ - move: Move; - /** The move's {@linkcode MoveCategory} after variable-category effects are applied */ - moveCategory: MoveCategory; - /** If `true`, ignores this Pokemon's defensive ability effects */ - ignoreAbility?: boolean; - /** If `true`, ignores the attacking Pokemon's ability effects */ - ignoreSourceAbility?: boolean; - /** If `true`, ignores the ally Pokemon's ability effects */ - ignoreAllyAbility?: boolean; - /** If `true`, ignores the ability effects of the attacking pokemon's ally */ - ignoreSourceAllyAbility?: boolean; - /** If `true`, calculates damage for a critical hit */ - isCritical?: boolean; - /** If `true`, suppresses changes to game state during the calculation */ - simulated?: boolean; - /** If defined, used in place of calculated effectiveness values */ - effectiveness?: number; -}; - -/** Type for the parameters of {@linkcode Pokemon#getBaseDamage | getBaseDamage} */ -type getBaseDamageParams = Omit; - -/** Type for the parameters of {@linkcode Pokemon#getAttackDamage | getAttackDamage} */ -type getAttackDamageParams = Omit; - export abstract class Pokemon extends Phaser.GameObjects.Container { /** * This pokemon's {@link https://bulbapedia.bulbagarden.net/wiki/Personality_value | Personality value/PID}, @@ -244,20 +214,46 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @todo Make private */ public status: Status | null; + /** + * The Pokémon's current friendship value, ranging from 0 to 255. + * @see {@link https://bulbapedia.bulbagarden.net/wiki/Friendship} + */ public friendship: number; + /** + * The level at which this Pokémon was met + * @remarks + * Primarily used for displaying in the summary screen + */ public metLevel: number; + /** + * The ID of the biome this Pokémon was met in + * @remarks + * Primarily used for display in the summary screen. + */ public metBiome: BiomeId | -1; + // TODO: figure out why this is used and document it (seems only to be read for getting the Pokémon's egg moves) public metSpecies: SpeciesId; + /** The wave index at which this Pokémon was met/encountered */ public metWave: number; public luck: number; public pauseEvolutions: boolean; public pokerus: boolean; + /** + * Indicates whether this Pokémon has left or is about to leave the field + * @remarks + * When `true` on a Wild Pokemon, this indicates it is about to flee. + */ public switchOutStatus = false; public evoCounter: number; + /** The type this Pokémon turns into when Terastallized */ public teraType: PokemonType; + /** Whether this Pokémon is currently Terastallized */ public isTerastallized: boolean; + /** The set of Types that have been boosted by this Pokémon's Stellar Terastallization. */ public stellarTypesBoosted: PokemonType[]; + // TODO: Create a fusionData class / interface and move all fusion-related fields there, exposed via getters + /** If this Pokémon is a fusion, the species it is fused with; `null` if not a fusion */ public fusionSpecies: PokemonSpecies | null; public fusionFormIndex: number; public fusionAbilityIndex: number; @@ -289,11 +285,18 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** Used by Mystery Encounters to execute pokemon-specific logic (such as stat boosts) at start of battle */ public mysteryEncounterBattleEffects?: (pokemon: Pokemon) => void; + /** The position of this Pokémon on the field */ public fieldPosition: FieldPosition; public maskEnabled: boolean; public maskSprite: Phaser.GameObjects.Sprite | null; + /** + * The set of all TMs that have been used on this Pokémon + * + * @remarks + * Used to allow re-learning TM moves via, e.g., the Memory Mushroom + */ public usedTMs: MoveId[]; private shinySparkle: Phaser.GameObjects.Sprite; @@ -518,7 +521,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { abstract initBattleInfo(): void; - isOnField(): boolean { + public isOnField(): boolean { if (!globalScene) { return false; } @@ -570,7 +573,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return this.isAllowedInBattle() && (!onField || this.isOnField()); } - getDexAttr(): bigint { + public getDexAttr(): bigint { let ret = 0n; if (this.gender !== Gender.GENDERLESS) { ret |= this.gender !== Gender.FEMALE ? DexAttr.MALE : DexAttr.FEMALE; @@ -584,9 +587,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Sets the Pokemon's name. Only called when loading a Pokemon so this function needs to be called when * initializing hardcoded Pokemon or else it will not display the form index name properly. - * @returns n/a */ - generateName(): void { + public generateName(): void { if (!this.fusionSpecies) { this.name = this.species.getName(this.formIndex); return; @@ -840,11 +842,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * Gracefully handle errors loading a variant sprite. Log if it fails and attempt to fall back on * non-experimental sprites before giving up. * - * @param cacheKey the cache key for the variant color sprite - * @param attemptedSpritePath the sprite path that failed to load - * @param useExpSprite was the attempted sprite experimental - * @param battleSpritePath the filename of the sprite - * @param optionalParams any additional params to log + * @param cacheKey - The cache key for the variant color sprite + * @param attemptedSpritePath - The sprite path that failed to load + * @param useExpSprite - Whether the attempted sprite was experimental + * @param battleSpritePath - The filename of the sprite + * @param optionalParams - Any additional params to log */ async fallbackVariantColor( cacheKey: string, @@ -887,7 +889,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return this.fallbackVariantColor(cacheKey, spritePath, useExpSprite, battleSpritePath, error); }) .then(c => { - if (!isNullOrUndefined(c)) { + if (c != null) { variantColorCache[cacheKey] = c; } }); @@ -910,6 +912,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return this.fusionSpecies.forms[this.fusionFormIndex].formKey; } + //#region Atlas and sprite ID methods // TODO: Add more documentation for all these attributes. // They may be all similar, but what each one actually _does_ is quite unclear at first glance @@ -1038,6 +1041,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { fusionVariant, ); } + //#endregion Atlas and sprite ID methods /** * Return this Pokemon's {@linkcode PokemonSpeciesForm | SpeciesForm}. @@ -1067,7 +1071,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * (such as by the effects of {@linkcode MoveId.TRANSFORM} or {@linkcode AbilityId.IMPOSTER}. * @returns Whether this Pokemon is currently transformed. */ - isTransformed(): boolean { + public isTransformed(): boolean { return this.summonData.speciesForm !== null; } @@ -1076,7 +1080,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @param target - The {@linkcode Pokemon} being transformed into * @returns Whether this Pokemon can transform into `target`. */ - canTransformInto(target: Pokemon): boolean { + public canTransformInto(target: Pokemon): boolean { return !( // Neither pokemon can be already transformed ( @@ -1097,7 +1101,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @param useIllusion - Whether to consider the species of this Pokemon's illusion; default `false` * @returns The {@linkcode PokemonSpeciesForm} of this Pokemon's fusion counterpart. */ - getFusionSpeciesForm(ignoreOverride = false, useIllusion = false): PokemonSpeciesForm { + public getFusionSpeciesForm(ignoreOverride = false, useIllusion = false): PokemonSpeciesForm { const fusionSpecies: PokemonSpecies = useIllusion && this.summonData.illusion ? this.summonData.illusion.fusionSpecies! : this.fusionSpecies!; const fusionFormIndex = @@ -1142,7 +1146,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** Resets the pokemon's field sprite properties, including position, alpha, and scale */ - resetSprite(): void { + public resetSprite(): void { // Resetting properties should not be shown on the field this.setVisible(false); @@ -1193,9 +1197,9 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Attempts to animate a given {@linkcode Phaser.GameObjects.Sprite} * @see {@linkcode Phaser.GameObjects.Sprite.play} - * @param sprite {@linkcode Phaser.GameObjects.Sprite} to animate - * @param tintSprite {@linkcode Phaser.GameObjects.Sprite} placed on top of the sprite to add a color tint - * @param animConfig {@linkcode String} to pass to {@linkcode Phaser.GameObjects.Sprite.play} + * @param sprite - Sprite to animate + * @param tintSprite - Sprite placed on top of the sprite to add a color tint + * @param animConfig - String to pass to the sprite's {@linkcode Phaser.GameObjects.Sprite.play | play} method * @returns true if the sprite was able to be animated */ tryPlaySprite(sprite: Phaser.GameObjects.Sprite, tintSprite: Phaser.GameObjects.Sprite, key: string): boolean { @@ -1260,7 +1264,12 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } } - setFieldPosition(fieldPosition: FieldPosition, duration?: number): Promise { + /** + * Set the field position of this Pokémon + * @param fieldPosition - The new field position + * @param duration - How long the transition should take, in milliseconds; if `0` or `undefined`, the position is changed instantly + */ + public setFieldPosition(fieldPosition: FieldPosition, duration?: number): Promise { return new Promise(resolve => { if (fieldPosition === this.fieldPosition) { resolve(); @@ -1476,7 +1485,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } const ally = this.getAlly(); - if (!isNullOrUndefined(ally)) { + if (ally != null) { applyAbAttrs("AllyStatMultiplierAbAttr", { pokemon: ally, stat, @@ -1610,10 +1619,12 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return baseStats; } + // TODO: Convert this into a getter getNature(): Nature { return this.customPokemonData.nature !== -1 ? this.customPokemonData.nature : this.nature; } + // TODO: Convert this into a setter OR just add a listener for calculateStats... setNature(nature: Nature): void { this.nature = nature; this.calculateStats(); @@ -1624,7 +1635,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { this.calculateStats(); } - generateNature(naturePool?: Nature[]): void { + /** + * Randomly generate and set this Pokémon's nature + * @param naturePool - An optional array of Natures to choose from. If not provided, all natures will be considered. + */ + private generateNature(naturePool?: Nature[]): void { if (naturePool === undefined) { naturePool = getEnumValues(Nature); } @@ -1632,10 +1647,12 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { this.setNature(nature); } + // TODO: Convert this into a getter isFullHp(): boolean { return this.hp >= this.getMaxHp(); } + // TODO: Convert this into a getter getMaxHp(): number { return this.getStat(Stat.HP); } @@ -1645,6 +1662,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return this.getMaxHp() - this.hp; } + /** + * Return the ratio of this Pokémon's current HP to its maximum HP + * @param precise - Whether to return the exact HP ratio (e.g. `0.54321`), or one rounded to the nearest %; default `false` + * @returns The current HP ratio + */ getHpRatio(precise = false): number { return precise ? this.hp / this.getMaxHp() : Math.round((this.hp / this.getMaxHp()) * 100) / 100; } @@ -1659,7 +1681,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (useIllusion && this.summonData.illusion) { return this.summonData.illusion.gender; } - if (!ignoreOverride && !isNullOrUndefined(this.summonData.gender)) { + if (!ignoreOverride && this.summonData.gender != null) { return this.summonData.gender; } return this.gender; @@ -1675,25 +1697,37 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (useIllusion && this.summonData.illusion?.fusionGender) { return this.summonData.illusion.fusionGender; } - if (!ignoreOverride && !isNullOrUndefined(this.summonData.fusionGender)) { + if (!ignoreOverride && this.summonData.fusionGender != null) { return this.summonData.fusionGender; } return this.fusionGender; } /** - * Check whether this Pokemon is shiny. - * @param useIllusion - Whether to consider this pokemon's illusion if present; default `false` + * Check whether this Pokémon is shiny, including its fusion species + * + * @param useIllusion - Whether to consider an active illusion * @returns Whether this Pokemon is shiny + * @see {@linkcode isBaseShiny} */ isShiny(useIllusion = false): boolean { return this.isBaseShiny(useIllusion) || this.isFusionShiny(useIllusion); } + /** + * Get whether this Pokémon's _base_ species is shiny + * @param useIllusion - Whether to consider an active illusion; default `false` + * @returns Whether the pokemon is shiny + */ isBaseShiny(useIllusion = false) { return useIllusion ? (this.summonData.illusion?.shiny ?? this.shiny) : this.shiny; } + /** + * Get whether this Pokémon's _fusion_ species is shiny + * @param useIllusion - Whether to consider an active illusion; default `true` + * @returns Whether this Pokémon's fusion species is shiny, or `false` if there is no fusion + */ isFusionShiny(useIllusion = false) { if (!this.isFusion(useIllusion)) { return false; @@ -1703,7 +1737,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Check whether this Pokemon is doubly shiny (both normal and fusion are shiny). - * @param useIllusion - Whether to consider this pokemon's illusion if present; default `false` + * @param useIllusion - Whether to consider an active illusion; default `false` * @returns Whether this pokemon's base and fusion counterparts are both shiny. */ isDoubleShiny(useIllusion = false): boolean { @@ -1711,10 +1745,10 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Return this Pokemon's {@linkcode Variant | shiny variant}. + * Return this Pokemon's shiny variant. * If a fusion, returns the maximum of the two variants. * Only meaningful if this pokemon is actually shiny. - * @param useIllusion - Whether to consider this pokemon's illusion if present; default `false` + * @param useIllusion - Whether to consider an active illusion; default `false` * @returns The shiny variant of this Pokemon. */ getVariant(useIllusion = false): Variant { @@ -1729,6 +1763,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Return the base pokemon's variant. Equivalent to {@linkcode getVariant} if this pokemon is not a fusion. + * @param useIllusion - Whether to consider an active illusion; default `false` * @returns The shiny variant of this Pokemon's base species. */ getBaseVariant(useIllusion = false): Variant { @@ -1737,10 +1772,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Return the fused pokemon's variant. + * Get the shiny variant of this Pokémon's _fusion_ species * * @remarks * Always returns `0` if the pokemon is not a fusion. + * @param useIllusion - Whether to consider an active illusion * @returns The shiny variant of this pokemon's fusion species. */ getFusionVariant(useIllusion = false): Variant { @@ -1761,7 +1797,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Return whether this {@linkcode Pokemon} is currently fused with anything. - * @param useIllusion - Whether to consider this pokemon's illusion if present; default `false` + * @param useIllusion - Whether to consider an active illusion; default `false` * @returns Whether this Pokemon is currently fused with another species. */ isFusion(useIllusion = false): boolean { @@ -1770,7 +1806,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Return this {@linkcode Pokemon}'s name. - * @param useIllusion - Whether to consider this pokemon's illusion if present; default `false` + * @param useIllusion - Whether to consider an active illusion; default `false` * @returns This Pokemon's name. * @see {@linkcode getNameToRender} - gets this Pokemon's display name. */ @@ -1794,7 +1830,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @returns Whether this Pokemon has this species as either its base or fusion counterpart. */ hasSpecies(species: SpeciesId, formKey?: string): boolean { - if (isNullOrUndefined(formKey)) { + if (formKey == null) { return this.species.speciesId === species || this.fusionSpecies?.speciesId === species; } @@ -1876,8 +1912,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @param includeTeraType - Whether to use this Pokemon's tera type if Terastallized; default `false` * @param forDefend - Whether this Pokemon is currently receiving an attack; default `false` * @param ignoreOverride - Whether to ignore any overrides caused by {@linkcode MoveId.TRANSFORM | Transform}; default `false` - * @param useIllusion - Whether to consider this Pokemon's illusion if present; default `false` - * @returns An array of {@linkcode PokemonType}s corresponding to this Pokemon's typing (real or percieved). + * @param useIllusion - Whether to consider an active illusion; default `false` + * @returns An array of {@linkcode PokemonType}s corresponding to this Pokemon's typing (real or perceived). */ public getTypes( includeTeraType = false, @@ -1942,7 +1978,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { secondType = fusionType1; } - if (secondType === PokemonType.UNKNOWN && isNullOrUndefined(fusionType2)) { + if (secondType === PokemonType.UNKNOWN && fusionType2 == null) { // If second pokemon was monotype and shared its primary type secondType = customTypes @@ -2025,12 +2061,12 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return allAbilities[Overrides.ENEMY_ABILITY_OVERRIDE]; } if (this.isFusion()) { - if (!isNullOrUndefined(this.fusionCustomPokemonData?.ability) && this.fusionCustomPokemonData.ability !== -1) { + if (this.fusionCustomPokemonData?.ability != null && this.fusionCustomPokemonData.ability !== -1) { return allAbilities[this.fusionCustomPokemonData.ability]; } return allAbilities[this.getFusionSpeciesForm(ignoreOverride).getAbility(this.fusionAbilityIndex)]; } - if (!isNullOrUndefined(this.customPokemonData.ability) && this.customPokemonData.ability !== -1) { + if (this.customPokemonData.ability != null && this.customPokemonData.ability !== -1) { return allAbilities[this.customPokemonData.ability]; } let abilityId = this.getSpeciesForm(ignoreOverride).getAbility(this.abilityIndex); @@ -2054,7 +2090,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (Overrides.ENEMY_PASSIVE_ABILITY_OVERRIDE && this.isEnemy()) { return allAbilities[Overrides.ENEMY_PASSIVE_ABILITY_OVERRIDE]; } - if (!isNullOrUndefined(this.customPokemonData.passive) && this.customPokemonData.passive !== -1) { + if (this.customPokemonData.passive != null && this.customPokemonData.passive !== -1) { return allAbilities[this.customPokemonData.passive]; } @@ -2085,10 +2121,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Sets the {@linkcode Pokemon}'s ability and activates it if it normally activates on summon + * Set this Pokémon's temporary ability, activating it if it normally activates on summon * * Also clears primal weather if it is from the ability being changed - * @param ability New Ability + * @param ability - The temporary ability to set + * @param passive - Whether to set the passive ability instead of the non-passive one; default `false` */ public setTempAbility(ability: Ability, passive = false): void { applyOnLoseAbAttrs({ pokemon: this, passive }); @@ -2148,11 +2185,13 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Checks whether an ability of a pokemon can be currently applied. This should rarely be + * Check whether this Pokémon can apply its current ability + * + * @remarks + * This should rarely be * directly called, as {@linkcode hasAbility} and {@linkcode hasAbilityWithAttr} already call this. - * @see {@linkcode hasAbility} {@linkcode hasAbilityWithAttr} Intended ways to check abilities in most cases - * @param passive If true, check if passive can be applied instead of non-passive - * @returns `true` if the ability can be applied + * @param passive - Whether to check the passive (`true`) or non-passive (`false`) ability; default `false` + * @returns Whether the ability can be applied */ public canApplyAbility(passive = false): boolean { if (passive && !this.hasPassive()) { @@ -2238,7 +2277,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { public getWeight(): number { const autotomizedTag = this.getTag(AutotomizedTag); let weightRemoved = 0; - if (!isNullOrUndefined(autotomizedTag)) { + if (autotomizedTag != null) { weightRemoved = 100 * autotomizedTag.autotomizeCount; } const minWeight = 0.1; @@ -2367,14 +2406,14 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Calculates the effectiveness of a move against the Pokémon. - * This includes modifiers from move and ability attributes. - * @param source {@linkcode Pokemon} The attacking Pokémon. - * @param move {@linkcode Move} The move being used by the attacking Pokémon. - * @param ignoreAbility Whether to ignore abilities that might affect type effectiveness or immunity (defaults to `false`). - * @param simulated Whether to apply abilities via simulated calls (defaults to `true`) - * @param cancelled {@linkcode BooleanHolder} Stores whether the move was cancelled by a non-type-based immunity. - * @param useIllusion - Whether we want the attack move effectiveness on the illusion or not + * Calculate the effectiveness of the move against this Pokémon, including + * modifiers from move and ability attributes + * @param source - The attacking Pokémon. + * @param move - The move being used by the attacking Pokémon. + * @param ignoreAbility - Whether to ignore abilities that might affect type effectiveness or immunity (defaults to `false`). + * @param simulated - Whether to apply abilities via simulated calls (defaults to `true`) + * @param cancelled - Stores whether the move was cancelled by a non-type-based immunity. + * @param useIllusion - Whether to consider an active illusion * @returns The type damage multiplier, indicating the effectiveness of the move */ getMoveEffectiveness( @@ -2385,7 +2424,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { cancelled?: BooleanHolder, useIllusion = false, ): TypeDamageMultiplier { - if (!isNullOrUndefined(this.turnData?.moveEffectiveness)) { + if (this.turnData?.moveEffectiveness != null) { return this.turnData?.moveEffectiveness; } @@ -2428,14 +2467,15 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { if (!cancelledHolder.value) { const defendingSidePlayField = this.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField(); - defendingSidePlayField.forEach(p => + defendingSidePlayField.forEach((p: (typeof defendingSidePlayField)[0]) => { applyAbAttrs("FieldPriorityMoveImmunityAbAttr", { pokemon: p, opponent: source, move, cancelled: cancelledHolder, - }), - ); + simulated, + }); + }); } } @@ -2456,7 +2496,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { typeMultiplier.value = 0; } - return (!cancelledHolder.value ? typeMultiplier.value : 0) as TypeDamageMultiplier; + return (cancelledHolder.value ? 0 : typeMultiplier.value) as TypeDamageMultiplier; } /** @@ -2542,10 +2582,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Computes the given Pokemon's matchup score against this Pokemon. + * Compute the given Pokémon's matchup score against this Pokémon + * @remarks * In most cases, this score ranges from near-zero to 16, but the maximum possible matchup score is 64. - * @param opponent {@linkcode Pokemon} The Pokemon to compare this Pokemon against - * @returns A score value based on how favorable this Pokemon is when fighting the given Pokemon + * @param opponent - The Pokemon to compare this Pokémon against + * @returns A score value based on how favorable this Pokémon is when fighting the given Pokémon */ getMatchupScore(opponent: Pokemon): number { const enemyTypes = opponent.getTypes(true, false, false, true); @@ -2622,6 +2663,12 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return (atkScore + defScore) * Math.min(hpDiffRatio, 1); } + /** + * Get the first evolution this Pokémon meets the conditions to evolve into + * @remarks + * Fusion evolutions are also considered. + * @returns The evolution this pokemon can currently evolve into, or `null` if it cannot evolve + */ getEvolution(): SpeciesFormEvolution | null { if (pokemonEvolutions.hasOwnProperty(this.species.speciesId)) { const evolutions = pokemonEvolutions[this.species.speciesId]; @@ -2637,7 +2684,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { e => new FusionSpeciesFormEvolution(this.species.speciesId, e), ); for (const fe of fusionEvolutions) { - if (fe.validate(this)) { + if (fe.validate(this, true)) { return fe; } } @@ -2647,11 +2694,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Gets all level up moves in a given range for a particular pokemon. - * @param startingLevel Don't include moves below this level - * @param includeEvolutionMoves Whether to include evolution moves - * @param simulateEvolutionChain Whether to include moves from prior evolutions - * @param includeRelearnerMoves Whether to include moves that would require a relearner. Note the move relearner inherently allows evolution moves + * Get all level up moves in a given range for a particular pokemon. + * @param startingLevel - Don't include moves below this level + * @param includeEvolutionMoves - Whether to include evolution moves + * @param simulateEvolutionChain - Whether to include moves from prior evolutions + * @param includeRelearnerMoves - Whether to include moves that would require a relearner. Note the move relearner inherently allows evolution moves * @returns A list of moves and the levels they can be learned at */ getLevelMoves( @@ -2776,21 +2823,23 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * This causes problems when there are intentional duplicates (i.e. Smeargle with Sketch) */ if (levelMoves) { - this.getUniqueMoves(levelMoves, ret); + Pokemon.getUniqueMoves(levelMoves, ret); } return ret; } /** - * Helper function for getLevelMoves. + * Helper function for getLevelMoves + * + * @remarks * Finds all non-duplicate items from the input, and pushes them into the output. * Two items count as duplicate if they have the same Move, regardless of level. * - * @param levelMoves the input array to search for non-duplicates from - * @param ret the output array to be pushed into. + * @param levelMoves - The input array to search for non-duplicates from + * @param ret - The output array to be pushed into. */ - private getUniqueMoves(levelMoves: LevelMoves, ret: LevelMoves): void { + private static getUniqueMoves(levelMoves: LevelMoves, ret: LevelMoves): void { const uniqueMoves: MoveId[] = []; for (const lm of levelMoves) { if (!uniqueMoves.find(m => m === lm[1])) { @@ -2802,13 +2851,17 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Get a list of all egg moves - * * @returns list of egg moves */ getEggMoves(): MoveId[] | undefined { return speciesEggMoves[this.getSpeciesForm().getRootSpeciesId()]; } + /** + * Create a new {@linkcode PokemonMove} and set it to the specified move index in this Pokémon's moveset. + * @param moveIndex - The index of the move to set + * @param moveId - The ID of the move to set + */ setMove(moveIndex: number, moveId: MoveId): void { if (moveId === MoveId.NONE) { return; @@ -2822,14 +2875,16 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Function that tries to set a Pokemon shiny based on the trainer's trainer ID and secret ID. + * Attempt to set the Pokémon's shininess based on the trainer's trainer ID and secret ID. * Endless Pokemon in the end biome are unable to be set to shiny * + * @remarks + * * The exact mechanic is that it calculates E as the XOR of the player's trainer ID and secret ID. * F is calculated as the XOR of the first 16 bits of the Pokemon's ID with the last 16 bits. * The XOR of E and F are then compared to the {@linkcode shinyThreshold} (or {@linkcode thresholdOverride} if set) to see whether or not to generate a shiny. * The base shiny odds are {@linkcode BASE_SHINY_CHANCE} / 65536 - * @param thresholdOverride number that is divided by 2^16 (65536) to get the shiny chance, overrides {@linkcode shinyThreshold} if set (bypassing shiny rate modifiers such as Shiny Charm) + * @param thresholdOverride - number that is divided by 2^16 (65536) to get the shiny chance, overrides {@linkcode shinyThreshold} if set (bypassing shiny rate modifiers such as Shiny Charm) * @returns true if the Pokemon has been set as a shiny, false otherwise */ trySetShiny(thresholdOverride?: number): boolean { @@ -2870,14 +2925,16 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Function that tries to set a Pokemon shiny based on seed. + * Tries to set a Pokémon's shininess based on seed + * + * @remarks * For manual use only, usually to roll a Pokemon's shiny chance a second time. * If it rolls shiny, or if it's already shiny, also sets a random variant and give the Pokemon the associated luck. * * The base shiny odds are {@linkcode BASE_SHINY_CHANCE} / `65536` - * @param thresholdOverride number that is divided by `2^16` (`65536`) to get the shiny chance, overrides {@linkcode shinyThreshold} if set (bypassing shiny rate modifiers such as Shiny Charm) - * @param applyModifiersToOverride If {@linkcode thresholdOverride} is set and this is true, will apply Shiny Charm and event modifiers to {@linkcode thresholdOverride} - * @returns `true` if the Pokemon has been set as a shiny, `false` otherwise + * @param thresholdOverride - number that is divided by `2^16` (`65536`) to get the shiny chance, overrides {@linkcode shinyThreshold} if set (bypassing shiny rate modifiers such as Shiny Charm) + * @param applyModifiersToOverride - If {@linkcode thresholdOverride} is set and this is true, will apply Shiny Charm and event modifiers to {@linkcode thresholdOverride} + * @returns Whether this Pokémon was set to shiny */ public trySetShinySeed(thresholdOverride?: number, applyModifiersToOverride?: boolean): boolean { if (!this.shiny) { @@ -2903,11 +2960,18 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Generates a shiny variant - * @returns `0-2`, with the following probabilities: - * - Has a 10% chance of returning `2` (epic variant) - * - Has a 30% chance of returning `1` (rare variant) - * - Has a 60% chance of returning `0` (basic shiny) + * Randomly generate a shiny variant + * + * @remarks + * Variants are returned with the following probabilities: + * + * | Variant | Description | Probability | + * |---------|----------------|-------------| + * | 0 | Basic shiny | 60% | + * | 1 | Rare variant | 30% | + * | 2 | Epic variant | 10% | + * + * @returns The randomly chosen shiny variant */ protected generateShinyVariant(): Variant { const formIndex: number = this.formIndex; @@ -2943,12 +3007,14 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Function that tries to set a Pokemon to have its hidden ability based on seed, if it exists. + * Function that tries to set this Pokemon to have its hidden ability based on seed, if it exists. + * + * @remarks * For manual use only, usually to roll a Pokemon's hidden ability chance a second time. * * The base hidden ability odds are {@linkcode BASE_HIDDEN_ABILITY_CHANCE} / `65536` - * @param thresholdOverride number that is divided by `2^16` (`65536`) to get the HA chance, overrides {@linkcode haThreshold} if set (bypassing HA rate modifiers such as Ability Charm) - * @param applyModifiersToOverride If {@linkcode thresholdOverride} is set and this is true, will apply Ability Charm to {@linkcode thresholdOverride} + * @param thresholdOverride - number that is divided by `2^16` (`65536`) to get the HA chance, overrides {@linkcode haThreshold} if set (bypassing HA rate modifiers such as Ability Charm) + * @param applyModifiersToOverride - If {@linkcode thresholdOverride} is set and this is true, will apply Ability Charm to {@linkcode thresholdOverride} * @returns `true` if the Pokemon has been set to have its hidden ability, `false` otherwise */ public tryRerollHiddenAbilitySeed(thresholdOverride?: number, applyModifiersToOverride?: boolean): boolean { @@ -2967,6 +3033,10 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return this.abilityIndex === 2; } + /** + * Generate a fusion species and add it to this Pokémon + * @param forStarter - Whether this fusion is being generated for a starter Pokémon; default `false` + */ public generateFusionSpecies(forStarter?: boolean): void { const hiddenAbilityChance = new NumberHolder(BASE_HIDDEN_ABILITY_CHANCE); if (!this.hasTrainer()) { @@ -3033,6 +3103,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { this.generateName(); } + /** Remove the fusion species from this Pokémon */ public clearFusionSpecies(): void { this.fusionSpecies = null; this.fusionFormIndex = 0; @@ -3047,296 +3118,9 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { this.calculateStats(); } - /** Generates a semi-random moveset for a Pokemon */ + /** Generate a semi-random moveset for this Pokémon */ public generateAndPopulateMoveset(): void { - this.moveset = []; - let movePool: [MoveId, number][] = []; - const allLevelMoves = this.getLevelMoves(1, true, true, this.hasTrainer()); - if (!allLevelMoves) { - console.warn("Error encountered trying to generate moveset for:", this.species.name); - return; - } - - for (const levelMove of allLevelMoves) { - if (this.level < levelMove[0]) { - break; - } - let weight = levelMove[0] + 20; - // Evolution Moves - if (levelMove[0] === EVOLVE_MOVE) { - weight = 70; - } - // Assume level 1 moves with 80+ BP are "move reminder" moves and bump their weight. Trainers use actual relearn moves. - if ( - (levelMove[0] === 1 && allMoves[levelMove[1]].power >= 80) - || (levelMove[0] === RELEARN_MOVE && this.hasTrainer()) - ) { - weight = 60; - } - if (!movePool.some(m => m[0] === levelMove[1]) && !allMoves[levelMove[1]].name.endsWith(" (N)")) { - movePool.push([levelMove[1], weight]); - } - } - - if (this.hasTrainer()) { - const tms = Object.keys(tmSpecies); - for (const tm of tms) { - const moveId = Number.parseInt(tm) as MoveId; - let compatible = false; - for (const p of tmSpecies[tm]) { - if (Array.isArray(p)) { - if ( - p[0] === this.species.speciesId - || (this.fusionSpecies - && p[0] === this.fusionSpecies.speciesId - && p.slice(1).indexOf(this.species.forms[this.formIndex]) > -1) - ) { - compatible = true; - break; - } - } else if (p === this.species.speciesId || (this.fusionSpecies && p === this.fusionSpecies.speciesId)) { - compatible = true; - break; - } - } - if (compatible && !movePool.some(m => m[0] === moveId) && !allMoves[moveId].name.endsWith(" (N)")) { - if (tmPoolTiers[moveId] === ModifierTier.COMMON && this.level >= 15) { - movePool.push([moveId, 24]); - } else if (tmPoolTiers[moveId] === ModifierTier.GREAT && this.level >= 30) { - movePool.push([moveId, 28]); - } else if (tmPoolTiers[moveId] === ModifierTier.ULTRA && this.level >= 50) { - movePool.push([moveId, 34]); - } - } - } - - // No egg moves below level 60 - if (this.level >= 60) { - for (let i = 0; i < 3; i++) { - const moveId = speciesEggMoves[this.species.getRootSpeciesId()][i]; - if (!movePool.some(m => m[0] === moveId) && !allMoves[moveId].name.endsWith(" (N)")) { - movePool.push([moveId, 60]); - } - } - const moveId = speciesEggMoves[this.species.getRootSpeciesId()][3]; - // No rare egg moves before e4 - if ( - this.level >= 170 - && !movePool.some(m => m[0] === moveId) - && !allMoves[moveId].name.endsWith(" (N)") - && !this.isBoss() - ) { - movePool.push([moveId, 50]); - } - if (this.fusionSpecies) { - for (let i = 0; i < 3; i++) { - const moveId = speciesEggMoves[this.fusionSpecies.getRootSpeciesId()][i]; - if (!movePool.some(m => m[0] === moveId) && !allMoves[moveId].name.endsWith(" (N)")) { - movePool.push([moveId, 60]); - } - } - const moveId = speciesEggMoves[this.fusionSpecies.getRootSpeciesId()][3]; - // No rare egg moves before e4 - if ( - this.level >= 170 - && !movePool.some(m => m[0] === moveId) - && !allMoves[moveId].name.endsWith(" (N)") - && !this.isBoss() - ) { - movePool.push([moveId, 50]); - } - } - } - } - - // Bosses never get self ko moves or Pain Split - if (this.isBoss()) { - movePool = movePool.filter( - m => !allMoves[m[0]].hasAttr("SacrificialAttr") && !allMoves[m[0]].hasAttr("HpSplitAttr"), - ); - } - // No one gets Memento or Final Gambit - movePool = movePool.filter(m => !allMoves[m[0]].hasAttr("SacrificialAttrOnHit")); - if (this.hasTrainer()) { - // Trainers never get OHKO moves - movePool = movePool.filter(m => !allMoves[m[0]].hasAttr("OneHitKOAttr")); - // Half the weight of self KO moves - movePool = movePool.map(m => [m[0], m[1] * (allMoves[m[0]].hasAttr("SacrificialAttr") ? 0.5 : 1)]); - // Trainers get a weight bump to stat buffing moves - movePool = movePool.map(m => [ - m[0], - m[1] * (allMoves[m[0]].getAttrs("StatStageChangeAttr").some(a => a.stages > 1 && a.selfTarget) ? 1.25 : 1), - ]); - // Trainers get a weight decrease to multiturn moves - movePool = movePool.map(m => [ - m[0], - m[1] * (!!allMoves[m[0]].isChargingMove() || !!allMoves[m[0]].hasAttr("RechargeAttr") ? 0.7 : 1), - ]); - } - - // Weight towards higher power moves, by reducing the power of moves below the highest power. - // Caps max power at 90 to avoid something like hyper beam ruining the stats. - // This is a pretty soft weighting factor, although it is scaled with the weight multiplier. - const maxPower = Math.min( - movePool.reduce((v, m) => Math.max(allMoves[m[0]].calculateEffectivePower(), v), 40), - 90, - ); - movePool = movePool.map(m => [ - m[0], - m[1] - * (allMoves[m[0]].category === MoveCategory.STATUS - ? 1 - : Math.max(Math.min(allMoves[m[0]].calculateEffectivePower() / maxPower, 1), 0.5)), - ]); - - // Weight damaging moves against the lower stat. This uses a non-linear relationship. - // If the higher stat is 1 - 1.09x higher, no change. At higher stat ~1.38x lower stat, off-stat moves have half weight. - // One third weight at ~1.58x higher, one quarter weight at ~1.73x higher, one fifth at ~1.87x, and one tenth at ~2.35x higher. - const atk = this.getStat(Stat.ATK); - const spAtk = this.getStat(Stat.SPATK); - const worseCategory: MoveCategory = atk > spAtk ? MoveCategory.SPECIAL : MoveCategory.PHYSICAL; - const statRatio = worseCategory === MoveCategory.PHYSICAL ? atk / spAtk : spAtk / atk; - movePool = movePool.map(m => [ - m[0], - m[1] * (allMoves[m[0]].category === worseCategory ? Math.min(Math.pow(statRatio, 3) * 1.3, 1) : 1), - ]); - - /** The higher this is the more the game weights towards higher level moves. At `0` all moves are equal weight. */ - let weightMultiplier = 1.6; - if (this.isBoss()) { - weightMultiplier += 0.4; - } - const baseWeights: [MoveId, number][] = movePool.map(m => [ - m[0], - Math.ceil(Math.pow(m[1], weightMultiplier) * 100), - ]); - - const STAB_BLACKLIST: ReadonlySet = new Set([ - MoveId.BEAT_UP, - MoveId.BELCH, - MoveId.BIDE, - MoveId.COMEUPPANCE, - MoveId.COUNTER, - MoveId.DOOM_DESIRE, - MoveId.DRAGON_RAGE, - MoveId.DREAM_EATER, - MoveId.ENDEAVOR, - MoveId.EXPLOSION, - MoveId.FAKE_OUT, - MoveId.FIRST_IMPRESSION, - MoveId.FISSURE, - MoveId.FLING, - MoveId.FOCUS_PUNCH, - MoveId.FUTURE_SIGHT, - MoveId.GUILLOTINE, - MoveId.HOLD_BACK, - MoveId.HORN_DRILL, - MoveId.LAST_RESORT, - MoveId.METAL_BURST, - MoveId.MIRROR_COAT, - MoveId.MISTY_EXPLOSION, - MoveId.NATURAL_GIFT, - MoveId.NATURES_MADNESS, - MoveId.NIGHT_SHADE, - MoveId.PSYWAVE, - MoveId.RUINATION, - MoveId.SELF_DESTRUCT, - MoveId.SHEER_COLD, - MoveId.SHELL_TRAP, - MoveId.SKY_DROP, - MoveId.SNORE, - MoveId.SONIC_BOOM, - MoveId.SPIT_UP, - MoveId.STEEL_BEAM, - MoveId.STEEL_ROLLER, - MoveId.SUPER_FANG, - MoveId.SYNCHRONOISE, - MoveId.UPPER_HAND, - ]); - - // All Pokemon force a STAB move first - const stabMovePool = baseWeights.filter( - m => - allMoves[m[0]].category !== MoveCategory.STATUS - && this.isOfType(allMoves[m[0]].type) - && !STAB_BLACKLIST.has(m[0]), - ); - - if (stabMovePool.length > 0) { - const totalWeight = stabMovePool.reduce((v, m) => v + m[1], 0); - let rand = randSeedInt(totalWeight); - let index = 0; - while (rand > stabMovePool[index][1]) { - rand -= stabMovePool[index++][1]; - } - this.moveset.push(new PokemonMove(stabMovePool[index][0])); - } else { - // If there are no damaging STAB moves, just force a random damaging move - const attackMovePool = baseWeights.filter( - m => allMoves[m[0]].category !== MoveCategory.STATUS && !STAB_BLACKLIST.has(m[0]), - ); - if (attackMovePool.length > 0) { - const totalWeight = attackMovePool.reduce((v, m) => v + m[1], 0); - let rand = randSeedInt(totalWeight); - let index = 0; - while (rand > attackMovePool[index][1]) { - rand -= attackMovePool[index++][1]; - } - this.moveset.push(new PokemonMove(attackMovePool[index][0], 0, 0)); - } - } - - while (baseWeights.length > this.moveset.length && this.moveset.length < 4) { - if (this.hasTrainer()) { - // Sqrt the weight of any damaging moves with overlapping types. This is about a 0.05 - 0.1 multiplier. - // Other damaging moves 2x weight if 0-1 damaging moves, 0.5x if 2, 0.125x if 3. These weights get 20x if STAB. - // Status moves remain unchanged on weight, this encourages 1-2 - movePool = baseWeights - .filter( - m => - !this.moveset.some( - mo => - m[0] === mo.moveId - || (allMoves[m[0]].hasAttr("SacrificialAttr") && mo.getMove().hasAttr("SacrificialAttr")), // Only one self-KO move allowed - ), - ) - .map(m => { - let ret: number; - if ( - this.moveset.some( - mo => mo.getMove().category !== MoveCategory.STATUS && mo.getMove().type === allMoves[m[0]].type, - ) - ) { - ret = Math.ceil(Math.sqrt(m[1])); - } else if (allMoves[m[0]].category !== MoveCategory.STATUS) { - ret = Math.ceil( - (m[1] / Math.max(Math.pow(4, this.moveset.filter(mo => (mo.getMove().power ?? 0) > 1).length) / 8, 0.5)) - * (this.isOfType(allMoves[m[0]].type) && !STAB_BLACKLIST.has(m[0]) ? 20 : 1), - ); - } else { - ret = m[1]; - } - return [m[0], ret]; - }); - } else { - // Non-trainer pokemon just use normal weights - movePool = baseWeights.filter( - m => - !this.moveset.some( - mo => - m[0] === mo.moveId - || (allMoves[m[0]].hasAttr("SacrificialAttr") && mo.getMove().hasAttr("SacrificialAttr")), // Only one self-KO move allowed - ), - ); - } - const totalWeight = movePool.reduce((v, m) => v + m[1], 0); - let rand = randSeedInt(totalWeight); - let index = 0; - while (rand > movePool[index][1]) { - rand -= movePool[index++][1]; - } - this.moveset.push(new PokemonMove(movePool[index][0])); - } + generateMoveset(this); // Trigger FormChange, except for enemy Pokemon during Mystery Encounters, to avoid crashes if ( @@ -3353,6 +3137,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return move?.isUsable(this, ignorePp) ?? false; } + /** Show this Pokémon's info panel */ showInfo(): void { if (!this.battleInfo.visible) { const otherBattleInfo = globalScene.fieldUI @@ -3381,7 +3166,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } } - hideInfo(): Promise { + /** Hide this Pokémon's info panel */ + async hideInfo(): Promise { return new Promise(resolve => { if (this.battleInfo?.visible) { globalScene.tweens.add({ @@ -3405,14 +3191,6 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { }); } - /** - * sets if the pokemon is switching out (if it's a enemy wild implies it's going to flee) - * @param status - boolean - */ - setSwitchOutStatus(status: boolean): void { - this.switchOutStatus = status; - } - updateInfo(instant?: boolean): Promise { return this.battleInfo.updateInfo(this, instant); } @@ -3423,8 +3201,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Adds experience to this PlayerPokemon, subject to wave based level caps. - * @param exp The amount of experience to add - * @param ignoreLevelCap Whether to ignore level caps when adding experience (defaults to false) + * @param exp - The amount of experience to add + * @param ignoreLevelCap - Whether to ignore level caps when adding experience; default `false` */ addExp(exp: number, ignoreLevelCap = false) { const maxExpLevel = globalScene.getMaxExpLevel(ignoreLevelCap); @@ -3441,8 +3219,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Compares if `this` and {@linkcode target} are on the same team. - * @param target the {@linkcode Pokemon} to compare against. + * Check whether the specified Pokémon is an opponent + * @param target - The {@linkcode Pokemon} to compare against * @returns `true` if the two pokemon are allies, `false` otherwise */ public isOpponent(target: Pokemon): boolean { @@ -3487,17 +3265,18 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Calculates the stat stage multiplier of the user against an opponent. + * Calculate the stat stage multiplier of the user against an opponent * - * Note that this does not apply to evasion or accuracy + * @remarks + * This does not apply to evasion or accuracy * @see {@linkcode getAccuracyMultiplier} * @param stat - The {@linkcode EffectiveStat} to calculate * @param opponent - The {@linkcode Pokemon} being targeted * @param move - The {@linkcode Move} being used - * @param ignoreOppAbility determines whether the effects of the opponent's abilities (i.e. Unaware) should be ignored (`false` by default) - * @param isCritical determines whether a critical hit has occurred or not (`false` by default) - * @param simulated determines whether effects are applied without altering game state (`true` by default) - * @param ignoreHeldItems determines whether this Pokemon's held items should be ignored during the stat calculation, default `false` + * @param ignoreOppAbility - determines whether the effects of the opponent's abilities (i.e. Unaware) should be ignored; default `false` + * @param isCritical - determines whether a critical hit has occurred or not; default `false` + * @param simulated - determines whether effects are applied without altering game state; default `true` + * @param ignoreHeldItems - determines whether this Pokemon's held items should be ignored during the stat calculation; default `false` * @returns the stat stage multiplier to be used for effective stat calculation */ getStatStageMultiplier( @@ -3608,7 +3387,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { }); const ally = this.getAlly(); - if (!isNullOrUndefined(ally)) { + if (ally != null) { const ignore = this.hasAbilityWithAttr("MoveAbilityBypassAbAttr") || sourceMove.hasFlag(MoveFlags.IGNORE_ABILITIES); applyAbAttrs("AllyStatMultiplierAbAttr", { @@ -3634,15 +3413,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Calculates the base damage of the given move against this Pokemon when attacked by the given source. * Used during damage calculation and for Shell Side Arm's forecasting effect. - * @param source - The attacking {@linkcode Pokemon}. - * @param move - The {@linkcode Move} used in the attack. - * @param moveCategory - The move's {@linkcode MoveCategory} after variable-category effects are applied. - * @param ignoreAbility - If `true`, ignores this Pokemon's defensive ability effects (defaults to `false`). - * @param ignoreSourceAbility - If `true`, ignore's the attacking Pokemon's ability effects (defaults to `false`). - * @param ignoreAllyAbility - If `true`, ignores the ally Pokemon's ability effects (defaults to `false`). - * @param ignoreSourceAllyAbility - If `true`, ignores the attacking Pokemon's ally's ability effects (defaults to `false`). - * @param isCritical - if `true`, calculates effective stats as if the hit were critical (defaults to `false`). - * @param simulated - if `true`, suppresses changes to game state during calculation (defaults to `true`). + * @param __namedParameters.source - Needed for proper typedoc rendering * @returns The move's base damage against this Pokemon when used by the source Pokemon. */ getBaseDamage({ @@ -3760,15 +3531,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Calculates the damage of an attack made by another Pokemon against this Pokemon - * @param source {@linkcode Pokemon} the attacking Pokemon - * @param move The {@linkcode Move} used in the attack - * @param ignoreAbility If `true`, ignores this Pokemon's defensive ability effects - * @param ignoreSourceAbility If `true`, ignores the attacking Pokemon's ability effects - * @param ignoreAllyAbility If `true`, ignores the ally Pokemon's ability effects - * @param ignoreSourceAllyAbility If `true`, ignores the ability effects of the attacking pokemon's ally - * @param isCritical If `true`, calculates damage for a critical hit. - * @param simulated If `true`, suppresses changes to game state during the calculation. - * @param effectiveness If defined, used in place of calculated effectiveness values + * @param __namedParameters.source - Needed for proper typedoc rendering * @returns The {@linkcode DamageCalculationResult} */ getAttackDamage({ @@ -4024,7 +3787,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { const ally = this.getAlly(); /** Additionally apply friend guard damage reduction if ally has it. */ - if (globalScene.currentBattle.double && !isNullOrUndefined(ally) && ally.isActive(true)) { + if (globalScene.currentBattle.double && ally != null && ally.isActive(true)) { applyAbAttrs("AlliedFieldDamageReductionAbAttr", { ...abAttrParams, // Same parameters as before, except we are applying the ally's ability @@ -4097,12 +3860,12 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Called by damageAndUpdate() - * @param damage integer - * @param ignoreSegments boolean, not currently used - * @param preventEndure used to update damage if endure or sturdy - * @param ignoreFaintPhase flag on whether to add FaintPhase if pokemon after applying damage faints - * @returns integer representing damage dealt + * Submethod called by {@linkcode damageAndUpdate} to apply damage to this Pokemon and adjust its HP. + * @param damage - The damage to deal + * @param _ignoreSegments - Whether to ignore boss segments; default `false` + * @param preventEndure - Whether to allow the damage to bypass an Endure/Sturdy effect + * @param ignoreFaintPhase - Whether to ignore adding a FaintPhase if this damage causes a faint + * @returns The actual damage dealt */ damage(damage: number, _ignoreSegments = false, preventEndure = false, ignoreFaintPhase = false): number { if (this.isFainted()) { @@ -4130,15 +3893,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { damage = Math.min(damage, this.hp); this.hp = this.hp - damage; if (this.isFainted() && !ignoreFaintPhase) { - /** - * When adding the FaintPhase, want to toggle future unshiftPhase() and queueMessage() calls - * to appear before the FaintPhase (as FaintPhase will potentially end the encounter and add Phases such as - * GameOverPhase, VictoryPhase, etc.. that will interfere with anything else that happens during this MoveEffectPhase) - * - * Once the MoveEffectPhase is over (and calls it's .end() function, shiftPhase() will reset the PhaseQueueSplice via clearPhaseQueueSplice() ) - */ - globalScene.phaseManager.setPhaseQueueSplice(); - globalScene.phaseManager.unshiftNew("FaintPhase", this.getBattlerIndex(), preventEndure); + globalScene.phaseManager.queueFaintPhase(this.getBattlerIndex(), preventEndure); this.destroySubstitute(); this.lapseTag(BattlerTagType.COMMANDED); } @@ -4148,14 +3903,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Given the damage, adds a new DamagePhase and update HP values, etc. * - * Checks for 'Indirect' HitResults to account for Endure/Reviver Seed applying correctly - * @param damage integer - passed to damage() - * @param result an enum if it's super effective, not very, etc. - * @param isCritical boolean if move is a critical hit - * @param ignoreSegments boolean, passed to damage() and not used currently - * @param preventEndure boolean, ignore endure properties of pokemon, passed to damage() - * @param ignoreFaintPhase boolean to ignore adding a FaintPhase, passsed to damage() - * @returns integer of damage done + * @remarks + * Checks for {@linkcode HitResult.INDIRECT | Indirect} hits to account for Endure/Reviver Seed applying correctly + * @param damage - The damage to inflict on this Pokémon + * @param __namedParameters.source - Needed for proper typedoc rendering + * @returns Amount of damage actually done */ damageAndUpdate( damage: number, @@ -4166,10 +3918,17 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { ignoreFaintPhase = false, source, }: { + /** + * An enum if it's super effective, not very effective, etc; default {@linkcode HitResult.EFFECTIVE} + */ result?: DamageResult; + /** Whether the attack was a critical hit */ isCritical?: boolean; + /** Whether to ignore boss segments */ ignoreSegments?: boolean; + /** Whether to ignore adding a FaintPhase if this damage causes a faint; default `false` */ ignoreFaintPhase?: boolean; + /** The Pokémon inflicting the damage, or undefined if not caused by a Pokémon */ source?: Pokemon; } = {}, ): number { @@ -4186,11 +3945,6 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { damage = 0; } damage = this.damage(damage, ignoreSegments, isIndirectDamage, ignoreFaintPhase); - // Ensure the battle-info bar's HP is updated, though only if the battle info is visible - // TODO: When battle-info UI is refactored, make this only update the HP bar - if (this.battleInfo.visible) { - this.updateInfo(); - } // Damage amount may have changed, but needed to be queued before calling damage function damagePhase.updateAmount(damage); /** @@ -4203,17 +3957,25 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return damage; } - heal(amount: number): number { + /** + * Restore a specific amount of HP to this Pokémon + * @param amount - The amount of HP to restore + * @returns The true amount of HP restored; may be less than `amount` if `amount` would overheal + */ + public heal(amount: number): number { const healAmount = Math.min(amount, this.getMaxHp() - this.hp); this.hp += healAmount; return healAmount; } - isBossImmune(): boolean { + public isBossImmune(): boolean { return this.isBoss(); } - isMax(): boolean { + /** + * @returns Whether this Pokémon is in a Dynamax or Gigantamax form + */ + public isMax(): boolean { const maxForms = [ SpeciesFormKey.GIGANTAMAX, SpeciesFormKey.GIGANTAMAX_RAPID, @@ -4225,7 +3987,10 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { ); } - isMega(): boolean { + /** + * @returns Whether this Pokémon is in a Mega or Primal form + */ + public isMega(): boolean { const megaForms = [ SpeciesFormKey.MEGA, SpeciesFormKey.MEGA_X, @@ -4238,7 +4003,14 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { ); } - canAddTag(tagType: BattlerTagType): boolean { + /** + * Check whether a battler tag can be added to this Pokémon + * + * @param tagType - The tag to check + * @returns - Whether the tag can be added + * @see {@linkcode addTag} + */ + public canAddTag(tagType: BattlerTagType): boolean { if (this.getTag(tagType)) { return false; } @@ -4262,7 +4034,20 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return !cancelled.value; } - addTag(tagType: BattlerTagType, turnCount = 0, sourceMove?: MoveId, sourceId?: number): boolean { + /** + * Add a new {@linkcode BattlerTag} of the specified `tagType` + * + * @remarks + * Also ensures the tag is able to be applied, similar to {@linkcode canAddTag} + * + * @param tagType - The type of tag to add + * @param turnCount - The number of turns the tag should last; default `0` + * @param sourceMove - The id of the move that causing the tag to be added, if caused by a move + * @param sourceId - The {@linkcode Pokemon#id | id} of the pokemon causing the tag to be added, if caused by a Pokémon + * @returns Whether the tag was successfully added + * @see {@linkcode canAddTag} + */ + public addTag(tagType: BattlerTagType, turnCount = 0, sourceMove?: MoveId, sourceId?: number): boolean { const existingTag = this.getTag(tagType); if (existingTag) { existingTag.onOverlap(this); @@ -4271,6 +4056,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { const newTag = getBattlerTag(tagType, turnCount, sourceMove!, sourceId!); // TODO: are the bangs correct? + // TODO: Just call canAddTag() here? Can possibly overload it to accept an actual tag instead of just a type const cancelled = new BooleanHolder(false); applyAbAttrs("BattlerTagImmunityAbAttr", { pokemon: this, tag: newTag, cancelled }); if (cancelled.value) { @@ -4293,31 +4079,46 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return false; } - getTag(tagType: BattlerTagType.GRUDGE): GrudgeTag | undefined; - getTag(tagType: BattlerTagType.SUBSTITUTE): SubstituteTag | undefined; - getTag(tagType: BattlerTagType): BattlerTag | undefined; - getTag(tagType: Constructor): T | undefined; - getTag(tagType: BattlerTagType | Constructor): BattlerTag | undefined { + // TODO: Utilize a type map for these so we can avoid overloads + public getTag(tagType: BattlerTagType.GRUDGE): GrudgeTag | undefined; + public getTag(tagType: BattlerTagType.SUBSTITUTE): SubstituteTag | undefined; + public getTag(tagType: BattlerTagType): BattlerTag | undefined; + public getTag(tagType: Constructor): T | undefined; + public getTag(tagType: BattlerTagType | Constructor): BattlerTag | undefined { return typeof tagType === "function" ? this.summonData.tags.find(t => t instanceof tagType) : this.summonData.tags.find(t => t.tagType === tagType); } - findTag(tagFilter: (tag: BattlerTag) => boolean) { - return this.summonData.tags.find(t => tagFilter(t)); + /** + * Find the first `BattlerTag` matching the specified predicate + * @remarks + * Equivalent to `this.summonData.tags.find(tagFilter)`. + * @param tagFilter - The predicate to match against + * @returns The first matching tag, or `undefined` if none match + */ + public findTag(tagFilter: (tag: BattlerTag) => boolean) { + return this.summonData.tags.find(tagFilter); } - findTags(tagFilter: (tag: BattlerTag) => boolean): BattlerTag[] { - return this.summonData.tags.filter(t => tagFilter(t)); + /** + * Return the list of `BattlerTag`s that satisfy the given predicate + * @remarks + * Equivalent to `this.summonData.tags.filter(tagFilter)`. + * @param tagFilter - The predicate to match against + * @returns The filtered list of tags + */ + public findTags(tagFilter: (tag: BattlerTag) => boolean): BattlerTag[] { + return this.summonData.tags.filter(tagFilter); } /** * Tick down the first {@linkcode BattlerTag} found matching the given {@linkcode BattlerTagType}, * removing it if its duration goes below 0. - * @param tagType the {@linkcode BattlerTagType} to check against - * @returns `true` if the tag was present + * @param tagType - The `BattlerTagType` to lapse + * @returns Whether the tag was present */ - lapseTag(tagType: BattlerTagType): boolean { + public lapseTag(tagType: BattlerTagType): boolean { const tags = this.summonData.tags; const tag = tags.find(t => t.tagType === tagType); if (!tag) { @@ -4332,11 +4133,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Tick down all {@linkcode BattlerTags} matching the given {@linkcode BattlerTagLapseType}, - * removing any whose durations fall below 0. - * @param tagType the {@linkcode BattlerTagLapseType} to tick down + * Tick down all {@linkcode BattlerTags} that lapse on the provided + * `lapseType`, removing any whose durations fall below 0. + * @param lapseType - The type of lapse to process */ - lapseTags(lapseType: BattlerTagLapseType): void { + public lapseTags(lapseType: BattlerTagLapseType): void { const tags = this.summonData.tags; tags .filter( @@ -4351,10 +4152,14 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Remove the first tag matching the given {@linkcode BattlerTagType}. - * @param tagType the {@linkcode BattlerTagType} to search for and remove + * Remove the first tag matching `tagType` and invoke its + * {@linkcode BattlerTag#onRemove | onRemove} method. + * @remarks + * Only removes the first matching tag, if multiple are present; to remove all + * matching tags, use {@linkcode findAndRemoveTags} instead. + * @param tagType - The tag type to search for and remove */ - removeTag(tagType: BattlerTagType): void { + public removeTag(tagType: BattlerTagType): void { const tags = this.summonData.tags; const tag = tags.find(t => t.tagType === tagType); if (tag) { @@ -4364,10 +4169,14 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Find and remove all {@linkcode BattlerTag}s matching the given function. - * @param tagFilter a function dictating which tags to remove + * Find and remove all {@linkcode BattlerTag}s matching the given function and + * invoke their {@linkcode BattlerTag#onRemove | onRemove} methods. + * @remarks + * Removes all matching tags; to remove only the first matching tag, use + * {@linkcode removeTag} instead. + * @param tagFilter - A function dictating which tags to remove */ - findAndRemoveTags(tagFilter: (tag: BattlerTag) => boolean): void { + public findAndRemoveTags(tagFilter: (tag: BattlerTag) => boolean): void { const tags = this.summonData.tags; const tagsToRemove = tags.filter(t => tagFilter(t)); for (const tag of tagsToRemove) { @@ -4377,11 +4186,22 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } } - removeTagsBySourceId(sourceId: number): void { + /** + * Remove all tags that were applied by a Pokémon with the given `sourceId`, + * invoking their {@linkcode BattlerTag#onRemove | onRemove} methods. + * @param sourceId - Tags with this {@linkcode Pokemon#id | id} as their {@linkcode BattlerTag#sourceId | sourceId} will be removed + * @see {@linkcode findAndRemoveTags} + */ + public removeTagsBySourceId(sourceId: number): void { this.findAndRemoveTags(t => t.isSourceLinked() && t.sourceId === sourceId); } - transferTagsBySourceId(sourceId: number, newSourceId: number): void { + /** + * Change the `sourceId` of all tags on this Pokémon with the given `sourceId` to `newSourceId`. + * @param sourceId - The {@linkcode Pokemon#id | id} of the pokemon whose tags are to be transferred + * @param newSourceId - The {@linkcode Pokemon#id | id} of the pokemon to which the tags are being transferred + */ + public transferTagsBySourceId(sourceId: number, newSourceId: number): void { this.summonData.tags.forEach(t => { if (t.sourceId === sourceId) { t.sourceId = newSourceId; @@ -4390,11 +4210,14 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Transferring stat changes and Tags - * @param source {@linkcode Pokemon} the pokemon whose stats/Tags are to be passed on from, ie: the Pokemon using Baton Pass + * Transfer stat changes and Tags from another Pokémon + * + * @remarks + * Used to implement Baton Pass and switching via the Baton item. + * + * @param source - The pokemon whose stats/Tags are to be passed on from, ie: the Pokemon using Baton Pass */ - transferSummon(source: Pokemon): void { - // Copy all stat stages + public transferSummon(source: Pokemon): void { for (const s of BATTLE_STATS) { const sourceStage = source.getStatStage(s); if (this.isPlayer() && sourceStage === 6) { @@ -4424,9 +4247,9 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Gets whether the given move is currently disabled for this Pokemon. + * Get whether the given move is currently disabled for this Pokémon * - * @param moveId - The {@linkcode MoveId} ID of the move to check + * @param moveId - The ID of the move to check * @returns `true` if the move is disabled for this Pokemon, otherwise `false` * * @see {@linkcode MoveRestrictionBattlerTag} @@ -4436,9 +4259,9 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Gets whether the given move is currently disabled for the user based on the player's target selection + * Get whether the given move is currently disabled for the user based on the player's target selection * - * @param moveId - The {@linkcode MoveId} ID of the move to check + * @param moveId - The ID of the move to check * @param user - The move user * @param target - The target of the move * @@ -4456,11 +4279,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Gets the {@link MoveRestrictionBattlerTag} that is restricting a move, if it exists. + * Get the {@link MoveRestrictionBattlerTag} that is restricting a move, if it exists. * - * @param moveId - {@linkcode MoveId} ID of the move to check - * @param user - {@linkcode Pokemon} the move user, optional and used when the target is a factor in the move's restricted status - * @param target - {@linkcode Pokemon} the target of the move, optional and used when the target is a factor in the move's restricted status + * @param moveId - The ID of the move to check + * @param user - The move user, optional and used when the target is a factor in the move's restricted status + * @param target - The target of the move; optional, and used when the target is a factor in the move's restricted status * @returns The first tag on this Pokemon that restricts the move, or `null` if the move is not restricted. */ getRestrictingTag(moveId: MoveId, user?: Pokemon, target?: Pokemon): MoveRestrictionBattlerTag | null { @@ -4485,6 +4308,12 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return this.summonData.moveHistory; } + /** + * Add a new entry to this Pokemon's move history + * @remarks + * Does nothing if this Pokemon is not currently on the field. + * @param turnMove - The move to add to the history + */ public pushMoveHistory(turnMove: TurnMove): void { if (!this.isOnField()) { return; @@ -4502,7 +4331,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @returns An array of {@linkcode TurnMove}, as specified above. */ // TODO: Update documentation in dancer PR to mention "getLastNonVirtualMove" - getLastXMoves(moveCount = 1): TurnMove[] { + public getLastXMoves(moveCount = 1): TurnMove[] { const moveHistory = this.getMoveHistory(); if (moveCount > 0) { return moveHistory.slice(Math.max(moveHistory.length - moveCount, 0)).reverse(); @@ -4520,7 +4349,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @returns The last move this Pokemon has used satisfying the aforementioned conditions, * or `undefined` if no applicable moves have been used since switching in. */ - getLastNonVirtualMove(ignoreStruggle = false, ignoreFollowUp = true): TurnMove | undefined { + public getLastNonVirtualMove(ignoreStruggle = false, ignoreFollowUp = true): TurnMove | undefined { return this.getLastXMoves(-1).find( m => m.move !== MoveId.NONE @@ -4533,7 +4362,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * Return this Pokemon's move queue, consisting of all the moves it is slated to perform. * @returns An array of {@linkcode TurnMove}, as described above */ - getMoveQueue(): TurnMove[] { + public getMoveQueue(): TurnMove[] { return this.summonData.moveQueue; } @@ -4541,11 +4370,17 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * Add a new entry to the end of this Pokemon's move queue. * @param queuedMove - A {@linkcode TurnMove} to push to this Pokemon's queue. */ - pushMoveQueue(queuedMove: TurnMove): void { + public pushMoveQueue(queuedMove: TurnMove): void { this.summonData.moveQueue.push(queuedMove); } - changeForm(formChange: SpeciesFormChange): Promise { + /** + * Change this Pokémon's form to the specified form, loading the required + * assets and updating its stats and info display. + * @param formChange - The form to change to + * @returns A Promise that resolves once the form change has completed. + */ + public async changeForm(formChange: SpeciesFormChange): Promise { return new Promise(resolve => { this.formIndex = Math.max( this.species.forms.findIndex(f => f.formKey === formChange.formKey), @@ -4567,7 +4402,12 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { }); } - cry(soundConfig?: Phaser.Types.Sound.SoundConfig, sceneOverride?: BattleScene): AnySound | null { + /** + * Play this Pokémon's cry sound + * @param soundConfig - Optional sound configuration to apply to the cry + * @param sceneOverride - Optional scene to use instead of the global scene + */ + public cry(soundConfig?: Phaser.Types.Sound.SoundConfig, sceneOverride?: BattleScene): AnySound | null { const scene = sceneOverride ?? globalScene; // TODO: is `sceneOverride` needed? const cry = this.getSpeciesForm(undefined, true).cry(soundConfig); if (!cry) { @@ -4606,8 +4446,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return cry; } - // biome-ignore lint: there are a ton of issues.. - faintCry(callback: Function): void { + /** + * Play this Pokémon's faint cry, pausing its animation until the cry is finished. + * @param callback - A function to be called once the cry has finished playing + */ + public faintCry(callback: () => any): void { if (this.fusionSpecies && this.getSpeciesForm() !== this.getFusionSpeciesForm()) { this.fusionFaintCry(callback); return; @@ -4679,8 +4522,12 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { }); } - // biome-ignore lint/complexity/noBannedTypes: Consider refactoring to change type of Function - private fusionFaintCry(callback: Function): void { + /** + * Play this Pokémon's fusion faint cry, which is a mixture of the faint cries + * for both of its species + * @param callback - A function to be called once the cry has finished playing + */ + private fusionFaintCry(callback: () => any): void { const key = this.species.getCryKey(this.formIndex); let i = 0; let rate = 0.85; @@ -4790,7 +4637,12 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { }); } - isOppositeGender(pokemon: Pokemon): boolean { + /** + * Check the specified pokemon is considered to be the opposite gender as this pokemon + * @param pokemon - The Pokémon to compare against + * @returns Whether the pokemon are considered to be opposite genders + */ + public isOppositeGender(pokemon: Pokemon): boolean { return ( this.gender !== Gender.GENDERLESS && pokemon.gender === (this.gender === Gender.MALE ? Gender.FEMALE : Gender.MALE) @@ -4800,12 +4652,12 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Display an immunity message for a failed status application. * @param quiet - Whether to suppress message and return early - * @param reason - The reason for the status application failure - + * @param reason - The reason for the status application failure; * can be "overlap" (already has same status), "other" (generic fail message) * or a {@linkcode TerrainType} for terrain-based blockages. * Default `"other"` */ - queueStatusImmuneMessage( + public queueStatusImmuneMessage( quiet: boolean, reason: "overlap" | "other" | Exclude = "other", ): void { @@ -5032,7 +4884,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * * ⚠️ This method does **not** check for feasibility; that is the responsibility of the caller. */ - doSetStatus(effect: Exclude): void; + public doSetStatus(effect: Exclude): void; /** * Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect. * @param effect - {@linkcode StatusEffect.SLEEP} @@ -5042,7 +4894,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * * ⚠️ This method does **not** check for feasibility; that is the responsibility of the caller. */ - doSetStatus(effect: StatusEffect.SLEEP, sleepTurnsRemaining?: number): void; + public doSetStatus(effect: StatusEffect.SLEEP, sleepTurnsRemaining?: number): void; /** * Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect. * @param effect - The {@linkcode StatusEffect} to set @@ -5053,7 +4905,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * * ⚠️ This method does **not** check for feasibility; that is the responsibility of the caller. */ - doSetStatus(effect: StatusEffect, sleepTurnsRemaining?: number): void; + public doSetStatus(effect: StatusEffect, sleepTurnsRemaining?: number): void; /** * Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect. * @param effect - The {@linkcode StatusEffect} to set @@ -5065,7 +4917,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * ⚠️ This method does **not** check for feasibility; that is the responsibility of the caller. * @todo Make this and all related fields private and change tests to use a field-based helper or similar */ - doSetStatus( + public doSetStatus( effect: StatusEffect, sleepTurnsRemaining = effect !== StatusEffect.SLEEP ? 0 : this.randBattleSeedIntRange(2, 4), ): void { @@ -5114,11 +4966,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Resets the status of a pokemon. - * @param revive Whether revive should be cured; defaults to true. - * @param confusion Whether resetStatus should include confusion or not; defaults to false. - * @param reloadAssets Whether to reload the assets or not; defaults to false. - * @param asPhase Whether to reset the status in a phase or immediately + * Reset this Pokémon's status + * @param revive - Whether revive should be cured; default `true` + * @param confusion - Whether to also cure confusion; default `false` + * @param reloadAssets - Whether to reload the assets or not; default `false` + * @param asPhase - Whether to reset the status in a phase or immediately; default `true` */ resetStatus(revive = true, confusion = false, reloadAssets = false, asPhase = true): void { const lastStatus = this.status?.effect; @@ -5134,9 +4986,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Performs the action of clearing a Pokemon's status - * + * Perform the action of clearing a Pokemon's status + * @remarks * This is a helper to {@linkcode resetStatus}, which should be called directly instead of this method + * @param confusion - Whether to also clear this Pokémon's confusion + * @param reloadAssets - Whether to reload this pokemon's assets */ public clearStatus(confusion: boolean, reloadAssets: boolean) { const lastStatus = this.status?.effect; @@ -5155,11 +5009,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Checks if this Pokemon is protected by Safeguard - * @param attacker the {@linkcode Pokemon} inflicting status on this Pokemon - * @returns `true` if this Pokemon is protected by Safeguard; `false` otherwise. + * Check if this Pokémon is protected by Safeguard + * @param attacker - The Pokémon responsible for the interaction that needs to check against Safeguard + * @returns Whether this Pokémon is protected by Safeguard */ - isSafeguarded(attacker: Pokemon): boolean { + public isSafeguarded(attacker: Pokemon): boolean { const defendingSide = this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; if (globalScene.arena.getTagOnSide(ArenaTagType.SAFEGUARD, defendingSide)) { const bypassed = new BooleanHolder(false); @@ -5172,11 +5026,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Performs miscellaneous setup for when the Pokemon is summoned, like generating the substitute sprite + * Perform miscellaneous setup for when the Pokemon is summoned, like generating the substitute sprite * @param resetSummonData - Whether to additionally reset the Pokemon's summon data (default: `false`) */ public fieldSetup(resetSummonData?: boolean): void { - this.setSwitchOutStatus(false); + this.switchOutStatus = false; if (globalScene) { globalScene.triggerPokemonFormChange(this, SpeciesFormChangePostMoveTrigger, true); } @@ -5204,7 +5058,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * Reset this Pokemon's {@linkcode PokemonSummonData | SummonData} and {@linkcode PokemonTempSummonData | TempSummonData} * in preparation for switching pokemon, as well as removing any relevant on-switch tags. */ - resetSummonData(): void { + public resetSummonData(): void { const illusion: IllusionData | null = this.summonData.illusion; if (this.summonData.speciesForm) { this.summonData.speciesForm = null; @@ -5221,17 +5075,21 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Reset a {@linkcode Pokemon}'s per-battle {@linkcode PokemonBattleData | battleData}, + * Reset this Pokémon's per-battle {@linkcode PokemonBattleData | battleData} * as well as any transient {@linkcode PokemonWaveData | waveData} for the current wave. + * + * @remarks * Should be called once per arena transition (new biome/trainer battle/Mystery Encounter). */ - resetBattleAndWaveData(): void { + public resetBattleAndWaveData(): void { this.battleData = new PokemonBattleData(); this.resetWaveData(); } /** - * Reset a {@linkcode Pokemon}'s {@linkcode PokemonWaveData | waveData}. + * Reset this Pokémon's {@linkcode PokemonWaveData | waveData} + * + * @remarks * Should be called upon starting a new wave in addition to whenever an arena transition occurs. * @see {@linkcode resetBattleAndWaveData} */ @@ -5240,6 +5098,16 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { this.tempSummonData.waveTurnCount = 1; } + /** + * Reset this Pokémon's Terastallization state + * + * @remarks + * Responsible for all of the cleanup required when a pokemon goes from being + * terastallized to no longer terastallized: + * - Resetting stellar type boosts + * - Updating the Pokémon's terastallization-dependent form + * - Adjusting the sprite pipeline to remove the Tera effect + */ resetTera(): void { const wasTerastallized = this.isTerastallized; this.isTerastallized = false; @@ -5250,6 +5118,9 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } } + /** + * Clear this Pokémon's transient turn data + */ resetTurnData(): void { this.turnData = new PokemonTurnData(); } @@ -5259,6 +5130,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { return (this.getSpeciesForm().getBaseExp() * this.level) / 5 + 1; } + //#region Sprite and Animation Methods setFrameRate(frameRate: number) { globalScene.anims.get(this.getBattleSpriteKey()).frameRate = frameRate; try { @@ -5335,6 +5207,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } } + /** Play the shiny sparkle animation and effects, if applicable */ sparkle(): void { if (this.shinySparkle) { doShinySparkleAnim(this.shinySparkle, this.variant); @@ -5666,15 +5539,19 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { fusionCanvas.remove(); } + //#endregion Sprite and Animation Methods + /** - * Generates a random number using the current battle's seed, or the global seed if `globalScene.currentBattle` is falsy + * Generate a random number using the current battle's seed, or the global seed if `globalScene.currentBattle` is falsy + * + * @remarks * This calls either {@linkcode BattleScene.randBattleSeedInt}({@linkcode range}, {@linkcode min}) in `src/battle-scene.ts` * which calls {@linkcode Battle.randSeedInt}({@linkcode range}, {@linkcode min}) in `src/battle.ts` * which calls {@linkcode randSeedInt randSeedInt}({@linkcode range}, {@linkcode min}) in `src/utils.ts`, * or it directly calls {@linkcode randSeedInt randSeedInt}({@linkcode range}, {@linkcode min}) in `src/utils.ts` if there is no current battle * - * @param range How large of a range of random numbers to choose from. If {@linkcode range} <= 1, returns {@linkcode min} - * @param min The minimum integer to pick, default `0` + * @param range - How large of a range of random numbers to choose from. If {@linkcode range} <= 1, returns {@linkcode min} + * @param min - The minimum integer to pick; default `0` * @returns A random integer between {@linkcode min} and ({@linkcode min} + {@linkcode range} - 1) */ randBattleSeedInt(range: number, min = 0): number { @@ -5682,10 +5559,10 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Generates a random number using the current battle's seed, or the global seed if `globalScene.currentBattle` is falsy - * @param min The minimum integer to generate - * @param max The maximum integer to generate - * @returns a random integer between {@linkcode min} and {@linkcode max} inclusive + * Generate a random number using the current battle's seed, or the global seed if `globalScene.currentBattle` is falsy + * @param min - The minimum integer to generate + * @param max - The maximum integer to generate + * @returns A random integer between {@linkcode min} and {@linkcode max} (inclusive) */ randBattleSeedIntRange(min: number, max: number): number { return globalScene.currentBattle ? globalScene.randBattleSeedInt(max - min + 1, min) : randSeedIntRange(min, max); @@ -5693,9 +5570,9 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Causes a Pokemon to leave the field (such as in preparation for a switch out/escape). - * @param clearEffects Indicates if effects should be cleared (true) or passed - * to the next pokemon, such as during a baton pass (false) - * @param hideInfo Indicates if this should also play the animation to hide the Pokemon's + * @param clearEffects - Indicates if effects should be cleared (true) or passed + * to the next pokemon, such as during a baton pass (false) + * @param hideInfo - Indicates if this should also play the animation to hide the Pokemon's * info container. */ leaveField(clearEffects = true, hideInfo = true, destroy = false) { @@ -5715,25 +5592,33 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } // Trigger abilities that activate upon leaving the field applyAbAttrs("PreLeaveFieldAbAttr", { pokemon: this }); - this.setSwitchOutStatus(true); + this.switchOutStatus = true; globalScene.triggerPokemonFormChange(this, SpeciesFormChangeActiveTrigger, true); globalScene.field.remove(this, destroy); } + /** + * @inheritdoc {@linkcode Phaser.GameObjects.Container#destroy} + * + * ### Custom Behavior + * In addition to the base `destroy` behavior, this also destroys the Pokemon's + * {@linkcode battleInfo} and substitute sprite (as applicable). + */ destroy(): void { this.battleInfo?.destroy(); this.destroySubstitute(); super.destroy(); } + // TODO: Turn this into a getter getBattleInfo(): BattleInfo { return this.battleInfo; } /** - * Checks whether or not the Pokemon's root form has the same ability - * @param abilityIndex the given ability index we are checking - * @returns true if the abilities are the same + * Check whether or not this Pokémon's root form has the same ability + * @param abilityIndex - The ability index to check + * @returns Whether the Pokemon's root form has the same ability */ hasSameAbilityInRootForm(abilityIndex: number): boolean { const currentAbilityIndex = this.abilityIndex; @@ -5742,9 +5627,9 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Helper function to check if the player already owns the starter data of the Pokemon's + * Helper function to check if the player already owns the starter data of this Pokémon's * current ability - * @param ownedAbilityAttrs the owned abilityAttr of this Pokemon's root form + * @param ownedAbilityAttrs - The owned abilityAttr of this Pokemon's root form * @returns true if the player already has it, false otherwise */ checkIfPlayerHasAbilityOfStarter(ownedAbilityAttrs: number): boolean { @@ -5785,8 +5670,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** * Record a berry being eaten for ability and move triggers. * Only tracks things that proc _every_ time a berry is eaten. - * @param berryType The type of berry being eaten. - * @param updateHarvest Whether to track the berry for harvest; default `true`. + * @param berryType - The type of berry being eaten. + * @param updateHarvest - Whether to track the berry for harvest; default `true`. */ public recordEatenBerry(berryType: BerryType, updateHarvest = true) { this.battleData.hasEatenBerry = true; @@ -5797,6 +5682,14 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { this.turnData.berriesEaten.push(berryType); } + /** + * Get the number of persistent treasure items this Pokemon has + * @remarks + * Persistent treasure items are defined as held items that give money + * after battle, such as the Lucky Egg or the Amulet Coin. + * Used exclusively for Gimmighoul's evolution condition + * @returns The number of persistent treasure items this Pokémon has + */ getPersistentTreasureCount(): number { return ( this.getHeldItems().filter(m => m.is("DamageMoneyRewardModifier")).length @@ -5927,10 +5820,10 @@ export class PlayerPokemon extends Pokemon { } /** - * Causes this mon to leave the field (via {@linkcode leaveField}) and then - * opens the party switcher UI to switch a new mon in - * @param switchType the {@linkcode SwitchType} for this switch-out. If this is - * `BATON_PASS` or `SHED_TAIL`, this Pokemon's effects are not cleared upon leaving + * Cause this Pokémon to leave the field (via {@linkcode leaveField}) and then + * open the party switcher UI to switch in a new Pokémon + * @param switchType - The type of this switch-out. If this is + * `BATON_PASS` or `SHED_TAIL`, this Pokémon's effects are not cleared upon leaving * the field. */ switchOut(switchType: SwitchType = SwitchType.SWITCH): Promise { @@ -5943,8 +5836,7 @@ export class PlayerPokemon extends Pokemon { this.getFieldIndex(), (slotIndex: number, _option: PartyOption) => { if (slotIndex >= globalScene.currentBattle.getBattlerCount() && slotIndex < 6) { - globalScene.phaseManager.prependNewToPhase( - "MoveEndPhase", + globalScene.phaseManager.queueDeferred( "SwitchSummonPhase", switchType, this.getFieldIndex(), @@ -6264,8 +6156,8 @@ export class PlayerPokemon extends Pokemon { } /** - * Returns a Promise to fuse two PlayerPokemon together - * @param pokemon The PlayerPokemon to fuse to this one + * Fuse another PlayerPokemon into this one + * @param pokemon - The PlayerPokemon to fuse to this one */ fuse(pokemon: PlayerPokemon): void { this.fusionSpecies = pokemon.species; @@ -6403,13 +6295,13 @@ export class EnemyPokemon extends Pokemon { if ( speciesId in Overrides.ENEMY_FORM_OVERRIDES - && !isNullOrUndefined(Overrides.ENEMY_FORM_OVERRIDES[speciesId]) + && Overrides.ENEMY_FORM_OVERRIDES[speciesId] != null && this.species.forms[Overrides.ENEMY_FORM_OVERRIDES[speciesId]] ) { this.formIndex = Overrides.ENEMY_FORM_OVERRIDES[speciesId]; } else if (globalScene.gameMode.isDaily && globalScene.gameMode.isWaveFinal(globalScene.currentBattle.waveIndex)) { const eventBoss = getDailyEventSeedBoss(globalScene.seed); - if (!isNullOrUndefined(eventBoss)) { + if (eventBoss != null) { this.formIndex = eventBoss.formIndex; } } @@ -6756,7 +6648,7 @@ export class EnemyPokemon extends Pokemon { /** * Determines the Pokemon the given move would target if used by this Pokemon - * @param moveId {@linkcode MoveId} The move to be used + * @param moveId - The move to be used * @returns The indexes of the Pokemon the given move would target */ getNextTargets(moveId: MoveId): BattlerIndex[] { @@ -6869,7 +6761,11 @@ export class EnemyPokemon extends Pokemon { return 0; } - damage(damage: number, ignoreSegments = false, preventEndure = false, ignoreFaintPhase = false): number { + /** + * @inheritdoc + * @param ignoreSegments - Whether to ignore boss segments when applying damage + */ + public damage(damage: number, ignoreSegments = false, preventEndure = false, ignoreFaintPhase = false): number { if (this.isFainted()) { return 0; } @@ -6925,7 +6821,7 @@ export class EnemyPokemon extends Pokemon { return ret; } - canBypassBossSegments(segmentCount = 1): boolean { + private canBypassBossSegments(segmentCount = 1): boolean { if ( globalScene.currentBattle.battleSpec === BattleSpec.FINAL_BOSS && !this.formIndex @@ -6939,18 +6835,28 @@ export class EnemyPokemon extends Pokemon { /** * Go through a boss' health segments and give stats boosts for each newly cleared segment + * + * @remarks * The base boost is 1 to a random stat that's not already maxed out per broken shield * For Pokemon with 3 health segments or more, breaking the last shield gives +2 instead * For Pokemon with 5 health segments or more, breaking the last two shields give +2 each - * @param segmentIndex index of the segment to get down to (0 = no shield left, 1 = 1 shield left, etc.) + * @param segmentIndex - index of the segment to get down to (0 = no shield left, 1 = 1 shield left, etc.) */ handleBossSegmentCleared(segmentIndex: number): void { + let doStatBoost = !this.hasTrainer(); + // TODO: Rewrite this bespoke logic to improve clarity while (this.bossSegmentIndex > 0 && segmentIndex - 1 < this.bossSegmentIndex) { + this.bossSegmentIndex--; + + // Continue, _not_ break here, to ensure that each segment is still broken + if (!doStatBoost) { + continue; + } + let boostedStat: EffectiveStat | undefined; // Filter out already maxed out stat stages and weigh the rest based on existing stats const leftoverStats = EFFECTIVE_STATS.filter((s: EffectiveStat) => this.getStatStage(s) < 6); const statWeights = leftoverStats.map((s: EffectiveStat) => this.getStat(s, false)); - let boostedStat: EffectiveStat | undefined; const statThresholds: number[] = []; let totalWeight = 0; @@ -6969,18 +6875,18 @@ export class EnemyPokemon extends Pokemon { } if (boostedStat === undefined) { - this.bossSegmentIndex--; - return; + doStatBoost = false; + continue; } let stages = 1; // increase the boost if the boss has at least 3 segments and we passed last shield - if (this.bossSegments >= 3 && this.bossSegmentIndex === 1) { + if (this.bossSegments >= 3 && this.bossSegmentIndex === 0) { stages++; } // increase the boost if the boss has at least 5 segments and we passed the second to last shield - if (this.bossSegments >= 5 && this.bossSegmentIndex === 2) { + if (this.bossSegments >= 5 && this.bossSegmentIndex === 1) { stages++; } @@ -6993,26 +6899,25 @@ export class EnemyPokemon extends Pokemon { true, true, ); - this.bossSegmentIndex--; } } - getFieldIndex(): number { + public getFieldIndex(): number { return globalScene.getEnemyField().indexOf(this); } - getBattlerIndex(): BattlerIndex { + public getBattlerIndex(): BattlerIndex { return BattlerIndex.ENEMY + this.getFieldIndex(); } /** * Add a new pokemon to the player's party (at `slotIndex` if set). * The new pokemon's visibility will be set to `false`. - * @param pokeballType the type of pokeball the pokemon was caught with - * @param slotIndex an optional index to place the pokemon in the party - * @returns the pokemon that was added or null if the pokemon could not be added + * @param pokeballType - The type of pokeball the pokemon was caught with + * @param slotIndex - An optional index to place the pokemon in the party + * @returns The pokemon that was added or null if the pokemon could not be added */ - addToParty(pokeballType: PokeballType, slotIndex = -1) { + public addToParty(pokeballType: PokeballType, slotIndex = -1) { const party = globalScene.getPlayerParty(); let ret: PlayerPokemon | null = null; @@ -7055,11 +6960,11 @@ export class EnemyPokemon extends Pokemon { * Show or hide the type effectiveness multiplier window * Passing undefined will hide the window */ - updateEffectiveness(effectiveness?: string) { + public updateEffectiveness(effectiveness?: string) { this.battleInfo.updateEffectiveness(effectiveness); } - toggleFlyout(visible: boolean): void { + public toggleFlyout(visible: boolean): void { this.battleInfo.toggleFlyout(visible); } } diff --git a/src/game-mode.ts b/src/game-mode.ts index 9ea3adf59d3..e543e3c42ca 100644 --- a/src/game-mode.ts +++ b/src/game-mode.ts @@ -14,7 +14,7 @@ import { SpeciesId } from "#enums/species-id"; import type { Arena } from "#field/arena"; import { classicFixedBattles, type FixedBattleConfigs } from "#trainers/fixed-battle-configs"; import { applyChallenges } from "#utils/challenge-utils"; -import { BooleanHolder, isNullOrUndefined, randSeedInt, randSeedItem } from "#utils/common"; +import { BooleanHolder, randSeedInt, randSeedItem } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import i18next from "i18next"; @@ -146,7 +146,7 @@ export class GameMode implements GameModeConfig { * - Town */ getStartingBiome(): BiomeId { - if (!isNullOrUndefined(Overrides.STARTING_BIOME_OVERRIDE)) { + if (Overrides.STARTING_BIOME_OVERRIDE != null) { return Overrides.STARTING_BIOME_OVERRIDE; } @@ -234,7 +234,7 @@ export class GameMode implements GameModeConfig { getOverrideSpecies(waveIndex: number): PokemonSpecies | null { if (this.isDaily && this.isWaveFinal(waveIndex)) { const eventBoss = getDailyEventSeedBoss(globalScene.seed); - if (!isNullOrUndefined(eventBoss)) { + if (eventBoss != null) { // Cannot set form index here, it will be overriden when adding it as enemy pokemon. return getPokemonSpecies(eventBoss.speciesId); } diff --git a/src/modifier/init-modifier-pools.ts b/src/modifier/init-modifier-pools.ts index ba12920407d..e6ec69eac7f 100644 --- a/src/modifier/init-modifier-pools.ts +++ b/src/modifier/init-modifier-pools.ts @@ -31,7 +31,6 @@ import { } from "#modifiers/modifier-pools"; import { WeightedModifierType } from "#modifiers/modifier-type"; import type { WeightedModifierTypeWeightFunc } from "#types/modifier-types"; -import { isNullOrUndefined } from "#utils/common"; /** * Initialize the wild modifier pool @@ -409,7 +408,7 @@ function initUltraModifierPool() { if (!isHoldingOrb) { const moveset = p .getMoveset(true) - .filter(m => !isNullOrUndefined(m)) + .filter(m => m != null) .map(m => m.moveId); const canSetStatus = p.canSetStatus(StatusEffect.TOXIC, true, true, null, true); @@ -455,7 +454,7 @@ function initUltraModifierPool() { if (!isHoldingOrb) { const moveset = p .getMoveset(true) - .filter(m => !isNullOrUndefined(m)) + .filter(m => m != null) .map(m => m.moveId); const canSetStatus = p.canSetStatus(StatusEffect.BURN, true, true, null, true); diff --git a/src/modifier/modifier-type.ts b/src/modifier/modifier-type.ts index 579fb75ce3b..d67011bc145 100644 --- a/src/modifier/modifier-type.ts +++ b/src/modifier/modifier-type.ts @@ -119,15 +119,7 @@ import type { PokemonMoveSelectFilter, PokemonSelectFilter } from "#ui/party-ui- import { PartyUiHandler } from "#ui/party-ui-handler"; import { getModifierTierTextTint } from "#ui/text"; import { applyChallenges } from "#utils/challenge-utils"; -import { - BooleanHolder, - formatMoney, - isNullOrUndefined, - NumberHolder, - padInt, - randSeedInt, - randSeedItem, -} from "#utils/common"; +import { BooleanHolder, formatMoney, NumberHolder, padInt, randSeedInt, randSeedItem } from "#utils/common"; import { getEnumKeys, getEnumValues } from "#utils/enums"; import { getModifierPoolForType, getModifierType } from "#utils/modifier-utils"; import { toCamelCase } from "#utils/strings"; @@ -263,7 +255,7 @@ export class ModifierType { this.tier = modifier.modifierType.tier; return this; } - if (isNullOrUndefined(defaultTier)) { + if (defaultTier == null) { // If weight is 0, keep track of the first tier where the item was found defaultTier = modifier.modifierType.tier; } @@ -2920,7 +2912,7 @@ export function getPartyLuckValue(party: Pokemon[]): number { globalScene.executeWithSeedOffset( () => { const eventLuck = getDailyEventSeedLuck(globalScene.seed); - if (!isNullOrUndefined(eventLuck)) { + if (eventLuck != null) { DailyLuck.value = eventLuck; return; } diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index 1f470e592c2..19ddc77d436 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -13,7 +13,6 @@ import { getStatusEffectHealText } from "#data/status-effect"; import { BattlerTagType } from "#enums/battler-tag-type"; import { BerryType } from "#enums/berry-type"; import { Color, ShadowColor } from "#enums/color"; -import { Command } from "#enums/command"; import type { FormChangeItem } from "#enums/form-change-item"; import { LearnMoveType } from "#enums/learn-move-type"; import type { MoveId } from "#enums/move-id"; @@ -42,7 +41,7 @@ import type { import type { VoucherType } from "#system/voucher"; import type { ModifierInstanceMap, ModifierString } from "#types/modifier-types"; import { addTextObject } from "#ui/text"; -import { BooleanHolder, hslToHex, isNullOrUndefined, NumberHolder, randSeedFloat, toDmgValue } from "#utils/common"; +import { BooleanHolder, hslToHex, NumberHolder, randSeedFloat, toDmgValue } from "#utils/common"; import { getModifierType } from "#utils/modifier-utils"; import i18next from "i18next"; @@ -728,7 +727,7 @@ export abstract class PokemonHeldItemModifier extends PersistentModifier { } getPokemon(): Pokemon | undefined { - return globalScene.getPokemonById(this.pokemonId) ?? undefined; + return globalScene.getPokemonById(this.pokemonId); } getScoreMultiplier(): number { @@ -1542,30 +1541,16 @@ export class BypassSpeedChanceModifier extends PokemonHeldItemModifier { return new BypassSpeedChanceModifier(this.type, this.pokemonId, this.stackCount); } - /** - * Checks if {@linkcode BypassSpeedChanceModifier} should be applied - * @param pokemon the {@linkcode Pokemon} that holds the item - * @param doBypassSpeed {@linkcode BooleanHolder} that is `true` if speed should be bypassed - * @returns `true` if {@linkcode BypassSpeedChanceModifier} should be applied - */ - override shouldApply(pokemon?: Pokemon, doBypassSpeed?: BooleanHolder): boolean { - return super.shouldApply(pokemon, doBypassSpeed) && !!doBypassSpeed; - } - /** * Applies {@linkcode BypassSpeedChanceModifier} * @param pokemon the {@linkcode Pokemon} that holds the item - * @param doBypassSpeed {@linkcode BooleanHolder} that is `true` if speed should be bypassed * @returns `true` if {@linkcode BypassSpeedChanceModifier} has been applied */ - override apply(pokemon: Pokemon, doBypassSpeed: BooleanHolder): boolean { - if (!doBypassSpeed.value && pokemon.randBattleSeedInt(10) < this.getStackCount()) { - doBypassSpeed.value = true; - const isCommandFight = - globalScene.currentBattle.turnCommands[pokemon.getBattlerIndex()]?.command === Command.FIGHT; + override apply(pokemon: Pokemon): boolean { + if (pokemon.randBattleSeedInt(10) < this.getStackCount() && pokemon.addTag(BattlerTagType.BYPASS_SPEED)) { const hasQuickClaw = this.type.is("PokemonHeldItemModifierType") && this.type.id === "QUICK_CLAW"; - if (isCommandFight && hasQuickClaw) { + if (hasQuickClaw) { globalScene.phaseManager.queueMessage( i18next.t("modifier:bypassSpeedChanceApply", { pokemonName: getPokemonNameWithAffix(pokemon), @@ -2113,10 +2098,7 @@ export class PokemonHpRestoreModifier extends ConsumablePokemonModifier { * @returns `true` if the {@linkcode PokemonHpRestoreModifier} should be applied */ override shouldApply(playerPokemon?: PlayerPokemon, multiplier?: number): boolean { - return ( - super.shouldApply(playerPokemon) - && (this.fainted || (!isNullOrUndefined(multiplier) && typeof multiplier === "number")) - ); + return super.shouldApply(playerPokemon) && (this.fainted || (multiplier != null && typeof multiplier === "number")); } /** @@ -2753,10 +2735,10 @@ export class PokemonMultiHitModifier extends PokemonHeldItemModifier { return false; } - if (!isNullOrUndefined(count)) { + if (count != null) { return this.applyHitCountBoost(count); } - if (!isNullOrUndefined(damageMultiplier)) { + if (damageMultiplier != null) { return this.applyDamageModifier(pokemon, damageMultiplier); } diff --git a/src/overrides.ts b/src/overrides.ts index b8212ea8fd6..3f61196f0b4 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -1,4 +1,4 @@ -import { type PokeballCounts } from "#app/battle-scene"; +import type { PokeballCounts } from "#app/battle-scene"; import { EvolutionItem } from "#balance/pokemon-evolutions"; import { Gender } from "#data/gender"; import { AbilityId } from "#enums/ability-id"; @@ -18,10 +18,11 @@ import { Stat } from "#enums/stat"; import { StatusEffect } from "#enums/status-effect"; import { TimeOfDay } from "#enums/time-of-day"; import { TrainerType } from "#enums/trainer-type"; +import { TrainerVariant } from "#enums/trainer-variant"; import { Unlockables } from "#enums/unlockables"; import { VariantTier } from "#enums/variant-tier"; import { WeatherType } from "#enums/weather-type"; -import { type ModifierOverride } from "#modifiers/modifier-type"; +import type { ModifierOverride } from "#modifiers/modifier-type"; import { Variant } from "#sprites/variant"; /** @@ -311,8 +312,12 @@ export type BattleStyle = "double" | "single" | "even-doubles" | "odd-doubles"; export type RandomTrainerOverride = { /** The Type of trainer to force */ trainerType: Exclude; - /* If the selected trainer type has a double version, it will always use its double version. */ - alwaysDouble?: boolean; + /** + * The {@linkcode TrainerVariant} to force. + * @remarks + * `TrainerVariant.DOUBLE` cannot be forced on the first wave of a game due to issues with trainer party generation. + */ + trainerVariant?: TrainerVariant; }; /** The type of the {@linkcode DefaultOverrides} class */ diff --git a/src/phase-manager.ts b/src/phase-manager.ts index 4bb7e0a4b37..350e77e52eb 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -1,10 +1,21 @@ +/** + * Manager for phases used by battle scene. + * + * @remarks + * **This file must not be imported or used directly.** + * The manager is exclusively used by the Battle Scene and is NOT intended for external use. + * @module + */ + import { PHASE_START_COLOR } from "#app/constants/colors"; +import { DynamicQueueManager } from "#app/dynamic-queue-manager"; import { globalScene } from "#app/global-scene"; import type { Phase } from "#app/phase"; -import { type PhasePriorityQueue, PostSummonPhasePriorityQueue } from "#data/phase-priority-queue"; -import type { DynamicPhaseType } from "#enums/dynamic-phase-type"; +import { PhaseTree } from "#app/phase-tree"; +import { BattleType } from "#enums/battle-type"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; import type { Pokemon } from "#field/pokemon"; -import { ActivatePriorityQueuePhase } from "#phases/activate-priority-queue-phase"; +import type { PokemonMove } from "#moves/pokemon-move"; import { AddEnemyBuffModifierPhase } from "#phases/add-enemy-buff-modifier-phase"; import { AttemptCapturePhase } from "#phases/attempt-capture-phase"; import { AttemptRunPhase } from "#phases/attempt-run-phase"; @@ -16,6 +27,7 @@ import { CheckSwitchPhase } from "#phases/check-switch-phase"; import { CommandPhase } from "#phases/command-phase"; import { CommonAnimPhase } from "#phases/common-anim-phase"; import { DamageAnimPhase } from "#phases/damage-anim-phase"; +import { DynamicPhaseMarker } from "#phases/dynamic-phase-marker"; import { EggHatchPhase } from "#phases/egg-hatch-phase"; import { EggLapsePhase } from "#phases/egg-lapse-phase"; import { EggSummaryPhase } from "#phases/egg-summary-phase"; @@ -100,17 +112,7 @@ import { UnavailablePhase } from "#phases/unavailable-phase"; import { UnlockPhase } from "#phases/unlock-phase"; import { VictoryPhase } from "#phases/victory-phase"; import { WeatherEffectPhase } from "#phases/weather-effect-phase"; -import type { PhaseMap, PhaseString } from "#types/phase-types"; -import { type Constructor, coerceArray } from "#utils/common"; - -/** - * @module - * Manager for phases used by battle scene. - * - * @remarks - * **This file must not be imported or used directly.** - * The manager is exclusively used by the Battle Scene and is NOT intended for external use. - */ +import type { PhaseConditionFunc, PhaseMap, PhaseString } from "#types/phase-types"; /** * Object that holds all of the phase constructors. @@ -121,7 +123,6 @@ import { type Constructor, coerceArray } from "#utils/common"; * This allows for easy creation of new phases without needing to import each phase individually. */ const PHASES = Object.freeze({ - ActivatePriorityQueuePhase, AddEnemyBuffModifierPhase, AttemptCapturePhase, AttemptRunPhase, @@ -133,6 +134,7 @@ const PHASES = Object.freeze({ CommandPhase, CommonAnimPhase, DamageAnimPhase, + DynamicPhaseMarker, EggHatchPhase, EggLapsePhase, EggSummaryPhase, @@ -221,33 +223,30 @@ const PHASES = Object.freeze({ /** Maps Phase strings to their constructors */ export type PhaseConstructorMap = typeof PHASES; +/** Phases pushed at the end of each {@linkcode TurnStartPhase} */ +const turnEndPhases: readonly PhaseString[] = [ + "WeatherEffectPhase", + "PositionalTagPhase", + "BerryPhase", + "CheckStatusEffectPhase", + "TurnEndPhase", +] as const; + /** * PhaseManager is responsible for managing the phases in the battle scene */ export class PhaseManager { /** PhaseQueue: dequeue/remove the first element to get the next phase */ - public phaseQueue: Phase[] = []; - public conditionalQueue: Array<[() => boolean, Phase]> = []; - /** PhaseQueuePrepend: is a temp storage of what will be added to PhaseQueue */ - private phaseQueuePrepend: Phase[] = []; + private readonly phaseQueue: PhaseTree = new PhaseTree(); - /** overrides default of inserting phases to end of phaseQueuePrepend array. Useful for inserting Phases "out of order" */ - private phaseQueuePrependSpliceIndex = -1; - private nextCommandPhaseQueue: Phase[] = []; - - /** Storage for {@linkcode PhasePriorityQueue}s which hold phases whose order dynamically changes */ - private dynamicPhaseQueues: PhasePriorityQueue[]; - /** Parallel array to {@linkcode dynamicPhaseQueues} - matches phase types to their queues */ - private dynamicPhaseTypes: Constructor[]; + /** Holds priority queues for dynamically ordered phases */ + public dynamicQueueManager = new DynamicQueueManager(); + /** The currently-running phase */ private currentPhase: Phase; + /** The phase put on standby if {@linkcode overridePhase} is called */ private standbyPhase: Phase | null = null; - constructor() { - this.dynamicPhaseQueues = [new PostSummonPhasePriorityQueue()]; - this.dynamicPhaseTypes = [PostSummonPhase]; - } - /** * Clear all previously set phases, then add a new {@linkcode TitlePhase} to transition to the title screen. * @param addLogin - Whether to add a new {@linkcode LoginPhase} before the {@linkcode TitlePhase} @@ -275,123 +274,76 @@ export class PhaseManager { } /** - * Adds a phase to the conditional queue and ensures it is executed only when the specified condition is met. - * - * This method allows deferring the execution of a phase until certain conditions are met, which is useful for handling - * situations like abilities and entry hazards that depend on specific game states. - * - * @param phase - The phase to be added to the conditional queue. - * @param condition - A function that returns a boolean indicating whether the phase should be executed. - * + * Adds a phase to the end of the queue + * @param phase - The {@linkcode Phase} to add */ - pushConditionalPhase(phase: Phase, condition: () => boolean): void { - this.conditionalQueue.push([condition, phase]); + public pushPhase(phase: Phase): void { + this.phaseQueue.pushPhase(this.checkDynamic(phase)); } /** - * Adds a phase to nextCommandPhaseQueue, as long as boolean passed in is false - * @param phase {@linkcode Phase} the phase to add - * @param defer boolean on which queue to add to, defaults to false, and adds to phaseQueue + * Queue a phase to be run immediately after the current phase finishes. \ + * Unshifted phases are run in FIFO order if multiple are queued during a single phase's execution. + * @param phase - The {@linkcode Phase} to add */ - pushPhase(phase: Phase, defer = false): void { - if (this.getDynamicPhaseType(phase) !== undefined) { - this.pushDynamicPhase(phase); - } else { - (!defer ? this.phaseQueue : this.nextCommandPhaseQueue).push(phase); - } + public unshiftPhase(phase: Phase): void { + const toAdd = this.checkDynamic(phase); + phase.is("MovePhase") ? this.phaseQueue.addAfter(toAdd, "MoveEndPhase") : this.phaseQueue.addPhase(toAdd); } /** - * Adds Phase(s) to the end of phaseQueuePrepend, or at phaseQueuePrependSpliceIndex - * @param phases {@linkcode Phase} the phase(s) to add + * Helper method to queue a phase as dynamic if necessary + * @param phase - The phase to check + * @returns The {@linkcode Phase} or a {@linkcode DynamicPhaseMarker} to be used in its place */ - unshiftPhase(...phases: Phase[]): void { - if (this.phaseQueuePrependSpliceIndex === -1) { - this.phaseQueuePrepend.push(...phases); - } else { - this.phaseQueuePrepend.splice(this.phaseQueuePrependSpliceIndex, 0, ...phases); + private checkDynamic(phase: Phase): Phase { + if (this.dynamicQueueManager.queueDynamicPhase(phase)) { + return new DynamicPhaseMarker(phase.phaseName); } + return phase; } /** * Clears the phaseQueue + * @param leaveUnshifted - If `true`, leaves the top level of the tree intact; default `false` */ - clearPhaseQueue(): void { - this.phaseQueue.splice(0, this.phaseQueue.length); + public clearPhaseQueue(leaveUnshifted = false): void { + this.phaseQueue.clear(leaveUnshifted); } - /** - * Clears all phase-related stuff, including all phase queues, the current and standby phases, and a splice index - */ - clearAllPhases(): void { - for (const queue of [this.phaseQueue, this.phaseQueuePrepend, this.conditionalQueue, this.nextCommandPhaseQueue]) { - queue.splice(0, queue.length); - } - this.dynamicPhaseQueues.forEach(queue => queue.clear()); + /** Clears all phase queues and the standby phase */ + public clearAllPhases(): void { + this.clearPhaseQueue(); + this.dynamicQueueManager.clearQueues(); this.standbyPhase = null; - this.clearPhaseQueueSplice(); } /** - * Used by function unshiftPhase(), sets index to start inserting at current length instead of the end of the array, useful if phaseQueuePrepend gets longer with Phases + * Determines the next phase to run and starts it. + * @privateRemarks + * This is called by {@linkcode Phase.end} by default, and should not be called by other methods. */ - setPhaseQueueSplice(): void { - this.phaseQueuePrependSpliceIndex = this.phaseQueuePrepend.length; - } - - /** - * Resets phaseQueuePrependSpliceIndex to -1, implies that calls to unshiftPhase will insert at end of phaseQueuePrepend - */ - clearPhaseQueueSplice(): void { - this.phaseQueuePrependSpliceIndex = -1; - } - - /** - * Is called by each Phase implementations "end()" by default - * We dump everything from phaseQueuePrepend to the start of of phaseQueue - * then removes first Phase and starts it - */ - shiftPhase(): void { + public shiftPhase(): void { if (this.standbyPhase) { this.currentPhase = this.standbyPhase; this.standbyPhase = null; return; } - if (this.phaseQueuePrependSpliceIndex > -1) { - this.clearPhaseQueueSplice(); - } - this.phaseQueue.unshift(...this.phaseQueuePrepend); - this.phaseQueuePrepend.splice(0); + let nextPhase = this.phaseQueue.getNextPhase(); - const unactivatedConditionalPhases: [() => boolean, Phase][] = []; - // Check if there are any conditional phases queued - for (const [condition, phase] of this.conditionalQueue) { - // Evaluate the condition associated with the phase - if (condition()) { - // If the condition is met, add the phase to the phase queue - this.pushPhase(phase); - } else { - // If the condition is not met, re-add the phase back to the end of the conditional queue - unactivatedConditionalPhases.push([condition, phase]); - } + if (nextPhase?.is("DynamicPhaseMarker")) { + nextPhase = this.dynamicQueueManager.popNextPhase(nextPhase.phaseType); } - this.conditionalQueue = unactivatedConditionalPhases; - - // If no phases are left, unshift phases to start a new turn. - if (this.phaseQueue.length === 0) { - this.populatePhaseQueue(); - // Clear the conditionalQueue if there are no phases left in the phaseQueue - this.conditionalQueue = []; + if (nextPhase == null) { + this.turnStart(); + } else { + this.currentPhase = nextPhase; } - // Bang is justified as `populatePhaseQueue` ensures we always have _something_ in the queue at all times - this.currentPhase = this.phaseQueue.shift()!; - this.startCurrentPhase(); } - /** * Helper method to start and log the current phase. */ @@ -400,7 +352,14 @@ export class PhaseManager { this.currentPhase.start(); } - overridePhase(phase: Phase): boolean { + /** + * Overrides the currently running phase with another + * @param phase - The {@linkcode Phase} to override the current one with + * @returns If the override succeeded + * + * @todo This is antithetical to the phase structure and used a single time. Remove it. + */ + public overridePhase(phase: Phase): boolean { if (this.standbyPhase) { return false; } @@ -413,173 +372,47 @@ export class PhaseManager { } /** - * Find a specific {@linkcode Phase} in the phase queue. + * Determine if there is a queued {@linkcode Phase} meeting the specified conditions. + * @param type - The {@linkcode PhaseString | type} of phase to search for + * @param condition - An optional {@linkcode PhaseConditionFunc} to add conditions to the search + * @returns Whether a matching phase exists + */ + public hasPhaseOfType(type: T, condition?: PhaseConditionFunc): boolean { + return this.dynamicQueueManager.exists(type, condition) || this.phaseQueue.exists(type, condition); + } + + /** + * Attempt to find and remove the first queued {@linkcode Phase} matching the given conditions. + * @param type - The {@linkcode PhaseString | type} of phase to search for + * @param phaseFilter - An optional {@linkcode PhaseConditionFunc} to add conditions to the search + * @returns Whether a phase was successfully removed + */ + public tryRemovePhase(type: T, phaseFilter?: PhaseConditionFunc): boolean { + if (this.dynamicQueueManager.removePhase(type, phaseFilter)) { + return true; + } + return this.phaseQueue.remove(type, phaseFilter); + } + + /** + * Removes all {@linkcode Phase}s of the given type from the queue + * @param phaseType - The {@linkcode PhaseString | type} of phase to search for * - * @param phaseFilter filter function to use to find the wanted phase - * @returns the found phase or undefined if none found + * @remarks + * This is not intended to be used with dynamically ordered phases, and does not operate on the dynamic queue. \ + * However, it does remove {@linkcode DynamicPhaseMarker}s and so would prevent such phases from activating. */ - findPhase

(phaseFilter: (phase: P) => boolean): P | undefined { - return this.phaseQueue.find(phaseFilter) as P | undefined; - } - - tryReplacePhase(phaseFilter: (phase: Phase) => boolean, phase: Phase): boolean { - const phaseIndex = this.phaseQueue.findIndex(phaseFilter); - if (phaseIndex > -1) { - this.phaseQueue[phaseIndex] = phase; - return true; - } - return false; - } - - tryRemovePhase(phaseFilter: (phase: Phase) => boolean): boolean { - const phaseIndex = this.phaseQueue.findIndex(phaseFilter); - if (phaseIndex > -1) { - this.phaseQueue.splice(phaseIndex, 1); - return true; - } - return false; + public removeAllPhasesOfType(type: PhaseString): void { + this.phaseQueue.removeAll(type); } /** - * Will search for a specific phase in {@linkcode phaseQueuePrepend} via filter, and remove the first result if a match is found. - * @param phaseFilter filter function - */ - tryRemoveUnshiftedPhase(phaseFilter: (phase: Phase) => boolean): boolean { - const phaseIndex = this.phaseQueuePrepend.findIndex(phaseFilter); - if (phaseIndex > -1) { - this.phaseQueuePrepend.splice(phaseIndex, 1); - return true; - } - return false; - } - - /** - * Tries to add the input phase to index before target phase in the phaseQueue, else simply calls unshiftPhase() - * @param phase - The phase to be added - * @param targetPhase - The phase to search for in phaseQueue - * @returns boolean if a targetPhase was found and added - */ - prependToPhase(phase: Phase | Phase[], targetPhase: PhaseString): boolean { - phase = coerceArray(phase); - const target = PHASES[targetPhase]; - const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof target); - - if (targetIndex !== -1) { - this.phaseQueue.splice(targetIndex, 0, ...phase); - return true; - } - this.unshiftPhase(...phase); - return false; - } - - /** - * Tries to add the input phase(s) to index after target phase in the {@linkcode phaseQueue}, else simply calls {@linkcode unshiftPhase()} - * @param phase {@linkcode Phase} the phase(s) to be added - * @param targetPhase {@linkcode Phase} the type of phase to search for in {@linkcode phaseQueue} - * @param condition Condition the target phase must meet to be appended to - * @returns `true` if a `targetPhase` was found to append to - */ - appendToPhase(phase: Phase | Phase[], targetPhase: PhaseString, condition?: (p: Phase) => boolean): boolean { - phase = coerceArray(phase); - const target = PHASES[targetPhase]; - const targetIndex = this.phaseQueue.findIndex(ph => ph instanceof target && (!condition || condition(ph))); - - if (targetIndex !== -1 && this.phaseQueue.length > targetIndex) { - this.phaseQueue.splice(targetIndex + 1, 0, ...phase); - return true; - } - this.unshiftPhase(...phase); - return false; - } - - /** - * Checks a phase and returns the matching {@linkcode DynamicPhaseType}, or undefined if it does not match one - * @param phase The phase to check - * @returns The corresponding {@linkcode DynamicPhaseType} or `undefined` - */ - public getDynamicPhaseType(phase: Phase | null): DynamicPhaseType | undefined { - let phaseType: DynamicPhaseType | undefined; - this.dynamicPhaseTypes.forEach((cls, index) => { - if (phase instanceof cls) { - phaseType = index; - } - }); - - return phaseType; - } - - /** - * Pushes a phase onto its corresponding dynamic queue and marks the activation point in {@linkcode phaseQueue} - * - * The {@linkcode ActivatePriorityQueuePhase} will run the top phase in the dynamic queue (not necessarily {@linkcode phase}) - * @param phase The phase to push - */ - public pushDynamicPhase(phase: Phase): void { - const type = this.getDynamicPhaseType(phase); - if (type === undefined) { - return; - } - - this.pushPhase(new ActivatePriorityQueuePhase(type)); - this.dynamicPhaseQueues[type].push(phase); - } - - /** - * Attempt to remove one or more Phases from the given DynamicPhaseQueue, removing the equivalent amount of {@linkcode ActivatePriorityQueuePhase}s from the queue. - * @param type - The {@linkcode DynamicPhaseType} to check - * @param phaseFilter - The function to select phases for removal - * @param removeCount - The maximum number of phases to remove, or `all` to remove all matching phases; - * default `1` - * @todo Remove this eventually once the patchwork bug this is used for is fixed - */ - public tryRemoveDynamicPhase( - type: DynamicPhaseType, - phaseFilter: (phase: Phase) => boolean, - removeCount: number | "all" = 1, - ): void { - const numRemoved = this.dynamicPhaseQueues[type].tryRemovePhase(phaseFilter, removeCount); - for (let x = 0; x < numRemoved; x++) { - this.tryRemovePhase(p => p.is("ActivatePriorityQueuePhase")); - } - } - - /** - * Unshifts the top phase from the corresponding dynamic queue onto {@linkcode phaseQueue} - * @param type {@linkcode DynamicPhaseType} The type of dynamic phase to start - */ - public startDynamicPhaseType(type: DynamicPhaseType): void { - const phase = this.dynamicPhaseQueues[type].pop(); - if (phase) { - this.unshiftPhase(phase); - } - } - - /** - * Unshifts an {@linkcode ActivatePriorityQueuePhase} for {@linkcode phase}, then pushes {@linkcode phase} to its dynamic queue - * - * This is the same as {@linkcode pushDynamicPhase}, except the activation phase is unshifted - * - * {@linkcode phase} is not guaranteed to be the next phase from the queue to run (if the queue is not empty) - * @param phase The phase to add - * @returns - */ - public startDynamicPhase(phase: Phase): void { - const type = this.getDynamicPhaseType(phase); - if (type === undefined) { - return; - } - - this.unshiftPhase(new ActivatePriorityQueuePhase(type)); - this.dynamicPhaseQueues[type].push(phase); - } - - /** - * Adds a MessagePhase, either to PhaseQueuePrepend or nextCommandPhaseQueue + * Adds a `MessagePhase` to the queue * @param message - string for MessagePhase * @param callbackDelay - optional param for MessagePhase constructor * @param prompt - optional param for MessagePhase constructor * @param promptDelay - optional param for MessagePhase constructor - * @param defer - Whether to allow the phase to be deferred + * @param defer - If `true`, push the phase instead of unshifting; default `false` * * @see {@linkcode MessagePhase} for more details on the parameters */ @@ -591,20 +424,18 @@ export class PhaseManager { defer?: boolean | null, ) { const phase = new MessagePhase(message, callbackDelay, prompt, promptDelay); - if (!defer) { - // adds to the end of PhaseQueuePrepend - this.unshiftPhase(phase); - } else { - //remember that pushPhase adds it to nextCommandPhaseQueue + if (defer) { this.pushPhase(phase); + } else { + this.unshiftPhase(phase); } } /** - * Queue a phase to show or hide the ability flyout bar. + * Queues an ability bar flyout phase via {@linkcode unshiftPhase} * @param pokemon - The {@linkcode Pokemon} whose ability is being activated * @param passive - Whether the ability is a passive - * @param show - Whether to show or hide the bar + * @param show - If `true`, show the bar. Otherwise, hide it */ public queueAbilityDisplay(pokemon: Pokemon, passive: boolean, show: boolean): void { this.unshiftPhase(show ? new ShowAbilityPhase(pokemon.getBattlerIndex(), passive) : new HideAbilityPhase()); @@ -620,14 +451,12 @@ export class PhaseManager { } /** - * Moves everything from nextCommandPhaseQueue to phaseQueue (keeping order) + * Clear all dynamic queues and begin a new {@linkcode TurnInitPhase} for the new turn. + * Called whenever the current phase queue is empty. */ - private populatePhaseQueue(): void { - if (this.nextCommandPhaseQueue.length > 0) { - this.phaseQueue.push(...this.nextCommandPhaseQueue); - this.nextCommandPhaseQueue.splice(0, this.nextCommandPhaseQueue.length); - } - this.phaseQueue.push(new TurnInitPhase()); + private turnStart(): void { + this.dynamicQueueManager.clearQueues(); + this.currentPhase = new TurnInitPhase(); } /** @@ -669,50 +498,119 @@ export class PhaseManager { } /** - * Create a new phase and immediately prepend it to an existing phase in the phase queue. - * Equivalent to calling {@linkcode create} followed by {@linkcode prependToPhase}. - * @param targetPhase - The phase to search for in phaseQueue - * @param phase - The name of the phase to create + * Add a {@linkcode FaintPhase} to the queue * @param args - The arguments to pass to the phase constructor - * @returns `true` if a `targetPhase` was found to prepend to + * + * @remarks + * + * Faint phases are ordered in a special way to allow battle effects to settle before the pokemon faints. + * @see {@linkcode PhaseTree.addPhase} */ - public prependNewToPhase( - targetPhase: PhaseString, - phase: T, - ...args: ConstructorParameters - ): boolean { - return this.prependToPhase(this.create(phase, ...args), targetPhase); + public queueFaintPhase(...args: ConstructorParameters): void { + this.phaseQueue.addPhase(this.create("FaintPhase", ...args), true); } /** - * Create a new phase and immediately append it to an existing phase the phase queue. - * Equivalent to calling {@linkcode create} followed by {@linkcode appendToPhase}. - * @param targetPhase - The phase to search for in phaseQueue - * @param phase - The name of the phase to create - * @param args - The arguments to pass to the phase constructor - * @returns `true` if a `targetPhase` was found to append to + * Attempts to add {@linkcode PostSummonPhase}s for the enemy pokemon + * + * This is used to ensure that wild pokemon (which have no {@linkcode SummonPhase}) do not queue a {@linkcode PostSummonPhase} + * until all pokemon are on the field. */ - public appendNewToPhase( - targetPhase: PhaseString, - phase: T, - ...args: ConstructorParameters - ): boolean { - return this.appendToPhase(this.create(phase, ...args), targetPhase); + public tryAddEnemyPostSummonPhases(): void { + if ( + ![BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType) + && !this.phaseQueue.exists("SummonPhase") + ) { + globalScene.getEnemyField().forEach(p => { + this.pushPhase(new PostSummonPhase(p.getBattlerIndex(), "SummonPhase")); + }); + } } - public startNewDynamicPhase( + /** + * Create a new phase and queue it to run after all others queued by the currently running phase. + * @param phase - The name of the phase to create + * @param args - The arguments to pass to the phase constructor + * + * @deprecated Only used for switches and should be phased out eventually. + */ + public queueDeferred( phase: T, ...args: ConstructorParameters ): void { - this.startDynamicPhase(this.create(phase, ...args)); + this.phaseQueue.unshiftToCurrent(this.create(phase, ...args)); + } + + /** + * Finds the first {@linkcode MovePhase} meeting the condition + * @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function + * @returns The MovePhase, or `undefined` if it does not exist + */ + public getMovePhase(phaseCondition: PhaseConditionFunc<"MovePhase">): MovePhase | undefined { + return this.dynamicQueueManager.getMovePhase(phaseCondition); + } + + /** + * Finds and cancels the first {@linkcode MovePhase} meeting the condition + * @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function + */ + public cancelMove(phaseCondition: PhaseConditionFunc<"MovePhase">): void { + this.dynamicQueueManager.cancelMovePhase(phaseCondition); + } + + /** + * Finds the first {@linkcode MovePhase} meeting the condition and forces it next + * @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function + */ + public forceMoveNext(phaseCondition: PhaseConditionFunc<"MovePhase">): void { + this.dynamicQueueManager.setMoveTimingModifier(phaseCondition, MovePhaseTimingModifier.FIRST); + } + + /** + * Finds the first {@linkcode MovePhase} meeting the condition and forces it last + * @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function + */ + public forceMoveLast(phaseCondition: PhaseConditionFunc<"MovePhase">): void { + this.dynamicQueueManager.setMoveTimingModifier(phaseCondition, MovePhaseTimingModifier.LAST); + } + + /** + * Finds the first {@linkcode MovePhase} meeting the condition and changes its move + * @param phaseCondition - The {@linkcode PhaseConditionFunc | condition} function + * @param move - The {@linkcode PokemonMove | move} to use in replacement + */ + public changePhaseMove(phaseCondition: PhaseConditionFunc<"MovePhase">, move: PokemonMove): void { + this.dynamicQueueManager.setMoveForPhase(phaseCondition, move); + } + + /** + * Redirects moves which were targeted at a {@linkcode Pokemon} that has been removed + * @param removedPokemon - The removed {@linkcode Pokemon} + * @param allyPokemon - The ally of the removed pokemon + */ + public redirectMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void { + this.dynamicQueueManager.redirectMoves(removedPokemon, allyPokemon); + } + + /** Queues phases which run at the end of each turn */ + public queueTurnEndPhases(): void { + turnEndPhases.forEach(p => { + this.pushNew(p); + }); } /** Prevents end of turn effects from triggering when transitioning to a new biome on a X0 wave */ public onInterlude(): void { - const phasesToRemove = ["WeatherEffectPhase", "BerryPhase", "CheckStatusEffectPhase"]; - this.phaseQueue = this.phaseQueue.filter(p => !phasesToRemove.includes(p.phaseName)); + const phasesToRemove: readonly PhaseString[] = [ + "WeatherEffectPhase", + "BerryPhase", + "CheckStatusEffectPhase", + ] as const; + for (const phaseType of phasesToRemove) { + this.phaseQueue.removeAll(phaseType); + } - const turnEndPhase = this.findPhase(p => p.phaseName === "TurnEndPhase"); + const turnEndPhase = this.phaseQueue.find("TurnEndPhase"); if (turnEndPhase) { turnEndPhase.upcomingInterlude = true; } diff --git a/src/phase-tree.ts b/src/phase-tree.ts new file mode 100644 index 00000000000..69bb72ca4f0 --- /dev/null +++ b/src/phase-tree.ts @@ -0,0 +1,205 @@ +// biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports +import type { PhaseManager } from "#app/@types/phase-types"; +import type { DynamicPhaseMarker } from "#phases/dynamic-phase-marker"; +// biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports + +import type { PhaseMap, PhaseString } from "#app/@types/phase-types"; +import type { Phase } from "#app/phase"; +import type { PhaseConditionFunc } from "#types/phase-types"; + +/** + * The PhaseTree is the central storage location for {@linkcode Phase}s by the {@linkcode PhaseManager}. + * + * It has a tiered structure, where unshifted phases are added one level above the currently running Phase. Phases are generally popped from the Tree in FIFO order. + * + * Dynamically ordered phases are queued into the Tree only as {@linkcode DynamicPhaseMarker | Marker}s and as such are not guaranteed to run FIFO (otherwise, they would not be dynamic) + */ +export class PhaseTree { + /** Storage for all levels in the tree. This is a simple array because only one Phase may have "children" at a time. */ + private levels: Phase[][] = [[]]; + /** The level of the currently running {@linkcode Phase} in the Tree (note that such phase is not actually in the Tree while it is running) */ + private currentLevel = 0; + /** + * True if a "deferred" level exists + * @see {@linkcode addPhase} + */ + private deferredActive = false; + + /** + * Adds a {@linkcode Phase} to the specified level + * @param phase - The phase to add + * @param level - The numeric level to add the phase + * @throws Error if `level` is out of legal bounds + */ + private add(phase: Phase, level: number): void { + const addLevel = this.levels[level]; + if (addLevel == null) { + throw new Error("Attempted to add a phase to a nonexistent level of the PhaseTree!\nLevel: " + level.toString()); + } + this.levels[level].push(phase); + } + + /** + * Used by the {@linkcode PhaseManager} to add phases to the Tree + * @param phase - The {@linkcode Phase} to be added + * @param defer - Whether to defer the execution of this phase by allowing subsequently-added phases to run before it + * + * @privateRemarks + * Deferral is implemented by moving the queue at {@linkcode currentLevel} up one level and inserting the new phase below it. + * {@linkcode deferredActive} is set until the moved queue (and anything added to it) is exhausted. + * + * If {@linkcode deferredActive} is `true` when a deferred phase is added, the phase will be pushed to the second-highest level queue. + * That is, it will execute after the originally deferred phase, but there is no possibility for nesting with deferral. + * + * @todo `setPhaseQueueSplice` had strange behavior. This is simpler, but there are probably some remnant edge cases with the current implementation + */ + public addPhase(phase: Phase, defer = false): void { + if (defer && !this.deferredActive) { + this.deferredActive = true; + this.levels.splice(-1, 0, []); + } + this.add(phase, this.levels.length - 1 - +defer); + } + + /** + * Adds a {@linkcode Phase} after the first occurence of the given type, or to the top of the Tree if no such phase exists + * @param phase - The {@linkcode Phase} to be added + * @param type - A {@linkcode PhaseString} representing the type to search for + */ + public addAfter(phase: Phase, type: PhaseString): void { + for (let i = this.levels.length - 1; i >= 0; i--) { + const insertIdx = this.levels[i].findIndex(p => p.is(type)) + 1; + if (insertIdx !== 0) { + this.levels[i].splice(insertIdx, 0, phase); + return; + } + } + + this.addPhase(phase); + } + + /** + * Unshifts a {@linkcode Phase} to the current level. + * This is effectively the same as if the phase were added immediately after the currently-running phase, before it started. + * @param phase - The {@linkcode Phase} to be added + */ + public unshiftToCurrent(phase: Phase): void { + this.levels[this.currentLevel].unshift(phase); + } + + /** + * Pushes a {@linkcode Phase} to the last level of the queue. It will run only after all previously queued phases have been executed. + * @param phase - The {@linkcode Phase} to be added + */ + public pushPhase(phase: Phase): void { + this.add(phase, 0); + } + + /** + * Removes and returns the first {@linkcode Phase} from the topmost level of the tree + * @returns - The next {@linkcode Phase}, or `undefined` if the Tree is empty + */ + public getNextPhase(): Phase | undefined { + this.currentLevel = this.levels.length - 1; + while (this.currentLevel > 0 && this.levels[this.currentLevel].length === 0) { + this.deferredActive = false; + this.levels.pop(); + this.currentLevel--; + } + + // TODO: right now, this is preventing properly marking when one set of unshifted phases ends + this.levels.push([]); + return this.levels[this.currentLevel].shift(); + } + + /** + * Finds a particular {@linkcode Phase} in the Tree by searching in pop order + * @param phaseType - The {@linkcode PhaseString | type} of phase to search for + * @param phaseFilter - A {@linkcode PhaseConditionFunc} to specify conditions for the phase + * @returns The matching {@linkcode Phase}, or `undefined` if none exists + */ + public find

(phaseType: P, phaseFilter?: PhaseConditionFunc

): PhaseMap[P] | undefined { + for (let i = this.levels.length - 1; i >= 0; i--) { + const level = this.levels[i]; + const phase = level.find((p): p is PhaseMap[P] => p.is(phaseType) && (!phaseFilter || phaseFilter(p))); + if (phase) { + return phase; + } + } + } + + /** + * Finds a particular {@linkcode Phase} in the Tree by searching in pop order + * @param phaseType - The {@linkcode PhaseString | type} of phase to search for + * @param phaseFilter - A {@linkcode PhaseConditionFunc} to specify conditions for the phase + * @returns The matching {@linkcode Phase}, or `undefined` if none exists + */ + public findAll

(phaseType: P, phaseFilter?: PhaseConditionFunc

): PhaseMap[P][] { + const phases: PhaseMap[P][] = []; + for (let i = this.levels.length - 1; i >= 0; i--) { + const level = this.levels[i]; + const levelPhases = level.filter((p): p is PhaseMap[P] => p.is(phaseType) && (!phaseFilter || phaseFilter(p))); + phases.push(...levelPhases); + } + return phases; + } + + /** + * Clears the Tree + * @param leaveFirstLevel - If `true`, leaves the top level of the tree intact + * + * @privateremarks + * The parameter on this method exists because {@linkcode PhaseManager.clearPhaseQueue} previously (probably by mistake) ignored `phaseQueuePrepend`. + * + * This is (probably by mistake) relied upon by certain ME functions. + */ + public clear(leaveFirstLevel = false) { + this.levels = [leaveFirstLevel ? (this.levels.at(-1) ?? []) : []]; + } + + /** + * Finds and removes a single {@linkcode Phase} from the Tree + * @param phaseType - The {@linkcode PhaseString | type} of phase to search for + * @param phaseFilter - A {@linkcode PhaseConditionFunc} to specify conditions for the phase + * @returns Whether a removal occurred + */ + public remove

(phaseType: P, phaseFilter?: PhaseConditionFunc

): boolean { + for (let i = this.levels.length - 1; i >= 0; i--) { + const level = this.levels[i]; + const phaseIndex = level.findIndex(p => p.is(phaseType) && (!phaseFilter || phaseFilter(p))); + if (phaseIndex !== -1) { + level.splice(phaseIndex, 1); + return true; + } + } + return false; + } + + /** + * Removes all occurrences of {@linkcode Phase}s of the given type + * @param phaseType - The {@linkcode PhaseString | type} of phase to search for + */ + public removeAll(phaseType: PhaseString): void { + for (let i = 0; i < this.levels.length; i++) { + const level = this.levels[i].filter(phase => !phase.is(phaseType)); + this.levels[i] = level; + } + } + + /** + * Determines if a particular phase exists in the Tree + * @param phaseType - The {@linkcode PhaseString | type} of phase to search for + * @param phaseFilter - A {@linkcode PhaseConditionFunc} to specify conditions for the phase + * @returns Whether a matching phase exists + */ + public exists

(phaseType: P, phaseFilter?: PhaseConditionFunc

): boolean { + for (const level of this.levels) { + for (const phase of level) { + if (phase.is(phaseType) && (!phaseFilter || phaseFilter(phase))) { + return true; + } + } + } + return false; + } +} diff --git a/src/phases/activate-priority-queue-phase.ts b/src/phases/activate-priority-queue-phase.ts deleted file mode 100644 index a31d3291a60..00000000000 --- a/src/phases/activate-priority-queue-phase.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { globalScene } from "#app/global-scene"; -import { Phase } from "#app/phase"; -import type { DynamicPhaseType } from "#enums/dynamic-phase-type"; - -export class ActivatePriorityQueuePhase extends Phase { - public readonly phaseName = "ActivatePriorityQueuePhase"; - private type: DynamicPhaseType; - - constructor(type: DynamicPhaseType) { - super(); - this.type = type; - } - - override start() { - super.start(); - globalScene.phaseManager.startDynamicPhaseType(this.type); - this.end(); - } - - public getType(): DynamicPhaseType { - return this.type; - } -} diff --git a/src/phases/battle-end-phase.ts b/src/phases/battle-end-phase.ts index 8a798d67554..45b0db76ced 100644 --- a/src/phases/battle-end-phase.ts +++ b/src/phases/battle-end-phase.ts @@ -18,23 +18,11 @@ export class BattleEndPhase extends BattlePhase { super.start(); // cull any extra `BattleEnd` phases from the queue. - globalScene.phaseManager.phaseQueue = globalScene.phaseManager.phaseQueue.filter(phase => { - if (phase.is("BattleEndPhase")) { - this.isVictory ||= phase.isVictory; - return false; - } - return true; - }); - // `phaseQueuePrepend` is private, so we have to use this inefficient loop. - while ( - globalScene.phaseManager.tryRemoveUnshiftedPhase(phase => { - if (phase.is("BattleEndPhase")) { - this.isVictory ||= phase.isVictory; - return true; - } - return false; - }) - ) {} + this.isVictory ||= globalScene.phaseManager.hasPhaseOfType( + "BattleEndPhase", + (phase: BattleEndPhase) => phase.isVictory, + ); + globalScene.phaseManager.removeAllPhasesOfType("BattleEndPhase"); globalScene.gameData.gameStats.battles++; if ( diff --git a/src/phases/check-status-effect-phase.ts b/src/phases/check-status-effect-phase.ts index bdaa536986a..5955cd42c55 100644 --- a/src/phases/check-status-effect-phase.ts +++ b/src/phases/check-status-effect-phase.ts @@ -1,20 +1,14 @@ import { globalScene } from "#app/global-scene"; import { Phase } from "#app/phase"; -import type { BattlerIndex } from "#enums/battler-index"; export class CheckStatusEffectPhase extends Phase { public readonly phaseName = "CheckStatusEffectPhase"; - private order: BattlerIndex[]; - constructor(order: BattlerIndex[]) { - super(); - this.order = order; - } start() { const field = globalScene.getField(); - for (const o of this.order) { - if (field[o].status?.isPostTurn()) { - globalScene.phaseManager.unshiftNew("PostTurnStatusEffectPhase", o); + for (const p of field) { + if (p?.status?.isPostTurn()) { + globalScene.phaseManager.unshiftNew("PostTurnStatusEffectPhase", p.getBattlerIndex()); } } this.end(); diff --git a/src/phases/check-switch-phase.ts b/src/phases/check-switch-phase.ts index 504bb6eb4bd..a55db4203bc 100644 --- a/src/phases/check-switch-phase.ts +++ b/src/phases/check-switch-phase.ts @@ -28,7 +28,8 @@ export class CheckSwitchPhase extends BattlePhase { // ...if the user is playing in Set Mode if (globalScene.battleStyle === BattleStyle.SET) { - return super.end(); + this.end(true); + return; } // ...if the checked Pokemon is somehow not on the field @@ -44,7 +45,8 @@ export class CheckSwitchPhase extends BattlePhase { .slice(1) .filter(p => p.isActive()).length === 0 ) { - return super.end(); + this.end(true); + return; } // ...or if any player Pokemon has an effect that prevents the checked Pokemon from switching @@ -53,7 +55,8 @@ export class CheckSwitchPhase extends BattlePhase { || pokemon.isTrapped() || globalScene.getPlayerField().some(p => p.getTag(BattlerTagType.COMMANDED)) ) { - return super.end(); + this.end(true); + return; } globalScene.ui.showText( @@ -71,10 +74,17 @@ export class CheckSwitchPhase extends BattlePhase { }, () => { globalScene.ui.setMode(UiMode.MESSAGE); - this.end(); + this.end(true); }, ); }, ); } + + public override end(queuePostSummon = false): void { + if (queuePostSummon) { + globalScene.phaseManager.unshiftNew("PostSummonPhase", this.fieldIndex); + } + super.end(); + } } diff --git a/src/phases/dynamic-phase-marker.ts b/src/phases/dynamic-phase-marker.ts new file mode 100644 index 00000000000..e2b241f29de --- /dev/null +++ b/src/phases/dynamic-phase-marker.ts @@ -0,0 +1,17 @@ +import type { PhaseString } from "#app/@types/phase-types"; +import { Phase } from "#app/phase"; + +/** + * This phase exists for the sole purpose of marking the location and type of a dynamic phase for the phase manager + */ +export class DynamicPhaseMarker extends Phase { + public override readonly phaseName = "DynamicPhaseMarker"; + + /** The type of phase which this phase is a marker for */ + public phaseType: PhaseString; + + constructor(type: PhaseString) { + super(); + this.phaseType = type; + } +} diff --git a/src/phases/egg-hatch-phase.ts b/src/phases/egg-hatch-phase.ts index 946288c4fd8..3f9b999e0c1 100644 --- a/src/phases/egg-hatch-phase.ts +++ b/src/phases/egg-hatch-phase.ts @@ -225,7 +225,7 @@ export class EggHatchPhase extends Phase { } end() { - if (globalScene.phaseManager.findPhase(p => p.is("EggHatchPhase"))) { + if (globalScene.phaseManager.hasPhaseOfType("EggHatchPhase")) { this.eggHatchHandler.clear(); } else { globalScene.time.delayedCall(250, () => globalScene.setModifiersVisible(true)); diff --git a/src/phases/encounter-phase.ts b/src/phases/encounter-phase.ts index 0918ced65e5..9345170e718 100644 --- a/src/phases/encounter-phase.ts +++ b/src/phases/encounter-phase.ts @@ -565,29 +565,6 @@ export class EncounterPhase extends BattlePhase { }); if (![BattleType.TRAINER, BattleType.MYSTERY_ENCOUNTER].includes(globalScene.currentBattle.battleType)) { - enemyField.map(p => - globalScene.phaseManager.pushConditionalPhase( - globalScene.phaseManager.create("PostSummonPhase", p.getBattlerIndex()), - () => { - // if there is not a player party, we can't continue - if (globalScene.getPlayerParty().length === 0) { - return false; - } - // how many player pokemon are on the field ? - const pokemonsOnFieldCount = globalScene.getPlayerParty().filter(p => p.isOnField()).length; - // if it's a 2vs1, there will never be a 2nd pokemon on our field even - const requiredPokemonsOnField = Math.min( - globalScene.getPlayerParty().filter(p => !p.isFainted()).length, - 2, - ); - // if it's a double, there should be 2, otherwise 1 - if (globalScene.currentBattle.double) { - return pokemonsOnFieldCount === requiredPokemonsOnField; - } - return pokemonsOnFieldCount === 1; - }, - ), - ); const ivScannerModifier = globalScene.findModifier(m => m instanceof IvScannerModifier); if (ivScannerModifier) { enemyField.map(p => globalScene.phaseManager.pushNew("ScanIvsPhase", p.getBattlerIndex())); @@ -596,36 +573,30 @@ export class EncounterPhase extends BattlePhase { if (!this.loaded) { const availablePartyMembers = globalScene.getPokemonAllowedInBattle(); + const minPartySize = globalScene.currentBattle.double ? 2 : 1; + const currentBattle = globalScene.currentBattle; + const checkSwitch = + currentBattle.battleType !== BattleType.TRAINER + && (currentBattle.waveIndex > 1 || !globalScene.gameMode.isDaily) + && availablePartyMembers.length > minPartySize; + const phaseManager = globalScene.phaseManager; if (!availablePartyMembers[0].isOnField()) { - globalScene.phaseManager.pushNew("SummonPhase", 0); + phaseManager.pushNew("SummonPhase", 0, true, false, checkSwitch); } - if (globalScene.currentBattle.double) { + if (currentBattle.double) { if (availablePartyMembers.length > 1) { - globalScene.phaseManager.pushNew("ToggleDoublePositionPhase", true); + phaseManager.pushNew("ToggleDoublePositionPhase", true); if (!availablePartyMembers[1].isOnField()) { - globalScene.phaseManager.pushNew("SummonPhase", 1); + phaseManager.pushNew("SummonPhase", 1, true, false, checkSwitch); } } } else { if (availablePartyMembers.length > 1 && availablePartyMembers[1].isOnField()) { globalScene.phaseManager.pushNew("ReturnPhase", 1); } - globalScene.phaseManager.pushNew("ToggleDoublePositionPhase", false); - } - - if ( - globalScene.currentBattle.battleType !== BattleType.TRAINER - && (globalScene.currentBattle.waveIndex > 1 || !globalScene.gameMode.isDaily) - ) { - const minPartySize = globalScene.currentBattle.double ? 2 : 1; - if (availablePartyMembers.length > minPartySize) { - globalScene.phaseManager.pushNew("CheckSwitchPhase", 0, globalScene.currentBattle.double); - if (globalScene.currentBattle.double) { - globalScene.phaseManager.pushNew("CheckSwitchPhase", 1, globalScene.currentBattle.double); - } - } + phaseManager.pushNew("ToggleDoublePositionPhase", false); } } handleTutorial(Tutorial.Access_Menu).then(() => super.end()); diff --git a/src/phases/faint-phase.ts b/src/phases/faint-phase.ts index 349dfcfa8e5..821d16c6546 100644 --- a/src/phases/faint-phase.ts +++ b/src/phases/faint-phase.ts @@ -17,7 +17,6 @@ import type { EnemyPokemon, PlayerPokemon, Pokemon } from "#field/pokemon"; import { PokemonInstantReviveModifier } from "#modifiers/modifier"; import { PokemonMove } from "#moves/pokemon-move"; import { PokemonPhase } from "#phases/pokemon-phase"; -import { isNullOrUndefined } from "#utils/common"; import i18next from "i18next"; export class FaintPhase extends PokemonPhase { @@ -187,7 +186,7 @@ export class FaintPhase extends PokemonPhase { // in double battles redirect potential moves off fainted pokemon const allyPokemon = pokemon.getAlly(); - if (globalScene.currentBattle.double && !isNullOrUndefined(allyPokemon)) { + if (globalScene.currentBattle.double && allyPokemon != null) { globalScene.redirectPokemonMoves(pokemon, allyPokemon); } diff --git a/src/phases/game-over-phase.ts b/src/phases/game-over-phase.ts index dd29b97d590..f229f872958 100644 --- a/src/phases/game-over-phase.ts +++ b/src/phases/game-over-phase.ts @@ -84,19 +84,12 @@ export class GameOverPhase extends BattlePhase { globalScene.phaseManager.pushNew("EncounterPhase", true); const availablePartyMembers = globalScene.getPokemonAllowedInBattle().length; - - globalScene.phaseManager.pushNew("SummonPhase", 0); - if (globalScene.currentBattle.double && availablePartyMembers > 1) { - globalScene.phaseManager.pushNew("SummonPhase", 1); - } - if ( + const checkSwitch = globalScene.currentBattle.waveIndex > 1 - && globalScene.currentBattle.battleType !== BattleType.TRAINER - ) { - globalScene.phaseManager.pushNew("CheckSwitchPhase", 0, globalScene.currentBattle.double); - if (globalScene.currentBattle.double && availablePartyMembers > 1) { - globalScene.phaseManager.pushNew("CheckSwitchPhase", 1, globalScene.currentBattle.double); - } + && globalScene.currentBattle.battleType !== BattleType.TRAINER; + globalScene.phaseManager.pushNew("SummonPhase", 0, true, false, checkSwitch); + if (globalScene.currentBattle.double && availablePartyMembers > 1) { + globalScene.phaseManager.pushNew("SummonPhase", 1, true, false, checkSwitch); } globalScene.ui.fadeIn(1250); @@ -267,7 +260,6 @@ export class GameOverPhase extends BattlePhase { .then(success => doGameOver(!globalScene.gameMode.isDaily || !!success)) .catch(_err => { globalScene.phaseManager.clearPhaseQueue(); - globalScene.phaseManager.clearPhaseQueueSplice(); globalScene.phaseManager.unshiftNew("MessagePhase", i18next.t("menu:serverCommunicationFailed"), 2500); // force the game to reload after 2 seconds. setTimeout(() => { diff --git a/src/phases/learn-move-phase.ts b/src/phases/learn-move-phase.ts index 4fc38b08d16..bbd1d0f5a2e 100644 --- a/src/phases/learn-move-phase.ts +++ b/src/phases/learn-move-phase.ts @@ -187,7 +187,7 @@ export class LearnMovePhase extends PlayerPartyMemberPokemonPhase { pokemon.usedTMs = []; } pokemon.usedTMs.push(this.moveId); - globalScene.phaseManager.tryRemovePhase(phase => phase.is("SelectModifierPhase")); + globalScene.phaseManager.tryRemovePhase("SelectModifierPhase"); } else if (this.learnMoveType === LearnMoveType.MEMORY) { if (this.cost !== -1) { if (!Overrides.WAIVE_ROLL_FEE_OVERRIDE) { @@ -197,7 +197,7 @@ export class LearnMovePhase extends PlayerPartyMemberPokemonPhase { } globalScene.playSound("se/buy"); } else { - globalScene.phaseManager.tryRemovePhase(phase => phase.is("SelectModifierPhase")); + globalScene.phaseManager.tryRemovePhase("SelectModifierPhase"); } } pokemon.setMove(index, this.moveId); diff --git a/src/phases/login-phase.ts b/src/phases/login-phase.ts index f310c60b0d4..d81b9b614f2 100644 --- a/src/phases/login-phase.ts +++ b/src/phases/login-phase.ts @@ -33,7 +33,7 @@ export class LoginPhase extends Phase { globalScene.ui.showText(i18next.t("menu:logInOrCreateAccount")); } - globalScene.playSound("menu_open"); + globalScene.playSound("ui/menu_open"); const loadData = () => { updateUserInfo().then(success => { @@ -53,7 +53,7 @@ export class LoginPhase extends Phase { loadData(); }, () => { - globalScene.playSound("menu_open"); + globalScene.playSound("ui/menu_open"); globalScene.ui.setMode(UiMode.REGISTRATION_FORM, { buttonActions: [ () => { diff --git a/src/phases/move-charge-phase.ts b/src/phases/move-charge-phase.ts index 0c83db10511..5dd75f4bab8 100644 --- a/src/phases/move-charge-phase.ts +++ b/src/phases/move-charge-phase.ts @@ -75,7 +75,7 @@ export class MoveChargePhase extends PokemonPhase { // Otherwise, add the attack portion to the user's move queue to execute next turn. // TODO: This checks status twice for a single-turn usage... if (instantCharge.value) { - globalScene.phaseManager.tryRemovePhase(phase => phase.is("MoveEndPhase") && phase.getPokemon() === user); + globalScene.phaseManager.tryRemovePhase("MoveEndPhase", phase => phase.getPokemon() === user); globalScene.phaseManager.unshiftNew("MovePhase", user, [this.targetIndex], this.move, this.useMode); } else { user.pushMoveQueue({ move: move.id, targets: [this.targetIndex], useMode: this.useMode }); diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 6c143f1f8a1..be6d0164698 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -1,7 +1,6 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; -import type { Phase } from "#app/phase"; import { ConditionalProtectTag } from "#data/arena-tag"; import { MoveAnim } from "#data/battle-anims"; import { DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag, TypeBoostTag } from "#data/battler-tags"; @@ -17,6 +16,7 @@ import { MoveCategory } from "#enums/move-category"; import { MoveEffectTrigger } from "#enums/move-effect-trigger"; import { MoveFlags } from "#enums/move-flags"; import { MoveId } from "#enums/move-id"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; import { MoveResult } from "#enums/move-result"; import { MoveTarget } from "#enums/move-target"; import { isReflected, MoveUseMode } from "#enums/move-use-mode"; @@ -40,7 +40,7 @@ import { DamageAchv } from "#system/achv"; import type { DamageResult } from "#types/damage-result"; import type { TurnMove } from "#types/turn-move"; import type { nil } from "#utils/common"; -import { BooleanHolder, isNullOrUndefined, NumberHolder } from "#utils/common"; +import { BooleanHolder, NumberHolder } from "#utils/common"; import i18next from "i18next"; export type HitCheckEntry = [HitCheckResult, TypeDamageMultiplier]; @@ -67,12 +67,6 @@ export class MoveEffectPhase extends PokemonPhase { /** Is this the last strike of a move? */ private lastHit: boolean; - /** - * Phases queued during moves; used to add a new MovePhase for reflected moves after triggering. - * TODO: Remove this and move the reflection logic to ability-side - */ - private queuedPhases: Phase[] = []; - /** * @param useMode - The {@linkcode MoveUseMode} corresponding to how this move was used. */ @@ -148,7 +142,7 @@ export class MoveEffectPhase extends PokemonPhase { } /** - * Queue the phaes that should occur when the target reflects the move back to the user + * Queue the phases that should occur when the target reflects the move back to the user * @param user - The {@linkcode Pokemon} using this phase's invoked move * @param target - The {@linkcode Pokemon} that is reflecting the move * TODO: Rework this to use `onApply` of Magic Coat @@ -159,24 +153,21 @@ export class MoveEffectPhase extends PokemonPhase { : [user.getBattlerIndex()]; // TODO: ability displays should be handled by the ability if (!target.getTag(BattlerTagType.MAGIC_COAT)) { - this.queuedPhases.push( - globalScene.phaseManager.create( - "ShowAbilityPhase", - target.getBattlerIndex(), - target.getPassiveAbility().hasAttr("ReflectStatusMoveAbAttr"), - ), + globalScene.phaseManager.unshiftNew( + "ShowAbilityPhase", + target.getBattlerIndex(), + target.getPassiveAbility().hasAttr("ReflectStatusMoveAbAttr"), ); - this.queuedPhases.push(globalScene.phaseManager.create("HideAbilityPhase")); + globalScene.phaseManager.unshiftNew("HideAbilityPhase"); } - this.queuedPhases.push( - globalScene.phaseManager.create( - "MovePhase", - target, - newTargets, - new PokemonMove(this.move.id), - MoveUseMode.REFLECTED, - ), + globalScene.phaseManager.unshiftNew( + "MovePhase", + target, + newTargets, + new PokemonMove(this.move.id), + MoveUseMode.REFLECTED, + MovePhaseTimingModifier.FIRST, ); } @@ -344,9 +335,6 @@ export class MoveEffectPhase extends PokemonPhase { return; } - if (this.queuedPhases.length > 0) { - globalScene.phaseManager.appendToPhase(this.queuedPhases, "MoveEndPhase"); - } const moveType = user.getMoveType(this.move, true); if (this.move.category !== MoveCategory.STATUS && !user.stellarTypesBoosted.includes(moveType)) { user.stellarTypesBoosted.push(moveType); @@ -645,13 +633,18 @@ export class MoveEffectPhase extends PokemonPhase { return move.getAttrs("HitsTagAttr").some(hta => hta.tagType === semiInvulnerableTag.tagType); } - /** @returns The {@linkcode Pokemon} using this phase's invoked move */ - public getUserPokemon(): Pokemon | null { + /** + * @todo Investigate why this doesn't use `BattlerIndex` + * @returns The {@linkcode Pokemon} using this phase's invoked move + */ + public getUserPokemon(): Pokemon | undefined { // TODO: Make this purely a battler index if (this.battlerIndex > BattlerIndex.ENEMY_2) { return globalScene.getPokemonById(this.battlerIndex); } - return (this.player ? globalScene.getPlayerField() : globalScene.getEnemyField())[this.fieldIndex]; + // TODO: Figure out why this uses `fieldIndex` instead of `BattlerIndex` + // TODO: Remove `?? undefined` once field pokemon getters are made sane + return (this.player ? globalScene.getPlayerField() : globalScene.getEnemyField())[this.fieldIndex] ?? undefined; } /** @@ -740,7 +733,7 @@ export class MoveEffectPhase extends PokemonPhase { (attr: MoveAttr) => attr.is("MoveEffectAttr") && attr.trigger === triggerType - && (isNullOrUndefined(selfTarget) || attr.selfTarget === selfTarget) + && (selfTarget == null || attr.selfTarget === selfTarget) && (!attr.firstHitOnly || this.firstHit) && (!attr.lastHitOnly || this.lastHit) && (!attr.firstTargetOnly || (firstTarget ?? true)), @@ -765,7 +758,7 @@ export class MoveEffectPhase extends PokemonPhase { */ protected applyMoveEffects(target: Pokemon, effectiveness: TypeDamageMultiplier, firstTarget: boolean): void { const user = this.getUserPokemon(); - if (isNullOrUndefined(user)) { + if (user == null) { return; } @@ -900,10 +893,7 @@ export class MoveEffectPhase extends PokemonPhase { * @param target - The {@linkcode Pokemon} that fainted */ protected onFaintTarget(user: Pokemon, target: Pokemon): void { - // set splice index here, so future scene queues happen before FaintedPhase - globalScene.phaseManager.setPhaseQueueSplice(); - - globalScene.phaseManager.unshiftNew("FaintPhase", target.getBattlerIndex(), false, user); + globalScene.phaseManager.queueFaintPhase(target.getBattlerIndex(), false, user); target.destroySubstitute(); target.lapseTag(BattlerTagType.COMMANDED); diff --git a/src/phases/move-header-phase.ts b/src/phases/move-header-phase.ts index 5c69dcd1217..5b8a6f998a1 100644 --- a/src/phases/move-header-phase.ts +++ b/src/phases/move-header-phase.ts @@ -5,8 +5,8 @@ import { BattlePhase } from "#phases/battle-phase"; export class MoveHeaderPhase extends BattlePhase { public readonly phaseName = "MoveHeaderPhase"; - public pokemon: Pokemon; public move: PokemonMove; + public pokemon: Pokemon; constructor(pokemon: Pokemon, move: PokemonMove) { super(); @@ -15,6 +15,10 @@ export class MoveHeaderPhase extends BattlePhase { this.move = move; } + public getPokemon(): Pokemon { + return this.pokemon; + } + canMove(): boolean { return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon); } diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 885c1028f01..94da051c2cf 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -3,6 +3,7 @@ import { MOVE_COLOR } from "#app/constants/colors"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; import Overrides from "#app/overrides"; +import { PokemonPhase } from "#app/phases/pokemon-phase"; import { CenterOfAttentionTag } from "#data/battler-tags"; import { SpeciesFormChangePreMoveTrigger } from "#data/form-change-triggers"; import { getStatusEffectActivationText, getStatusEffectHealText } from "#data/status-effect"; @@ -15,6 +16,7 @@ import { BattlerTagType } from "#enums/battler-tag-type"; import { CommonAnim } from "#enums/move-anims-common"; import { MoveFlags } from "#enums/move-flags"; import { MoveId } from "#enums/move-id"; +import { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; import { MoveResult } from "#enums/move-result"; import { isIgnorePP, isIgnoreStatus, isReflected, isVirtual, MoveUseMode } from "#enums/move-use-mode"; import { PokemonType } from "#enums/pokemon-type"; @@ -24,20 +26,19 @@ import type { Pokemon } from "#field/pokemon"; import { applyMoveAttrs } from "#moves/apply-attrs"; import { frenzyMissFunc } from "#moves/move-utils"; import type { PokemonMove } from "#moves/pokemon-move"; -import { BattlePhase } from "#phases/battle-phase"; import type { TurnMove } from "#types/turn-move"; import { NumberHolder } from "#utils/common"; import { enumValueToKey } from "#utils/enums"; import i18next from "i18next"; -export class MovePhase extends BattlePhase { +export class MovePhase extends PokemonPhase { public readonly phaseName = "MovePhase"; protected _pokemon: Pokemon; - protected _move: PokemonMove; + public move: PokemonMove; protected _targets: BattlerIndex[]; public readonly useMode: MoveUseMode; // Made public for quash - /** Whether the current move is forced last (used for Quash). */ - protected forcedLast: boolean; + /** The timing modifier of the move (used by Quash and to force called moves to the front of their queue) */ + public timingModifier: MovePhaseTimingModifier; /** Whether the current move should fail but still use PP. */ protected failed = false; /** Whether the current move should fail and retain PP. */ @@ -59,14 +60,6 @@ export class MovePhase extends BattlePhase { this._pokemon = pokemon; } - public get move(): PokemonMove { - return this._move; - } - - protected set move(move: PokemonMove) { - this._move = move; - } - public get targets(): BattlerIndex[] { return this._targets; } @@ -81,16 +74,22 @@ export class MovePhase extends BattlePhase { * @param move - The {@linkcode PokemonMove} to use * @param useMode - The {@linkcode MoveUseMode} corresponding to this move's means of execution (usually `MoveUseMode.NORMAL`). * Not marked optional to ensure callers correctly pass on `useModes`. - * @param forcedLast - Whether to force this phase to occur last in order (for {@linkcode MoveId.QUASH}); default `false` + * @param timingModifier - The {@linkcode MovePhaseTimingModifier} for the move; Default {@linkcode MovePhaseTimingModifier.NORMAL} */ - constructor(pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, useMode: MoveUseMode, forcedLast = false) { - super(); + constructor( + pokemon: Pokemon, + targets: BattlerIndex[], + move: PokemonMove, + useMode: MoveUseMode, + timingModifier: MovePhaseTimingModifier = MovePhaseTimingModifier.NORMAL, + ) { + super(pokemon.getBattlerIndex()); this.pokemon = pokemon; this.targets = targets; this.move = move; this.useMode = useMode; - this.forcedLast = forcedLast; + this.timingModifier = timingModifier; this.moveHistoryEntry = { move: MoveId.NONE, targets, @@ -121,14 +120,6 @@ export class MovePhase extends BattlePhase { this.cancelled = true; } - /** - * Shows whether the current move has been forced to the end of the turn - * Needed for speed order, see {@linkcode MoveId.QUASH} - */ - public isForcedLast(): boolean { - return this.forcedLast; - } - public start(): void { super.start(); diff --git a/src/phases/mystery-encounter-phases.ts b/src/phases/mystery-encounter-phases.ts index df670deaf26..bb3f4a92033 100644 --- a/src/phases/mystery-encounter-phases.ts +++ b/src/phases/mystery-encounter-phases.ts @@ -14,7 +14,7 @@ import type { OptionSelectSettings } from "#mystery-encounters/encounter-phase-u import { transitionMysteryEncounterIntroVisuals } from "#mystery-encounters/encounter-phase-utils"; import type { MysteryEncounterOption, OptionPhaseCallback } from "#mystery-encounters/mystery-encounter-option"; import { SeenEncounterData } from "#mystery-encounters/mystery-encounter-save-data"; -import { isNullOrUndefined, randSeedItem } from "#utils/common"; +import { randSeedItem } from "#utils/common"; import i18next from "i18next"; /** @@ -48,7 +48,6 @@ export class MysteryEncounterPhase extends Phase { // Clears out queued phases that are part of standard battle globalScene.phaseManager.clearPhaseQueue(); - globalScene.phaseManager.clearPhaseQueueSplice(); const encounter = globalScene.currentBattle.mysteryEncounter!; encounter.updateSeedOffset(); @@ -93,7 +92,7 @@ export class MysteryEncounterPhase extends Phase { if (option.onPreOptionPhase) { globalScene.executeWithSeedOffset(async () => { return await option.onPreOptionPhase!().then(result => { - if (isNullOrUndefined(result) || result) { + if (result == null || result) { this.continueEncounter(); } }); @@ -233,9 +232,7 @@ export class MysteryEncounterBattleStartCleanupPhase extends Phase { }); // Remove any status tick phases - while (globalScene.phaseManager.findPhase(p => p.is("PostTurnStatusEffectPhase"))) { - globalScene.phaseManager.tryRemovePhase(p => p.is("PostTurnStatusEffectPhase")); - } + globalScene.phaseManager.removeAllPhasesOfType("PostTurnStatusEffectPhase"); // The total number of Pokemon in the player's party that can legally fight const legalPlayerPokemon = globalScene.getPokemonAllowedInBattle(); @@ -412,16 +409,21 @@ export class MysteryEncounterBattlePhase extends Phase { } const availablePartyMembers = globalScene.getPlayerParty().filter(p => p.isAllowedInBattle()); + const minPartySize = globalScene.currentBattle.double ? 2 : 1; + const checkSwitch = + encounterMode !== MysteryEncounterMode.TRAINER_BATTLE + && !this.disableSwitch + && availablePartyMembers.length > minPartySize; if (!availablePartyMembers[0].isOnField()) { - globalScene.phaseManager.pushNew("SummonPhase", 0); + globalScene.phaseManager.pushNew("SummonPhase", 0, true, false, checkSwitch); } if (globalScene.currentBattle.double) { if (availablePartyMembers.length > 1) { globalScene.phaseManager.pushNew("ToggleDoublePositionPhase", true); if (!availablePartyMembers[1].isOnField()) { - globalScene.phaseManager.pushNew("SummonPhase", 1); + globalScene.phaseManager.pushNew("SummonPhase", 1, true, false, checkSwitch); } } } else { @@ -432,16 +434,6 @@ export class MysteryEncounterBattlePhase extends Phase { globalScene.phaseManager.pushNew("ToggleDoublePositionPhase", false); } - if (encounterMode !== MysteryEncounterMode.TRAINER_BATTLE && !this.disableSwitch) { - const minPartySize = globalScene.currentBattle.double ? 2 : 1; - if (availablePartyMembers.length > minPartySize) { - globalScene.phaseManager.pushNew("CheckSwitchPhase", 0, globalScene.currentBattle.double); - if (globalScene.currentBattle.double) { - globalScene.phaseManager.pushNew("CheckSwitchPhase", 1, globalScene.currentBattle.double); - } - } - } - this.end(); } @@ -540,7 +532,7 @@ export class MysteryEncounterRewardsPhase extends Phase { if (encounter.doEncounterRewards) { encounter.doEncounterRewards(); } else if (this.addHealPhase) { - globalScene.phaseManager.tryRemovePhase(p => p.is("SelectModifierPhase")); + globalScene.phaseManager.removeAllPhasesOfType("SelectModifierPhase"); globalScene.phaseManager.unshiftNew("SelectModifierPhase", 0, undefined, { fillRemaining: false, rerollMultiplier: -1, @@ -578,7 +570,7 @@ export class PostMysteryEncounterPhase extends Phase { if (this.onPostOptionSelect) { globalScene.executeWithSeedOffset(async () => { return await this.onPostOptionSelect!().then(result => { - if (isNullOrUndefined(result) || result) { + if (result == null || result) { this.continueEncounter(); } }); diff --git a/src/phases/new-battle-phase.ts b/src/phases/new-battle-phase.ts index b9a57161bd0..7b5d132ccd2 100644 --- a/src/phases/new-battle-phase.ts +++ b/src/phases/new-battle-phase.ts @@ -6,12 +6,7 @@ export class NewBattlePhase extends BattlePhase { start() { super.start(); - // cull any extra `NewBattle` phases from the queue. - globalScene.phaseManager.phaseQueue = globalScene.phaseManager.phaseQueue.filter( - phase => !phase.is("NewBattlePhase"), - ); - // `phaseQueuePrepend` is private, so we have to use this inefficient loop. - while (globalScene.phaseManager.tryRemoveUnshiftedPhase(phase => phase.is("NewBattlePhase"))) {} + globalScene.phaseManager.removeAllPhasesOfType("NewBattlePhase"); globalScene.newBattle(); diff --git a/src/phases/obtain-status-effect-phase.ts b/src/phases/obtain-status-effect-phase.ts index 4846130cf4d..b9f3e266d87 100644 --- a/src/phases/obtain-status-effect-phase.ts +++ b/src/phases/obtain-status-effect-phase.ts @@ -23,6 +23,7 @@ export class ObtainStatusEffectPhase extends PokemonPhase { * @param sourceText - The text to show for the source of the status effect, if any; default `null`. * @param statusMessage - A string containing text to be displayed upon status setting; * defaults to normal key for status if empty or omitted. + * @todo stop passing `null` to the phase */ constructor( battlerIndex: BattlerIndex, diff --git a/src/phases/party-member-pokemon-phase.ts b/src/phases/party-member-pokemon-phase.ts index 9536dafda60..545799cf36a 100644 --- a/src/phases/party-member-pokemon-phase.ts +++ b/src/phases/party-member-pokemon-phase.ts @@ -22,4 +22,8 @@ export abstract class PartyMemberPokemonPhase extends FieldPhase { getPokemon(): Pokemon { return this.getParty()[this.partyMemberIndex]; } + + isPlayer(): boolean { + return this.player; + } } diff --git a/src/phases/pokemon-anim-phase.ts b/src/phases/pokemon-anim-phase.ts index 39e9c609aec..c45f201641c 100644 --- a/src/phases/pokemon-anim-phase.ts +++ b/src/phases/pokemon-anim-phase.ts @@ -4,7 +4,6 @@ import { PokemonAnimType } from "#enums/pokemon-anim-type"; import { SpeciesId } from "#enums/species-id"; import type { Pokemon } from "#field/pokemon"; import { BattlePhase } from "#phases/battle-phase"; -import { isNullOrUndefined } from "#utils/common"; export class PokemonAnimPhase extends BattlePhase { public readonly phaseName = "PokemonAnimPhase"; @@ -52,7 +51,7 @@ export class PokemonAnimPhase extends BattlePhase { private doSubstituteAddAnim(): void { const substitute = this.pokemon.getTag(SubstituteTag); - if (isNullOrUndefined(substitute)) { + if (substitute == null) { this.end(); return; } @@ -336,7 +335,7 @@ export class PokemonAnimPhase extends BattlePhase { // Note: unlike the other Commander animation, this is played through the // Dondozo instead of the Tatsugiri. const tatsugiri = this.pokemon.getAlly(); - if (isNullOrUndefined(tatsugiri)) { + if (tatsugiri == null) { console.warn("Aborting COMMANDER_REMOVE anim: Tatsugiri is undefined"); this.end(); return; diff --git a/src/phases/pokemon-heal-phase.ts b/src/phases/pokemon-heal-phase.ts index 02bb3a0b968..258ddb0b624 100644 --- a/src/phases/pokemon-heal-phase.ts +++ b/src/phases/pokemon-heal-phase.ts @@ -64,7 +64,8 @@ export class PokemonHealPhase extends CommonAnimPhase { } const hasMessage = !!this.message; - const healOrDamage = !pokemon.isFullHp() || this.hpHealed < 0; + const canRestorePP = this.fullRestorePP && pokemon.getMoveset().some(mv => mv.ppUsed > 0); + const healOrDamage = !pokemon.isFullHp() || this.hpHealed < 0 || canRestorePP; const healBlock = pokemon.getTag(BattlerTagType.HEAL_BLOCK) as HealBlockTag; let lastStatusEffect = StatusEffect.NONE; diff --git a/src/phases/pokemon-phase.ts b/src/phases/pokemon-phase.ts index 1a1a7e2efa3..92b29889079 100644 --- a/src/phases/pokemon-phase.ts +++ b/src/phases/pokemon-phase.ts @@ -9,7 +9,9 @@ export abstract class PokemonPhase extends FieldPhase { * TODO: Make this either use IDs or `BattlerIndex`es, not a weird mix of both */ protected battlerIndex: BattlerIndex | number; + // TODO: Why is this needed? public player: boolean; + /** @todo Remove in favor of `battlerIndex` pleas for fuck's sake */ public fieldIndex: number; constructor(battlerIndex?: BattlerIndex | number) { @@ -32,10 +34,11 @@ export abstract class PokemonPhase extends FieldPhase { this.fieldIndex = battlerIndex % 2; } + // TODO: This should have `undefined` in its signature getPokemon(): Pokemon { if (this.battlerIndex > BattlerIndex.ENEMY_2) { - return globalScene.getPokemonById(this.battlerIndex)!; //TODO: is this bang correct? + return globalScene.getPokemonById(this.battlerIndex)!; } - return globalScene.getField()[this.battlerIndex]!; //TODO: is this bang correct? + return globalScene.getField()[this.battlerIndex]!; } } diff --git a/src/phases/post-summon-activate-ability-phase.ts b/src/phases/post-summon-activate-ability-phase.ts index 5f790c01ad1..a2b6c059bee 100644 --- a/src/phases/post-summon-activate-ability-phase.ts +++ b/src/phases/post-summon-activate-ability-phase.ts @@ -6,8 +6,8 @@ import { PostSummonPhase } from "#phases/post-summon-phase"; * Helper to {@linkcode PostSummonPhase} which applies abilities */ export class PostSummonActivateAbilityPhase extends PostSummonPhase { - private priority: number; - private passive: boolean; + private readonly priority: number; + private readonly passive: boolean; constructor(battlerIndex: BattlerIndex, priority: number, passive: boolean) { super(battlerIndex); diff --git a/src/phases/post-summon-phase.ts b/src/phases/post-summon-phase.ts index 5de068f2ae5..136f2fbd601 100644 --- a/src/phases/post-summon-phase.ts +++ b/src/phases/post-summon-phase.ts @@ -1,21 +1,35 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; +import type { PhaseString } from "#app/@types/phase-types"; import { globalScene } from "#app/global-scene"; import { EntryHazardTag } from "#data/arena-tag"; import { MysteryEncounterPostSummonTag } from "#data/battler-tags"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import type { BattlerIndex } from "#enums/battler-index"; import { BattlerTagType } from "#enums/battler-tag-type"; import { StatusEffect } from "#enums/status-effect"; import { PokemonPhase } from "#phases/pokemon-phase"; export class PostSummonPhase extends PokemonPhase { public readonly phaseName = "PostSummonPhase"; + /** Used to determine whether to push or unshift {@linkcode PostSummonActivateAbilityPhase}s */ + public readonly source: PhaseString; + + constructor(battlerIndex?: BattlerIndex | number, source: PhaseString = "SwitchSummonPhase") { + super(battlerIndex); + this.source = source; + } + start() { super.start(); const pokemon = this.getPokemon(); - + console.log("Ran PSP for:", pokemon.name); if (pokemon.status?.effect === StatusEffect.TOXIC) { pokemon.status.toxicTurnCount = 0; } + + globalScene.arena.applyTags(ArenaTagType.PENDING_HEAL, false, pokemon); + globalScene.arena.applyTags(EntryHazardTag, false, pokemon); // If this is mystery encounter and has post summon phase tag, apply post summon effects @@ -25,8 +39,7 @@ export class PostSummonPhase extends PokemonPhase { ) { pokemon.lapseTag(BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON); } - - const field = pokemon.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField(); + const field = pokemon.isPlayer() ? globalScene.getPlayerField(true) : globalScene.getEnemyField(true); for (const p of field) { applyAbAttrs("CommanderAbAttr", { pokemon: p }); } diff --git a/src/phases/quiet-form-change-phase.ts b/src/phases/quiet-form-change-phase.ts index ef53b16cc56..920ff2252b8 100644 --- a/src/phases/quiet-form-change-phase.ts +++ b/src/phases/quiet-form-change-phase.ts @@ -9,7 +9,6 @@ import { BattleSpec } from "#enums/battle-spec"; import { BattlerTagType } from "#enums/battler-tag-type"; import type { Pokemon } from "#field/pokemon"; import { BattlePhase } from "#phases/battle-phase"; -import type { MovePhase } from "#phases/move-phase"; export class QuietFormChangePhase extends BattlePhase { public readonly phaseName = "QuietFormChangePhase"; @@ -170,12 +169,7 @@ export class QuietFormChangePhase extends BattlePhase { this.pokemon.initBattleInfo(); this.pokemon.cry(); - const movePhase = globalScene.phaseManager.findPhase( - p => p.is("MovePhase") && p.pokemon === this.pokemon, - ) as MovePhase; - if (movePhase) { - movePhase.cancel(); - } + globalScene.phaseManager.cancelMove(p => p.pokemon === this.pokemon); } if (this.formChange.trigger instanceof SpeciesFormChangeTeraTrigger) { const params = { pokemon: this.pokemon }; diff --git a/src/phases/revival-blessing-phase.ts b/src/phases/revival-blessing-phase.ts index fdb108d62ac..5d75f2c9b47 100644 --- a/src/phases/revival-blessing-phase.ts +++ b/src/phases/revival-blessing-phase.ts @@ -5,7 +5,7 @@ import type { PlayerPokemon } from "#field/pokemon"; import { BattlePhase } from "#phases/battle-phase"; import type { PartyOption } from "#ui/party-ui-handler"; import { PartyUiHandler, PartyUiMode } from "#ui/party-ui-handler"; -import { isNullOrUndefined, toDmgValue } from "#utils/common"; +import { toDmgValue } from "#utils/common"; import i18next from "i18next"; /** @@ -42,11 +42,7 @@ export class RevivalBlessingPhase extends BattlePhase { ); const allyPokemon = this.user.getAlly(); - if ( - globalScene.currentBattle.double - && globalScene.getPlayerParty().length > 1 - && !isNullOrUndefined(allyPokemon) - ) { + if (globalScene.currentBattle.double && globalScene.getPlayerParty().length > 1 && allyPokemon != null) { if (slotIndex <= 1) { // Revived ally pokemon globalScene.phaseManager.unshiftNew( diff --git a/src/phases/select-modifier-phase.ts b/src/phases/select-modifier-phase.ts index 3c378a95b2a..2031fc5c5f1 100644 --- a/src/phases/select-modifier-phase.ts +++ b/src/phases/select-modifier-phase.ts @@ -27,7 +27,7 @@ import { BattlePhase } from "#phases/battle-phase"; import type { ModifierSelectUiHandler } from "#ui/modifier-select-ui-handler"; import { SHOP_OPTIONS_ROW_LIMIT } from "#ui/modifier-select-ui-handler"; import { PartyOption, PartyUiHandler, PartyUiMode } from "#ui/party-ui-handler"; -import { isNullOrUndefined, NumberHolder } from "#utils/common"; +import { NumberHolder } from "#utils/common"; import i18next from "i18next"; export type ModifierSelectCallback = (rowCursor: number, cursor: number) => boolean; @@ -429,7 +429,7 @@ export class SelectModifierPhase extends BattlePhase { } let multiplier = 1; - if (!isNullOrUndefined(this.customModifierSettings?.rerollMultiplier)) { + if (this.customModifierSettings?.rerollMultiplier != null) { if (this.customModifierSettings.rerollMultiplier < 0) { // Completely overrides reroll cost to -1 and early exits return -1; diff --git a/src/phases/select-starter-phase.ts b/src/phases/select-starter-phase.ts index a08394e3acb..e923efaa678 100644 --- a/src/phases/select-starter-phase.ts +++ b/src/phases/select-starter-phase.ts @@ -4,13 +4,11 @@ import { Phase } from "#app/phase"; import { SpeciesFormChangeMoveLearnedTrigger } from "#data/form-change-triggers"; import { Gender } from "#data/gender"; import { ChallengeType } from "#enums/challenge-type"; -import type { SpeciesId } from "#enums/species-id"; import { UiMode } from "#enums/ui-mode"; import { overrideHeldItems, overrideModifiers } from "#modifiers/modifier"; -import { SaveSlotUiMode } from "#ui/save-slot-select-ui-handler"; -import type { Starter } from "#ui/starter-select-ui-handler"; +import type { Starter } from "#types/save-data"; +import { SaveSlotUiMode } from "#ui/handlers/save-slot-select-ui-handler"; import { applyChallenges } from "#utils/challenge-utils"; -import { isNullOrUndefined } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import SoundFade from "phaser3-rex-plugins/plugins/soundfade"; @@ -45,33 +43,32 @@ export class SelectStarterPhase extends Phase { const loadPokemonAssets: Promise[] = []; starters.forEach((starter: Starter, i: number) => { if (!i && Overrides.STARTER_SPECIES_OVERRIDE) { - starter.species = getPokemonSpecies(Overrides.STARTER_SPECIES_OVERRIDE as SpeciesId); + starter.speciesId = Overrides.STARTER_SPECIES_OVERRIDE; } - const starterProps = globalScene.gameData.getSpeciesDexAttrProps(starter.species, starter.dexAttr); - let starterFormIndex = Math.min(starterProps.formIndex, Math.max(starter.species.forms.length - 1, 0)); + const species = getPokemonSpecies(starter.speciesId); + let starterFormIndex = starter.formIndex; if ( - starter.species.speciesId in Overrides.STARTER_FORM_OVERRIDES - && !isNullOrUndefined(Overrides.STARTER_FORM_OVERRIDES[starter.species.speciesId]) - && starter.species.forms[Overrides.STARTER_FORM_OVERRIDES[starter.species.speciesId]!] + starter.speciesId in Overrides.STARTER_FORM_OVERRIDES + && Overrides.STARTER_FORM_OVERRIDES[starter.speciesId] != null + && species.forms[Overrides.STARTER_FORM_OVERRIDES[starter.speciesId]!] ) { - starterFormIndex = Overrides.STARTER_FORM_OVERRIDES[starter.species.speciesId]!; + starterFormIndex = Overrides.STARTER_FORM_OVERRIDES[starter.speciesId]!; } let starterGender = - starter.species.malePercent !== null ? (!starterProps.female ? Gender.MALE : Gender.FEMALE) : Gender.GENDERLESS; + species.malePercent !== null ? (!starter.female ? Gender.MALE : Gender.FEMALE) : Gender.GENDERLESS; if (Overrides.GENDER_OVERRIDE !== null) { starterGender = Overrides.GENDER_OVERRIDE; } - const starterIvs = globalScene.gameData.dexData[starter.species.speciesId].ivs.slice(0); const starterPokemon = globalScene.addPlayerPokemon( - starter.species, + species, globalScene.gameMode.getStartingLevel(), starter.abilityIndex, starterFormIndex, starterGender, - starterProps.shiny, - starterProps.variant, - starterIvs, + starter.shiny, + starter.variant, + starter.ivs, starter.nature, ); starter.moveset && starterPokemon.tryPopulateMoveset(starter.moveset); @@ -79,7 +76,7 @@ export class SelectStarterPhase extends Phase { starterPokemon.passive = true; } starterPokemon.luck = globalScene.gameData.getDexAttrLuck( - globalScene.gameData.dexData[starter.species.speciesId].caughtAttr, + globalScene.gameData.dexData[species.speciesId].caughtAttr, ); if (starter.pokerus) { starterPokemon.pokerus = true; @@ -89,7 +86,7 @@ export class SelectStarterPhase extends Phase { starterPokemon.nickname = starter.nickname; } - if (!isNullOrUndefined(starter.teraType)) { + if (starter.teraType != null) { starterPokemon.teraType = starter.teraType; } else { starterPokemon.teraType = starterPokemon.species.type1; diff --git a/src/phases/stat-stage-change-phase.ts b/src/phases/stat-stage-change-phase.ts index 6c15342ddeb..3c2d1cb5fad 100644 --- a/src/phases/stat-stage-change-phase.ts +++ b/src/phases/stat-stage-change-phase.ts @@ -13,7 +13,7 @@ import type { Pokemon } from "#field/pokemon"; import { ResetNegativeStatStageModifier } from "#modifiers/modifier"; import { PokemonPhase } from "#phases/pokemon-phase"; import type { ConditionalUserFieldProtectStatAbAttrParams, PreStatStageChangeAbAttrParams } from "#types/ability-types"; -import { BooleanHolder, isNullOrUndefined, NumberHolder } from "#utils/common"; +import { BooleanHolder, NumberHolder } from "#utils/common"; import i18next from "i18next"; export type StatStageChangeCallback = ( @@ -153,7 +153,7 @@ export class StatStageChangePhase extends PokemonPhase { applyAbAttrs("ConditionalUserFieldProtectStatAbAttr", abAttrParams); // TODO: Consider skipping this call if `cancelled` is false. const ally = pokemon.getAlly(); - if (!isNullOrUndefined(ally)) { + if (ally != null) { applyAbAttrs("ConditionalUserFieldProtectStatAbAttr", { ...abAttrParams, pokemon: ally }); } @@ -223,10 +223,7 @@ export class StatStageChangePhase extends PokemonPhase { }); // Look for any other stat change phases; if this is the last one, do White Herb check - const existingPhase = globalScene.phaseManager.findPhase( - p => p.is("StatStageChangePhase") && p.battlerIndex === this.battlerIndex, - ); - if (!existingPhase?.is("StatStageChangePhase")) { + if (!globalScene.phaseManager.hasPhaseOfType("StatStageChangePhase", p => p.battlerIndex === this.battlerIndex)) { // Apply White Herb if needed const whiteHerb = globalScene.applyModifier( ResetNegativeStatStageModifier, @@ -297,49 +294,6 @@ export class StatStageChangePhase extends PokemonPhase { } } - aggregateStatStageChanges(): void { - const accEva: BattleStat[] = [Stat.ACC, Stat.EVA]; - const isAccEva = accEva.some(s => this.stats.includes(s)); - let existingPhase: StatStageChangePhase; - if (this.stats.length === 1) { - while ( - (existingPhase = globalScene.phaseManager.findPhase( - p => - p.is("StatStageChangePhase") - && p.battlerIndex === this.battlerIndex - && p.stats.length === 1 - && p.stats[0] === this.stats[0] - && p.selfTarget === this.selfTarget - && p.showMessage === this.showMessage - && p.ignoreAbilities === this.ignoreAbilities, - ) as StatStageChangePhase) - ) { - this.stages += existingPhase.stages; - - if (!globalScene.phaseManager.tryRemovePhase(p => p === existingPhase)) { - break; - } - } - } - while ( - (existingPhase = globalScene.phaseManager.findPhase( - p => - p.is("StatStageChangePhase") - && p.battlerIndex === this.battlerIndex - && p.selfTarget === this.selfTarget - && accEva.some(s => p.stats.includes(s)) === isAccEva - && p.stages === this.stages - && p.showMessage === this.showMessage - && p.ignoreAbilities === this.ignoreAbilities, - ) as StatStageChangePhase) - ) { - this.stats.push(...existingPhase.stats); - if (!globalScene.phaseManager.tryRemovePhase(p => p === existingPhase)) { - break; - } - } - } - getStatStageChangeMessages(stats: BattleStat[], stages: number, relStages: number[]): string[] { const messages: string[] = []; diff --git a/src/phases/summon-phase.ts b/src/phases/summon-phase.ts index dda70f46ec9..26a8ba40ffc 100644 --- a/src/phases/summon-phase.ts +++ b/src/phases/summon-phase.ts @@ -16,12 +16,14 @@ import i18next from "i18next"; export class SummonPhase extends PartyMemberPokemonPhase { // The union type is needed to keep typescript happy as these phases extend from SummonPhase public readonly phaseName: "SummonPhase" | "SummonMissingPhase" | "SwitchSummonPhase" | "ReturnPhase" = "SummonPhase"; - private loaded: boolean; + private readonly loaded: boolean; + private readonly checkSwitch: boolean; - constructor(fieldIndex: number, player = true, loaded = false) { + constructor(fieldIndex: number, player = true, loaded = false, checkSwitch = false) { super(fieldIndex, player); this.loaded = loaded; + this.checkSwitch = checkSwitch; } start() { @@ -288,7 +290,17 @@ export class SummonPhase extends PartyMemberPokemonPhase { } queuePostSummon(): void { - globalScene.phaseManager.pushNew("PostSummonPhase", this.getPokemon().getBattlerIndex()); + if (this.checkSwitch) { + globalScene.phaseManager.pushNew( + "CheckSwitchPhase", + this.getPokemon().getFieldIndex(), + globalScene.currentBattle.double, + ); + } else { + globalScene.phaseManager.pushNew("PostSummonPhase", this.getPokemon().getBattlerIndex(), this.phaseName); + } + + globalScene.phaseManager.tryAddEnemyPostSummonPhases(); } end() { @@ -296,4 +308,8 @@ export class SummonPhase extends PartyMemberPokemonPhase { super.end(); } + + public getFieldIndex(): number { + return this.fieldIndex; + } } diff --git a/src/phases/switch-phase.ts b/src/phases/switch-phase.ts index 83a699b6b08..9ab06ec827c 100644 --- a/src/phases/switch-phase.ts +++ b/src/phases/switch-phase.ts @@ -1,5 +1,4 @@ import { globalScene } from "#app/global-scene"; -import { DynamicPhaseType } from "#enums/dynamic-phase-type"; import { SwitchType } from "#enums/switch-type"; import { UiMode } from "#enums/ui-mode"; import { BattlePhase } from "#phases/battle-phase"; @@ -77,14 +76,6 @@ export class SwitchPhase extends BattlePhase { fieldIndex, (slotIndex: number, option: PartyOption) => { if (slotIndex >= globalScene.currentBattle.getBattlerCount() && slotIndex < 6) { - // Remove any pre-existing PostSummonPhase under the same field index. - // Pre-existing PostSummonPhases may occur when this phase is invoked during a prompt to switch at the start of a wave. - // TODO: Separate the animations from `SwitchSummonPhase` and co. into another phase and use that on initial switch - this is a band-aid fix - globalScene.phaseManager.tryRemoveDynamicPhase( - DynamicPhaseType.POST_SUMMON, - p => p.is("PostSummonPhase") && p.player && p.fieldIndex === this.fieldIndex, - "all", - ); const switchType = option === PartyOption.PASS_BATON ? SwitchType.BATON_PASS : this.switchType; globalScene.phaseManager.unshiftNew("SwitchSummonPhase", switchType, fieldIndex, slotIndex, this.doReturn); } diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index ac47068c619..8cc7843b55f 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -241,11 +241,11 @@ export class SwitchSummonPhase extends SummonPhase { globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true); // Reverts to weather-based forms when weather suppressors (Cloud Nine/Air Lock) are switched out - globalScene.arena.triggerWeatherBasedFormChanges(); + globalScene.arena.triggerWeatherBasedFormChanges(pokemon); } queuePostSummon(): void { - globalScene.phaseManager.startNewDynamicPhase("PostSummonPhase", this.getPokemon().getBattlerIndex()); + globalScene.phaseManager.unshiftNew("PostSummonPhase", this.getPokemon().getBattlerIndex()); } /** diff --git a/src/phases/title-phase.ts b/src/phases/title-phase.ts index d422766bf09..9535ea1c8e9 100644 --- a/src/phases/title-phase.ts +++ b/src/phases/title-phase.ts @@ -18,7 +18,8 @@ import { vouchers } from "#system/voucher"; import type { SessionSaveData } from "#types/save-data"; import type { OptionSelectConfig, OptionSelectItem } from "#ui/abstract-option-select-ui-handler"; import { SaveSlotUiMode } from "#ui/save-slot-select-ui-handler"; -import { isLocal, isLocalServerConnected, isNullOrUndefined } from "#utils/common"; +import { isLocal, isLocalServerConnected } from "#utils/common"; +import { getPokemonSpecies } from "#utils/pokemon-utils"; import i18next from "i18next"; export class TitlePhase extends Phase { @@ -218,23 +219,19 @@ export class TitlePhase extends Phase { const party = globalScene.getPlayerParty(); const loadPokemonAssets: Promise[] = []; for (const starter of starters) { - const starterProps = globalScene.gameData.getSpeciesDexAttrProps(starter.species, starter.dexAttr); - const starterFormIndex = Math.min(starterProps.formIndex, Math.max(starter.species.forms.length - 1, 0)); + const species = getPokemonSpecies(starter.speciesId); + const starterFormIndex = starter.formIndex; const starterGender = - starter.species.malePercent !== null - ? !starterProps.female - ? Gender.MALE - : Gender.FEMALE - : Gender.GENDERLESS; + species.malePercent !== null ? (starter.female ? Gender.FEMALE : Gender.MALE) : Gender.GENDERLESS; const starterPokemon = globalScene.addPlayerPokemon( - starter.species, + species, startingLevel, starter.abilityIndex, starterFormIndex, starterGender, - starterProps.shiny, - starterProps.variant, - undefined, + starter.shiny, + starter.variant, + starter.ivs, starter.nature, ); starterPokemon.setVisible(false); @@ -289,7 +286,7 @@ export class TitlePhase extends Phase { } else { // Grab first 10 chars of ISO date format (YYYY-MM-DD) and convert to base64 let seed: string = btoa(new Date().toISOString().substring(0, 10)); - if (!isNullOrUndefined(Overrides.DAILY_RUN_SEED_OVERRIDE)) { + if (Overrides.DAILY_RUN_SEED_OVERRIDE != null) { seed = Overrides.DAILY_RUN_SEED_OVERRIDE; } generateDaily(seed); @@ -315,23 +312,15 @@ export class TitlePhase extends Phase { if (this.loaded) { const availablePartyMembers = globalScene.getPokemonAllowedInBattle().length; - - globalScene.phaseManager.pushNew("SummonPhase", 0, true, true); - if (globalScene.currentBattle.double && availablePartyMembers > 1) { - globalScene.phaseManager.pushNew("SummonPhase", 1, true, true); - } - - if ( + const minPartySize = globalScene.currentBattle.double ? 2 : 1; + const checkSwitch = globalScene.currentBattle.battleType !== BattleType.TRAINER && (globalScene.currentBattle.waveIndex > 1 || !globalScene.gameMode.isDaily) - ) { - const minPartySize = globalScene.currentBattle.double ? 2 : 1; - if (availablePartyMembers > minPartySize) { - globalScene.phaseManager.pushNew("CheckSwitchPhase", 0, globalScene.currentBattle.double); - if (globalScene.currentBattle.double) { - globalScene.phaseManager.pushNew("CheckSwitchPhase", 1, globalScene.currentBattle.double); - } - } + && availablePartyMembers > minPartySize; + + globalScene.phaseManager.pushNew("SummonPhase", 0, true, true, checkSwitch); + if (globalScene.currentBattle.double && availablePartyMembers > 1) { + globalScene.phaseManager.pushNew("SummonPhase", 1, true, true, checkSwitch); } } diff --git a/src/phases/turn-end-phase.ts b/src/phases/turn-end-phase.ts index ca0f9520dcb..e80ce97c327 100644 --- a/src/phases/turn-end-phase.ts +++ b/src/phases/turn-end-phase.ts @@ -25,6 +25,7 @@ export class TurnEndPhase extends FieldPhase { globalScene.currentBattle.incrementTurn(); globalScene.eventTarget.dispatchEvent(new TurnEndEvent()); + globalScene.phaseManager.dynamicQueueManager.clearLastTurnOrder(); globalScene.phaseManager.hideAbilityBar(); diff --git a/src/phases/turn-start-phase.ts b/src/phases/turn-start-phase.ts index 1733901d527..cd45a73c813 100644 --- a/src/phases/turn-start-phase.ts +++ b/src/phases/turn-start-phase.ts @@ -1,89 +1,31 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import type { TurnCommand } from "#app/battle"; import { globalScene } from "#app/global-scene"; -import { TrickRoomTag } from "#data/arena-tag"; -import { allMoves } from "#data/data-lists"; -import { BattlerIndex } from "#enums/battler-index"; +import { ArenaTagSide } from "#enums/arena-tag-side"; +import type { BattlerIndex } from "#enums/battler-index"; import { Command } from "#enums/command"; -import { Stat } from "#enums/stat"; import { SwitchType } from "#enums/switch-type"; import type { Pokemon } from "#field/pokemon"; import { BypassSpeedChanceModifier } from "#modifiers/modifier"; import { PokemonMove } from "#moves/pokemon-move"; import { FieldPhase } from "#phases/field-phase"; -import { BooleanHolder, randSeedShuffle } from "#utils/common"; +import { inSpeedOrder } from "#utils/speed-order-generator"; export class TurnStartPhase extends FieldPhase { public readonly phaseName = "TurnStartPhase"; /** - * Helper method to retrieve the current speed order of the combattants. - * It also checks for Trick Room and reverses the array if it is present. - * @returns The {@linkcode BattlerIndex}es of all on-field Pokemon, sorted in speed order. - * @todo Make this private - */ - getSpeedOrder(): BattlerIndex[] { - const playerField = globalScene.getPlayerField().filter(p => p.isActive()); - const enemyField = globalScene.getEnemyField().filter(p => p.isActive()); - - // Shuffle the list before sorting so speed ties produce random results - // This is seeded with the current turn to prevent turn order varying - // based on how long since you last reloaded. - let orderedTargets = (playerField as Pokemon[]).concat(enemyField); - globalScene.executeWithSeedOffset( - () => { - orderedTargets = randSeedShuffle(orderedTargets); - }, - globalScene.currentBattle.turn, - globalScene.waveSeed, - ); - - // Check for Trick Room and reverse sort order if active. - // Notably, Pokerogue does NOT have the "outspeed trick room" glitch at >1809 spd. - const speedReversed = new BooleanHolder(false); - globalScene.arena.applyTags(TrickRoomTag, false, speedReversed); - - orderedTargets.sort((a: Pokemon, b: Pokemon) => { - const aSpeed = a.getEffectiveStat(Stat.SPD); - const bSpeed = b.getEffectiveStat(Stat.SPD); - - return speedReversed.value ? aSpeed - bSpeed : bSpeed - aSpeed; - }); - - return orderedTargets.map(t => t.getFieldIndex() + (t.isEnemy() ? BattlerIndex.ENEMY : BattlerIndex.PLAYER)); - } - - /** - * This takes the result of {@linkcode getSpeedOrder} and applies priority / bypass speed attributes to it. - * This also considers the priority levels of various commands and changes the result of `getSpeedOrder` based on such. - * @returns The `BattlerIndex`es of all on-field Pokemon sorted in action order. + * Returns an ordering of the current field based on command priority + * @returns The sequence of commands for this turn */ getCommandOrder(): BattlerIndex[] { - let moveOrder = this.getSpeedOrder(); - // The creation of the battlerBypassSpeed object contains checks for the ability Quick Draw and the held item Quick Claw - // The ability Mycelium Might disables Quick Claw's activation when using a status move - // This occurs before the main loop because of battles with more than two Pokemon - const battlerBypassSpeed = {}; - - globalScene.getField(true).forEach(p => { - const bypassSpeed = new BooleanHolder(false); - const canCheckHeldItems = new BooleanHolder(true); - applyAbAttrs("BypassSpeedChanceAbAttr", { pokemon: p, bypass: bypassSpeed }); - applyAbAttrs("PreventBypassSpeedChanceAbAttr", { - pokemon: p, - bypass: bypassSpeed, - canCheckHeldItems, - }); - if (canCheckHeldItems.value) { - globalScene.applyModifiers(BypassSpeedChanceModifier, p.isPlayer(), p, bypassSpeed); - } - battlerBypassSpeed[p.getBattlerIndex()] = bypassSpeed; - }); + const playerField = globalScene.getPlayerField(true).map(p => p.getBattlerIndex()); + const enemyField = globalScene.getEnemyField(true).map(p => p.getBattlerIndex()); + const orderedTargets: BattlerIndex[] = playerField.concat(enemyField); // The function begins sorting orderedTargets based on command priority, move priority, and possible speed bypasses. // Non-FIGHT commands (SWITCH, BALL, RUN) have a higher command priority and will always occur before any FIGHT commands. - moveOrder = moveOrder.slice(0); - moveOrder.sort((a, b) => { + orderedTargets.sort((a, b) => { const aCommand = globalScene.currentBattle.turnCommands[a]; const bCommand = globalScene.currentBattle.turnCommands[b]; @@ -94,41 +36,14 @@ export class TurnStartPhase extends FieldPhase { if (bCommand?.command === Command.FIGHT) { return -1; } - } else if (aCommand?.command === Command.FIGHT) { - const aMove = allMoves[aCommand.move!.move]; - const bMove = allMoves[bCommand!.move!.move]; - - const aUser = globalScene.getField(true).find(p => p.getBattlerIndex() === a)!; - const bUser = globalScene.getField(true).find(p => p.getBattlerIndex() === b)!; - - const aPriority = aMove.getPriority(aUser, false); - const bPriority = bMove.getPriority(bUser, false); - - // The game now checks for differences in priority levels. - // If the moves share the same original priority bracket, it can check for differences in battlerBypassSpeed and return the result. - // This conditional is used to ensure that Quick Claw can still activate with abilities like Stall and Mycelium Might (attack moves only) - // Otherwise, the game returns the user of the move with the highest priority. - const isSameBracket = Math.ceil(aPriority) - Math.ceil(bPriority) === 0; - if (aPriority !== bPriority) { - if (isSameBracket && battlerBypassSpeed[a].value !== battlerBypassSpeed[b].value) { - return battlerBypassSpeed[a].value ? -1 : 1; - } - return aPriority < bPriority ? 1 : -1; - } } - // If there is no difference between the move's calculated priorities, - // check for differences in battlerBypassSpeed and returns the result. - if (battlerBypassSpeed[a].value !== battlerBypassSpeed[b].value) { - return battlerBypassSpeed[a].value ? -1 : 1; - } - - const aIndex = moveOrder.indexOf(a); - const bIndex = moveOrder.indexOf(b); + const aIndex = orderedTargets.indexOf(a); + const bIndex = orderedTargets.indexOf(b); return aIndex < bIndex ? -1 : aIndex > bIndex ? 1 : 0; }); - return moveOrder; + return orderedTargets; } // TODO: Refactor this alongside `CommandPhase.handleCommand` to use SEPARATE METHODS @@ -139,9 +54,8 @@ export class TurnStartPhase extends FieldPhase { const field = globalScene.getField(); const moveOrder = this.getCommandOrder(); - for (const o of this.getSpeedOrder()) { - const pokemon = field[o]; - const preTurnCommand = globalScene.currentBattle.preTurnCommands[o]; + for (const pokemon of inSpeedOrder(ArenaTagSide.BOTH)) { + const preTurnCommand = globalScene.currentBattle.preTurnCommands[pokemon.getBattlerIndex()]; if (preTurnCommand?.skip) { continue; @@ -154,6 +68,10 @@ export class TurnStartPhase extends FieldPhase { } const phaseManager = globalScene.phaseManager; + for (const pokemon of inSpeedOrder(ArenaTagSide.BOTH)) { + applyAbAttrs("BypassSpeedChanceAbAttr", { pokemon }); + globalScene.applyModifiers(BypassSpeedChanceModifier, pokemon.isPlayer(), pokemon); + } moveOrder.forEach((o, index) => { const pokemon = field[o]; @@ -178,13 +96,8 @@ export class TurnStartPhase extends FieldPhase { // TODO: Re-order these phases to be consistent with mainline turn order: // https://www.smogon.com/forums/threads/sword-shield-battle-mechanics-research.3655528/page-64#post-9244179 - phaseManager.pushNew("WeatherEffectPhase"); - phaseManager.pushNew("PositionalTagPhase"); - phaseManager.pushNew("BerryPhase"); - - phaseManager.pushNew("CheckStatusEffectPhase", moveOrder); - - phaseManager.pushNew("TurnEndPhase"); + // TODO: In an ideal world, this is handled by the phase manager. The change is nontrivial due to the ordering of post-turn phases like those queued by VictoryPhase + globalScene.phaseManager.queueTurnEndPhases(); /* * `this.end()` will call `PhaseManager#shiftPhase()`, which dumps everything from `phaseQueuePrepend` diff --git a/src/queues/move-phase-priority-queue.ts b/src/queues/move-phase-priority-queue.ts new file mode 100644 index 00000000000..5f0b20c3c2e --- /dev/null +++ b/src/queues/move-phase-priority-queue.ts @@ -0,0 +1,103 @@ +import type { PokemonMove } from "#app/data/moves/pokemon-move"; +import type { Pokemon } from "#app/field/pokemon"; +import { globalScene } from "#app/global-scene"; +import type { MovePhase } from "#app/phases/move-phase"; +import { PokemonPhasePriorityQueue } from "#app/queues/pokemon-phase-priority-queue"; +import type { BattlerIndex } from "#enums/battler-index"; +import type { MovePhaseTimingModifier } from "#enums/move-phase-timing-modifier"; +import type { PhaseConditionFunc } from "#types/phase-types"; + +/** A priority queue responsible for the ordering of {@linkcode MovePhase}s */ +export class MovePhasePriorityQueue extends PokemonPhasePriorityQueue { + private lastTurnOrder: Pokemon[] = []; + + protected override reorder(): void { + super.reorder(); + this.sortPostSpeed(); + } + + public cancelMove(condition: PhaseConditionFunc<"MovePhase">): void { + this.queue.find(p => condition(p))?.cancel(); + } + + public setTimingModifier(condition: PhaseConditionFunc<"MovePhase">, modifier: MovePhaseTimingModifier): void { + const phase = this.queue.find(p => condition(p)); + if (phase != null) { + phase.timingModifier = modifier; + } + } + + public setMoveForPhase(condition: PhaseConditionFunc<"MovePhase">, move: PokemonMove) { + const phase = this.queue.find(p => condition(p)); + if (phase != null) { + phase.move = move; + } + } + + public redirectMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void { + // failsafe: if not a double battle just return + if (!globalScene.currentBattle.double) { + return; + } + + // TODO: simplify later + if (allyPokemon?.isActive(true)) { + this.queue + .filter( + mp => + mp.targets.length === 1 + && mp.targets[0] === removedPokemon.getBattlerIndex() + && mp.pokemon.isPlayer() !== allyPokemon.isPlayer(), + ) + .forEach(targetingMovePhase => { + if (targetingMovePhase && targetingMovePhase.targets[0] !== allyPokemon.getBattlerIndex()) { + targetingMovePhase.targets[0] = allyPokemon.getBattlerIndex(); + } + }); + } + } + + public setMoveOrder(order: BattlerIndex[]) { + this.setOrder = order; + } + + public override pop(): MovePhase | undefined { + this.reorder(); + const phase = this.queue.shift(); + if (phase) { + this.lastTurnOrder.push(phase.pokemon); + } + return phase; + } + + public getTurnOrder(): Pokemon[] { + return this.lastTurnOrder; + } + + public clearTurnOrder(): void { + this.lastTurnOrder = []; + } + + public override clear(): void { + this.setOrder = undefined; + this.lastTurnOrder = []; + super.clear(); + } + + private sortPostSpeed(): void { + this.queue.sort((a: MovePhase, b: MovePhase) => { + const priority = [a, b].map(movePhase => { + const move = movePhase.move.getMove(); + return move.getPriority(movePhase.pokemon, true); + }); + + const timingModifiers = [a, b].map(movePhase => movePhase.timingModifier); + + if (timingModifiers[0] !== timingModifiers[1]) { + return timingModifiers[1] - timingModifiers[0]; + } + + return priority[1] - priority[0]; + }); + } +} diff --git a/src/queues/pokemon-phase-priority-queue.ts b/src/queues/pokemon-phase-priority-queue.ts new file mode 100644 index 00000000000..3098c5be435 --- /dev/null +++ b/src/queues/pokemon-phase-priority-queue.ts @@ -0,0 +1,20 @@ +import type { DynamicPhase } from "#app/@types/phase-types"; +import { PriorityQueue } from "#app/queues/priority-queue"; +import { sortInSpeedOrder } from "#app/utils/speed-order"; +import type { BattlerIndex } from "#enums/battler-index"; + +/** A generic speed-based priority queue of {@linkcode DynamicPhase}s */ +export class PokemonPhasePriorityQueue extends PriorityQueue { + protected setOrder: BattlerIndex[] | undefined; + protected override reorder(): void { + const setOrder = this.setOrder; + if (setOrder) { + this.queue.sort( + (a, b) => + setOrder.indexOf(a.getPokemon().getBattlerIndex()) - setOrder.indexOf(b.getPokemon().getBattlerIndex()), + ); + } else { + this.queue = sortInSpeedOrder(this.queue); + } + } +} diff --git a/src/queues/pokemon-priority-queue.ts b/src/queues/pokemon-priority-queue.ts new file mode 100644 index 00000000000..597bfb32c0d --- /dev/null +++ b/src/queues/pokemon-priority-queue.ts @@ -0,0 +1,10 @@ +import type { Pokemon } from "#app/field/pokemon"; +import { PriorityQueue } from "#app/queues/priority-queue"; +import { sortInSpeedOrder } from "#app/utils/speed-order"; + +/** A priority queue of {@linkcode Pokemon}s */ +export class PokemonPriorityQueue extends PriorityQueue { + protected override reorder(): void { + this.queue = sortInSpeedOrder(this.queue); + } +} diff --git a/src/queues/post-summon-phase-priority-queue.ts b/src/queues/post-summon-phase-priority-queue.ts new file mode 100644 index 00000000000..37da90a1427 --- /dev/null +++ b/src/queues/post-summon-phase-priority-queue.ts @@ -0,0 +1,45 @@ +import { globalScene } from "#app/global-scene"; +import { PostSummonActivateAbilityPhase } from "#app/phases/post-summon-activate-ability-phase"; +import type { PostSummonPhase } from "#app/phases/post-summon-phase"; +import { PokemonPhasePriorityQueue } from "#app/queues/pokemon-phase-priority-queue"; +import { sortInSpeedOrder } from "#app/utils/speed-order"; + +/** + * Priority Queue for {@linkcode PostSummonPhase} and {@linkcode PostSummonActivateAbilityPhase} + * + * Orders phases first by ability priority, then by the {@linkcode Pokemon}'s effective speed + */ +export class PostSummonPhasePriorityQueue extends PokemonPhasePriorityQueue { + protected override reorder(): void { + this.queue = sortInSpeedOrder(this.queue, false); + this.queue.sort((phaseA, phaseB) => phaseB.getPriority() - phaseA.getPriority()); + } + + public override push(phase: PostSummonPhase): void { + super.push(phase); + this.queueAbilityPhase(phase); + } + + /** + * Queues all necessary {@linkcode PostSummonActivateAbilityPhase}s for each pushed {@linkcode PostSummonPhase} + * @param phase - The {@linkcode PostSummonPhase} that was pushed onto the queue + */ + private queueAbilityPhase(phase: PostSummonPhase): void { + if (phase instanceof PostSummonActivateAbilityPhase) { + return; + } + + const phasePokemon = phase.getPokemon(); + + phasePokemon.getAbilityPriorities().forEach((priority, idx) => { + const activateAbilityPhase = new PostSummonActivateAbilityPhase( + phasePokemon.getBattlerIndex(), + priority, + idx !== 0, + ); + phase.source === "SummonPhase" + ? globalScene.phaseManager.pushPhase(activateAbilityPhase) + : globalScene.phaseManager.unshiftPhase(activateAbilityPhase); + }); + } +} diff --git a/src/queues/priority-queue.ts b/src/queues/priority-queue.ts new file mode 100644 index 00000000000..b53cfec3f4d --- /dev/null +++ b/src/queues/priority-queue.ts @@ -0,0 +1,78 @@ +/** + * Stores a list of elements. + * + * Dynamically updates ordering to always pop the highest "priority", based on implementation of {@linkcode reorder}. + */ +export abstract class PriorityQueue { + protected queue: T[] = []; + + /** + * Sorts the elements in the queue + */ + protected abstract reorder(): void; + + /** + * Calls {@linkcode reorder} and shifts the queue + * @returns The front element of the queue after sorting, or `undefined` if the queue is empty + * @sealed + */ + public pop(): T | undefined { + if (this.isEmpty()) { + return; + } + + this.reorder(); + return this.queue.shift(); + } + + /** + * Adds an element to the queue + * @param element The element to add + */ + public push(element: T): void { + this.queue.push(element); + } + + /** + * Removes all elements from the queue + * @sealed + */ + public clear(): void { + this.queue.splice(0, this.queue.length); + } + + /** + * @returns Whether the queue is empty + * @sealed + */ + public isEmpty(): boolean { + return this.queue.length === 0; + } + + /** + * Removes the first element matching the condition + * @param condition - An optional condition function (defaults to a function that always returns `true`) + * @returns Whether a removal occurred + */ + public remove(condition: (t: T) => boolean = () => true): boolean { + // Reorder to remove the first element + this.reorder(); + const index = this.queue.findIndex(condition); + if (index === -1) { + return false; + } + + this.queue.splice(index, 1); + return true; + } + + /** @returns An element matching the condition function */ + public find(condition?: (t: T) => boolean): T | undefined { + return this.queue.find(e => !condition || condition(e)); + } + + /** @returns Whether an element matching the condition function exists */ + public has(condition?: (t: T) => boolean): boolean { + return this.queue.some(e => !condition || condition(e)); + } +} diff --git a/src/sprites/variant.ts b/src/sprites/variant.ts index 28d7ed13839..9d7a20bc058 100644 --- a/src/sprites/variant.ts +++ b/src/sprites/variant.ts @@ -2,7 +2,6 @@ import { globalScene } from "#app/global-scene"; import { VariantTier } from "#enums/variant-tier"; import type { Pokemon } from "#field/pokemon"; import { hasExpSprite } from "#sprites/sprite-utils"; -import { isNullOrUndefined } from "#utils/common"; export type Variant = 0 | 1 | 2; @@ -138,7 +137,7 @@ export async function populateVariantColorCache( return fallbackVariantColor(cacheKey, spritePath, useExpSprite, battleSpritePath, error); }) .then(c => { - if (!isNullOrUndefined(c)) { + if (c != null) { variantColorCache[cacheKey] = c; } }); diff --git a/src/system/arena-data.ts b/src/system/arena-data.ts index 18620e15223..0d40a9c6234 100644 --- a/src/system/arena-data.ts +++ b/src/system/arena-data.ts @@ -47,8 +47,12 @@ export class ArenaData { } this.biome = source.biome; - this.weather = source.weather ? new Weather(source.weather.weatherType, source.weather.turnsLeft) : null; - this.terrain = source.terrain ? new Terrain(source.terrain.terrainType, source.terrain.turnsLeft) : null; + this.weather = source.weather + ? new Weather(source.weather.weatherType, source.weather.turnsLeft, source.weather.maxDuration) + : null; + this.terrain = source.terrain + ? new Terrain(source.terrain.terrainType, source.terrain.turnsLeft, source.terrain.maxDuration) + : null; this.positionalTags = source.positionalTags ?? []; } } diff --git a/src/system/game-data.ts b/src/system/game-data.ts index 8c2a1219245..3ffa7482706 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -1021,6 +1021,7 @@ export class GameData { WeatherType.NONE, globalScene.arena.weather?.weatherType!, globalScene.arena.weather?.turnsLeft!, + globalScene.arena.weather?.maxDuration!, ), ); // TODO: is this bang correct? @@ -1030,6 +1031,7 @@ export class GameData { TerrainType.NONE, globalScene.arena.terrain?.terrainType!, globalScene.arena.terrain?.turnsLeft!, + globalScene.arena.terrain?.maxDuration!, ), ); // TODO: is this bang correct? @@ -1039,12 +1041,14 @@ export class GameData { if (globalScene.arena.tags) { for (const tag of globalScene.arena.tags) { if (tag instanceof EntryHazardTag) { - const { tagType, side, turnCount, layers, maxLayers } = tag as EntryHazardTag; + const { tagType, side, turnCount, maxDuration, layers, maxLayers } = tag as EntryHazardTag; globalScene.arena.eventTarget.dispatchEvent( - new TagAddedEvent(tagType, side, turnCount, layers, maxLayers), + new TagAddedEvent(tagType, side, turnCount, maxDuration, layers, maxLayers), ); } else { - globalScene.arena.eventTarget.dispatchEvent(new TagAddedEvent(tag.tagType, tag.side, tag.turnCount)); + globalScene.arena.eventTarget.dispatchEvent( + new TagAddedEvent(tag.tagType, tag.side, tag.turnCount, tag.maxDuration), + ); } } } diff --git a/src/system/ribbons/ribbon-methods.ts b/src/system/ribbons/ribbon-methods.ts index 138c0be7b51..f1aeb9fefc2 100644 --- a/src/system/ribbons/ribbon-methods.ts +++ b/src/system/ribbons/ribbon-methods.ts @@ -2,7 +2,6 @@ import { globalScene } from "#app/global-scene"; import { pokemonPrevolutions } from "#balance/pokemon-evolutions"; import type { SpeciesId } from "#enums/species-id"; import type { RibbonFlag } from "#system/ribbons/ribbon-data"; -import { isNullOrUndefined } from "#utils/common"; /** * Award one or more ribbons to a species and its pre-evolutions @@ -14,7 +13,7 @@ export function awardRibbonsToSpeciesLine(id: SpeciesId, ribbons: RibbonFlag): v const dexData = globalScene.gameData.dexData; dexData[id].ribbons.award(ribbons); // Mark all pre-evolutions of the Pokémon with the same ribbon flags. - for (let prevoId = pokemonPrevolutions[id]; !isNullOrUndefined(prevoId); prevoId = pokemonPrevolutions[prevoId]) { + for (let prevoId = pokemonPrevolutions[id]; prevoId != null; prevoId = pokemonPrevolutions[prevoId]) { dexData[prevoId].ribbons.award(ribbons); } } diff --git a/src/system/version-migration/versions/v1_0_4.ts b/src/system/version-migration/versions/v1_0_4.ts index 8229b9320d5..5342396d576 100644 --- a/src/system/version-migration/versions/v1_0_4.ts +++ b/src/system/version-migration/versions/v1_0_4.ts @@ -8,7 +8,6 @@ import type { SessionSaveData, SystemSaveData } from "#types/save-data"; import type { SessionSaveMigrator } from "#types/session-save-migrator"; import type { SettingsSaveMigrator } from "#types/settings-save-migrator"; import type { SystemSaveMigrator } from "#types/system-save-migrator"; -import { isNullOrUndefined } from "#utils/common"; /** * Migrate ability starter data if empty for caught species. @@ -82,7 +81,7 @@ const fixLegendaryStats: SystemSaveMigrator = { const fixStarterData: SystemSaveMigrator = { version: "1.0.4", migrate: (data: SystemSaveData): void => { - if (!isNullOrUndefined(data.starterData)) { + if (data.starterData != null) { for (const starterId of defaultStarterSpecies) { if (data.starterData[starterId]?.abilityAttr) { data.starterData[starterId].abilityAttr |= AbilityAttr.ABILITY_1; @@ -198,7 +197,7 @@ const migrateCustomPokemonData: SessionSaveMigrator = { pokemon["fusionMysteryEncounterPokemonData"] = null; } pokemon.customPokemonData = pokemon.customPokemonData ?? new CustomPokemonData(); - if (!isNullOrUndefined(pokemon["natureOverride"]) && pokemon["natureOverride"] >= 0) { + if (pokemon["natureOverride"] != null && pokemon["natureOverride"] >= 0) { pokemon.customPokemonData.nature = pokemon["natureOverride"]; pokemon["natureOverride"] = -1; } diff --git a/src/system/version-migration/versions/v1_7_0.ts b/src/system/version-migration/versions/v1_7_0.ts index 6d365cf31ac..e526ccd2c2b 100644 --- a/src/system/version-migration/versions/v1_7_0.ts +++ b/src/system/version-migration/versions/v1_7_0.ts @@ -3,7 +3,6 @@ import { DexAttr } from "#enums/dex-attr"; import type { SessionSaveData, SystemSaveData } from "#types/save-data"; import type { SessionSaveMigrator } from "#types/session-save-migrator"; import type { SystemSaveMigrator } from "#types/system-save-migrator"; -import { isNullOrUndefined } from "#utils/common"; import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils"; /** @@ -68,13 +67,13 @@ const migrateTera: SessionSaveMigrator = { } data.party.forEach(p => { - if (isNullOrUndefined(p.teraType)) { + if (p.teraType == null) { p.teraType = getPokemonSpeciesForm(p.species, p.formIndex).type1; } }); data.enemyParty.forEach(p => { - if (isNullOrUndefined(p.teraType)) { + if (p.teraType == null) { p.teraType = getPokemonSpeciesForm(p.species, p.formIndex).type1; } }); diff --git a/src/timed-event-manager.ts b/src/timed-event-manager.ts index ed92a1c9ca5..7db89b2a0ef 100644 --- a/src/timed-event-manager.ts +++ b/src/timed-event-manager.ts @@ -9,7 +9,6 @@ import { TextStyle } from "#enums/text-style"; import { WeatherType } from "#enums/weather-type"; import { addTextObject } from "#ui/text"; import type { nil } from "#utils/common"; -import { isNullOrUndefined } from "#utils/common"; import i18next from "i18next"; export enum EventType { @@ -428,7 +427,7 @@ export class TimedEventManager { getEventBannerLangs(): string[] { const ret: string[] = []; - ret.push(...timedEvents.find(te => this.isActive(te) && !isNullOrUndefined(te.availableLangs))?.availableLangs!); + ret.push(...timedEvents.find(te => this.isActive(te) && te.availableLangs != null)?.availableLangs!); return ret; } @@ -437,7 +436,7 @@ export class TimedEventManager { timedEvents .filter(te => this.isActive(te)) .map(te => { - if (!isNullOrUndefined(te.eventEncounters)) { + if (te.eventEncounters != null) { ret.push(...te.eventEncounters); } }); @@ -452,7 +451,7 @@ export class TimedEventManager { let multiplier = CLASSIC_CANDY_FRIENDSHIP_MULTIPLIER; const classicFriendshipEvents = timedEvents.filter(te => this.isActive(te)); for (const fe of classicFriendshipEvents) { - if (!isNullOrUndefined(fe.classicFriendshipMultiplier) && fe.classicFriendshipMultiplier > multiplier) { + if (fe.classicFriendshipMultiplier != null && fe.classicFriendshipMultiplier > multiplier) { multiplier = fe.classicFriendshipMultiplier; } } @@ -476,7 +475,7 @@ export class TimedEventManager { timedEvents .filter(te => this.isActive(te)) .map(te => { - if (!isNullOrUndefined(te.delibirdyBuff)) { + if (te.delibirdyBuff != null) { ret.push(...te.delibirdyBuff); } }); @@ -492,7 +491,7 @@ export class TimedEventManager { timedEvents .filter(te => this.isActive(te)) .map(te => { - if (!isNullOrUndefined(te.weather)) { + if (te.weather != null) { ret.push(...te.weather); } }); @@ -504,7 +503,7 @@ export class TimedEventManager { timedEvents .filter(te => this.isActive(te)) .map(te => { - if (!isNullOrUndefined(te.mysteryEncounterTierChanges)) { + if (te.mysteryEncounterTierChanges != null) { ret.push(...te.mysteryEncounterTierChanges); } }); @@ -514,7 +513,7 @@ export class TimedEventManager { getEventMysteryEncountersDisabled(): MysteryEncounterType[] { const ret: MysteryEncounterType[] = []; timedEvents - .filter(te => this.isActive(te) && !isNullOrUndefined(te.mysteryEncounterTierChanges)) + .filter(te => this.isActive(te) && te.mysteryEncounterTierChanges != null) .map(te => { te.mysteryEncounterTierChanges?.map(metc => { if (metc.disable) { @@ -531,7 +530,7 @@ export class TimedEventManager { ): MysteryEncounterTier { let ret = normal; timedEvents - .filter(te => this.isActive(te) && !isNullOrUndefined(te.mysteryEncounterTierChanges)) + .filter(te => this.isActive(te) && te.mysteryEncounterTierChanges != null) .map(te => { te.mysteryEncounterTierChanges?.map(metc => { if (metc.mysteryEncounter === encounterType) { @@ -544,7 +543,7 @@ export class TimedEventManager { getEventLuckBoost(): number { let ret = 0; - const luckEvents = timedEvents.filter(te => this.isActive(te) && !isNullOrUndefined(te.luckBoost)); + const luckEvents = timedEvents.filter(te => this.isActive(te) && te.luckBoost != null); for (const le of luckEvents) { ret += le.luckBoost!; } @@ -556,7 +555,7 @@ export class TimedEventManager { timedEvents .filter(te => this.isActive(te)) .map(te => { - if (!isNullOrUndefined(te.luckBoostedSpecies)) { + if (te.luckBoostedSpecies != null) { ret.push(...te.luckBoostedSpecies.filter(s => !ret.includes(s))); } }); @@ -576,7 +575,7 @@ export class TimedEventManager { getFixedBattleEventRewards(wave: number): string[] { const ret: string[] = []; timedEvents - .filter(te => this.isActive(te) && !isNullOrUndefined(te.classicWaveRewards)) + .filter(te => this.isActive(te) && te.classicWaveRewards != null) .map(te => { ret.push(...te.classicWaveRewards!.filter(cwr => cwr.wave === wave).map(cwr => cwr.type)); }); @@ -586,7 +585,7 @@ export class TimedEventManager { // Gets the extra shiny chance for trainers due to event (odds/65536) getClassicTrainerShinyChance(): number { let ret = 0; - const tsEvents = timedEvents.filter(te => this.isActive(te) && !isNullOrUndefined(te.trainerShinyChance)); + const tsEvents = timedEvents.filter(te => this.isActive(te) && te.trainerShinyChance != null); tsEvents.map(t => (ret += t.trainerShinyChance!)); return ret; } @@ -594,7 +593,7 @@ export class TimedEventManager { getEventBgmReplacement(bgm: string): string { let ret = bgm; timedEvents.map(te => { - if (this.isActive(te) && !isNullOrUndefined(te.music)) { + if (this.isActive(te) && te.music != null) { te.music.map(mr => { if (mr[0] === bgm) { console.log(`it is ${te.name} so instead of ${mr[0]} we play ${mr[1]}`); diff --git a/src/ui/containers/arena-flyout.ts b/src/ui/containers/arena-flyout.ts index a73846de1ac..355f3edb293 100644 --- a/src/ui/containers/arena-flyout.ts +++ b/src/ui/containers/arena-flyout.ts @@ -285,6 +285,12 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { switch (arenaEffectChangedEvent.constructor) { case TagAddedEvent: { const tagAddedEvent = arenaEffectChangedEvent as TagAddedEvent; + + const excludedTags = [ArenaTagType.PENDING_HEAL]; + if (excludedTags.includes(tagAddedEvent.arenaTagType)) { + return; + } + const isArenaTrapTag = globalScene.arena.getTag(tagAddedEvent.arenaTagType) instanceof EntryHazardTag; let arenaEffectType: ArenaEffectType; @@ -317,7 +323,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { this.fieldEffectInfo.push({ name, effectType: arenaEffectType, - maxDuration: tagAddedEvent.duration, + maxDuration: tagAddedEvent.maxDuration, duration: tagAddedEvent.duration, tagType: tagAddedEvent.arenaTagType, }); @@ -353,7 +359,7 @@ export class ArenaFlyout extends Phaser.GameObjects.Container { ), effectType: fieldEffectChangedEvent instanceof WeatherChangedEvent ? ArenaEffectType.WEATHER : ArenaEffectType.TERRAIN, - maxDuration: fieldEffectChangedEvent.duration, + maxDuration: fieldEffectChangedEvent.maxDuration, duration: fieldEffectChangedEvent.duration, }; diff --git a/src/ui/containers/pokedex-mon-container.ts b/src/ui/containers/pokedex-mon-container.ts index 15ef6c9b5c8..158f42dd42a 100644 --- a/src/ui/containers/pokedex-mon-container.ts +++ b/src/ui/containers/pokedex-mon-container.ts @@ -3,7 +3,6 @@ import type { PokemonSpecies } from "#data/pokemon-species"; import { TextStyle } from "#enums/text-style"; import type { Variant } from "#sprites/variant"; import { addTextObject } from "#ui/text"; -import { isNullOrUndefined } from "#utils/common"; interface SpeciesDetails { shiny?: boolean; @@ -177,16 +176,16 @@ export class PokedexMonContainer extends Phaser.GameObjects.Container { const defaultDexAttr = globalScene.gameData.getSpeciesDefaultDexAttr(species, false, true); const defaultProps = globalScene.gameData.getSpeciesDexAttrProps(species, defaultDexAttr); - if (!isNullOrUndefined(formIndex)) { + if (formIndex != null) { defaultProps.formIndex = formIndex; } - if (!isNullOrUndefined(shiny)) { + if (shiny != null) { defaultProps.shiny = shiny; } - if (!isNullOrUndefined(variant)) { + if (variant != null) { defaultProps.variant = variant; } - if (!isNullOrUndefined(female)) { + if (female != null) { defaultProps.female = female; } diff --git a/src/ui/handlers/mystery-encounter-ui-handler.ts b/src/ui/handlers/mystery-encounter-ui-handler.ts index bbbd3cb4af8..e4c9dfbfee3 100644 --- a/src/ui/handlers/mystery-encounter-ui-handler.ts +++ b/src/ui/handlers/mystery-encounter-ui-handler.ts @@ -13,7 +13,7 @@ import { PartyUiMode } from "#ui/party-ui-handler"; import { addBBCodeTextObject, getBBCodeFrag } from "#ui/text"; import { UiHandler } from "#ui/ui-handler"; import { addWindow, WindowVariant } from "#ui/ui-theme"; -import { fixedInt, isNullOrUndefined } from "#utils/common"; +import { fixedInt } from "#utils/common"; import i18next from "i18next"; import type BBCodeText from "phaser3-rex-plugins/plugins/bbcodetext"; @@ -95,12 +95,10 @@ export class MysteryEncounterUiHandler extends UiHandler { super.show(args); this.overrideSettings = (args[0] as OptionSelectSettings) ?? {}; - const showDescriptionContainer = isNullOrUndefined(this.overrideSettings?.hideDescription) - ? true - : !this.overrideSettings.hideDescription; - const slideInDescription = isNullOrUndefined(this.overrideSettings?.slideInDescription) - ? true - : this.overrideSettings.slideInDescription; + const showDescriptionContainer = + this.overrideSettings?.hideDescription == null ? true : !this.overrideSettings.hideDescription; + const slideInDescription = + this.overrideSettings?.slideInDescription == null ? true : this.overrideSettings.slideInDescription; const startingCursorIndex = this.overrideSettings?.startingCursorIndex ?? 0; this.cursorContainer.setVisible(true); @@ -567,7 +565,7 @@ export class MysteryEncounterUiHandler extends UiHandler { } this.tooltipContainer.setVisible(true); - if (isNullOrUndefined(cursor) || cursor > this.optionsContainer.length - 2) { + if (cursor == null || cursor > this.optionsContainer.length - 2) { // Ignore hovers on view party button // Hide dex progress if visible this.showHideDexProgress(false); diff --git a/src/ui/handlers/pokedex-page-ui-handler.ts b/src/ui/handlers/pokedex-page-ui-handler.ts index 253309bf94a..31e2998b850 100644 --- a/src/ui/handlers/pokedex-page-ui-handler.ts +++ b/src/ui/handlers/pokedex-page-ui-handler.ts @@ -54,7 +54,7 @@ import { PokedexInfoOverlay } from "#ui/pokedex-info-overlay"; import { StatsContainer } from "#ui/stats-container"; import { addBBCodeTextObject, addTextObject, getTextColor, getTextStyleOptions } from "#ui/text"; import { addWindow } from "#ui/ui-theme"; -import { BooleanHolder, getLocalizedSpriteKey, isNullOrUndefined, padInt, rgbHexToRgba } from "#utils/common"; +import { BooleanHolder, getLocalizedSpriteKey, padInt, rgbHexToRgba } from "#utils/common"; import { getEnumValues } from "#utils/enums"; import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils"; import { toCamelCase, toTitleCase } from "#utils/strings"; @@ -2424,11 +2424,7 @@ export class PokedexPageUiHandler extends MessageUiHandler { // We will only update the sprite if there is a change to form, shiny/variant // or gender for species with gender sprite differences const shouldUpdateSprite = - (species?.genderDiffs && !isNullOrUndefined(female)) - || !isNullOrUndefined(formIndex) - || !isNullOrUndefined(shiny) - || !isNullOrUndefined(variant) - || forceUpdate; + (species?.genderDiffs && female != null) || formIndex != null || shiny != null || variant != null || forceUpdate; if (this.activeTooltip === "CANDY") { if (this.species && this.pokemonCandyContainer.visible) { diff --git a/src/ui/handlers/pokedex-scan-ui-handler.ts b/src/ui/handlers/pokedex-scan-ui-handler.ts index 1f5195588f3..18afd0598c2 100644 --- a/src/ui/handlers/pokedex-scan-ui-handler.ts +++ b/src/ui/handlers/pokedex-scan-ui-handler.ts @@ -6,7 +6,6 @@ import { FilterTextRow } from "#ui/filter-text"; import type { InputFieldConfig } from "#ui/form-modal-ui-handler"; import { FormModalUiHandler } from "#ui/form-modal-ui-handler"; import type { ModalConfig } from "#ui/modal-ui-handler"; -import { isNullOrUndefined } from "#utils/common"; import i18next from "i18next"; export class PokedexScanUiHandler extends FormModalUiHandler { @@ -132,7 +131,7 @@ export class PokedexScanUiHandler extends FormModalUiHandler { return { label: value, handler: () => { - if (!isNullOrUndefined(evt.data) || evt.inputType?.toLowerCase() === "deletecontentbackward") { + if (evt.data != null || evt.inputType?.toLowerCase() === "deletecontentbackward") { inputObject.setText(value); } ui.revertMode(); diff --git a/src/ui/handlers/save-slot-select-ui-handler.ts b/src/ui/handlers/save-slot-select-ui-handler.ts index a71be5dd070..194971a005f 100644 --- a/src/ui/handlers/save-slot-select-ui-handler.ts +++ b/src/ui/handlers/save-slot-select-ui-handler.ts @@ -13,7 +13,7 @@ import { MessageUiHandler } from "#ui/message-ui-handler"; import { RunDisplayMode } from "#ui/run-info-ui-handler"; import { addTextObject } from "#ui/text"; import { addWindow } from "#ui/ui-theme"; -import { fixedInt, formatLargeNumber, getPlayTimeString, isNullOrUndefined } from "#utils/common"; +import { fixedInt, formatLargeNumber, getPlayTimeString } from "#utils/common"; import i18next from "i18next"; const SESSION_SLOTS_COUNT = 5; @@ -405,7 +405,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler { } this.setArrowVisibility(hasData); } - if (!isNullOrUndefined(prevSlotIndex)) { + if (prevSlotIndex != null) { this.revertSessionSlot(prevSlotIndex); } diff --git a/src/ui/handlers/starter-select-ui-handler.ts b/src/ui/handlers/starter-select-ui-handler.ts index 18be7e130e3..8c3594546f1 100644 --- a/src/ui/handlers/starter-select-ui-handler.ts +++ b/src/ui/handlers/starter-select-ui-handler.ts @@ -49,7 +49,7 @@ import { achvs } from "#system/achv"; import { RibbonData } from "#system/ribbons/ribbon-data"; import { SettingKeyboard } from "#system/settings-keyboard"; import type { DexEntry } from "#types/dex-data"; -import type { DexAttrProps, StarterAttributes, StarterDataEntry, StarterMoveset } from "#types/save-data"; +import type { Starter, StarterAttributes, StarterDataEntry, StarterMoveset } from "#types/save-data"; import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler"; import { DropDown, DropDownLabel, DropDownOption, DropDownState, DropDownType, SortCriteria } from "#ui/dropdown"; import { FilterBar } from "#ui/filter-bar"; @@ -66,7 +66,6 @@ import { BooleanHolder, fixedInt, getLocalizedSpriteKey, - isNullOrUndefined, NumberHolder, padInt, randIntRange, @@ -83,18 +82,6 @@ import type BBCodeText from "phaser3-rex-plugins/plugins/bbcodetext"; export type StarterSelectCallback = (starters: Starter[]) => void; -export interface Starter { - species: PokemonSpecies; - dexAttr: bigint; - abilityIndex: number; - passive: boolean; - nature: Nature; - moveset?: StarterMoveset; - pokerus: boolean; - nickname?: string; - teraType?: PokemonType; -} - interface LanguageSetting { starterInfoTextSize: string; instructionTextSize: string; @@ -365,15 +352,13 @@ export class StarterSelectUiHandler extends MessageUiHandler { private allSpecies: PokemonSpecies[] = []; private lastSpecies: PokemonSpecies; private speciesLoaded: Map = new Map(); + + private starters: Starter[] = []; public starterSpecies: PokemonSpecies[] = []; private pokerusSpecies: PokemonSpecies[] = []; - private starterAttr: bigint[] = []; - private starterAbilityIndexes: number[] = []; - private starterNatures: Nature[] = []; - private starterTeras: PokemonType[] = []; - private starterMovesets: StarterMoveset[] = []; private speciesStarterDexEntry: DexEntry | null; private speciesStarterMoves: MoveId[]; + private canCycleShiny: boolean; private canCycleForm: boolean; private canCycleGender: boolean; @@ -2548,7 +2533,7 @@ export class StarterSelectUiHandler extends MessageUiHandler { case Button.CYCLE_TERA: if (this.canCycleTera) { const speciesForm = getPokemonSpeciesForm(this.lastSpecies.speciesId, starterAttributes.form ?? 0); - if (speciesForm.type1 === this.teraCursor && !isNullOrUndefined(speciesForm.type2)) { + if (speciesForm.type1 === this.teraCursor && speciesForm.type2 != null) { starterAttributes.tera = speciesForm.type2; originalStarterAttributes.tera = starterAttributes.tera; this.setSpeciesDetails(this.lastSpecies, { @@ -2759,12 +2744,26 @@ export class StarterSelectUiHandler extends MessageUiHandler { props.variant, ); + const { dexEntry, starterDataEntry } = this.getSpeciesData(species.speciesId); + + const starter = { + speciesId: species.speciesId, + shiny: props.shiny, + variant: props.variant, + formIndex: props.formIndex, + female: props.female, + abilityIndex, + passive: !(starterDataEntry.passiveAttr ^ (PassiveAttr.ENABLED | PassiveAttr.UNLOCKED)), + nature, + moveset, + pokerus: this.pokerusSpecies.includes(species), + nickname: this.starterPreferences[species.speciesId]?.nickname, + teraType, + ivs: dexEntry.ivs, + }; + + this.starters.push(starter); this.starterSpecies.push(species); - this.starterAttr.push(dexAttr); - this.starterAbilityIndexes.push(abilityIndex); - this.starterNatures.push(nature); - this.starterTeras.push(teraType); - this.starterMovesets.push(moveset); if (this.speciesLoaded.get(species.speciesId) || randomSelection) { getPokemonSpeciesForm(species.speciesId, props.formIndex).cry(); } @@ -2790,7 +2789,7 @@ export class StarterSelectUiHandler extends MessageUiHandler { */ switchMoveHandler(targetIndex: number, newMove: MoveId, previousMove: MoveId) { const starterMoveset = this.starterMoveset; - if (isNullOrUndefined(starterMoveset)) { + if (starterMoveset == null) { console.warn("Trying to update a non-existing moveset"); return; } @@ -2834,7 +2833,7 @@ export class StarterSelectUiHandler extends MessageUiHandler { for (const [index, species] of this.starterSpecies.entries()) { if (species.speciesId === id) { - this.starterMovesets[index] = this.starterMoveset; + this.starters[index].moveset = this.starterMoveset; } } } @@ -3641,20 +3640,20 @@ export class StarterSelectUiHandler extends MessageUiHandler { const starterIndex = this.starterSpecies.indexOf(species); - let props: DexAttrProps; + const props = globalScene.gameData.getSpeciesDexAttrProps(species, defaultDexAttr); if (starterIndex > -1) { - props = globalScene.gameData.getSpeciesDexAttrProps(species, this.starterAttr[starterIndex]); + const starter = this.starters[starterIndex]; this.setSpeciesDetails( species, { - shiny: props.shiny, - formIndex: props.formIndex, - female: props.female, - variant: props.variant, - abilityIndex: this.starterAbilityIndexes[starterIndex], - natureIndex: this.starterNatures[starterIndex], - teraType: this.starterTeras[starterIndex], + shiny: starter.shiny, + formIndex: starter.formIndex, + female: starter.female, + variant: starter.variant, + abilityIndex: starter.abilityIndex, + natureIndex: starter.nature, + teraType: starter.teraType, }, false, ); @@ -3665,7 +3664,6 @@ export class StarterSelectUiHandler extends MessageUiHandler { const { dexEntry } = this.getSpeciesData(species.speciesId); const defaultNature = starterAttributes?.nature || globalScene.gameData.getSpeciesDefaultNature(species, dexEntry); - props = globalScene.gameData.getSpeciesDexAttrProps(species, defaultDexAttr); if (starterAttributes?.variant && !Number.isNaN(starterAttributes.variant) && props.shiny) { props.variant = starterAttributes.variant as Variant; } @@ -3687,7 +3685,7 @@ export class StarterSelectUiHandler extends MessageUiHandler { ); } - if (!isNullOrUndefined(props.formIndex)) { + if (props.formIndex != null) { // If switching forms while the pokemon is in the team, update its moveset this.updateSelectedStarterMoveset(species.speciesId); } @@ -3809,10 +3807,7 @@ export class StarterSelectUiHandler extends MessageUiHandler { // We will only update the sprite if there is a change to form, shiny/variant // or gender for species with gender sprite differences const shouldUpdateSprite = - (species?.genderDiffs && !isNullOrUndefined(female)) - || !isNullOrUndefined(formIndex) - || !isNullOrUndefined(shiny) - || !isNullOrUndefined(variant); + (species?.genderDiffs && female != null) || formIndex != null || shiny != null || variant != null; const isFreshStartChallenge = globalScene.gameMode.hasChallenge(Challenges.FRESH_START); @@ -3850,7 +3845,7 @@ export class StarterSelectUiHandler extends MessageUiHandler { ); // TODO: is this bang correct? this.abilityCursor = abilityIndex !== undefined ? abilityIndex : (abilityIndex = oldAbilityIndex); this.natureCursor = natureIndex !== undefined ? natureIndex : (natureIndex = oldNatureIndex); - this.teraCursor = !isNullOrUndefined(teraType) ? teraType : (teraType = oldTeraType); + this.teraCursor = teraType != null ? teraType : (teraType = oldTeraType); const [isInParty, partyIndex]: [boolean, number] = this.isInParty(species); // we use this to firstly check if the pokemon is in the party, and if so, to get the party index in order to update the icon image if (isInParty) { this.updatePartyIcon(species, partyIndex); @@ -3914,10 +3909,15 @@ export class StarterSelectUiHandler extends MessageUiHandler { const starterIndex = this.starterSpecies.indexOf(species); if (starterIndex > -1) { - this.starterAttr[starterIndex] = this.dexAttrCursor; - this.starterAbilityIndexes[starterIndex] = this.abilityCursor; - this.starterNatures[starterIndex] = this.natureCursor; - this.starterTeras[starterIndex] = this.teraCursor; + const starter = this.starters[starterIndex]; + const props = globalScene.gameData.getSpeciesDexAttrProps(species, this.dexAttrCursor); + starter.shiny = props.shiny; + starter.variant = props.variant; + starter.female = props.female; + starter.formIndex = props.formIndex; + starter.abilityIndex = this.abilityCursor; + starter.nature = this.natureCursor; + starter.teraType = this.teraCursor; } const assetLoadCancelled = new BooleanHolder(false); @@ -3991,7 +3991,7 @@ export class StarterSelectUiHandler extends MessageUiHandler { this.canCycleTera = !this.statsMode && this.allowTera - && !isNullOrUndefined(getPokemonSpeciesForm(species.speciesId, formIndex ?? 0).type2) + && getPokemonSpeciesForm(species.speciesId, formIndex ?? 0).type2 != null && !globalScene.gameMode.hasChallenge(Challenges.FRESH_START); } @@ -4219,11 +4219,7 @@ export class StarterSelectUiHandler extends MessageUiHandler { popStarter(index: number): void { this.starterSpecies.splice(index, 1); - this.starterAttr.splice(index, 1); - this.starterAbilityIndexes.splice(index, 1); - this.starterNatures.splice(index, 1); - this.starterTeras.splice(index, 1); - this.starterMovesets.splice(index, 1); + this.starters.splice(index, 1); for (let s = 0; s < this.starterSpecies.length; s++) { const species = this.starterSpecies[s]; @@ -4447,27 +4443,11 @@ export class StarterSelectUiHandler extends MessageUiHandler { () => { const startRun = () => { globalScene.money = globalScene.gameMode.getStartingMoney(); + const starters = this.starters.slice(0); ui.setMode(UiMode.STARTER_SELECT); - const thisObj = this; const originalStarterSelectCallback = this.starterSelectCallback; this.starterSelectCallback = null; - originalStarterSelectCallback?.( - new Array(this.starterSpecies.length).fill(0).map((_, i) => { - const starterSpecies = thisObj.starterSpecies[i]; - const { starterDataEntry } = this.getSpeciesData(starterSpecies.speciesId); - return { - species: starterSpecies, - dexAttr: thisObj.starterAttr[i], - abilityIndex: thisObj.starterAbilityIndexes[i], - passive: !(starterDataEntry.passiveAttr ^ (PassiveAttr.ENABLED | PassiveAttr.UNLOCKED)), - nature: thisObj.starterNatures[i] as Nature, - teraType: thisObj.starterTeras[i] as PokemonType, - moveset: thisObj.starterMovesets[i], - pokerus: thisObj.pokerusSpecies.includes(starterSpecies), - nickname: thisObj.starterPreferences[starterSpecies.speciesId]?.nickname, - }; - }), - ); + originalStarterSelectCallback?.(starters); }; startRun(); }, @@ -4496,10 +4476,17 @@ export class StarterSelectUiHandler extends MessageUiHandler { */ isPartyValid(): boolean { let canStart = false; - for (const species of this.starterSpecies) { + for (let s = 0; s < this.starterSpecies.length; s++) { + const species = this.starterSpecies[s]; + const starter = this.starters[s]; const isValidForChallenge = checkStarterValidForChallenge( species, - globalScene.gameData.getSpeciesDexAttrProps(species, this.getCurrentDexProps(species.speciesId)), + { + formIndex: starter.formIndex, + shiny: starter.shiny, + variant: starter.variant, + female: starter.female ?? false, + }, false, ); canStart ||= isValidForChallenge; @@ -4592,7 +4579,7 @@ export class StarterSelectUiHandler extends MessageUiHandler { this.canCycleTera = !this.statsMode && this.allowTera - && !isNullOrUndefined(getPokemonSpeciesForm(this.lastSpecies.speciesId, formIndex ?? 0).type2) + && getPokemonSpeciesForm(this.lastSpecies.speciesId, formIndex ?? 0).type2 != null && !globalScene.gameMode.hasChallenge(Challenges.FRESH_START); this.updateInstructions(); } diff --git a/src/ui/handlers/summary-ui-handler.ts b/src/ui/handlers/summary-ui-handler.ts index 1c647573cbf..c9c8229ebfd 100644 --- a/src/ui/handlers/summary-ui-handler.ts +++ b/src/ui/handlers/summary-ui-handler.ts @@ -27,15 +27,7 @@ import { getVariantTint } from "#sprites/variant"; import { achvs } from "#system/achv"; import { addBBCodeTextObject, addTextObject, getBBCodeFrag, getTextColor } from "#ui/text"; import { UiHandler } from "#ui/ui-handler"; -import { - fixedInt, - formatStat, - getLocalizedSpriteKey, - getShinyDescriptor, - isNullOrUndefined, - padInt, - rgbHexToRgba, -} from "#utils/common"; +import { fixedInt, formatStat, getLocalizedSpriteKey, getShinyDescriptor, padInt, rgbHexToRgba } from "#utils/common"; import { getEnumValues } from "#utils/enums"; import { toCamelCase, toTitleCase } from "#utils/strings"; import { argbFromRgba } from "@material/material-color-utilities"; @@ -895,10 +887,7 @@ export class SummaryUiHandler extends UiHandler { profileContainer.add(luckText); } - if ( - globalScene.gameData.achvUnlocks.hasOwnProperty(achvs.TERASTALLIZE.id) - && !isNullOrUndefined(this.pokemon) - ) { + if (globalScene.gameData.achvUnlocks.hasOwnProperty(achvs.TERASTALLIZE.id) && this.pokemon != null) { const teraIcon = globalScene.add.sprite(123, 26, "button_tera"); teraIcon.setName("terastallize-icon"); teraIcon.setFrame(PokemonType[this.pokemon.getTeraType()].toLowerCase()); diff --git a/src/ui/handlers/target-select-ui-handler.ts b/src/ui/handlers/target-select-ui-handler.ts index 4e3096b96f4..bd81278c661 100644 --- a/src/ui/handlers/target-select-ui-handler.ts +++ b/src/ui/handlers/target-select-ui-handler.ts @@ -8,7 +8,7 @@ import type { Pokemon } from "#field/pokemon"; import type { ModifierBar } from "#modifiers/modifier"; import { getMoveTargets } from "#moves/move-utils"; import { UiHandler } from "#ui/ui-handler"; -import { fixedInt, isNullOrUndefined } from "#utils/common"; +import { fixedInt } from "#utils/common"; export type TargetSelectCallback = (targets: BattlerIndex[]) => void; @@ -71,7 +71,7 @@ export class TargetSelectUiHandler extends UiHandler { */ resetCursor(cursorN: number, user: Pokemon): void { if ( - !isNullOrUndefined(cursorN) + cursorN != null && ([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2].includes(cursorN) || user.tempSummonData.waveTurnCount === 1) ) { // Reset cursor on the first turn of a fight or if an ally was targeted last turn @@ -90,13 +90,10 @@ export class TargetSelectUiHandler extends UiHandler { this.targetSelectCallback(button === Button.ACTION ? targetIndexes : []); success = true; if (this.fieldIndex === BattlerIndex.PLAYER) { - if (isNullOrUndefined(this.cursor0) || this.cursor0 !== this.cursor) { + if (this.cursor0 == null || this.cursor0 !== this.cursor) { this.cursor0 = this.cursor; } - } else if ( - this.fieldIndex === BattlerIndex.PLAYER_2 - && (isNullOrUndefined(this.cursor1) || this.cursor1 !== this.cursor) - ) { + } else if (this.fieldIndex === BattlerIndex.PLAYER_2 && (this.cursor1 == null || this.cursor1 !== this.cursor)) { this.cursor1 = this.cursor; } } else if (this.isMultipleTargets) { diff --git a/src/ui/handlers/test-dialogue-ui-handler.ts b/src/ui/handlers/test-dialogue-ui-handler.ts index bd5c1a1dc37..b33e6726547 100644 --- a/src/ui/handlers/test-dialogue-ui-handler.ts +++ b/src/ui/handlers/test-dialogue-ui-handler.ts @@ -4,7 +4,6 @@ import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler"; import type { InputFieldConfig } from "#ui/form-modal-ui-handler"; import { FormModalUiHandler } from "#ui/form-modal-ui-handler"; import type { ModalConfig } from "#ui/modal-ui-handler"; -import { isNullOrUndefined } from "#utils/common"; import i18next from "i18next"; export class TestDialogueUiHandler extends FormModalUiHandler { @@ -18,7 +17,7 @@ export class TestDialogueUiHandler extends FormModalUiHandler { .map((t, i) => { const value = Object.values(object)[i]; - if (typeof value === "object" && !isNullOrUndefined(value)) { + if (typeof value === "object" && value != null) { // we check for not null or undefined here because if the language json file has a null key, the typeof will still be an object, but that object will be null, causing issues // If the value is an object, execute the same process // si el valor es un objeto ejecuta el mismo proceso @@ -27,7 +26,7 @@ export class TestDialogueUiHandler extends FormModalUiHandler { t => t.length > 0, ); } - if (typeof value === "string" || isNullOrUndefined(value)) { + if (typeof value === "string" || value == null) { // we check for null or undefined here as per above - the typeof is still an object but the value is null so we need to exit out of this and pass the null key // Return in the format expected by i18next @@ -109,7 +108,7 @@ export class TestDialogueUiHandler extends FormModalUiHandler { handler: () => { // this is here to make sure that if you try to backspace then enter, the last known evt.data (backspace) is picked up // this is because evt.data is null for backspace, so without this, the autocomplete windows just closes - if (!isNullOrUndefined(evt.data) || evt.inputType?.toLowerCase() === "deletecontentbackward") { + if (evt.data != null || evt.inputType?.toLowerCase() === "deletecontentbackward") { const separatedArray = inputObject.text.split(" "); separatedArray[separatedArray.length - 1] = value; inputObject.setText(separatedArray.join(" ")); diff --git a/src/utils/anim-utils.ts b/src/utils/anim-utils.ts new file mode 100644 index 00000000000..f1a06552d38 --- /dev/null +++ b/src/utils/anim-utils.ts @@ -0,0 +1,26 @@ +import { globalScene } from "#app/global-scene"; +import type { SceneBase } from "#app/scene-base"; + +/** + * Plays a Tween animation, resolving once the animation completes. + * @param config - The config for a single Tween + * @param scene - The {@linkcode SceneBase} on which the Tween plays; default {@linkcode globalScene} + * @returns A Promise that resolves once the Tween has been played. + * + * @privateRemarks + * The `config` input should not include an `onComplete` field as that callback is + * used to resolve the Promise containing the Tween animation. + * However, `config`'s type cannot be changed to something like `Omit` + * due to how the type for `TweenBuilderConfig` is defined. + */ +export async function playTween( + config: Phaser.Types.Tweens.TweenBuilderConfig, + scene: SceneBase = globalScene, +): Promise { + await new Promise(resolve => + scene.tweens.add({ + ...config, + onComplete: resolve, + }), + ); +} diff --git a/src/utils/common.ts b/src/utils/common.ts index 2734b075a53..f0166b1e74c 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -458,15 +458,6 @@ export function truncateString(str: string, maxLength = 10) { return str; } -/** - * Report whether a given value is nullish (`null`/`undefined`). - * @param val - The value whose nullishness is being checked - * @returns `true` if `val` is either `null` or `undefined` - */ -export function isNullOrUndefined(val: any): val is null | undefined { - return val === null || val === undefined; -} - /** * This function is used in the context of a Pokémon battle game to calculate the actual integer damage value from a float result. * Many damage calculation formulas involve various parameters and result in float values. diff --git a/src/utils/cookies.ts b/src/utils/cookies.ts index e82895d1fac..407bd75da14 100644 --- a/src/utils/cookies.ts +++ b/src/utils/cookies.ts @@ -17,17 +17,17 @@ export function removeCookie(cName: string): void { export function getCookie(cName: string): string { // check if there are multiple cookies with the same name and delete them - if (document.cookie.split(";").filter(c => c.includes(cName)).length > 1) { + if (document.cookie.split(";").filter(c => c.trim().includes(cName)).length > 1) { removeCookie(cName); return ""; } const name = `${cName}=`; - const ca = document.cookie.split(";"); + const cookieArray = document.cookie.split(";"); // Check all cookies in the document and see if any of them match, grabbing the first one whose value lines up - for (const c of ca) { - const cTrimmed = c.trim(); - if (cTrimmed.startsWith(name)) { - return c.slice(name.length, c.length); + for (const cookie of cookieArray) { + const cookieTrimmed = cookie.trim(); + if (cookieTrimmed.startsWith(name)) { + return cookieTrimmed.slice(name.length, cookieTrimmed.length); } } return ""; diff --git a/src/utils/speed-order-generator.ts b/src/utils/speed-order-generator.ts new file mode 100644 index 00000000000..24f95de665f --- /dev/null +++ b/src/utils/speed-order-generator.ts @@ -0,0 +1,39 @@ +import { globalScene } from "#app/global-scene"; +import { PokemonPriorityQueue } from "#app/queues/pokemon-priority-queue"; +import { ArenaTagSide } from "#enums/arena-tag-side"; +import type { Pokemon } from "#field/pokemon"; + +/** + * A generator function which uses a priority queue to yield each pokemon from a given side of the field in speed order. + * @param side - The {@linkcode ArenaTagSide | side} of the field to use + * @returns A {@linkcode Generator} of {@linkcode Pokemon} + * + * @remarks + * This should almost always be used by iteration in a `for...of` loop + */ +export function* inSpeedOrder(side: ArenaTagSide = ArenaTagSide.BOTH): Generator { + let pokemonList: Pokemon[]; + switch (side) { + case ArenaTagSide.PLAYER: + pokemonList = globalScene.getPlayerField(true); + break; + case ArenaTagSide.ENEMY: + pokemonList = globalScene.getEnemyField(true); + break; + default: + pokemonList = globalScene.getField(true); + } + + const queue = new PokemonPriorityQueue(); + let i = 0; + pokemonList.forEach(p => { + queue.push(p); + }); + while (!queue.isEmpty()) { + // If the queue is not empty, this can never be undefined + i++; + yield queue.pop()!; + } + + return i; +} diff --git a/src/utils/speed-order.ts b/src/utils/speed-order.ts new file mode 100644 index 00000000000..1d894369bb3 --- /dev/null +++ b/src/utils/speed-order.ts @@ -0,0 +1,57 @@ +import { Pokemon } from "#app/field/pokemon"; +import { globalScene } from "#app/global-scene"; +import { BooleanHolder, randSeedShuffle } from "#app/utils/common"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import { Stat } from "#enums/stat"; + +/** Interface representing an object associated with a specific Pokemon */ +interface hasPokemon { + getPokemon(): Pokemon; +} + +/** + * Sorts an array of {@linkcode Pokemon} by speed, taking Trick Room into account. + * @param pokemonList - The list of Pokemon or objects containing Pokemon + * @param shuffleFirst - Whether to shuffle the list before sorting (to handle speed ties). Default `true`. + * @returns The sorted array of {@linkcode Pokemon} + */ +export function sortInSpeedOrder(pokemonList: T[], shuffleFirst = true): T[] { + pokemonList = shuffleFirst ? shufflePokemonList(pokemonList) : pokemonList; + sortBySpeed(pokemonList); + return pokemonList; +} + +/** + * @param pokemonList - The array of Pokemon or objects containing Pokemon + * @returns The shuffled array + */ +function shufflePokemonList(pokemonList: T[]): T[] { + // This is seeded with the current turn to prevent an inconsistency where it + // was varying based on how long since you last reloaded + globalScene.executeWithSeedOffset( + () => { + pokemonList = randSeedShuffle(pokemonList); + }, + globalScene.currentBattle.turn * 1000 + pokemonList.length, + globalScene.waveSeed, + ); + return pokemonList; +} + +/** Sorts an array of {@linkcode Pokemon} by speed (without shuffling) */ +function sortBySpeed(pokemonList: T[]): void { + pokemonList.sort((a, b) => { + const aSpeed = (a instanceof Pokemon ? a : a.getPokemon()).getEffectiveStat(Stat.SPD); + const bSpeed = (b instanceof Pokemon ? b : b.getPokemon()).getEffectiveStat(Stat.SPD); + + return bSpeed - aSpeed; + }); + + /** 'true' if Trick Room is on the field. */ + const speedReversed = new BooleanHolder(false); + globalScene.arena.applyTags(ArenaTagType.TRICK_ROOM, false, speedReversed); + + if (speedReversed.value) { + pokemonList.reverse(); + } +} diff --git a/src/vite.env.d.ts b/src/vite.env.d.ts index 68159908730..3192b81afd3 100644 --- a/src/vite.env.d.ts +++ b/src/vite.env.d.ts @@ -9,8 +9,9 @@ interface ImportMetaEnv { readonly VITE_DISCORD_CLIENT_ID?: string; readonly VITE_GOOGLE_CLIENT_ID?: string; readonly VITE_I18N_DEBUG?: string; + readonly NODE_ENV?: string; } -interface ImportMeta { +declare interface ImportMeta { readonly env: ImportMetaEnv; } diff --git a/test/@types/vitest.d.ts b/test/@types/vitest.d.ts index 9a6f07b4afb..43e9df190aa 100644 --- a/test/@types/vitest.d.ts +++ b/test/@types/vitest.d.ts @@ -1,7 +1,7 @@ import "vitest"; -import type { Phase } from "#app/phase"; import type Overrides from "#app/overrides"; +import type { Phase } from "#app/phase"; import type { ArenaTag } from "#data/arena-tag"; import type { TerrainType } from "#data/terrain"; import type { AbilityId } from "#enums/ability-id"; @@ -10,10 +10,14 @@ import type { ArenaTagType } from "#enums/arena-tag-type"; import type { BattlerTagType } from "#enums/battler-tag-type"; import type { MoveId } from "#enums/move-id"; import type { PokemonType } from "#enums/pokemon-type"; +import type { PositionalTag } from "#data/positional-tags/positional-tag"; import type { PositionalTagType } from "#enums/positional-tag-type"; import type { BattleStat, EffectiveStat } from "#enums/stat"; import type { WeatherType } from "#enums/weather-type"; +import type { Pokemon } from "#field/pokemon"; +import type { GameManager } from "#test/test-utils/game-manager"; import type { toHaveArenaTagOptions } from "#test/test-utils/matchers/to-have-arena-tag"; +import type { toHaveBattlerTagOptions } from "#test/test-utils/matchers/to-have-battler-tag"; import type { toHaveEffectiveStatOptions } from "#test/test-utils/matchers/to-have-effective-stat"; import type { toHavePositionalTagOptions } from "#test/test-utils/matchers/to-have-positional-tag"; import type { expectedStatusType } from "#test/test-utils/matchers/to-have-status-effect"; @@ -23,175 +27,212 @@ import type { TurnMove } from "#types/turn-move"; import type { AtLeastOne } from "#types/type-helpers"; import type { toDmgValue } from "#utils/common"; import type { expect } from "vitest"; -import type { toHaveBattlerTagOptions } from "#test/test-utils/matchers/to-have-battler-tag"; +// #region Boilerplate/Helpers declare module "vitest" { - interface Assertion { - // #region Generic Matchers - - /** - * Check whether an array contains EXACTLY the given items (in any order). - * - * Different from {@linkcode expect.arrayContaining} as the latter only checks for subset equality - * (as opposed to full equality). - * - * @param expected - The expected contents of the array, in any order - * @see {@linkcode expect.arrayContaining} - */ - toEqualArrayUnsorted(expected: T[]): void; - - // #endregion Generic Matchers - - // #region GameManager Matchers - - /** - * Check if the {@linkcode GameManager} has shown the given message at least once in the current battle. - * @param expectedMessage - The expected message - */ - toHaveShownMessage(expectedMessage: string): void; - /** - * @param expectedPhase - The expected {@linkcode PhaseString} - */ - toBeAtPhase(expectedPhase: PhaseString): void; - // #endregion GameManager Matchers - - // #region Arena Matchers - - /** - * Check whether the current {@linkcode WeatherType} is as expected. - * @param expectedWeatherType - The expected {@linkcode WeatherType} - */ - toHaveWeather(expectedWeatherType: WeatherType): void; - - /** - * Check whether the current {@linkcode TerrainType} is as expected. - * @param expectedTerrainType - The expected {@linkcode TerrainType} - */ - toHaveTerrain(expectedTerrainType: TerrainType): void; - - /** - * Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}. - * @param expectedTag - A partially-filled {@linkcode ArenaTag} containing the desired properties - */ - toHaveArenaTag(expectedTag: toHaveArenaTagOptions): void; - /** - * Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}. - * @param expectedType - The {@linkcode ArenaTagType} of the desired tag - * @param side - The {@linkcode ArenaTagSide | side(s) of the field} the tag should affect; default {@linkcode ArenaTagSide.BOTH} - */ - toHaveArenaTag(expectedType: ArenaTagType, side?: ArenaTagSide): void; - - /** - * Check whether the current {@linkcode Arena} contains the given {@linkcode PositionalTag}. - * @param expectedTag - A partially-filled `PositionalTag` containing the desired properties - */ - toHavePositionalTag

(expectedTag: toHavePositionalTagOptions

): void; - /** - * Check whether the current {@linkcode Arena} contains the given number of {@linkcode PositionalTag}s. - * @param expectedType - The {@linkcode PositionalTagType} of the desired tag - * @param count - The number of instances of {@linkcode expectedType} that should be active; - * defaults to `1` and must be within the range `[0, 4]` - */ - toHavePositionalTag(expectedType: PositionalTagType, count?: number): void; - - // #endregion Arena Matchers - - // #region Pokemon Matchers - - /** - * Check whether a {@linkcode Pokemon}'s current typing includes the given types. - * @param expectedTypes - The expected {@linkcode PokemonType}s to check against; must have length `>0` - * @param options - The {@linkcode toHaveTypesOptions | options} passed to the matcher - */ - toHaveTypes(expectedTypes: PokemonType[], options?: toHaveTypesOptions): void; - - /** - * Check whether a {@linkcode Pokemon} has used a move matching the given criteria. - * @param expectedMove - The {@linkcode MoveId} the Pokemon is expected to have used, - * or a partially filled {@linkcode TurnMove} containing the desired properties to check - * @param index - The index of the move history entry to check, in order from most recent to least recent; default `0` - * @see {@linkcode Pokemon.getLastXMoves} - */ - toHaveUsedMove(expectedMove: MoveId | AtLeastOne, index?: number): void; - - /** - * Check whether a {@linkcode Pokemon}'s effective stat is as expected - * (checked after all stat value modifications). - * @param stat - The {@linkcode EffectiveStat} to check - * @param expectedValue - The expected value of {@linkcode stat} - * @param options - The {@linkcode toHaveEffectiveStatOptions | options} passed to the matcher - * @remarks - * If you want to check the stat **before** modifiers are applied, use {@linkcode Pokemon.getStat} instead. - */ - toHaveEffectiveStat(stat: EffectiveStat, expectedValue: number, options?: toHaveEffectiveStatOptions): void; - - /** - * Check whether a {@linkcode Pokemon} has a specific {@linkcode StatusEffect | non-volatile status effect}. - * @param expectedStatusEffect - The {@linkcode StatusEffect} the Pokemon is expected to have, - * or a partially filled {@linkcode Status} containing the desired properties - */ - toHaveStatusEffect(expectedStatusEffect: expectedStatusType): void; - - /** - * Check whether a {@linkcode Pokemon} has a specific {@linkcode Stat} stage. - * @param stat - The {@linkcode BattleStat} to check - * @param expectedStage - The expected stat stage value of {@linkcode stat} - */ - toHaveStatStage(stat: BattleStat, expectedStage: number): void; - - /** - * Check whether a {@linkcode Pokemon} has the given {@linkcode BattlerTag}. - * @param expectedTag - A partially-filled {@linkcode BattlerTag} containing the desired properties - */ - toHaveBattlerTag(expectedTag: toHaveBattlerTagOptions): void; - /** - * Check whether a {@linkcode Pokemon} has the given {@linkcode BattlerTag}. - * @param expectedType - The expected {@linkcode BattlerTagType} - */ - toHaveBattlerTag(expectedType: BattlerTagType): void; - - /** - * Check whether a {@linkcode Pokemon} has applied a specific {@linkcode AbilityId}. - * @param expectedAbilityId - The `AbilityId` to check for - */ - toHaveAbilityApplied(expectedAbilityId: AbilityId): void; - - /** - * Check whether a {@linkcode Pokemon} has a specific amount of {@linkcode Stat.HP | HP}. - * @param expectedHp - The expected amount of {@linkcode Stat.HP | HP} to have - */ - toHaveHp(expectedHp: number): void; - - /** - * Check whether a {@linkcode Pokemon} has taken a specific amount of damage. - * @param expectedDamageTaken - The expected amount of damage taken - * @param roundDown - Whether to round down `expectedDamageTaken` with {@linkcode toDmgValue}; default `true` - */ - toHaveTakenDamage(expectedDamageTaken: number, roundDown?: boolean): void; - - /** - * Check whether a {@linkcode Pokemon} is currently fainted (as determined by {@linkcode Pokemon.isFainted}). - * @remarks - * When checking whether an enemy wild Pokemon is fainted, one must store a reference to it in a variable _before_ the fainting effect occurs. - * Otherwise, the Pokemon will be removed from the field and garbage collected. - */ - toHaveFainted(): void; - - /** - * Check whether a {@linkcode Pokemon} is at full HP. - */ - toHaveFullHp(): void; - /** - * Check whether a {@linkcode Pokemon} has consumed the given amount of PP for one of its moves. - * @param moveId - The {@linkcode MoveId} corresponding to the {@linkcode PokemonMove} that should have consumed PP - * @param ppUsed - The numerical amount of PP that should have been consumed, - * or `all` to indicate the move should be _out_ of PP - * @remarks - * If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.ENEMY_MOVESET_OVERRIDE} - * or does not contain exactly one copy of `moveId`, this will fail the test. - */ - toHaveUsedPP(moveId: MoveId, ppUsed: number | "all"): void; - - // #endregion Pokemon Matchers - } + interface Assertion + extends GenericMatchers, + RestrictMatcher, + RestrictMatcher, + RestrictMatcher {} } + +/** + * Utility type to restrict matchers' properties based on the type of `T`. + * If it does not extend `R`, all methods inside `M` will have their types resolved to `never`. + * @typeParam M - The type of the matchers object to restrict + * @typeParam T - The type parameter of the assertion + * @typeParam R - The type to restrict T based off of + * @privateRemarks + * We cannot remove incompatible methods outright as Typescript requires that + * interfaces extend solely off of types with statically known members. + */ +type RestrictMatcher = { + [k in keyof M]: T extends R ? M[k] : never; +}; +// #endregion Boilerplate/Helpers + +// #region Generic Matchers +interface GenericMatchers { + /** + * Check whether an array contains EXACTLY the given items (in any order). + * + * Different from {@linkcode expect.arrayContaining} as the latter only checks for subset equality + * (as opposed to full equality). + * + * @param expected - The expected contents of the array, in any order + * @see {@linkcode expect.arrayContaining} + */ + toEqualUnsorted: T extends (infer U)[] ? (expected: U[]) => void : never; + + /** + * Check whether a {@linkcode Map} contains the given key, disregarding its value. + * @param expectedKey - The key whose inclusion is being checked + * @privateRemarks + * While this functionality _could_ be simulated by writing + * `expect(x.get(y)).toBeDefined()` or + * `expect(x).toContain([y, expect.anything()])`, + * this is still preferred due to being more ergonomic and provides better error messsages. + */ + toHaveKey: T extends Map ? (expectedKey: K) => void : never; +} +// #endregion Generic Matchers + +// #region GameManager Matchers +interface GameManagerMatchers { + /** + * Check if the {@linkcode GameManager} has shown the given message at least once in the current test case. + * @param expectedMessage - The expected message to be displayed + * @remarks + * Strings consumed by this function should _always_ be produced by a call to `i18next.t` + * to avoid hardcoding text into test files. + */ + toHaveShownMessage(expectedMessage: string): void; + + /** + * Check if the currently-running {@linkcode Phase} is of the given type. + * @param expectedPhase - The expected {@linkcode PhaseString | name of the phase} + */ + toBeAtPhase(expectedPhase: PhaseString): void; +} // #endregion GameManager Matchers + +// #region Arena Matchers +interface ArenaMatchers { + /** + * Check whether the current {@linkcode WeatherType} is as expected. + * @param expectedWeatherType - The expected `WeatherType` + */ + toHaveWeather(expectedWeatherType: WeatherType): void; + + /** + * Check whether the current {@linkcode TerrainType} is as expected. + * @param expectedTerrainType - The expected `TerrainType` + */ + toHaveTerrain(expectedTerrainType: TerrainType): void; + + /** + * Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}. + * @param expectedTag - A partially-filled `ArenaTag` containing the desired properties + */ + toHaveArenaTag(expectedTag: toHaveArenaTagOptions): void; + /** + * Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}. + * @param expectedType - The {@linkcode ArenaTagType} of the desired tag + * @param side - The {@linkcode ArenaTagSide | side(s) of the field} the tag should affect; default {@linkcode ArenaTagSide.BOTH} + */ + toHaveArenaTag(expectedType: ArenaTagType, side?: ArenaTagSide): void; + + /** + * Check whether the current {@linkcode Arena} contains the given {@linkcode PositionalTag}. + * @param expectedTag - A partially-filled `PositionalTag` containing the desired properties + */ + toHavePositionalTag

(expectedTag: toHavePositionalTagOptions

): void; + /** + * Check whether the current {@linkcode Arena} contains the given number of {@linkcode PositionalTag}s. + * @param expectedType - The {@linkcode PositionalTagType} of the desired tag + * @param count - The number of instances of `expectedType` that should be active; + * defaults to `1` and must be within the range `[0, 4]` + */ + toHavePositionalTag(expectedType: PositionalTagType, count?: number): void; +} + +// #endregion Arena Matchers + +// #region Pokemon Matchers +interface PokemonMatchers { + /** + * Check whether a {@linkcode Pokemon}'s current typing includes the given types. + * @param expectedTypes - The expected {@linkcode PokemonType}s to check against; must have length `>0` + * @param options - The {@linkcode toHaveTypesOptions | options} passed to the matcher + */ + toHaveTypes(expectedTypes: PokemonType[], options?: toHaveTypesOptions): void; + + /** + * Check whether a {@linkcode Pokemon} has used a move matching the given criteria. + * @param expectedMove - The {@linkcode MoveId} the Pokemon is expected to have used, + * or a partially filled {@linkcode TurnMove} containing the desired properties to check + * @param index - The index of the move history entry to check, in order from most recent to least recent; default `0` + * @see {@linkcode Pokemon.getLastXMoves} + */ + toHaveUsedMove(expectedMove: MoveId | AtLeastOne, index?: number): void; + + /** + * Check whether a {@linkcode Pokemon}'s effective stat is as expected + * (checked after all stat value modifications). + * @param stat - The {@linkcode EffectiveStat} to check + * @param expectedValue - The expected value of {@linkcode stat} + * @param options - The {@linkcode toHaveEffectiveStatOptions | options} passed to the matcher + * @remarks + * If you want to check the stat **before** modifiers are applied, use {@linkcode Pokemon.getStat} instead. + */ + toHaveEffectiveStat(stat: EffectiveStat, expectedValue: number, options?: toHaveEffectiveStatOptions): void; + + /** + * Check whether a {@linkcode Pokemon} has a specific {@linkcode StatusEffect | non-volatile status effect}. + * @param expectedStatusEffect - The {@linkcode StatusEffect} the Pokemon is expected to have, + * or a partially filled {@linkcode Status} containing the desired properties + */ + toHaveStatusEffect(expectedStatusEffect: expectedStatusType): void; + + /** + * Check whether a {@linkcode Pokemon} has a specific {@linkcode Stat} stage. + * @param stat - The {@linkcode BattleStat} to check + * @param expectedStage - The expected stat stage value of {@linkcode stat} + */ + toHaveStatStage(stat: BattleStat, expectedStage: number): void; + + /** + * Check whether a {@linkcode Pokemon} has the given {@linkcode BattlerTag}. + * @param expectedTag - A partially-filled {@linkcode BattlerTag} containing the desired properties + */ + toHaveBattlerTag(expectedTag: toHaveBattlerTagOptions): void; + /** + * Check whether a {@linkcode Pokemon} has the given {@linkcode BattlerTag}. + * @param expectedType - The expected {@linkcode BattlerTagType} + */ + toHaveBattlerTag(expectedType: BattlerTagType): void; + + /** + * Check whether a {@linkcode Pokemon} has applied a specific {@linkcode AbilityId}. + * @param expectedAbilityId - The `AbilityId` to check for + */ + toHaveAbilityApplied(expectedAbilityId: AbilityId): void; + + /** + * Check whether a {@linkcode Pokemon} has a specific amount of {@linkcode Stat.HP | HP}. + * @param expectedHp - The expected amount of {@linkcode Stat.HP | HP} to have + */ + toHaveHp(expectedHp: number): void; + + /** + * Check whether a {@linkcode Pokemon} has taken a specific amount of damage. + * @param expectedDamageTaken - The expected amount of damage taken + * @param roundDown - Whether to round down `expectedDamageTaken` with {@linkcode toDmgValue}; default `true` + */ + toHaveTakenDamage(expectedDamageTaken: number, roundDown?: boolean): void; + + /** + * Check whether a {@linkcode Pokemon} is currently fainted (as determined by {@linkcode Pokemon.isFainted}). + * @remarks + * When checking whether an enemy wild Pokemon is fainted, one must store a reference to it in a variable _before_ the fainting effect occurs. + * Otherwise, the Pokemon will be removed from the field and garbage collected. + */ + toHaveFainted(): void; + + /** + * Check whether a {@linkcode Pokemon} is at full HP. + */ + toHaveFullHp(): void; + + /** + * Check whether a {@linkcode Pokemon} has consumed the given amount of PP for one of its moves. + * @param moveId - The {@linkcode MoveId} corresponding to the {@linkcode PokemonMove} that should have consumed PP + * @param ppUsed - The numerical amount of PP that should have been consumed, + * or `all` to indicate the move should be _out_ of PP + * @remarks + * If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.ENEMY_MOVESET_OVERRIDE} + * or does not contain exactly one copy of `moveId`, this will fail the test. + */ + toHaveUsedPP(moveId: MoveId, ppUsed: number | "all"): void; +} +// #endregion Pokemon Matchers diff --git a/test/abilities/ability-duplication.test.ts b/test/abilities/ability-duplication.test.ts index da572d94466..f684500ab90 100644 --- a/test/abilities/ability-duplication.test.ts +++ b/test/abilities/ability-duplication.test.ts @@ -30,12 +30,13 @@ describe("Ability Duplication", () => { .enemyMoveset(MoveId.SPLASH); }); + // TODO: Find a cleaner way of checking ability duplication effects than suppressing the ability it("huge power should only be applied once if both normal and passive", async () => { game.override.passiveAbility(AbilityId.HUGE_POWER); await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - const [magikarp] = game.scene.getPlayerField(); + const magikarp = game.field.getPlayerPokemon(); const magikarpAttack = magikarp.getEffectiveStat(Stat.ATK); magikarp.summonData.abilitySuppressed = true; @@ -48,7 +49,7 @@ describe("Ability Duplication", () => { await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - const [magikarp] = game.scene.getPlayerField(); + const magikarp = game.field.getPlayerPokemon(); const magikarpAttack = magikarp.getEffectiveStat(Stat.ATK); magikarp.summonData.abilitySuppressed = true; diff --git a/test/abilities/commander.test.ts b/test/abilities/commander.test.ts index d485cab83a2..8447b2a7d61 100644 --- a/test/abilities/commander.test.ts +++ b/test/abilities/commander.test.ts @@ -5,8 +5,7 @@ import { MoveId } from "#enums/move-id"; import { MoveResult } from "#enums/move-result"; import { PokemonAnimType } from "#enums/pokemon-anim-type"; import { SpeciesId } from "#enums/species-id"; -import type { EffectiveStat } from "#enums/stat"; -import { Stat } from "#enums/stat"; +import { EFFECTIVE_STATS } from "#enums/stat"; import { StatusEffect } from "#enums/status-effect"; import { WeatherType } from "#enums/weather-type"; import { GameManager } from "#test/test-utils/game-manager"; @@ -48,23 +47,24 @@ describe("Abilities - Commander", () => { const [tatsugiri, dondozo] = game.scene.getPlayerField(); - const affectedStats: EffectiveStat[] = [Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD]; - expect(game.scene.triggerPokemonBattleAnim).toHaveBeenLastCalledWith(tatsugiri, PokemonAnimType.COMMANDER_APPLY); - expect(dondozo.getTag(BattlerTagType.COMMANDED)).toBeDefined(); - affectedStats.forEach(stat => expect(dondozo.getStatStage(stat)).toBe(2)); - - game.move.select(MoveId.SPLASH, 1); + expect(dondozo).toHaveBattlerTag(BattlerTagType.COMMANDED); + EFFECTIVE_STATS.forEach(stat => { + expect(dondozo).toHaveStatStage(stat, 2); + }); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); expect(game.scene.currentBattle.turnCommands[0]?.skip).toBeTruthy(); // Force both enemies to target the Tatsugiri - await game.move.selectEnemyMove(MoveId.TACKLE, BattlerIndex.PLAYER); - await game.move.selectEnemyMove(MoveId.TACKLE, BattlerIndex.PLAYER); + await game.move.forceEnemyMove(MoveId.TACKLE, BattlerIndex.PLAYER); + await game.move.forceEnemyMove(MoveId.TACKLE, BattlerIndex.PLAYER); - await game.phaseInterceptor.to("BerryPhase", false); - game.scene.getEnemyField().forEach(enemy => expect(enemy.getLastXMoves(1)[0].result).toBe(MoveResult.MISS)); - expect(tatsugiri.isFullHp()).toBeTruthy(); + await game.toEndOfTurn(); + const [enemy1, enemy2] = game.scene.getEnemyField(); + expect(enemy1).toHaveUsedMove({ move: MoveId.TACKLE, result: MoveResult.MISS }); + expect(enemy2).toHaveUsedMove({ move: MoveId.TACKLE, result: MoveResult.MISS }); + expect(tatsugiri).toHaveFullHp(); }); it("should activate when a Dondozo switches in and cancel the source's move", async () => { @@ -72,7 +72,7 @@ describe("Abilities - Commander", () => { await game.classicMode.startBattle([SpeciesId.TATSUGIRI, SpeciesId.MAGIKARP, SpeciesId.DONDOZO]); - const tatsugiri = game.scene.getPlayerField()[0]; + const [tatsugiri, _, dondozo] = game.scene.getPlayerParty(); game.move.select(MoveId.LIQUIDATION, 0, BattlerIndex.ENEMY); game.doSwitchPokemon(2); @@ -80,12 +80,11 @@ describe("Abilities - Commander", () => { await game.phaseInterceptor.to("MovePhase", false); expect(game.scene.triggerPokemonBattleAnim).toHaveBeenCalledWith(tatsugiri, PokemonAnimType.COMMANDER_APPLY); - const dondozo = game.scene.getPlayerField()[1]; expect(dondozo.getTag(BattlerTagType.COMMANDED)).toBeDefined(); await game.phaseInterceptor.to("BerryPhase", false); expect(tatsugiri.getMoveHistory()).toHaveLength(0); - expect(game.scene.getEnemyField()[0].isFullHp()).toBeTruthy(); + expect(game.field.getEnemyPokemon()).toHaveFullHp(); }); it("source should reenter the field when Dondozo faints", async () => { @@ -192,26 +191,26 @@ describe("Abilities - Commander", () => { }); it("should interrupt the source's semi-invulnerability", async () => { - game.override.moveset([MoveId.SPLASH, MoveId.DIVE]).enemyMoveset(MoveId.SPLASH); - await game.classicMode.startBattle([SpeciesId.TATSUGIRI, SpeciesId.MAGIKARP, SpeciesId.DONDOZO]); - const tatsugiri = game.scene.getPlayerField()[0]; + const [tatsugiri, , dondozo] = game.scene.getPlayerParty(); - game.move.select(MoveId.DIVE, 0, BattlerIndex.ENEMY); - game.move.select(MoveId.SPLASH, 1); + game.move.use(MoveId.DIVE, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.move.forceEnemyMove(MoveId.SPLASH); + await game.move.forceEnemyMove(MoveId.SPLASH); await game.toNextTurn(); - expect(tatsugiri.getTag(BattlerTagType.UNDERWATER)).toBeDefined(); + expect(tatsugiri).toHaveBattlerTag(BattlerTagType.UNDERWATER); + game.doSwitchPokemon(2); - await game.phaseInterceptor.to("MovePhase", false); - const dondozo = game.scene.getPlayerField()[1]; - expect(tatsugiri.getTag(BattlerTagType.UNDERWATER)).toBeUndefined(); - expect(dondozo.getTag(BattlerTagType.COMMANDED)).toBeDefined(); - await game.toNextTurn(); - const enemy = game.scene.getEnemyField()[0]; - expect(enemy.isFullHp()).toBeTruthy(); + expect(tatsugiri).not.toHaveBattlerTag(BattlerTagType.UNDERWATER); + expect(dondozo).toHaveBattlerTag(BattlerTagType.COMMANDED); + + await game.toEndOfTurn(); + + expect(game.field.getEnemyPokemon()).toHaveFullHp(); }); }); diff --git a/test/abilities/dancer.test.ts b/test/abilities/dancer.test.ts index c651a341c42..e206152715e 100644 --- a/test/abilities/dancer.test.ts +++ b/test/abilities/dancer.test.ts @@ -34,7 +34,7 @@ describe("Abilities - Dancer", () => { game.override.enemyAbility(AbilityId.DANCER).enemySpecies(SpeciesId.MAGIKARP).enemyMoveset(MoveId.VICTORY_DANCE); await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]); - const [oricorio, feebas] = game.scene.getPlayerField(); + const [oricorio, feebas, magikarp1] = game.scene.getField(); game.move.changeMoveset(oricorio, [MoveId.SWORDS_DANCE, MoveId.VICTORY_DANCE, MoveId.SPLASH]); game.move.changeMoveset(feebas, [MoveId.SWORDS_DANCE, MoveId.SPLASH]); @@ -44,8 +44,9 @@ describe("Abilities - Dancer", () => { await game.phaseInterceptor.to("MovePhase"); // feebas uses swords dance await game.phaseInterceptor.to("MovePhase", false); // oricorio copies swords dance + // Dancer order will be Magikarp, Oricorio, Magikarp based on set turn order let currentPhase = game.scene.phaseManager.getCurrentPhase() as MovePhase; - expect(currentPhase.pokemon).toBe(oricorio); + expect(currentPhase.pokemon).toBe(magikarp1); expect(currentPhase.move.moveId).toBe(MoveId.SWORDS_DANCE); await game.phaseInterceptor.to("MoveEndPhase"); // end oricorio's move @@ -74,8 +75,8 @@ describe("Abilities - Dancer", () => { .enemyLevel(10); await game.classicMode.startBattle([SpeciesId.ORICORIO, SpeciesId.FEEBAS]); - const [oricorio] = game.scene.getPlayerField(); - const [, shuckle2] = game.scene.getEnemyField(); + const oricorio = game.field.getPlayerPokemon(); + const shuckle2 = game.scene.getEnemyField()[1]; game.move.select(MoveId.REVELATION_DANCE, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2); game.move.select(MoveId.FIERY_DANCE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2); diff --git a/test/abilities/flower-gift.test.ts b/test/abilities/flower-gift.test.ts index 6d8641917aa..74be845ffed 100644 --- a/test/abilities/flower-gift.test.ts +++ b/test/abilities/flower-gift.test.ts @@ -58,12 +58,12 @@ describe("Abilities - Flower Gift", () => { const ally_target = allyAttacker ? BattlerIndex.ENEMY : null; await game.classicMode.startBattle([SpeciesId.CHERRIM, SpeciesId.MAGIKARP]); - const target = allyAttacker ? game.scene.getEnemyField()[0] : game.scene.getPlayerField()[1]; + const target = allyAttacker ? game.field.getEnemyPokemon() : game.scene.getPlayerField()[1]; const initialHp = target.getMaxHp(); // Override the ability for the target and attacker only vi.spyOn(game.scene.getPlayerField()[1], "getAbility").mockReturnValue(allAbilities[allyAbility]); - vi.spyOn(game.scene.getEnemyField()[0], "getAbility").mockReturnValue(allAbilities[enemyAbility]); + vi.spyOn(game.field.getEnemyPokemon(), "getAbility").mockReturnValue(allAbilities[enemyAbility]); // turn 1 game.move.select(MoveId.SUNNY_DAY, 0); diff --git a/test/abilities/flower-veil.test.ts b/test/abilities/flower-veil.test.ts index 44274d86a1b..ec34f696bc9 100644 --- a/test/abilities/flower-veil.test.ts +++ b/test/abilities/flower-veil.test.ts @@ -66,7 +66,7 @@ describe("Abilities - Flower Veil", () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.BULBASAUR]); // Clear the ability of the ally to isolate the test - const ally = game.scene.getPlayerField()[1]!; + const ally = game.scene.getPlayerField()[1]; vi.spyOn(ally, "getAbility").mockReturnValue(allAbilities[AbilityId.BALL_FETCH]); game.move.select(MoveId.SPLASH); game.move.select(MoveId.SPLASH); diff --git a/test/abilities/forecast.test.ts b/test/abilities/forecast.test.ts index 87d1d20acdb..9bd40709a94 100644 --- a/test/abilities/forecast.test.ts +++ b/test/abilities/forecast.test.ts @@ -76,7 +76,7 @@ describe("Abilities - Forecast", () => { vi.spyOn(game.scene.getPlayerParty()[5], "getAbility").mockReturnValue(allAbilities[AbilityId.CLOUD_NINE]); - const castform = game.scene.getPlayerField()[0]; + const castform = game.field.getPlayerPokemon(); expect(castform.formIndex).toBe(NORMAL_FORM); game.move.select(MoveId.RAIN_DANCE); diff --git a/test/abilities/healer.test.ts b/test/abilities/healer.test.ts index 43280ff8271..fb28cd891ac 100644 --- a/test/abilities/healer.test.ts +++ b/test/abilities/healer.test.ts @@ -6,7 +6,6 @@ import { SpeciesId } from "#enums/species-id"; import { StatusEffect } from "#enums/status-effect"; import type { Pokemon } from "#field/pokemon"; import { GameManager } from "#test/test-utils/game-manager"; -import { isNullOrUndefined } from "#utils/common"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -37,7 +36,7 @@ describe("Abilities - Healer", () => { // Mock healer to have a 100% chance of healing its ally vi.spyOn(allAbilities[AbilityId.HEALER].getAttrs("PostTurnResetStatusAbAttr")[0], "getCondition").mockReturnValue( - (pokemon: Pokemon) => !isNullOrUndefined(pokemon.getAlly()), + (pokemon: Pokemon) => pokemon.getAlly() != null, ); }); diff --git a/test/abilities/magic-bounce.test.ts b/test/abilities/magic-bounce.test.ts index c15690c3f5d..6b7bc7453ed 100644 --- a/test/abilities/magic-bounce.test.ts +++ b/test/abilities/magic-bounce.test.ts @@ -64,7 +64,7 @@ describe("Abilities - Magic Bounce", () => { game.move.use(MoveId.SPLASH, 1); await game.phaseInterceptor.to("BerryPhase"); - const user = game.scene.getPlayerField()[0]; + const user = game.field.getPlayerPokemon(); expect(user.getStatStage(Stat.ATK)).toBe(-2); }); diff --git a/test/abilities/mirror-armor.test.ts b/test/abilities/mirror-armor.test.ts index b2bd9be4755..85d821d0683 100644 --- a/test/abilities/mirror-armor.test.ts +++ b/test/abilities/mirror-armor.test.ts @@ -92,8 +92,7 @@ describe("Ability - Mirror Armor", () => { game.override.battleStyle("double").enemyAbility(AbilityId.MIRROR_ARMOR).ability(AbilityId.INTIMIDATE); await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER]); - const [enemy1, enemy2] = game.scene.getEnemyField(); - const [player1, player2] = game.scene.getPlayerField(); + const [player1, player2, enemy1, enemy2] = game.scene.getField(); // Enemy has intimidate, enemy should lose -1 atk game.move.select(MoveId.SPLASH); diff --git a/test/abilities/mycelium-might.test.ts b/test/abilities/mycelium-might.test.ts index c3b7b4753b6..21b856d341e 100644 --- a/test/abilities/mycelium-might.test.ts +++ b/test/abilities/mycelium-might.test.ts @@ -2,8 +2,6 @@ import { AbilityId } from "#enums/ability-id"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; -import { TurnEndPhase } from "#phases/turn-end-phase"; -import { TurnStartPhase } from "#phases/turn-start-phase"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; @@ -45,65 +43,50 @@ describe("Abilities - Mycelium Might", () => { it("should move last in its priority bracket and ignore protective abilities", async () => { await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - const enemyPokemon = game.field.getEnemyPokemon(); - const playerIndex = game.field.getPlayerPokemon().getBattlerIndex(); - const enemyIndex = enemyPokemon.getBattlerIndex(); + const enemy = game.field.getEnemyPokemon(); + const player = game.field.getPlayerPokemon(); game.move.select(MoveId.BABY_DOLL_EYES); - await game.phaseInterceptor.to(TurnStartPhase, false); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const speedOrder = phase.getSpeedOrder(); - const commandOrder = phase.getCommandOrder(); + await game.phaseInterceptor.to("MoveEndPhase", false); // The opponent Pokemon (without Mycelium Might) goes first despite having lower speed than the player Pokemon. // The player Pokemon (with Mycelium Might) goes last despite having higher speed than the opponent. - expect(speedOrder).toEqual([playerIndex, enemyIndex]); - expect(commandOrder).toEqual([enemyIndex, playerIndex]); - await game.phaseInterceptor.to(TurnEndPhase); + expect(player.hp).not.toEqual(player.getMaxHp()); + await game.phaseInterceptor.to("TurnEndPhase"); // Despite the opponent's ability (Clear Body), its ATK stat stage is still reduced. - expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1); + expect(enemy.getStatStage(Stat.ATK)).toBe(-1); }); it("should still go first if a status move that is in a higher priority bracket than the opponent's move is used", async () => { game.override.enemyMoveset(MoveId.TACKLE); await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - const enemyPokemon = game.field.getEnemyPokemon(); - const playerIndex = game.field.getPlayerPokemon().getBattlerIndex(); - const enemyIndex = enemyPokemon.getBattlerIndex(); + const enemy = game.field.getEnemyPokemon(); + const player = game.field.getPlayerPokemon(); game.move.select(MoveId.BABY_DOLL_EYES); - await game.phaseInterceptor.to(TurnStartPhase, false); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const speedOrder = phase.getSpeedOrder(); - const commandOrder = phase.getCommandOrder(); + await game.phaseInterceptor.to("MoveEndPhase", false); // The player Pokemon (with M.M.) goes first because its move is still within a higher priority bracket than its opponent. // The enemy Pokemon goes second because its move is in a lower priority bracket. - expect(speedOrder).toEqual([playerIndex, enemyIndex]); - expect(commandOrder).toEqual([playerIndex, enemyIndex]); - await game.phaseInterceptor.to(TurnEndPhase); + expect(player.hp).toEqual(player.getMaxHp()); + await game.phaseInterceptor.to("TurnEndPhase"); // Despite the opponent's ability (Clear Body), its ATK stat stage is still reduced. - expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1); + expect(enemy.getStatStage(Stat.ATK)).toBe(-1); }); it("should not affect non-status moves", async () => { await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - const playerIndex = game.field.getPlayerPokemon().getBattlerIndex(); - const enemyIndex = game.field.getEnemyPokemon().getBattlerIndex(); + const player = game.field.getPlayerPokemon(); game.move.select(MoveId.QUICK_ATTACK); - await game.phaseInterceptor.to(TurnStartPhase, false); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const speedOrder = phase.getSpeedOrder(); - const commandOrder = phase.getCommandOrder(); + await game.phaseInterceptor.to("MoveEndPhase", false); // The player Pokemon (with M.M.) goes first because it has a higher speed and did not use a status move. // The enemy Pokemon (without M.M.) goes second because its speed is lower. // This means that the commandOrder should be identical to the speedOrder - expect(speedOrder).toEqual([playerIndex, enemyIndex]); - expect(commandOrder).toEqual([playerIndex, enemyIndex]); + expect(player.hp).toEqual(player.getMaxHp()); }); }); diff --git a/test/abilities/neutralizing-gas.test.ts b/test/abilities/neutralizing-gas.test.ts index 555e5f8a19c..fd9138e4174 100644 --- a/test/abilities/neutralizing-gas.test.ts +++ b/test/abilities/neutralizing-gas.test.ts @@ -59,7 +59,7 @@ describe("Abilities - Neutralizing Gas", () => { expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(1); }); - it.todo("should activate before other abilities", async () => { + it("should activate before other abilities", async () => { game.override.enemySpecies(SpeciesId.ACCELGOR).enemyLevel(100).enemyAbility(AbilityId.INTIMIDATE); await game.classicMode.startBattle([SpeciesId.FEEBAS]); diff --git a/test/abilities/no-guard.test.ts b/test/abilities/no-guard.test.ts index 9ce12e710e5..9fc308ab9e3 100644 --- a/test/abilities/no-guard.test.ts +++ b/test/abilities/no-guard.test.ts @@ -58,6 +58,6 @@ describe("Abilities - No Guard", () => { await game.classicMode.startBattle(); - expect(game.scene.getEnemyField().length).toBe(2); + expect(game.scene.getEnemyField()).toHaveLength(2); }); }); diff --git a/test/abilities/quick-draw.test.ts b/test/abilities/quick-draw.test.ts index ce5873af3a8..257892145e5 100644 --- a/test/abilities/quick-draw.test.ts +++ b/test/abilities/quick-draw.test.ts @@ -5,7 +5,7 @@ import { SpeciesId } from "#enums/species-id"; import { FaintPhase } from "#phases/faint-phase"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; describe("Abilities - Quick Draw", () => { let phaserGame: Phaser.Game; @@ -25,7 +25,6 @@ describe("Abilities - Quick Draw", () => { game = new GameManager(phaserGame); game.override .battleStyle("single") - .starterSpecies(SpeciesId.MAGIKARP) .ability(AbilityId.QUICK_DRAW) .moveset([MoveId.TACKLE, MoveId.TAIL_WHIP]) .enemyLevel(100) @@ -40,8 +39,8 @@ describe("Abilities - Quick Draw", () => { ).mockReturnValue(100); }); - test("makes pokemon going first in its priority bracket", async () => { - await game.classicMode.startBattle(); + it("makes pokemon go first in its priority bracket", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); const pokemon = game.field.getPlayerPokemon(); const enemy = game.field.getEnemyPokemon(); @@ -57,33 +56,27 @@ describe("Abilities - Quick Draw", () => { expect(pokemon.waveData.abilitiesApplied).toContain(AbilityId.QUICK_DRAW); }); - test( - "does not triggered by non damage moves", - { - retry: 5, - }, - async () => { - await game.classicMode.startBattle(); + it("is not triggered by non damaging moves", async () => { + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - const pokemon = game.field.getPlayerPokemon(); - const enemy = game.field.getEnemyPokemon(); + const pokemon = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); - pokemon.hp = 1; - enemy.hp = 1; + pokemon.hp = 1; + enemy.hp = 1; - game.move.select(MoveId.TAIL_WHIP); - await game.phaseInterceptor.to(FaintPhase, false); + game.move.select(MoveId.TAIL_WHIP); + await game.phaseInterceptor.to(FaintPhase, false); - expect(pokemon.isFainted()).toBe(true); - expect(enemy.isFainted()).toBe(false); - expect(pokemon.waveData.abilitiesApplied).not.contain(AbilityId.QUICK_DRAW); - }, - ); + expect(pokemon.isFainted()).toBe(true); + expect(enemy.isFainted()).toBe(false); + expect(pokemon.waveData.abilitiesApplied).not.contain(AbilityId.QUICK_DRAW); + }); - test("does not increase priority", async () => { + it("does not increase priority", async () => { game.override.enemyMoveset([MoveId.EXTREME_SPEED]); - await game.classicMode.startBattle(); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); const pokemon = game.field.getPlayerPokemon(); const enemy = game.field.getEnemyPokemon(); diff --git a/test/abilities/stall.test.ts b/test/abilities/stall.test.ts index 5b4e38f7099..b6a88964e09 100644 --- a/test/abilities/stall.test.ts +++ b/test/abilities/stall.test.ts @@ -1,7 +1,6 @@ import { AbilityId } from "#enums/ability-id"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; -import { TurnStartPhase } from "#phases/turn-start-phase"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; @@ -40,56 +39,41 @@ describe("Abilities - Stall", () => { it("Pokemon with Stall should move last in its priority bracket regardless of speed", async () => { await game.classicMode.startBattle([SpeciesId.SHUCKLE]); - const playerIndex = game.field.getPlayerPokemon().getBattlerIndex(); - const enemyIndex = game.field.getEnemyPokemon().getBattlerIndex(); + const player = game.field.getPlayerPokemon(); game.move.select(MoveId.QUICK_ATTACK); - await game.phaseInterceptor.to(TurnStartPhase, false); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const speedOrder = phase.getSpeedOrder(); - const commandOrder = phase.getCommandOrder(); + await game.phaseInterceptor.to("MoveEndPhase", false); // The player Pokemon (without Stall) goes first despite having lower speed than the opponent. // The opponent Pokemon (with Stall) goes last despite having higher speed than the player Pokemon. - expect(speedOrder).toEqual([enemyIndex, playerIndex]); - expect(commandOrder).toEqual([playerIndex, enemyIndex]); + expect(player).toHaveFullHp(); }); it("Pokemon with Stall will go first if a move that is in a higher priority bracket than the opponent's move is used", async () => { await game.classicMode.startBattle([SpeciesId.SHUCKLE]); - const playerIndex = game.field.getPlayerPokemon().getBattlerIndex(); - const enemyIndex = game.field.getEnemyPokemon().getBattlerIndex(); + const player = game.field.getPlayerPokemon(); game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to(TurnStartPhase, false); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const speedOrder = phase.getSpeedOrder(); - const commandOrder = phase.getCommandOrder(); + await game.phaseInterceptor.to("MoveEndPhase", false); // The opponent Pokemon (with Stall) goes first because its move is still within a higher priority bracket than its opponent. // The player Pokemon goes second because its move is in a lower priority bracket. - expect(speedOrder).toEqual([enemyIndex, playerIndex]); - expect(commandOrder).toEqual([enemyIndex, playerIndex]); + expect(player).not.toHaveFullHp(); }); it("If both Pokemon have stall and use the same move, speed is used to determine who goes first.", async () => { game.override.ability(AbilityId.STALL); await game.classicMode.startBattle([SpeciesId.SHUCKLE]); - const playerIndex = game.field.getPlayerPokemon().getBattlerIndex(); - const enemyIndex = game.field.getEnemyPokemon().getBattlerIndex(); + const player = game.field.getPlayerPokemon(); game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to(TurnStartPhase, false); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const speedOrder = phase.getSpeedOrder(); - const commandOrder = phase.getCommandOrder(); + await game.phaseInterceptor.to("MoveEndPhase", false); // The opponent Pokemon (with Stall) goes first because it has a higher speed. // The player Pokemon (with Stall) goes second because its speed is lower. - expect(speedOrder).toEqual([enemyIndex, playerIndex]); - expect(commandOrder).toEqual([enemyIndex, playerIndex]); + expect(player).not.toHaveFullHp(); }); }); diff --git a/test/abilities/storm-drain.test.ts b/test/abilities/storm-drain.test.ts index bc4d4f15cfa..5439459b1dd 100644 --- a/test/abilities/storm-drain.test.ts +++ b/test/abilities/storm-drain.test.ts @@ -37,9 +37,7 @@ describe("Abilities - Storm Drain", () => { it("should redirect water type moves", async () => { await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MAGIKARP]); - const enemy1 = game.scene.getEnemyField()[0]; - const enemy2 = game.scene.getEnemyField()[1]; - + const [enemy1, enemy2] = game.scene.getEnemyField(); game.field.mockAbility(enemy2, AbilityId.STORM_DRAIN); game.move.select(MoveId.WATER_GUN, BattlerIndex.PLAYER, BattlerIndex.ENEMY); @@ -53,8 +51,7 @@ describe("Abilities - Storm Drain", () => { game.override.moveset([MoveId.SPLASH, MoveId.AERIAL_ACE]); await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MAGIKARP]); - const enemy1 = game.scene.getEnemyField()[0]; - const enemy2 = game.scene.getEnemyField()[1]; + const [enemy1, enemy2] = game.scene.getEnemyField(); game.field.mockAbility(enemy2, AbilityId.STORM_DRAIN); @@ -83,8 +80,7 @@ describe("Abilities - Storm Drain", () => { game.override.ability(AbilityId.NORMALIZE); await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MAGIKARP]); - const enemy1 = game.scene.getEnemyField()[0]; - const enemy2 = game.scene.getEnemyField()[1]; + const [enemy1, enemy2] = game.scene.getEnemyField(); game.field.mockAbility(enemy2, AbilityId.STORM_DRAIN); game.move.select(MoveId.WATER_GUN, BattlerIndex.PLAYER, BattlerIndex.ENEMY); @@ -98,8 +94,7 @@ describe("Abilities - Storm Drain", () => { game.override.ability(AbilityId.LIQUID_VOICE); await game.classicMode.startBattle([SpeciesId.FEEBAS]); - const enemy1 = game.scene.getEnemyField()[0]; - const enemy2 = game.scene.getEnemyField()[1]; + const [enemy1, enemy2] = game.scene.getEnemyField(); game.field.mockAbility(enemy2, AbilityId.STORM_DRAIN); diff --git a/test/abilities/supreme-overlord.test.ts b/test/abilities/supreme-overlord.test.ts index a0f2d9050b3..d5470b70476 100644 --- a/test/abilities/supreme-overlord.test.ts +++ b/test/abilities/supreme-overlord.test.ts @@ -1,6 +1,7 @@ import { allMoves } from "#data/data-lists"; import { AbilityId } from "#enums/ability-id"; import { BattlerIndex } from "#enums/battler-index"; +import { BattlerTagType } from "#enums/battler-tag-type"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import type { Move } from "#moves/move"; @@ -166,4 +167,41 @@ describe("Abilities - Supreme Overlord", () => { expect(move.calculateBattlePower).toHaveLastReturnedWith(basePower); }); + + it("should not increase in power if ally faints while on the field", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER, SpeciesId.SQUIRTLE]); + + game.move.select(MoveId.TACKLE, BattlerIndex.PLAYER, BattlerIndex.ENEMY); + game.move.select(MoveId.LUNAR_DANCE, BattlerIndex.PLAYER_2); + await game.setTurnOrder([BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); + await game.toEndOfTurn(); + + expect(game.field.getPlayerPokemon()).not.toHaveBattlerTag(BattlerTagType.SUPREME_OVERLORD); + expect(move.calculateBattlePower).toHaveLastReturnedWith(basePower); + }); + + it("should persist fainted count through reload", async () => { + // Avoid learning moves + game.override.startingLevel(1000); + + await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER, SpeciesId.SQUIRTLE]); + + game.move.select(MoveId.LUNAR_DANCE); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + game.move.select(MoveId.TACKLE); + await game.toEndOfTurn(); + expect(move.calculateBattlePower).toHaveLastReturnedWith(basePower * 1.1); + + await game.toNextWave(); + await game.reload.reloadSession(); + + expect(game.field.getPlayerPokemon()).toHaveBattlerTag({ tagType: BattlerTagType.SUPREME_OVERLORD, faintCount: 1 }); + + game.move.select(MoveId.TACKLE); + await game.toEndOfTurn(); + expect(move.calculateBattlePower).toHaveLastReturnedWith(basePower * 1.1); + }); }); diff --git a/test/abilities/unburden.test.ts b/test/abilities/unburden.test.ts index c10dd404ab9..285ea8af32c 100644 --- a/test/abilities/unburden.test.ts +++ b/test/abilities/unburden.test.ts @@ -362,7 +362,7 @@ describe("Abilities - Unburden", () => { .startingHeldItems([{ name: "WIDE_LENS" }]); await game.classicMode.startBattle([SpeciesId.TREECKO, SpeciesId.FEEBAS, SpeciesId.MILOTIC]); - const treecko = game.scene.getPlayerField()[0]; + const treecko = game.field.getPlayerPokemon(); const treeckoInitialHeldItems = getHeldItemCount(treecko); const initialSpeed = treecko.getStat(Stat.SPD); @@ -374,7 +374,7 @@ describe("Abilities - Unburden", () => { game.doSelectPartyPokemon(0, "RevivalBlessingPhase"); await game.toNextTurn(); - expect(game.scene.getPlayerField()[0]).toBe(treecko); + expect(game.field.getPlayerPokemon()).toBe(treecko); expect(getHeldItemCount(treecko)).toBeLessThan(treeckoInitialHeldItems); expect(treecko.getEffectiveStat(Stat.SPD)).toBe(initialSpeed); }); diff --git a/test/ai/ai-moveset-gen.test.ts b/test/ai/ai-moveset-gen.test.ts new file mode 100644 index 00000000000..6d927926131 --- /dev/null +++ b/test/ai/ai-moveset-gen.test.ts @@ -0,0 +1,285 @@ +import { __INTERNAL_TEST_EXPORTS } from "#app/ai/ai-moveset-gen"; +import { + COMMON_TIER_TM_LEVEL_REQUIREMENT, + GREAT_TIER_TM_LEVEL_REQUIREMENT, + ULTRA_TIER_TM_LEVEL_REQUIREMENT, +} from "#balance/moveset-generation"; +import { allMoves, allSpecies } from "#data/data-lists"; +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import { TrainerSlot } from "#enums/trainer-slot"; +import { EnemyPokemon } from "#field/pokemon"; +import { GameManager } from "#test/test-utils/game-manager"; +import { NumberHolder } from "#utils/common"; +import { afterEach } from "node:test"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +/** + * Parameters for {@linkcode createTestablePokemon} + */ +interface MockPokemonParams { + /** The level to set the Pokémon to */ + level: number; + /** + * Whether the pokemon is a boss or not. + * @defaultValue `false` + */ + boss?: boolean; + /** + * The trainer slot to assign to the pokemon, if any. + * @defaultValue `TrainerSlot.NONE` + */ + trainerSlot?: TrainerSlot; + /** + * The form index to assign to the pokemon, if any. + * This *must* be one of the valid form indices for the species, or the test will break. + * @defaultValue `0` + */ + formIndex?: number; +} + +/** + * Construct an `EnemyPokemon` that can be used for testing + * @param species - The species ID of the pokemon to create + * @returns The newly created `EnemyPokemon`. + * @todo Move this to a dedicated unit test util folder if more tests come to rely on it + */ +function createTestablePokemon( + species: SpeciesId, + { level, trainerSlot = TrainerSlot.NONE, boss = false, formIndex = 0 }: MockPokemonParams, +): EnemyPokemon { + const pokemon = new EnemyPokemon(allSpecies[species], level, trainerSlot, boss); + if (formIndex !== 0) { + const formIndexLength = allSpecies[species]?.forms.length; + const name = allSpecies[species]?.name; + expect(formIndex, `${name} does not have a form with index ${formIndex}`).toBeLessThan(formIndexLength); + pokemon.formIndex = formIndex; + } + + return pokemon; +} + +describe("Unit Tests - ai-moveset-gen.ts", () => { + describe("filterPool", () => { + const { filterPool } = __INTERNAL_TEST_EXPORTS; + it("clones a pool when there are no predicates", () => { + const pool = new Map([ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + ]); + + const filtered = filterPool(pool, () => true); + const expected = [ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + ]; + expect(filtered).toEqual(expected); + }); + + it("does not modify the original pool", () => { + const pool = new Map([ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + ]); + const original = new Map(pool); + + filterPool(pool, moveId => moveId !== MoveId.TACKLE); + expect(pool).toEqual(original); + }); + + it("filters out moves that do not match the predicate", () => { + const pool = new Map([ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + [MoveId.SPLASH, 3], + ]); + const filtered = filterPool(pool, moveId => moveId !== MoveId.SPLASH); + expect(filtered).toEqual([ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + ]); + }); + + it("returns an empty array if no moves match the predicate", () => { + const pool = new Map([ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + ]); + const filtered = filterPool(pool, () => false); + expect(filtered).toEqual([]); + }); + + it("calculates totalWeight correctly when provided", () => { + const pool = new Map([ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + [MoveId.SPLASH, 3], + ]); + const totalWeight = new NumberHolder(0); + const filtered = filterPool(pool, moveId => moveId !== MoveId.SPLASH, totalWeight); + expect(filtered).toEqual([ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + ]); + expect(totalWeight.value).toBe(3); + }); + + it("Clears totalWeight when provided", () => { + const pool = new Map([ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + ]); + const totalWeight = new NumberHolder(42); + const filtered = filterPool(pool, () => false, totalWeight); + expect(filtered).toEqual([]); + expect(totalWeight.value).toBe(0); + }); + }); + + describe("getAllowedTmTiers", () => { + const { getAllowedTmTiers } = __INTERNAL_TEST_EXPORTS; + + it.each([ + { tierName: "common", resIdx: 0, level: COMMON_TIER_TM_LEVEL_REQUIREMENT - 1 }, + { tierName: "great", resIdx: 1, level: GREAT_TIER_TM_LEVEL_REQUIREMENT - 1 }, + { tierName: "ultra", resIdx: 2, level: ULTRA_TIER_TM_LEVEL_REQUIREMENT - 1 }, + ])("should prevent $name TMs when below level $level", ({ level, resIdx }) => { + expect(getAllowedTmTiers(level)[resIdx]).toBe(false); + }); + + it.each([ + { tierName: "common", resIdx: 0, level: COMMON_TIER_TM_LEVEL_REQUIREMENT }, + { tierName: "great", resIdx: 1, level: GREAT_TIER_TM_LEVEL_REQUIREMENT }, + { tierName: "ultra", resIdx: 2, level: ULTRA_TIER_TM_LEVEL_REQUIREMENT }, + ])("should allow $name TMs when at level $level", ({ level, resIdx }) => { + expect(getAllowedTmTiers(level)[resIdx]).toBe(true); + }); + }); + + // Unit tests for methods that require a game context + describe("", () => { + //#region boilerplate + let phaserGame: Phaser.Game; + let game: GameManager; + /**A pokemon object that will be cleaned up after every test */ + let pokemon: EnemyPokemon | null = null; + + beforeAll(async () => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + // Game manager can be reused between tests as we are not really modifying the global state + // So there is no need to put this in a beforeEach with cleanup in afterEach. + game = new GameManager(phaserGame); + }); + + afterEach(() => { + pokemon?.destroy(); + }); + // Sanitize the interceptor after running the suite to ensure other tests are not affected + afterAll(() => { + game.phaseInterceptor.restoreOg(); + }); + //#endregion boilerplate + + function createCharmander(_ = pokemon): asserts _ is EnemyPokemon { + pokemon?.destroy(); + pokemon = createTestablePokemon(SpeciesId.CHARMANDER, { level: 10 }); + expect(pokemon).toBeInstanceOf(EnemyPokemon); + } + describe("getAndWeightLevelMoves", () => { + const { getAndWeightLevelMoves } = __INTERNAL_TEST_EXPORTS; + + it("returns an empty map if getLevelMoves throws", async () => { + createCharmander(pokemon); + vi.spyOn(pokemon, "getLevelMoves").mockImplementation(() => { + throw new Error("fail"); + }); + // Suppress the warning from the test output + const warnMock = vi.spyOn(console, "warn").mockImplementationOnce(() => {}); + + const result = getAndWeightLevelMoves(pokemon); + expect(warnMock).toHaveBeenCalled(); + expect(result.size).toBe(0); + }); + + it("skips unimplemented moves", () => { + createCharmander(pokemon); + vi.spyOn(pokemon, "getLevelMoves").mockReturnValue([ + [1, MoveId.TACKLE], + [5, MoveId.GROWL], + ]); + vi.spyOn(allMoves[MoveId.TACKLE], "name", "get").mockReturnValue("Tackle (N)"); + const result = getAndWeightLevelMoves(pokemon); + expect(result.has(MoveId.TACKLE)).toBe(false); + expect(result.has(MoveId.GROWL)).toBe(true); + }); + + it("skips moves already in the pool", () => { + createCharmander(pokemon); + vi.spyOn(pokemon, "getLevelMoves").mockReturnValue([ + [1, MoveId.TACKLE], + [5, MoveId.TACKLE], + ]); + + const result = getAndWeightLevelMoves(pokemon); + expect(result.get(MoveId.TACKLE)).toBe(21); + }); + + it("weights moves based on level", () => { + createCharmander(pokemon); + vi.spyOn(pokemon, "getLevelMoves").mockReturnValue([ + [1, MoveId.TACKLE], + [5, MoveId.GROWL], + [9, MoveId.EMBER], + ]); + + const result = getAndWeightLevelMoves(pokemon); + expect(result.get(MoveId.TACKLE)).toBe(21); + expect(result.get(MoveId.GROWL)).toBe(25); + expect(result.get(MoveId.EMBER)).toBe(29); + }); + }); + }); +}); + +describe("Regression Tests - ai-moveset-gen.ts", () => { + //#region boilerplate + let phaserGame: Phaser.Game; + let game: GameManager; + /**A pokemon object that will be cleaned up after every test */ + let pokemon: EnemyPokemon | null = null; + + beforeAll(async () => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + // Game manager can be reused between tests as we are not really modifying the global state + // So there is no need to put this in a beforeEach with cleanup in afterEach. + game = new GameManager(phaserGame); + }); + + afterEach(() => { + pokemon?.destroy(); + }); + + afterAll(() => { + game.phaseInterceptor.restoreOg(); + }); + //#endregion boilerplate + + describe("getTmPoolForSpecies", () => { + const { getTmPoolForSpecies } = __INTERNAL_TEST_EXPORTS; + + it("should not crash when generating a moveset for Pokemon without TM moves", () => { + pokemon = createTestablePokemon(SpeciesId.DITTO, { level: 50 }); + expect(() => + getTmPoolForSpecies(SpeciesId.DITTO, ULTRA_TIER_TM_LEVEL_REQUIREMENT, "", new Map(), new Map(), new Map(), [ + true, + true, + true, + ]), + ).not.toThrow(); + }); + }); +}); diff --git a/test/battle/battle-order.test.ts b/test/battle/battle-order.test.ts index 0b24fcbfa7d..de13b22df79 100644 --- a/test/battle/battle-order.test.ts +++ b/test/battle/battle-order.test.ts @@ -1,7 +1,8 @@ import { AbilityId } from "#enums/ability-id"; +import { BattlerIndex } from "#enums/battler-index"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; -import type { TurnStartPhase } from "#phases/turn-start-phase"; +import type { MovePhase } from "#phases/move-phase"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -34,38 +35,34 @@ describe("Battle order", () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR]); const playerPokemon = game.field.getPlayerPokemon(); + const playerStartHp = playerPokemon.hp; const enemyPokemon = game.field.getEnemyPokemon(); + const enemyStartHp = enemyPokemon.hp; + vi.spyOn(playerPokemon, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 50]); // set playerPokemon's speed to 50 vi.spyOn(enemyPokemon, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set enemyPokemon's speed to 150 - game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to("TurnStartPhase", false); - const playerPokemonIndex = playerPokemon.getBattlerIndex(); - const enemyPokemonIndex = enemyPokemon.getBattlerIndex(); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const order = phase.getCommandOrder(); - expect(order[0]).toBe(enemyPokemonIndex); - expect(order[1]).toBe(playerPokemonIndex); + await game.phaseInterceptor.to("MoveEndPhase", false); + expect(playerPokemon.hp).not.toEqual(playerStartHp); + expect(enemyPokemon.hp).toEqual(enemyStartHp); }); it("Player faster than opponent 150 vs 50", async () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR]); const playerPokemon = game.field.getPlayerPokemon(); + const playerStartHp = playerPokemon.hp; const enemyPokemon = game.field.getEnemyPokemon(); + const enemyStartHp = enemyPokemon.hp; vi.spyOn(playerPokemon, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set playerPokemon's speed to 150 vi.spyOn(enemyPokemon, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 50]); // set enemyPokemon's speed to 50 game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to("TurnStartPhase", false); - const playerPokemonIndex = playerPokemon.getBattlerIndex(); - const enemyPokemonIndex = enemyPokemon.getBattlerIndex(); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const order = phase.getCommandOrder(); - expect(order[0]).toBe(playerPokemonIndex); - expect(order[1]).toBe(enemyPokemonIndex); + await game.phaseInterceptor.to("MoveEndPhase", false); + expect(playerPokemon.hp).toEqual(playerStartHp); + expect(enemyPokemon.hp).not.toEqual(enemyStartHp); }); it("double - both opponents faster than player 50/50 vs 150/150", async () => { @@ -73,23 +70,24 @@ describe("Battle order", () => { await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.BLASTOISE]); const playerPokemon = game.scene.getPlayerField(); + const playerHps = playerPokemon.map(p => p.hp); const enemyPokemon = game.scene.getEnemyField(); + const enemyHps = enemyPokemon.map(p => p.hp); playerPokemon.forEach(p => vi.spyOn(p, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 50])); // set both playerPokemons' speed to 50 enemyPokemon.forEach(p => vi.spyOn(p, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150])); // set both enemyPokemons' speed to 150 - const playerIndices = playerPokemon.map(p => p?.getBattlerIndex()); - const enemyIndices = enemyPokemon.map(p => p?.getBattlerIndex()); game.move.select(MoveId.TACKLE); game.move.select(MoveId.TACKLE, 1); - await game.phaseInterceptor.to("TurnStartPhase", false); + await game.move.selectEnemyMove(MoveId.TACKLE, BattlerIndex.PLAYER); + await game.move.selectEnemyMove(MoveId.TACKLE, BattlerIndex.PLAYER_2); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const order = phase.getCommandOrder(); - expect(order.slice(0, 2).includes(enemyIndices[0])).toBe(true); - expect(order.slice(0, 2).includes(enemyIndices[1])).toBe(true); - expect(order.slice(2, 4).includes(playerIndices[0])).toBe(true); - expect(order.slice(2, 4).includes(playerIndices[1])).toBe(true); + await game.phaseInterceptor.to("MoveEndPhase", true); + await game.phaseInterceptor.to("MoveEndPhase", false); + for (let i = 0; i < 2; i++) { + expect(playerPokemon[i].hp).not.toEqual(playerHps[i]); + expect(enemyPokemon[i].hp).toEqual(enemyHps[i]); + } }); it("double - speed tie except 1 - 100/100 vs 100/150", async () => { @@ -101,18 +99,13 @@ describe("Battle order", () => { playerPokemon.forEach(p => vi.spyOn(p, "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 100])); //set both playerPokemons' speed to 100 vi.spyOn(enemyPokemon[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 100]); // set enemyPokemon's speed to 100 vi.spyOn(enemyPokemon[1], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set enemyPokemon's speed to 150 - const playerIndices = playerPokemon.map(p => p?.getBattlerIndex()); - const enemyIndices = enemyPokemon.map(p => p?.getBattlerIndex()); game.move.select(MoveId.TACKLE); game.move.select(MoveId.TACKLE, 1); - await game.phaseInterceptor.to("TurnStartPhase", false); + await game.phaseInterceptor.to("MovePhase", false); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const order = phase.getCommandOrder(); - // enemy 2 should be first, followed by some other assortment of the other 3 pokemon - expect(order[0]).toBe(enemyIndices[1]); - expect(order.slice(1, 4)).toEqual(expect.arrayContaining([enemyIndices[0], ...playerIndices])); + const phase = game.scene.phaseManager.getCurrentPhase() as MovePhase; + expect(phase.pokemon).toEqual(enemyPokemon[1]); }); it("double - speed tie 100/150 vs 100/150", async () => { @@ -125,17 +118,13 @@ describe("Battle order", () => { vi.spyOn(playerPokemon[1], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set other playerPokemon's speed to 150 vi.spyOn(enemyPokemon[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 100]); // set one enemyPokemon's speed to 100 vi.spyOn(enemyPokemon[1], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, 150]); // set other enemyPokemon's speed to 150 - const playerIndices = playerPokemon.map(p => p?.getBattlerIndex()); - const enemyIndices = enemyPokemon.map(p => p?.getBattlerIndex()); game.move.select(MoveId.TACKLE); game.move.select(MoveId.TACKLE, 1); - await game.phaseInterceptor.to("TurnStartPhase", false); - const phase = game.scene.phaseManager.getCurrentPhase() as TurnStartPhase; - const order = phase.getCommandOrder(); - // P2/E2 should be randomly first/second, then P1/E1 randomly 3rd/4th - expect(order.slice(0, 2)).toStrictEqual(expect.arrayContaining([playerIndices[1], enemyIndices[1]])); - expect(order.slice(2, 4)).toStrictEqual(expect.arrayContaining([playerIndices[0], enemyIndices[0]])); + await game.phaseInterceptor.to("MovePhase", false); + + const phase = game.scene.phaseManager.getCurrentPhase() as MovePhase; + expect(enemyPokemon[1] === phase.pokemon || playerPokemon[1] === phase.pokemon); }); }); diff --git a/test/battler-tags/substitute.test.ts b/test/battler-tags/substitute.test.ts index 7ae60ad1408..a2ff539d2a8 100644 --- a/test/battler-tags/substitute.test.ts +++ b/test/battler-tags/substitute.test.ts @@ -49,7 +49,7 @@ describe("BattlerTag - SubstituteTag", () => { vi.spyOn(messages, "getPokemonNameWithAffix").mockReturnValue(""); vi.spyOn(mockPokemon.scene as BattleScene, "getPokemonById").mockImplementation(pokemonId => - mockPokemon.id === pokemonId ? mockPokemon : null, + mockPokemon.id === pokemonId ? mockPokemon : undefined, ); }); diff --git a/test/eggs/egg.test.ts b/test/eggs/egg.test.ts index 8b47e68f402..001adf83b37 100644 --- a/test/eggs/egg.test.ts +++ b/test/eggs/egg.test.ts @@ -202,7 +202,7 @@ describe("Egg Generation Tests", () => { const scene = game.scene; const eggMoveIndex = new Egg({ scene }).eggMoveIndex; - const result = !Utils.isNullOrUndefined(eggMoveIndex) && eggMoveIndex >= 0 && eggMoveIndex <= 3; + const result = eggMoveIndex != null && eggMoveIndex >= 0 && eggMoveIndex <= 3; expect(result).toBe(true); }); diff --git a/test/escape-calculations.test.ts b/test/escape-calculations.test.ts index fb677e81a45..e1e521f4394 100644 --- a/test/escape-calculations.test.ts +++ b/test/escape-calculations.test.ts @@ -7,6 +7,7 @@ import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +// TODO: These tests are stupid and need to be redone describe("Escape chance calculations", () => { let phaserGame: Phaser.Game; let game: GameManager; diff --git a/test/items/double-battle-chance-booster.test.ts b/test/items/double-battle-chance-booster.test.ts index 2c12b34eba3..ea3c400edb7 100644 --- a/test/items/double-battle-chance-booster.test.ts +++ b/test/items/double-battle-chance-booster.test.ts @@ -31,7 +31,7 @@ describe("Items - Double Battle Chance Boosters", () => { await game.classicMode.startBattle(); - expect(game.scene.getEnemyField().length).toBe(2); + expect(game.scene.getEnemyField()).toHaveLength(2); }); it("should guarantee double boss battle with 3 unique tiers", async () => { @@ -41,7 +41,7 @@ describe("Items - Double Battle Chance Boosters", () => { const enemyField = game.scene.getEnemyField(); - expect(enemyField.length).toBe(2); + expect(enemyField).toHaveLength(2); expect(enemyField[0].isBoss()).toBe(true); expect(enemyField[1].isBoss()).toBe(true); }); diff --git a/test/items/grip-claw.test.ts b/test/items/grip-claw.test.ts index 5ffebd76946..54a40942beb 100644 --- a/test/items/grip-claw.test.ts +++ b/test/items/grip-claw.test.ts @@ -44,7 +44,7 @@ describe("Items - Grip Claw", () => { it("should steal items on contact and only from the attack target", async () => { await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]); - const [playerPokemon] = game.scene.getPlayerField(); + const playerPokemon = game.field.getPlayerPokemon(); const gripClaw = playerPokemon.getHeldItems()[0] as ContactHeldItemTransferChanceModifier; vi.spyOn(gripClaw, "chance", "get").mockReturnValue(100); @@ -73,7 +73,7 @@ describe("Items - Grip Claw", () => { it("should not steal items when using a targetted, non attack move", async () => { await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]); - const [playerPokemon] = game.scene.getPlayerField(); + const playerPokemon = game.field.getPlayerPokemon(); const gripClaw = playerPokemon.getHeldItems()[0] as ContactHeldItemTransferChanceModifier; vi.spyOn(gripClaw, "chance", "get").mockReturnValue(100); diff --git a/test/items/multi-lens.test.ts b/test/items/multi-lens.test.ts index b69a07033c9..3686aff0fcf 100644 --- a/test/items/multi-lens.test.ts +++ b/test/items/multi-lens.test.ts @@ -103,7 +103,7 @@ describe("Items - Multi Lens", () => { await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); - const [magikarp] = game.scene.getPlayerField(); + const magikarp = game.field.getPlayerPokemon(); game.move.select(MoveId.SWIFT, 0); game.move.select(MoveId.SPLASH, 1); diff --git a/test/moves/ability-ignore-moves.test.ts b/test/moves/ability-ignore-moves.test.ts index e3a7c7db12f..089af242f87 100644 --- a/test/moves/ability-ignore-moves.test.ts +++ b/test/moves/ability-ignore-moves.test.ts @@ -102,7 +102,7 @@ describe("Moves - Ability-Ignoring Moves", () => { // Both the initial and redirected instruct use ignored sturdy const [enemy1, enemy2] = game.scene.getEnemyField(); - expect(enemy1.isFainted()).toBe(true); - expect(enemy2.isFainted()).toBe(true); + expect(enemy1).toHaveFainted(); + expect(enemy2).toHaveFainted(); }); }); diff --git a/test/moves/baton-pass.test.ts b/test/moves/baton-pass.test.ts index f9bd92a63cd..caabcfa7158 100644 --- a/test/moves/baton-pass.test.ts +++ b/test/moves/baton-pass.test.ts @@ -76,12 +76,7 @@ describe("Moves - Baton Pass", () => { expect(game.field.getEnemyPokemon().getStatStage(Stat.SPATK)).toEqual(2); // confirm that a switch actually happened. can't use species because I // can't find a way to override trainer parties with more than 1 pokemon species - expect(game.phaseInterceptor.log.slice(-4)).toEqual([ - "MoveEffectPhase", - "SwitchSummonPhase", - "SummonPhase", - "PostSummonPhase", - ]); + expect(game.field.getEnemyPokemon().summonData.moveHistory).toHaveLength(0); }); it("doesn't transfer effects that aren't transferrable", async () => { diff --git a/test/moves/defog.test.ts b/test/moves/defog.test.ts index 820dfaa6bcb..4ddb397ee71 100644 --- a/test/moves/defog.test.ts +++ b/test/moves/defog.test.ts @@ -1,4 +1,5 @@ import { AbilityId } from "#enums/ability-id"; +import { ArenaTagType } from "#enums/arena-tag-type"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; @@ -32,26 +33,21 @@ describe("Moves - Defog", () => { .enemyMoveset([MoveId.DEFOG, MoveId.GROWL]); }); + // TODO: Refactor these tests they suck ass it("should not allow Safeguard to be active", async () => { await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - const playerPokemon = game.scene.getPlayerField(); - const enemyPokemon = game.scene.getEnemyField(); + game.scene.arena.addTag(ArenaTagType.SAFEGUARD, 0, 0, 0); - game.move.select(MoveId.SAFEGUARD); - await game.move.selectEnemyMove(MoveId.DEFOG); - await game.phaseInterceptor.to("BerryPhase"); + game.move.use(MoveId.DEFOG); + await game.toEndOfTurn(); - expect(playerPokemon[0].isSafeguarded(enemyPokemon[0])).toBe(false); - - expect(true).toBe(true); + expect(game).not.toHaveArenaTag(ArenaTagType.SAFEGUARD); }); it("should not allow Mist to be active", async () => { await game.classicMode.startBattle([SpeciesId.REGIELEKI]); - const playerPokemon = game.scene.getPlayerField(); - game.move.select(MoveId.MIST); await game.move.selectEnemyMove(MoveId.DEFOG); @@ -62,8 +58,6 @@ describe("Moves - Defog", () => { await game.phaseInterceptor.to("BerryPhase"); - expect(playerPokemon[0].getStatStage(Stat.ATK)).toBe(-1); - - expect(true).toBe(true); + expect(game.field.getPlayerPokemon()).toHaveStatStage(Stat.ATK, -1); }); }); diff --git a/test/moves/delayed-attack.test.ts b/test/moves/delayed-attack.test.ts index 6817c7fd17a..e31c7f28e48 100644 --- a/test/moves/delayed-attack.test.ts +++ b/test/moves/delayed-attack.test.ts @@ -193,7 +193,7 @@ describe("Moves - Delayed Attacks", () => { // All attacks have concluded at this point, unshifting new `MoveEffectPhase`s to the queue. expectFutureSightActive(0); - const MEPs = game.scene.phaseManager.phaseQueue.filter(p => p.is("MoveEffectPhase")); + const MEPs = game.scene.phaseManager["phaseQueue"].findAll("MoveEffectPhase"); expect(MEPs).toHaveLength(4); expect(MEPs.map(mep => mep.getPokemon())).toEqual(oldOrder); }); diff --git a/test/moves/destiny-bond.test.ts b/test/moves/destiny-bond.test.ts index 118a45e7682..a5020b83944 100644 --- a/test/moves/destiny-bond.test.ts +++ b/test/moves/destiny-bond.test.ts @@ -160,11 +160,7 @@ describe("Moves - Destiny Bond", () => { game.override.moveset([MoveId.DESTINY_BOND, MoveId.CRUNCH]).battleStyle("double"); await game.classicMode.startBattle([SpeciesId.SHEDINJA, SpeciesId.BULBASAUR, SpeciesId.SQUIRTLE]); - const enemyPokemon0 = game.scene.getEnemyField()[0]; - const enemyPokemon1 = game.scene.getEnemyField()[1]; - const playerPokemon0 = game.scene.getPlayerField()[0]; - const playerPokemon1 = game.scene.getPlayerField()[1]; - + const [playerPokemon0, playerPokemon1, enemyPokemon0, enemyPokemon1] = game.scene.getField(); // Shedinja uses Destiny Bond, then ally Bulbasaur KO's Shedinja with Crunch game.move.select(MoveId.DESTINY_BOND, 0); game.move.select(MoveId.CRUNCH, 1, BattlerIndex.PLAYER); diff --git a/test/moves/dragon-tail.test.ts b/test/moves/dragon-tail.test.ts index e3a5bf459e8..28266465523 100644 --- a/test/moves/dragon-tail.test.ts +++ b/test/moves/dragon-tail.test.ts @@ -171,7 +171,7 @@ describe("Moves - Dragon Tail", () => { const enemy = game.field.getEnemyPokemon(); expect(enemy).toBeDefined(); expect(enemy.hp).toBe(Math.floor(enemy.getMaxHp() / 2)); - expect(game.scene.getEnemyField().length).toBe(1); + expect(game.scene.getEnemyField()).toHaveLength(1); }); it("should not cause a softlock when activating a player's reviver seed", async () => { diff --git a/test/moves/fell-stinger.test.ts b/test/moves/fell-stinger.test.ts index ede70b7af9b..4550cdffa12 100644 --- a/test/moves/fell-stinger.test.ts +++ b/test/moves/fell-stinger.test.ts @@ -107,7 +107,7 @@ describe("Moves - Fell Stinger", () => { await game.classicMode.startBattle([SpeciesId.LEAVANNY]); const leadPokemon = game.field.getPlayerPokemon(); - const leftEnemy = game.scene.getEnemyField()[0]!; + const leftEnemy = game.field.getEnemyPokemon(); // Turn 1: set Salt Cure, enemy splashes and does nothing game.move.select(MoveId.SALT_CURE, 0, leftEnemy.getBattlerIndex()); diff --git a/test/moves/focus-punch.test.ts b/test/moves/focus-punch.test.ts index d7b40569aaa..06594e85e27 100644 --- a/test/moves/focus-punch.test.ts +++ b/test/moves/focus-punch.test.ts @@ -3,7 +3,6 @@ import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { BerryPhase } from "#phases/berry-phase"; import { MessagePhase } from "#phases/message-phase"; -import { MoveHeaderPhase } from "#phases/move-header-phase"; import { SwitchSummonPhase } from "#phases/switch-summon-phase"; import { TurnStartPhase } from "#phases/turn-start-phase"; import { GameManager } from "#test/test-utils/game-manager"; @@ -116,7 +115,7 @@ describe("Moves - Focus Punch", () => { await game.phaseInterceptor.to(TurnStartPhase); expect(game.scene.phaseManager.getCurrentPhase() instanceof SwitchSummonPhase).toBeTruthy(); - expect(game.scene.phaseManager.phaseQueue.find(phase => phase instanceof MoveHeaderPhase)).toBeDefined(); + expect(game.scene.phaseManager.hasPhaseOfType("MoveHeaderPhase")).toBe(true); }); it("should replace the 'but it failed' text when the user gets hit", async () => { game.override.enemyMoveset([MoveId.TACKLE]); diff --git a/test/moves/healing-wish-lunar-dance.test.ts b/test/moves/healing-wish-lunar-dance.test.ts new file mode 100644 index 00000000000..0dcf993aeac --- /dev/null +++ b/test/moves/healing-wish-lunar-dance.test.ts @@ -0,0 +1,245 @@ +import { AbilityId } from "#enums/ability-id"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import { Challenges } from "#enums/challenges"; +import { MoveId } from "#enums/move-id"; +import { MoveResult } from "#enums/move-result"; +import { PokemonType } from "#enums/pokemon-type"; +import { SpeciesId } from "#enums/species-id"; +import { StatusEffect } from "#enums/status-effect"; +import { GameManager } from "#test/test-utils/game-manager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - Lunar Dance and Healing Wish", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override.battleStyle("double").enemyAbility(AbilityId.BALL_FETCH).enemyMoveset(MoveId.SPLASH); + }); + + describe.each([ + { moveName: "Healing Wish", moveId: MoveId.HEALING_WISH }, + { moveName: "Lunar Dance", moveId: MoveId.LUNAR_DANCE }, + ])("$moveName", ({ moveId }) => { + it("should sacrifice the user to restore the switched in Pokemon's HP", async () => { + await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER, SpeciesId.SQUIRTLE]); + + const [bulbasaur, charmander, squirtle] = game.scene.getPlayerParty(); + squirtle.hp = 1; + + game.move.use(MoveId.SPLASH, 0); + game.move.use(moveId, 1); + game.doSelectPartyPokemon(2); + + await game.toNextTurn(); + + expect(bulbasaur.isFullHp()).toBe(true); + expect(charmander.isFainted()).toBe(true); + expect(squirtle.isFullHp()).toBe(true); + }); + + it("should sacrifice the user to cure the switched in Pokemon's status", async () => { + game.override.statusEffect(StatusEffect.BURN); + + await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER, SpeciesId.SQUIRTLE]); + const [bulbasaur, charmander, squirtle] = game.scene.getPlayerParty(); + + game.move.use(MoveId.SPLASH, 0); + game.move.use(moveId, 1); + game.doSelectPartyPokemon(2); + + await game.toNextTurn(); + + expect(bulbasaur.status?.effect).toBe(StatusEffect.BURN); + expect(charmander.isFainted()).toBe(true); + expect(squirtle.status?.effect).toBeUndefined(); + }); + + it("should fail if the user has no non-fainted allies in their party", async () => { + game.override.battleStyle("single"); + + await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER]); + const [bulbasaur, charmander] = game.scene.getPlayerParty(); + + game.move.use(MoveId.MEMENTO); + game.doSelectPartyPokemon(1); + + await game.toNextTurn(); + + expect(bulbasaur.isFainted()).toBe(true); + expect(charmander.isActive(true)).toBe(true); + + game.move.use(moveId); + + await game.toEndOfTurn(); + + expect(charmander.isFullHp()); + expect(charmander.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + }); + + it("should fail if the user has no challenge-eligible allies", async () => { + game.override.battleStyle("single"); + // Mono normal challenge + game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, PokemonType.NORMAL + 1, 0); + await game.challengeMode.startBattle([SpeciesId.RATICATE, SpeciesId.ODDISH]); + + const raticate = game.field.getPlayerPokemon(); + + game.move.use(moveId); + await game.toNextTurn(); + + expect(raticate.isFullHp()).toBe(true); + expect(raticate.getLastXMoves()[0].result).toEqual(MoveResult.FAIL); + }); + + it("should store its effect if the switched-in Pokemon would be unaffected", async () => { + game.override.battleStyle("single"); + + await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER, SpeciesId.SQUIRTLE]); + + const [bulbasaur, charmander, squirtle] = game.scene.getPlayerParty(); + squirtle.hp = 1; + + game.move.use(moveId); + game.doSelectPartyPokemon(1); + + await game.toNextTurn(); + + // Bulbasaur fainted and stored a healing effect + expect(bulbasaur.isFainted()).toBe(true); + expect(charmander.isFullHp()).toBe(true); + expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase"); + expect(game.scene.arena.getTag(ArenaTagType.PENDING_HEAL)).toBeDefined(); + + // Switch to damaged Squirtle. HW/LD's effect should activate + game.doSwitchPokemon(2); + + await game.toEndOfTurn(); + expect(squirtle.isFullHp()).toBe(true); + expect(game.scene.arena.getTag(ArenaTagType.PENDING_HEAL)).toBeUndefined(); + + // Set Charmander's HP to 1, then switch back to Charmander. + // HW/LD shouldn't activate again + charmander.hp = 1; + game.doSwitchPokemon(2); + + await game.toEndOfTurn(); + expect(charmander.hp).toBe(1); + }); + + it("should only store one charge of the effect at a time", async () => { + game.override.battleStyle("single"); + + await game.classicMode.startBattle([ + SpeciesId.BULBASAUR, + SpeciesId.CHARMANDER, + SpeciesId.SQUIRTLE, + SpeciesId.PIKACHU, + ]); + + const [bulbasaur, charmander, squirtle, pikachu] = game.scene.getPlayerParty(); + [squirtle, pikachu].forEach(p => (p.hp = 1)); + + // Use HW/LD and send in Charmander. HW/LD's effect should be stored + game.move.use(moveId); + game.doSelectPartyPokemon(1); + + await game.toNextTurn(); + expect(bulbasaur.isFainted()).toBe(true); + expect(charmander.isFullHp()).toBe(true); + expect(charmander.isFullHp()); + expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase"); + expect(game.scene.arena.getTag(ArenaTagType.PENDING_HEAL)).toBeDefined(); + + // Use HW/LD again, sending in Squirtle. HW/LD should activate and heal Squirtle + game.move.use(moveId); + game.doSelectPartyPokemon(2); + + await game.toNextTurn(); + expect(charmander.isFainted()).toBe(true); + expect(squirtle.isFullHp()).toBe(true); + expect(squirtle.isFullHp()); + + // Switch again to Pikachu. HW/LD's effect shouldn't be present + game.doSwitchPokemon(3); + + expect(pikachu.isFullHp()).toBe(false); + }); + }); + + it("Lunar Dance should sacrifice the user to restore the switched in Pokemon's PP", async () => { + game.override.battleStyle("single"); + + await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER]); + + const [bulbasaur, charmander] = game.scene.getPlayerParty(); + + game.move.use(MoveId.SPLASH); + await game.toNextTurn(); + + game.doSwitchPokemon(1); + await game.toNextTurn(); + + game.move.use(MoveId.LUNAR_DANCE); + game.doSelectPartyPokemon(1); + + await game.toNextTurn(); + expect(charmander.isFainted()).toBeTruthy(); + bulbasaur.getMoveset().forEach(mv => expect(mv.ppUsed).toBe(0)); + }); + + it("should stack with each other", async () => { + game.override.battleStyle("single"); + + await game.classicMode.startBattle([ + SpeciesId.BULBASAUR, + SpeciesId.CHARMANDER, + SpeciesId.SQUIRTLE, + SpeciesId.PIKACHU, + ]); + + const [bulbasaur, charmander, squirtle, pikachu] = game.scene.getPlayerParty(); + [squirtle, pikachu].forEach(p => { + p.hp = 1; + p.getMoveset().forEach(mv => (mv.ppUsed = 1)); + }); + + game.move.use(MoveId.LUNAR_DANCE); + game.doSelectPartyPokemon(1); + + await game.toNextTurn(); + expect(bulbasaur.isFainted()).toBe(true); + expect(charmander.isFullHp()).toBe(true); + expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase"); + expect(game.scene.arena.getTag(ArenaTagType.PENDING_HEAL)).toBeDefined(); + + game.move.use(MoveId.HEALING_WISH); + game.doSelectPartyPokemon(2); + + // Lunar Dance should apply first since it was used first, restoring Squirtle's HP and PP + await game.toNextTurn(); + expect(squirtle.isFullHp()).toBe(true); + squirtle.getMoveset().forEach(mv => expect(mv.ppUsed).toBe(0)); + expect(game.scene.arena.getTag(ArenaTagType.PENDING_HEAL)).toBeDefined(); + + game.doSwitchPokemon(3); + + // Healing Wish should apply on the next switch, restoring Pikachu's HP + await game.toEndOfTurn(); + expect(pikachu.isFullHp()).toBe(true); + pikachu.getMoveset().forEach(mv => expect(mv.ppUsed).toBe(1)); + expect(game.scene.arena.getTag(ArenaTagType.PENDING_HEAL)).toBeUndefined(); + }); +}); diff --git a/test/moves/instruct.test.ts b/test/moves/instruct.test.ts index 27318105783..eb3eccff400 100644 --- a/test/moves/instruct.test.ts +++ b/test/moves/instruct.test.ts @@ -498,7 +498,7 @@ describe("Moves - Instruct", () => { .enemyLevel(1); await game.classicMode.startBattle([SpeciesId.KORAIDON, SpeciesId.KLEFKI]); - const koraidon = game.scene.getPlayerField()[0]!; + const koraidon = game.field.getPlayerPokemon(); game.move.select(MoveId.BREAKING_SWIPE); await game.phaseInterceptor.to("TurnEndPhase", false); @@ -527,7 +527,7 @@ describe("Moves - Instruct", () => { .enemyLevel(1); await game.classicMode.startBattle([SpeciesId.KORAIDON, SpeciesId.KLEFKI]); - const koraidon = game.scene.getPlayerField()[0]!; + const koraidon = game.field.getPlayerPokemon(); game.move.select(MoveId.BRUTAL_SWING); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); @@ -587,7 +587,7 @@ describe("Moves - Instruct", () => { .enemyLevel(5); await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.IVYSAUR]); - const [, ivysaur] = game.scene.getPlayerField(); + const ivysaur = game.scene.getPlayerField()[1]; game.move.select(MoveId.SPLASH, BattlerIndex.PLAYER); game.move.select(MoveId.SPLASH, BattlerIndex.PLAYER_2); diff --git a/test/moves/jaw-lock.test.ts b/test/moves/jaw-lock.test.ts index 441c74c7356..0ea2c0bd8bf 100644 --- a/test/moves/jaw-lock.test.ts +++ b/test/moves/jaw-lock.test.ts @@ -111,7 +111,8 @@ describe("Moves - Jaw Lock", () => { await game.classicMode.startBattle([SpeciesId.CHARMANDER, SpeciesId.BULBASAUR]); - const playerPokemon = game.scene.getPlayerField(); + const playerPokemon = game.field.getPlayerPokemon(); + const enemyPokemon = game.scene.getEnemyField(); game.move.select(MoveId.JAW_LOCK, 0, BattlerIndex.ENEMY); @@ -120,7 +121,7 @@ describe("Moves - Jaw Lock", () => { await game.phaseInterceptor.to(MoveEffectPhase); - expect(playerPokemon[0].getTag(BattlerTagType.TRAPPED)).toBeDefined(); + expect(playerPokemon.getTag(BattlerTagType.TRAPPED)).toBeDefined(); expect(enemyPokemon[0].getTag(BattlerTagType.TRAPPED)).toBeDefined(); await game.toNextTurn(); @@ -131,8 +132,8 @@ describe("Moves - Jaw Lock", () => { await game.phaseInterceptor.to(MoveEffectPhase); expect(enemyPokemon[1].getTag(BattlerTagType.TRAPPED)).toBeUndefined(); - expect(playerPokemon[0].getTag(BattlerTagType.TRAPPED)).toBeDefined(); - expect(playerPokemon[0].getTag(BattlerTagType.TRAPPED)?.sourceId).toBe(enemyPokemon[0].id); + expect(playerPokemon.getTag(BattlerTagType.TRAPPED)).toBeDefined(); + expect(playerPokemon.getTag(BattlerTagType.TRAPPED)?.sourceId).toBe(enemyPokemon[0].id); }); it("should not trap either pokemon if the target is protected", async () => { diff --git a/test/moves/lunar-dance.test.ts b/test/moves/lunar-dance.test.ts deleted file mode 100644 index 7386d15079b..00000000000 --- a/test/moves/lunar-dance.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { AbilityId } from "#enums/ability-id"; -import { MoveId } from "#enums/move-id"; -import { SpeciesId } from "#enums/species-id"; -import { StatusEffect } from "#enums/status-effect"; -import { GameManager } from "#test/test-utils/game-manager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -describe("Moves - Lunar Dance", () => { - let phaserGame: Phaser.Game; - let game: GameManager; - - beforeAll(() => { - phaserGame = new Phaser.Game({ - type: Phaser.HEADLESS, - }); - }); - - afterEach(() => { - game.phaseInterceptor.restoreOg(); - }); - - beforeEach(() => { - game = new GameManager(phaserGame); - game.override - .statusEffect(StatusEffect.BURN) - .battleStyle("double") - .enemyAbility(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.SPLASH); - }); - - it("should full restore HP, PP and status of switched in pokemon, then fail second use because no remaining backup pokemon in party", async () => { - await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.ODDISH, SpeciesId.RATTATA]); - - const [bulbasaur, oddish, rattata] = game.scene.getPlayerParty(); - game.move.changeMoveset(bulbasaur, [MoveId.LUNAR_DANCE, MoveId.SPLASH]); - game.move.changeMoveset(oddish, [MoveId.LUNAR_DANCE, MoveId.SPLASH]); - game.move.changeMoveset(rattata, [MoveId.LUNAR_DANCE, MoveId.SPLASH]); - - game.move.select(MoveId.SPLASH, 0); - game.move.select(MoveId.SPLASH, 1); - await game.toNextTurn(); - - // Bulbasaur should still be burned and have used a PP for splash and not at max hp - expect(bulbasaur.status?.effect).toBe(StatusEffect.BURN); - expect(bulbasaur.moveset[1]?.ppUsed).toBe(1); - expect(bulbasaur.hp).toBeLessThan(bulbasaur.getMaxHp()); - - // Switch out Bulbasaur for Rattata so we can swtich bulbasaur back in with lunar dance - game.doSwitchPokemon(2); - game.move.select(MoveId.SPLASH, 1); - await game.toNextTurn(); - - game.move.select(MoveId.SPLASH, 0); - game.move.select(MoveId.LUNAR_DANCE); - game.doSelectPartyPokemon(2); - await game.phaseInterceptor.to("SwitchPhase", false); - await game.toNextTurn(); - - // Bulbasaur should NOT have any status and have full PP for splash and be at max hp - expect(bulbasaur.status?.effect).toBeUndefined(); - expect(bulbasaur.moveset[1]?.ppUsed).toBe(0); - expect(bulbasaur.isFullHp()).toBe(true); - - game.move.select(MoveId.SPLASH, 0); - game.move.select(MoveId.LUNAR_DANCE); - await game.toNextTurn(); - - // Using Lunar dance again should fail because nothing in party and rattata should be alive - expect(rattata.status?.effect).toBe(StatusEffect.BURN); - expect(rattata.hp).toBeLessThan(rattata.getMaxHp()); - }); -}); diff --git a/test/moves/rage-fist.test.ts b/test/moves/rage-fist.test.ts index 61164b5710c..c58d1296ac5 100644 --- a/test/moves/rage-fist.test.ts +++ b/test/moves/rage-fist.test.ts @@ -166,7 +166,6 @@ describe("Moves - Rage Fist", () => { // Charizard hit game.move.select(MoveId.SPLASH); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.toNextTurn(); expect(getPartyHitCount()).toEqual([1, 0]); diff --git a/test/moves/revival-blessing.test.ts b/test/moves/revival-blessing.test.ts index 4dc7cb97f2d..8c751458ff7 100644 --- a/test/moves/revival-blessing.test.ts +++ b/test/moves/revival-blessing.test.ts @@ -119,17 +119,16 @@ describe("Moves - Revival Blessing", () => { game.override .battleStyle("double") .enemyMoveset([MoveId.REVIVAL_BLESSING]) - .moveset([MoveId.SPLASH]) + .moveset([MoveId.SPLASH, MoveId.JUDGMENT]) + .startingLevel(100) .startingWave(25); // 2nd rival battle - must have 3+ pokemon await game.classicMode.startBattle([SpeciesId.ARCEUS, SpeciesId.GIRATINA]); const enemyFainting = game.scene.getEnemyField()[0]; - game.move.select(MoveId.SPLASH, 0); + game.move.use(MoveId.JUDGMENT, 0, BattlerIndex.ENEMY); game.move.select(MoveId.SPLASH, 1); - await game.killPokemon(enemyFainting); - await game.phaseInterceptor.to("BerryPhase"); await game.toNextTurn(); // If there are incorrectly two switch phases into this slot, the fainted pokemon will end up in slot 3 // Make sure it's still in slot 1 diff --git a/test/moves/shell-trap.test.ts b/test/moves/shell-trap.test.ts index 5ecad3116af..2a83f2c3266 100644 --- a/test/moves/shell-trap.test.ts +++ b/test/moves/shell-trap.test.ts @@ -48,7 +48,7 @@ describe("Moves - Shell Trap", () => { await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2]); - await game.phaseInterceptor.to(MoveEndPhase); + await game.phaseInterceptor.to("MoveEndPhase"); const movePhase = game.scene.phaseManager.getCurrentPhase(); expect(movePhase instanceof MovePhase).toBeTruthy(); diff --git a/test/moves/tailwind.test.ts b/test/moves/tailwind.test.ts index 5c91a37f786..d9a0bdeb5f1 100644 --- a/test/moves/tailwind.test.ts +++ b/test/moves/tailwind.test.ts @@ -34,8 +34,7 @@ describe("Moves - Tailwind", () => { it("doubles the Speed stat of the Pokemons on its side", async () => { await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MEOWTH]); - const magikarp = game.scene.getPlayerField()[0]; - const meowth = game.scene.getPlayerField()[1]; + const [magikarp, meowth] = game.scene.getPlayerField(); const magikarpSpd = magikarp.getStat(Stat.SPD); const meowthSpd = meowth.getStat(Stat.SPD); diff --git a/test/moves/trick-room.test.ts b/test/moves/trick-room.test.ts index a1d81efb17e..d970dc9762d 100644 --- a/test/moves/trick-room.test.ts +++ b/test/moves/trick-room.test.ts @@ -5,10 +5,10 @@ import { BattlerIndex } from "#enums/battler-index"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; import { Stat } from "#enums/stat"; -import { TurnStartPhase } from "#phases/turn-start-phase"; +import { WeatherType } from "#enums/weather-type"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; describe("Move - Trick Room", () => { let phaserGame: Phaser.Game; @@ -56,13 +56,11 @@ describe("Move - Trick Room", () => { turnCount: 4, // The 5 turn limit _includes_ the current turn! }); - // Now, check that speed was indeed reduced - const turnOrderSpy = vi.spyOn(TurnStartPhase.prototype, "getSpeedOrder"); - - game.move.use(MoveId.SPLASH); + game.move.use(MoveId.SUNNY_DAY); + await game.move.forceEnemyMove(MoveId.RAIN_DANCE); await game.toEndOfTurn(); - expect(turnOrderSpy).toHaveLastReturnedWith([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + expect(game.scene.arena.getWeatherType()).toBe(WeatherType.SUNNY); }); it("should be removed when overlapped", async () => { diff --git a/test/moves/whirlwind.test.ts b/test/moves/whirlwind.test.ts index 61c05a30322..ac112f01ea3 100644 --- a/test/moves/whirlwind.test.ts +++ b/test/moves/whirlwind.test.ts @@ -10,6 +10,7 @@ import { PokemonType } from "#enums/pokemon-type"; import { SpeciesId } from "#enums/species-id"; import { StatusEffect } from "#enums/status-effect"; import { TrainerType } from "#enums/trainer-type"; +import { TrainerVariant } from "#enums/trainer-variant"; import { GameManager } from "#test/test-utils/game-manager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -193,7 +194,7 @@ describe("Moves - Whirlwind", () => { .battleType(BattleType.TRAINER) .randomTrainer({ trainerType: TrainerType.BREEDER, - alwaysDouble: true, + trainerVariant: TrainerVariant.DOUBLE, }) .enemyMoveset([MoveId.SPLASH, MoveId.LUNAR_DANCE]) .moveset([MoveId.WHIRLWIND, MoveId.SPLASH]); diff --git a/test/moves/wish.test.ts b/test/moves/wish.test.ts index 1c1f3f3b8ba..b64a15ac654 100644 --- a/test/moves/wish.test.ts +++ b/test/moves/wish.test.ts @@ -135,7 +135,7 @@ describe("Move - Wish", () => { // all wishes have activated and added healing phases expect(game).toHavePositionalTag(PositionalTagType.WISH, 0); - const healPhases = game.scene.phaseManager.phaseQueue.filter(p => p.is("PokemonHealPhase")); + const healPhases = game.scene.phaseManager["phaseQueue"].findAll("PokemonHealPhase"); expect(healPhases).toHaveLength(4); expect.soft(healPhases.map(php => php.getPokemon())).toEqual(oldOrder); diff --git a/test/mystery-encounter/encounter-test-utils.ts b/test/mystery-encounter/encounter-test-utils.ts index 7b2dbfc9aeb..165678a88da 100644 --- a/test/mystery-encounter/encounter-test-utils.ts +++ b/test/mystery-encounter/encounter-test-utils.ts @@ -17,7 +17,6 @@ import type { MessageUiHandler } from "#ui/message-ui-handler"; import type { MysteryEncounterUiHandler } from "#ui/mystery-encounter-ui-handler"; import type { OptionSelectUiHandler } from "#ui/option-select-ui-handler"; import type { PartyUiHandler } from "#ui/party-ui-handler"; -import { isNullOrUndefined } from "#utils/common"; import { expect, vi } from "vitest"; /** @@ -71,7 +70,6 @@ export async function runMysteryEncounterToEnd( // If a battle is started, fast forward to end of the battle game.onNextPrompt("CommandPhase", UiMode.COMMAND, () => { game.scene.phaseManager.clearPhaseQueue(); - game.scene.phaseManager.clearPhaseQueueSplice(); game.scene.phaseManager.unshiftPhase(new VictoryPhase(0)); game.endPhase(); }); @@ -147,7 +145,7 @@ export async function runSelectMysteryEncounterOption( break; } - if (!isNullOrUndefined(secondaryOptionSelect?.pokemonNo)) { + if (secondaryOptionSelect?.pokemonNo != null) { await handleSecondaryOptionSelect(game, secondaryOptionSelect.pokemonNo, secondaryOptionSelect.optionNo); } else { uiHandler.processInput(Button.ACTION); @@ -174,7 +172,7 @@ async function handleSecondaryOptionSelect(game: GameManager, pokemonNo: number, partyUiHandler.processInput(Button.ACTION); // If there is a second choice to make after selecting a Pokemon - if (!isNullOrUndefined(optionNo)) { + if (optionNo != null) { // Wait for Summary menu to close and second options to spawn const secondOptionUiHandler = game.scene.ui.handlers[UiMode.OPTION_SELECT] as OptionSelectUiHandler; vi.spyOn(secondOptionUiHandler, "show"); @@ -197,7 +195,6 @@ async function handleSecondaryOptionSelect(game: GameManager, pokemonNo: number, */ export async function skipBattleRunMysteryEncounterRewardsPhase(game: GameManager, runRewardsPhase = true) { game.scene.phaseManager.clearPhaseQueue(); - game.scene.phaseManager.clearPhaseQueueSplice(); game.scene.getEnemyParty().forEach(p => { p.hp = 0; p.status = new Status(StatusEffect.FAINT); diff --git a/test/mystery-encounter/encounters/part-timer-encounter.test.ts b/test/mystery-encounter/encounters/part-timer-encounter.test.ts index 1826c75381a..15d2664364c 100644 --- a/test/mystery-encounter/encounters/part-timer-encounter.test.ts +++ b/test/mystery-encounter/encounters/part-timer-encounter.test.ts @@ -168,6 +168,7 @@ describe("Part-Timer - Mystery Encounter", () => { // Override party levels to 50 so stats can be fully reflective scene.getPlayerParty().forEach(p => { p.level = 50; + p.ivs = [0, 0, 0, 0, 0, 0]; p.calculateStats(); }); await runMysteryEncounterToEnd(game, 2, { pokemonNo: 3 }); diff --git a/test/mystery-encounter/encounters/the-winstrate-challenge-encounter.test.ts b/test/mystery-encounter/encounters/the-winstrate-challenge-encounter.test.ts index 814e2ee07fb..3bbb858a15d 100644 --- a/test/mystery-encounter/encounters/the-winstrate-challenge-encounter.test.ts +++ b/test/mystery-encounter/encounters/the-winstrate-challenge-encounter.test.ts @@ -355,7 +355,6 @@ describe("The Winstrate Challenge - Mystery Encounter", () => { */ async function skipBattleToNextBattle(game: GameManager, isFinalBattle = false) { game.scene.phaseManager.clearPhaseQueue(); - game.scene.phaseManager.clearPhaseQueueSplice(); const commandUiHandler = game.scene.ui.handlers[UiMode.COMMAND]; commandUiHandler.clear(); game.scene.getEnemyParty().forEach(p => { diff --git a/test/setup/matchers.setup.ts b/test/setup/matchers.setup.ts index 88ca0a5c6bc..8ad14c8679a 100644 --- a/test/setup/matchers.setup.ts +++ b/test/setup/matchers.setup.ts @@ -1,5 +1,11 @@ +/** + * Setup file for custom matchers. + * Make sure to define the call signatures in `#test/@types/vitest.d.ts` too! + * @module + */ + import { toBeAtPhase } from "#test/test-utils/matchers/to-be-at-phase"; -import { toEqualArrayUnsorted } from "#test/test-utils/matchers/to-equal-array-unsorted"; +import { toEqualUnsorted } from "#test/test-utils/matchers/to-equal-unsorted"; import { toHaveAbilityApplied } from "#test/test-utils/matchers/to-have-ability-applied"; import { toHaveArenaTag } from "#test/test-utils/matchers/to-have-arena-tag"; import { toHaveBattlerTag } from "#test/test-utils/matchers/to-have-battler-tag"; @@ -7,6 +13,7 @@ import { toHaveEffectiveStat } from "#test/test-utils/matchers/to-have-effective import { toHaveFainted } from "#test/test-utils/matchers/to-have-fainted"; import { toHaveFullHp } from "#test/test-utils/matchers/to-have-full-hp"; import { toHaveHp } from "#test/test-utils/matchers/to-have-hp"; +import { toHaveKey } from "#test/test-utils/matchers/to-have-key"; import { toHavePositionalTag } from "#test/test-utils/matchers/to-have-positional-tag"; import { toHaveShownMessage } from "#test/test-utils/matchers/to-have-shown-message"; import { toHaveStatStage } from "#test/test-utils/matchers/to-have-stat-stage"; @@ -19,13 +26,9 @@ import { toHaveUsedPP } from "#test/test-utils/matchers/to-have-used-pp"; import { toHaveWeather } from "#test/test-utils/matchers/to-have-weather"; import { expect } from "vitest"; -/* - * Setup file for custom matchers. - * Make sure to define the call signatures in `#test/@types/vitest.d.ts` too! - */ - expect.extend({ - toEqualArrayUnsorted, + toEqualUnsorted, + toHaveKey, toHaveShownMessage, toBeAtPhase, toHaveWeather, diff --git a/test/test-utils/game-manager-utils.ts b/test/test-utils/game-manager-utils.ts index 26b7ccf1020..4be05bf0ddb 100644 --- a/test/test-utils/game-manager-utils.ts +++ b/test/test-utils/game-manager-utils.ts @@ -8,8 +8,7 @@ import { GameModes } from "#enums/game-modes"; import type { MoveId } from "#enums/move-id"; import type { SpeciesId } from "#enums/species-id"; import { PlayerPokemon } from "#field/pokemon"; -import type { StarterMoveset } from "#types/save-data"; -import type { Starter } from "#ui/starter-select-ui-handler"; +import type { Starter, StarterMoveset } from "#types/save-data"; import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils"; /** Function to convert Blob to string */ @@ -33,24 +32,24 @@ export function holdOn(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } -export function generateStarter(scene: BattleScene, species?: SpeciesId[]): Starter[] { +export function generateStarters(scene: BattleScene, speciesIds?: SpeciesId[]): Starter[] { const seed = "test"; - const starters = getTestRunStarters(seed, species); + const starters = getTestRunStarters(seed, speciesIds); const startingLevel = scene.gameMode.getStartingLevel(); for (const starter of starters) { - const starterProps = scene.gameData.getSpeciesDexAttrProps(starter.species, starter.dexAttr); - const starterFormIndex = Math.min(starterProps.formIndex, Math.max(starter.species.forms.length - 1, 0)); + const species = getPokemonSpecies(starter.speciesId); + const starterFormIndex = starter.formIndex; const starterGender = - starter.species.malePercent !== null ? (!starterProps.female ? Gender.MALE : Gender.FEMALE) : Gender.GENDERLESS; + species.malePercent !== null ? (starter.female ? Gender.FEMALE : Gender.MALE) : Gender.GENDERLESS; const starterPokemon = scene.addPlayerPokemon( - starter.species, + species, startingLevel, starter.abilityIndex, starterFormIndex, starterGender, - starterProps.shiny, - starterProps.variant, - undefined, + starter.shiny, + starter.variant, + starter.ivs, starter.nature, ); const moveset: MoveId[] = []; @@ -62,20 +61,23 @@ export function generateStarter(scene: BattleScene, species?: SpeciesId[]): Star return starters; } -function getTestRunStarters(seed: string, species?: SpeciesId[]): Starter[] { - if (!species) { +function getTestRunStarters(seed: string, speciesIds?: SpeciesId[]): Starter[] { + if (!speciesIds || speciesIds.length === 0) { return getDailyRunStarters(seed); } const starters: Starter[] = []; const startingLevel = getGameMode(GameModes.CLASSIC).getStartingLevel(); - for (const specie of species) { - const starterSpeciesForm = getPokemonSpeciesForm(specie, 0); + for (const speciesId of speciesIds) { + const starterSpeciesForm = getPokemonSpeciesForm(speciesId, 0); const starterSpecies = getPokemonSpecies(starterSpeciesForm.speciesId); const pokemon = new PlayerPokemon(starterSpecies, startingLevel, undefined, 0); const starter: Starter = { - species: starterSpecies, - dexAttr: pokemon.getDexAttr(), + speciesId, + shiny: pokemon.shiny, + variant: pokemon.variant, + formIndex: pokemon.formIndex, + ivs: pokemon.ivs, abilityIndex: pokemon.abilityIndex, passive: false, nature: pokemon.getNature(), @@ -89,22 +91,20 @@ function getTestRunStarters(seed: string, species?: SpeciesId[]): Starter[] { /** * Useful for populating party, wave index, etc. without having to spin up and run through an entire EncounterPhase */ -export function initSceneWithoutEncounterPhase(scene: BattleScene, species?: SpeciesId[]): void { - const starters = generateStarter(scene, species); +export function initSceneWithoutEncounterPhase(scene: BattleScene, speciesIds?: SpeciesId[]): void { + const starters = generateStarters(scene, speciesIds); starters.forEach(starter => { - const starterProps = scene.gameData.getSpeciesDexAttrProps(starter.species, starter.dexAttr); - const starterFormIndex = Math.min(starterProps.formIndex, Math.max(starter.species.forms.length - 1, 0)); + const starterFormIndex = starter.formIndex; const starterGender = Gender.MALE; - const starterIvs = scene.gameData.dexData[starter.species.speciesId].ivs.slice(0); const starterPokemon = scene.addPlayerPokemon( - starter.species, + getPokemonSpecies(starter.speciesId), scene.gameMode.getStartingLevel(), starter.abilityIndex, starterFormIndex, starterGender, - starterProps.shiny, - starterProps.variant, - starterIvs, + starter.shiny, + starter.variant, + starter.ivs, starter.nature, ); starter.moveset && starterPokemon.tryPopulateMoveset(starter.moveset); diff --git a/test/test-utils/game-manager.ts b/test/test-utils/game-manager.ts index 1b379daae88..abe0b8cfcf6 100644 --- a/test/test-utils/game-manager.ts +++ b/test/test-utils/game-manager.ts @@ -29,7 +29,7 @@ import { TurnEndPhase } from "#phases/turn-end-phase"; import { TurnInitPhase } from "#phases/turn-init-phase"; import { TurnStartPhase } from "#phases/turn-start-phase"; import { ErrorInterceptor } from "#test/test-utils/error-interceptor"; -import { generateStarter } from "#test/test-utils/game-manager-utils"; +import { generateStarters } from "#test/test-utils/game-manager-utils"; import { GameWrapper } from "#test/test-utils/game-wrapper"; import { ChallengeModeHelper } from "#test/test-utils/helpers/challenge-mode-helper"; import { ClassicModeHelper } from "#test/test-utils/helpers/classic-mode-helper"; @@ -52,7 +52,6 @@ import type { ModifierSelectUiHandler } from "#ui/modifier-select-ui-handler"; import type { PartyUiHandler } from "#ui/party-ui-handler"; import type { StarterSelectUiHandler } from "#ui/starter-select-ui-handler"; import type { TargetSelectUiHandler } from "#ui/target-select-ui-handler"; -import { isNullOrUndefined } from "#utils/common"; import fs from "node:fs"; import { AES, enc } from "crypto-js"; import { expect, vi } from "vitest"; @@ -216,7 +215,7 @@ export class GameManager { this.onNextPrompt("TitlePhase", UiMode.TITLE, () => { this.scene.gameMode = getGameMode(mode); - const starters = generateStarter(this.scene, species); + const starters = generateStarters(this.scene, species); const selectStarterPhase = new SelectStarterPhase(); this.scene.phaseManager.pushPhase(new EncounterPhase(false)); selectStarterPhase.initBattle(starters); @@ -240,7 +239,7 @@ export class GameManager { * @returns A Promise that resolves when the EncounterPhase ends. */ async runToMysteryEncounter(encounterType?: MysteryEncounterType, species?: SpeciesId[]) { - if (!isNullOrUndefined(encounterType)) { + if (encounterType != null) { this.override.disableTrainerWaves(); this.override.mysteryEncounter(encounterType); } @@ -252,7 +251,7 @@ export class GameManager { UiMode.TITLE, () => { this.scene.gameMode = getGameMode(GameModes.CLASSIC); - const starters = generateStarter(this.scene, species); + const starters = generateStarters(this.scene, species); const selectStarterPhase = new SelectStarterPhase(); this.scene.phaseManager.pushPhase(new EncounterPhase(false)); selectStarterPhase.initBattle(starters); @@ -272,7 +271,7 @@ export class GameManager { ); await this.phaseInterceptor.to("EncounterPhase"); - if (!isNullOrUndefined(encounterType)) { + if (encounterType != null) { expect(this.scene.currentBattle?.mysteryEncounter?.encounterType).toBe(encounterType); } } @@ -465,6 +464,9 @@ export class GameManager { * Faint a player or enemy pokemon instantly by setting their HP to 0. * @param pokemon - The player/enemy pokemon being fainted * @returns A Promise that resolves once the fainted pokemon's FaintPhase finishes running. + * @remarks + * This method *pushes* a FaintPhase and runs until it's finished. This may cause a turn to play out unexpectedly + * @todo Consider whether running the faint phase immediately can be done */ async killPokemon(pokemon: PlayerPokemon | EnemyPokemon) { pokemon.hp = 0; @@ -534,7 +536,7 @@ export class GameManager { } /** - * Intercepts `TurnStartPhase` and mocks {@linkcode TurnStartPhase.getSpeedOrder}'s return value. + * Modifies the queue manager to return move phases in a particular order * Used to manually modify Pokemon turn order. * Note: This *DOES NOT* account for priority. * @param order - The turn order to set as an array of {@linkcode BattlerIndex}es. @@ -546,7 +548,7 @@ export class GameManager { async setTurnOrder(order: BattlerIndex[]): Promise { await this.phaseInterceptor.to("TurnStartPhase", false); - vi.spyOn(this.scene.phaseManager.getCurrentPhase() as TurnStartPhase, "getSpeedOrder").mockReturnValue(order); + this.scene.phaseManager.dynamicQueueManager.setMoveOrder(order); } /** diff --git a/test/test-utils/helpers/challenge-mode-helper.ts b/test/test-utils/helpers/challenge-mode-helper.ts index 7bc40aec035..a1fe114013d 100644 --- a/test/test-utils/helpers/challenge-mode-helper.ts +++ b/test/test-utils/helpers/challenge-mode-helper.ts @@ -9,7 +9,7 @@ import { CommandPhase } from "#phases/command-phase"; import { EncounterPhase } from "#phases/encounter-phase"; import { SelectStarterPhase } from "#phases/select-starter-phase"; import { TurnInitPhase } from "#phases/turn-init-phase"; -import { generateStarter } from "#test/test-utils/game-manager-utils"; +import { generateStarters } from "#test/test-utils/game-manager-utils"; import { GameManagerHelper } from "#test/test-utils/helpers/game-manager-helper"; /** @@ -34,7 +34,7 @@ export class ChallengeModeHelper extends GameManagerHelper { * @param gameMode - Optional game mode to set. * @returns A promise that resolves when the summon phase is reached. */ - async runToSummon(species?: SpeciesId[]) { + async runToSummon(speciesIds?: SpeciesId[]) { await this.game.runToTitle(); if (this.game.override.disableShinies) { @@ -43,7 +43,7 @@ export class ChallengeModeHelper extends GameManagerHelper { this.game.onNextPrompt("TitlePhase", UiMode.TITLE, () => { this.game.scene.gameMode.challenges = this.challenges; - const starters = generateStarter(this.game.scene, species); + const starters = generateStarters(this.game.scene, speciesIds); const selectStarterPhase = new SelectStarterPhase(); this.game.scene.phaseManager.pushPhase(new EncounterPhase(false)); selectStarterPhase.initBattle(starters); diff --git a/test/test-utils/helpers/classic-mode-helper.ts b/test/test-utils/helpers/classic-mode-helper.ts index f813a8f797e..896de7a8b6f 100644 --- a/test/test-utils/helpers/classic-mode-helper.ts +++ b/test/test-utils/helpers/classic-mode-helper.ts @@ -10,7 +10,7 @@ import { CommandPhase } from "#phases/command-phase"; import { EncounterPhase } from "#phases/encounter-phase"; import { SelectStarterPhase } from "#phases/select-starter-phase"; import { TurnInitPhase } from "#phases/turn-init-phase"; -import { generateStarter } from "#test/test-utils/game-manager-utils"; +import { generateStarters } from "#test/test-utils/game-manager-utils"; import { GameManagerHelper } from "#test/test-utils/helpers/game-manager-helper"; /** @@ -35,7 +35,7 @@ export class ClassicModeHelper extends GameManagerHelper { // biome-ignore lint/style/useUnifiedTypeSignatures: Marks the overload for deprecation async runToSummon(): Promise; async runToSummon(species: SpeciesId[] | undefined): Promise; - async runToSummon(species?: SpeciesId[]): Promise { + async runToSummon(speciesIds?: SpeciesId[]): Promise { await this.game.runToTitle(); if (this.game.override.disableShinies) { @@ -50,7 +50,7 @@ export class ClassicModeHelper extends GameManagerHelper { this.game.onNextPrompt("TitlePhase", UiMode.TITLE, () => { this.game.scene.gameMode = getGameMode(GameModes.CLASSIC); - const starters = generateStarter(this.game.scene, species); + const starters = generateStarters(this.game.scene, speciesIds); const selectStarterPhase = new SelectStarterPhase(); this.game.scene.phaseManager.pushPhase(new EncounterPhase(false)); selectStarterPhase.initBattle(starters); diff --git a/test/test-utils/helpers/modifiers-helper.ts b/test/test-utils/helpers/modifiers-helper.ts index bfda35427fa..7d3e29c420f 100644 --- a/test/test-utils/helpers/modifiers-helper.ts +++ b/test/test-utils/helpers/modifiers-helper.ts @@ -40,10 +40,7 @@ export class ModifierHelper extends GameManagerHelper { * @returns `this` */ testCheck(modifier: ModifierTypeKeys, expectToBePreset: boolean): this { - if (expectToBePreset) { - expect(itemPoolChecks.get(modifier)).toBeTruthy(); - } - expect(itemPoolChecks.get(modifier)).toBeFalsy(); + (expectToBePreset ? expect(itemPoolChecks) : expect(itemPoolChecks).not).toHaveKey(modifier); return this; } diff --git a/test/test-utils/matchers/to-equal-array-unsorted.ts b/test/test-utils/matchers/to-equal-unsorted.ts similarity index 92% rename from test/test-utils/matchers/to-equal-array-unsorted.ts rename to test/test-utils/matchers/to-equal-unsorted.ts index 97398689032..c3d85288815 100644 --- a/test/test-utils/matchers/to-equal-array-unsorted.ts +++ b/test/test-utils/matchers/to-equal-unsorted.ts @@ -8,11 +8,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; * @param expected - The array to check equality with * @returns Whether the matcher passed */ -export function toEqualArrayUnsorted( - this: MatcherState, - received: unknown, - expected: unknown[], -): SyncExpectationResult { +export function toEqualUnsorted(this: MatcherState, received: unknown, expected: unknown[]): SyncExpectationResult { if (!Array.isArray(received)) { return { pass: this.isNot, diff --git a/test/test-utils/matchers/to-have-key.ts b/test/test-utils/matchers/to-have-key.ts new file mode 100644 index 00000000000..73d442fc979 --- /dev/null +++ b/test/test-utils/matchers/to-have-key.ts @@ -0,0 +1,47 @@ +import { getOnelineDiffStr } from "#test/test-utils/string-utils"; +import { receivedStr } from "#test/test-utils/test-utils"; +import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; + +/** + * Matcher that checks if a {@linkcode Map} contains the given key, regardless of its value. + * @param received - The received value. Should be a Map + * @param expectedKey - The key whose inclusion in the map is being checked + * @returns Whether the matcher passed + */ +export function toHaveKey(this: MatcherState, received: unknown, expectedKey: unknown): SyncExpectationResult { + if (!(received instanceof Map)) { + return { + pass: this.isNot, + message: () => `Expected to receive a Map, but got ${receivedStr(received)}!`, + }; + } + + if (received.size === 0) { + return { + pass: this.isNot, + message: () => "Expected to receive a non-empty Map, but received map was empty!", + expected: expectedKey, + actual: received, + }; + } + + const keys = [...received.keys()]; + const pass = this.equals(keys, expectedKey, [ + ...this.customTesters, + this.utils.iterableEquality, + this.utils.subsetEquality, + ]); + + const actualStr = getOnelineDiffStr.call(this, received); + const expectedStr = getOnelineDiffStr.call(this, expectedKey); + + return { + pass, + message: () => + pass + ? `Expected ${actualStr} to NOT have the key ${expectedStr}, but it did!` + : `Expected ${actualStr} to have the key ${expectedStr}, but it didn't!`, + expected: expectedKey, + actual: keys, + }; +} diff --git a/test/test-utils/matchers/to-have-terrain.ts b/test/test-utils/matchers/to-have-terrain.ts index f951abed0b3..9b6939168f0 100644 --- a/test/test-utils/matchers/to-have-terrain.ts +++ b/test/test-utils/matchers/to-have-terrain.ts @@ -8,8 +8,8 @@ import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils" import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; /** - * Matcher that checks if the {@linkcode TerrainType} is as expected - * @param received - The object to check. Should be an instance of {@linkcode GameManager}. + * Matcher that checks if the current {@linkcode TerrainType} is as expected. + * @param received - The object to check. Should be the current {@linkcode GameManager}. * @param expectedTerrainType - The expected {@linkcode TerrainType}, or {@linkcode TerrainType.NONE} if no terrain should be active * @returns Whether the matcher passed */ diff --git a/test/test-utils/matchers/to-have-weather.ts b/test/test-utils/matchers/to-have-weather.ts index ffb1e0aad97..7604cd5f890 100644 --- a/test/test-utils/matchers/to-have-weather.ts +++ b/test/test-utils/matchers/to-have-weather.ts @@ -8,8 +8,8 @@ import { toTitleCase } from "#utils/strings"; import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; /** - * Matcher that checks if the {@linkcode WeatherType} is as expected - * @param received - The object to check. Expects an instance of {@linkcode GameManager}. + * Matcher that checks if the current {@linkcode WeatherType} is as expected. + * @param received - The object to check. Should be the current {@linkcode GameManager} * @param expectedWeatherType - The expected {@linkcode WeatherType} * @returns Whether the matcher passed */ diff --git a/test/test-utils/setup/test-end-log.ts b/test/test-utils/setup/test-end-log.ts index 9814ba8a45c..5be8299b124 100644 --- a/test/test-utils/setup/test-end-log.ts +++ b/test/test-utils/setup/test-end-log.ts @@ -1,16 +1,16 @@ +/** + * Code to add markers to the beginning and end of tests. + * Intended for use with {@linkcode CustomDefaultReporter}, and placed inside test hooks + * (rather than as part of the reporter) to ensure Vitest waits for the log messages to be printed. + * @module + */ + // biome-ignore lint/correctness/noUnusedImports: TSDoc import type CustomDefaultReporter from "#test/test-utils/reporters/custom-default-reporter"; import { basename, join, relative } from "path"; import chalk from "chalk"; import type { RunnerTask, RunnerTaskResult, RunnerTestCase } from "vitest"; -/** - * @module - * Code to add markers to the beginning and end of tests. - * Intended for use with {@linkcode CustomDefaultReporter}, and placed inside test hooks - * (rather than as part of the reporter) to ensure Vitest waits for the log messages to be printed. - */ - /** A long string of "="s to partition off each test from one another. */ const TEST_END_BARRIER = chalk.bold.hex("#ff7c7cff")("=================="); diff --git a/test/utils/cookies.test.ts b/test/utils/cookies.test.ts new file mode 100644 index 00000000000..a5ea248c236 --- /dev/null +++ b/test/utils/cookies.test.ts @@ -0,0 +1,62 @@ +import { getCookie } from "#utils/cookies"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Unit Tests - cookies.ts", () => { + describe("getCookie", () => { + const cookieStart = document.cookie; + beforeEach(() => { + // clear cookie before each test + document.cookie = ""; + }); + + afterEach(() => { + // restore original cookie after each test + document.cookie = cookieStart; + }); + /** + * Spies on `document.cookie` and replaces its value with the provided string. + */ + function setDocumentCookie(value: string) { + vi.spyOn(document, "cookie", "get").mockReturnValue(value); + } + it("returns the value of a single cookie", () => { + setDocumentCookie("foo=bar"); + expect(getCookie("foo")).toBe("bar"); + }); + + it("returns empty string if cookie is not found", () => { + setDocumentCookie("foo=bar"); + expect(getCookie("baz")).toBe(""); + }); + + it("returns the value when multiple cookies exist", () => { + setDocumentCookie("foo=bar; baz=qux"); + expect(getCookie("baz")).toBe("qux"); + }); + + it("trims leading spaces in cookies", () => { + setDocumentCookie("foo=bar; baz=qux"); + expect(getCookie("baz")).toBe("qux"); + }); + + it("returns the value of the first matching cookie if only one exists", () => { + setDocumentCookie("foo=bar; test=val"); + expect(getCookie("foo")).toBe("bar"); + }); + + it("returns empty string if document.cookie is empty", () => { + setDocumentCookie(""); + expect(getCookie("foo")).toBe(""); + }); + + it("handles cookies that aren't separated with a space", () => { + setDocumentCookie("foo=bar;baz=qux;quux=corge;grault=garply"); + expect(getCookie("baz")).toBe("qux"); + }); + + it("handles cookies that may have leading tab characters", () => { + setDocumentCookie("foo=bar;\tbaz=qux"); + expect(getCookie("baz")).toBe("qux"); + }); + }); +});