Merge branch 'beta' into mock-console-log

This commit is contained in:
Bertie690 2025-08-31 20:00:27 -04:00 committed by GitHub
commit a0fd27b021
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
218 changed files with 8930 additions and 28576 deletions

View File

@ -20,6 +20,7 @@ permissions:
jobs:
create-release:
if: github.repository == 'pagefaultgames/pokerogue' && (vars.BETA_DEPLOY_BRANCH == '' || ! startsWith(vars.BETA_DEPLOY_BRANCH, 'release'))
timeout-minutes: 10
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed for github cli commands
runs-on: ubuntu-latest
@ -36,14 +37,24 @@ jobs:
exit 1
fi
shell: bash
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.PAGEFAULT_APP_ID }}
private-key: ${{ secrets.PAGEFAULT_APP_PRIVATE_KEY }}
- name: Check out code
uses: actions/checkout@v4
with:
submodules: "recursive"
# Always base off of beta branch, regardless of the branch the workflow was triggered from.
ref: beta
token: ${{ steps.app-token.outputs.token }}
- name: Create release branch
run: git checkout -b release
# In order to be able to open a PR into beta, we need the branch to have at least one change.
- name: Overwrite RELEASE file
run: |
@ -52,11 +63,14 @@ jobs:
echo "Release v${{ github.event.inputs.versionName }}" > RELEASE
git add RELEASE
git commit -m "Stage release v${{ github.event.inputs.versionName }}"
- name: Push new branch
run: git push origin release
# The repository variable is used by the deploy-beta workflow to determine whether to deploy from beta or release.
- name: Set repository variable
run: GITHUB_TOKEN="${{ secrets.RW_VARS_PAT }}" gh variable set BETA_DEPLOY_BRANCH --body "release"
run: GITHUB_TOKEN="${{ steps.app-token.outputs.token }}" gh variable set BETA_DEPLOY_BRANCH --body "release"
- name: Create pull request to main
run: |
gh pr create --base main \
@ -64,6 +78,7 @@ jobs:
--title "Release v${{ github.event.inputs.versionName }} to main" \
--body "This PR is for the release of v${{ github.event.inputs.versionName }}, and was created automatically by the GitHub Actions workflow invoked by ${{ github.actor }}" \
--draft
- name: Create pull request to beta
run: |
gh pr create --base beta \

View File

@ -12,6 +12,7 @@ on:
jobs:
deploy:
if: github.repository == 'pagefaultgames/pokerogue' && github.ref_name == (vars.BETA_DEPLOY_BRANCH || 'beta')
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

View File

@ -11,6 +11,7 @@ on:
jobs:
deploy:
if: github.repository == 'pagefaultgames/pokerogue'
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

View File

@ -5,10 +5,14 @@ on:
branches:
- main
- beta
- release
- 'hotfix*'
pull_request:
branches:
- main
- beta
- release
- 'hotfix*'
merge_group:
types: [checks_requested]
@ -16,6 +20,7 @@ jobs:
pages:
name: Github Pages
if: github.repository == 'pagefaultgames/pokerogue'
timeout-minutes: 10
runs-on: ubuntu-latest
env:
api-dir: ./
@ -65,7 +70,7 @@ jobs:
pnpm exec typedoc --out /tmp/docs --githubPages false --entryPoints ./src/
- name: Commit & Push docs
if: github.event_name == 'push'
if: github.event_name == 'push' && (github.ref_name == 'beta' || github.ref_name == 'main')
run: |
cd pokerogue_gh
git config user.email "github-actions[bot]@users.noreply.github.com"

View File

@ -5,16 +5,21 @@ on:
branches:
- main
- beta
- release
- 'hotfix*'
pull_request:
branches:
- main
- beta
- release
- 'hotfix*'
merge_group:
types: [checks_requested]
jobs:
run-linters:
name: Run linters
timeout-minutes: 10
runs-on: ubuntu-latest
steps:

View File

@ -6,6 +6,7 @@ jobs:
# Set the BETA_DEPLOY_BRANCH variable to beta when a release branch is deleted
update-release-var:
if: github.repository == 'pagefaultgames/pokerogue' && github.event.ref_type == 'branch' && github.event.ref == 'release'
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Set BETA_DEPLOY_BRANCH to beta

View File

@ -21,6 +21,7 @@ jobs:
test:
# We can't use dynmically named jobs until https://github.com/orgs/community/discussions/13261 is implemented
name: Shard
timeout-minutes: 10
runs-on: ubuntu-latest
if: ${{ !inputs.skip }}
steps:

View File

@ -5,16 +5,21 @@ on:
branches:
- main
- beta
- release
- 'hotfix*'
pull_request:
branches:
- main
- beta
- release
- 'hotfix*'
merge_group:
types: [checks_requested]
workflow_dispatch:
jobs:
check-path-change-filter:
timeout-minutes: 5
runs-on: ubuntu-latest
permissions:
pull-requests: read
@ -33,6 +38,8 @@ jobs:
name: Run Tests
needs: check-path-change-filter
strategy:
# don't stop upon 1 shard failing
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5]
uses: ./.github/workflows/test-shard-template.yml

1
RELEASE Normal file
View File

@ -0,0 +1 @@
Release v1.10.0

View File

@ -1,7 +1,7 @@
{
"name": "pokemon-rogue-battle",
"private": true,
"version": "1.9.6",
"version": "1.10.4",
"type": "module",
"scripts": {
"start": "vite",
@ -14,6 +14,7 @@
"test:watch": "vitest watch --coverage --no-isolate",
"test:silent": "vitest run --silent='passed-only' --no-isolate",
"test:create": "node scripts/create-test/create-test.js",
"scrape-trainers": "node scripts/scrape-trainer-names/main.js",
"typecheck": "tsc --noEmit",
"biome": "biome check --write --changed --no-errors-on-unmatched --diagnostic-level=error",
"biome-ci": "biome ci --diagnostic-level=error --reporter=github --no-errors-on-unmatched",

View File

@ -1102,6 +1102,112 @@
"volume": 100,
"pitch": 136,
"eventType": "AnimTimedSoundEvent"
},
{
"frameIndex": 0,
"resourceName": "PRAS- Strong Winds",
"bgX": 0,
"bgY": 0,
"opacity": 0,
"duration": 4,
"eventType": "AnimTimedAddBgEvent"
},
{
"frameIndex": 0,
"resourceName": "",
"bgX": 0,
"bgY": 0,
"opacity": 32,
"duration": 4,
"eventType": "AnimTimedUpdateBgEvent"
}
],
"4": [
{
"frameIndex": 4,
"resourceName": "",
"bgX": 10,
"bgY": 0,
"opacity": 64,
"duration": 4,
"eventType": "AnimTimedUpdateBgEvent"
}
],
"8": [
{
"frameIndex": 8,
"resourceName": "",
"bgX": 20,
"bgY": 0,
"opacity": 128,
"duration": 4,
"eventType": "AnimTimedUpdateBgEvent"
}
],
"12": [
{
"frameIndex": 12,
"resourceName": "",
"bgX": 30,
"bgY": 0,
"opacity": 128,
"duration": 4,
"eventType": "AnimTimedUpdateBgEvent"
}
],
"16": [
{
"frameIndex": 16,
"resourceName": "",
"bgX": 40,
"bgY": 0,
"opacity": 128,
"duration": 4,
"eventType": "AnimTimedUpdateBgEvent"
}
],
"20": [
{
"frameIndex": 20,
"resourceName": "",
"bgX": 50,
"bgY": 0,
"opacity": 128,
"duration": 4,
"eventType": "AnimTimedUpdateBgEvent"
}
],
"24": [
{
"frameIndex": 24,
"resourceName": "",
"bgX": 60,
"bgY": 0,
"opacity": 64,
"duration": 4,
"eventType": "AnimTimedUpdateBgEvent"
}
],
"28": [
{
"frameIndex": 28,
"resourceName": "",
"bgX": 70,
"bgY": 0,
"opacity": 32,
"duration": 4,
"eventType": "AnimTimedUpdateBgEvent"
}
],
"32": [
{
"frameIndex": 32,
"resourceName": "",
"bgX": 80,
"bgY": 0,
"opacity": 0,
"duration": 3,
"eventType": "AnimTimedUpdateBgEvent"
}
]
},

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 B

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 B

After

Width:  |  Height:  |  Size: 296 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 B

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 B

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 297 B

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -1,11 +0,0 @@
{
"0": {
"422110": "9d4f62",
"4a0808": "7f334a",
"dec56b": "ffd5f6",
"633121": "c66479",
"7b5231": "d98997",
"a57b4a": "e1b2d7",
"de8c29": "ffecfb"
}
}

View File

@ -1,11 +0,0 @@
{
"0": {
"4a1008": "7f334a",
"dec56b": "ffd5f6",
"633a21": "c66479",
"422119": "9d4f62",
"de9429": "ffecfb",
"7b5a31": "d98997",
"a57b4a": "e1b2d7"
}
}

View File

@ -1,15 +0,0 @@
{
"0": {
"193a29": "314a29",
"088c42": "4a8b4a",
"195a31": "416a39",
"dec56b": "ffd5f6",
"4a1008": "783046",
"422119": "944b5c",
"633a21": "bb5f73",
"7b5a31": "cc818e",
"a57b4a": "e1b2d7",
"de9429": "ffecfb",
"311010": "582333"
}
}

View File

@ -1,15 +0,0 @@
{
"0": {
"6b6b6b": "cf9bc4",
"424a42": "b184a8",
"ffffff": "ffd5f6",
"adadad": "e1b2d7",
"4a1008": "7f334a",
"dec56b": "ffecfb",
"422119": "9d4f62",
"7b5a31": "d98997",
"633a21": "c66479",
"a57b4a": "e1b2d7",
"de9429": "ffecfb"
}
}

View File

@ -515,14 +515,6 @@
"577": [1, 1, 1],
"578": [1, 1, 1],
"579": [1, 1, 1],
"585-autumn": [2, 0, 0],
"585-spring": [2, 0, 0],
"585-summer": [2, 0, 0],
"585-winter": [2, 0, 0],
"586-autumn": [1, 0, 0],
"586-spring": [1, 0, 0],
"586-summer": [1, 0, 0],
"586-winter": [1, 0, 0],
"587": [0, 1, 1],
"588": [0, 1, 1],
"589": [0, 1, 1],
@ -1521,14 +1513,6 @@
"577": [1, 1, 1],
"578": [1, 1, 1],
"579": [1, 1, 1],
"585-autumn": [2, 0, 0],
"585-spring": [2, 0, 0],
"585-summer": [1, 0, 0],
"585-winter": [2, 0, 0],
"586-autumn": [1, 0, 0],
"586-spring": [1, 0, 0],
"586-summer": [1, 0, 0],
"586-winter": [1, 0, 0],
"587": [0, 1, 1],
"588": [0, 1, 1],
"589": [0, 1, 1],

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -1,16 +0,0 @@
{
"0": {
"315231": "314a29",
"317b42": "416a39",
"42b542": "4a8b4a",
"ce9c08": "d89ca6",
"7b5210": "c16b7d",
"ffde52": "ffffff",
"bda58c": "d89ca6",
"9c7b5a": "c16b7d",
"f7efc5": "ffd5f6",
"524219": "783046",
"313131": "783046",
"525252": "c16b7d"
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -1,10 +0,0 @@
{
"0": {
"7b5231": "d98997",
"422110": "9d4f62",
"633121": "c66479",
"de8c29": "ffecfb",
"a57b4a": "e1b2d7",
"dec56b": "ffd5f6"
}
}

View File

@ -1,15 +0,0 @@
{
"0": {
"311010": "2a1418",
"731931": "5e263e",
"4a1008": "7f334a",
"633a21": "c66479",
"ce4263": "c66479",
"dec56b": "ffd5f6",
"422119": "9d4f62",
"7b5a31": "d98997",
"de9429": "ffecfb",
"dedede": "c8c8c8",
"a57b4a": "e1b2d7"
}
}

View File

@ -1,14 +0,0 @@
{
"0": {
"193a29": "314a29",
"195a31": "416a39",
"088c42": "4a8b4a",
"4a1008": "7f334a",
"7b5a31": "d98997",
"422119": "9d4f62",
"633a21": "c66479",
"de9429": "ffecfb",
"a57b4a": "e1b2d7",
"dec56b": "ffd5f6"
}
}

View File

@ -1,15 +0,0 @@
{
"0": {
"424a42": "99648f",
"ffffff": "ffd5f6",
"6b6b6b": "b184a8",
"adadad": "e1b2d7",
"4a1008": "7f334a",
"dec56b": "ffecfb",
"633a21": "c66479",
"422119": "9d4f62",
"7b5a31": "d98997",
"de9429": "ffecfb",
"a57b4a": "e1b2d7"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 B

After

Width:  |  Height:  |  Size: 183 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 B

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 B

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 B

After

Width:  |  Height:  |  Size: 183 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 B

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 B

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 B

@ -1 +1 @@
Subproject commit 813e5a34739100efd5936bc8a63301dfe451ff8d
Subproject commit 102cbdcd924e2a7cdc7eab64d1ce79f6ec7604ff

View File

@ -1,3 +1,7 @@
self.addEventListener('install', function () {
console.log('Service worker installing...');
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
})

179
scripts/helpers/strings.js Normal file
View File

@ -0,0 +1,179 @@
// #region Split string code
// Regexps involved with splitting words in various case formats.
// Sourced from https://www.npmjs.com/package/change-case (with slight tweaking here and there)
/**
* Regex to split at word boundaries.
* @type {RegExp}
*/
const SPLIT_LOWER_UPPER_RE = /([\p{Ll}\d])(\p{Lu})/gu;
/**
* Regex to split around single-letter uppercase words.
* @type {RegExp}
*/
const SPLIT_UPPER_UPPER_RE = /(\p{Lu})([\p{Lu}][\p{Ll}])/gu;
/**
* Regexp involved with stripping non-word delimiters from the result.
* @type {RegExp}
*/
const DELIM_STRIP_REGEXP = /[-_ ]+/giu;
// The replacement value for splits.
const SPLIT_REPLACE_VALUE = "$1\0$2";
/**
* Split any cased string into an array of its constituent words.
* @param {string} value
* @returns {string[]} The new string, delimited at each instance of one or more spaces, underscores, hyphens
* or lower-to-upper boundaries.
*/
function splitWords(value) {
let result = value.trim();
result = result.replace(SPLIT_LOWER_UPPER_RE, SPLIT_REPLACE_VALUE).replace(SPLIT_UPPER_UPPER_RE, SPLIT_REPLACE_VALUE);
result = result.replace(DELIM_STRIP_REGEXP, "\0");
// Trim the delimiter from around the output string
return trimFromStartAndEnd(result, "\0").split(/\0/g);
}
/**
* Helper function to remove one or more sequences of characters from either end of a string.
* @param {string} str - The string to replace
* @param {string} charToTrim - The string to remove
* @returns {string} The string having been trimmed
*/
function trimFromStartAndEnd(str, charToTrim) {
let start = 0;
let end = str.length;
const blockLength = charToTrim.length;
while (str.startsWith(charToTrim, start)) {
start += blockLength;
}
if (start - end === blockLength) {
// Occurs if the ENTIRE string is made up of charToTrim (at which point we return nothing)
return "";
}
while (str.endsWith(charToTrim, end)) {
end -= blockLength;
}
return str.slice(start, end);
}
// #endregion Split String code
/**
* Capitalize the first letter of a string.
* @example
* ```ts
* console.log(capitalizeFirstLetter("consectetur adipiscing elit")); // returns "Consectetur adipiscing elit"
* ```
* @param {string} str - The string whose first letter is to be capitalized
* @return {string} The original string with its first letter capitalized.
*/
export function capitalizeFirstLetter(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Helper method to convert a string into `Title Case` (such as one used for console logs).
* @example
* ```ts
* console.log(toTitleCase("lorem ipsum dolor sit amet")); // returns "Lorem Ipsum Dolor Sit Amet"
* ```
* @param {string} str - The string being converted
* @returns {string} The result of converting `str` into title case.
*/
export function toTitleCase(str) {
return splitWords(str)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
/**
* Helper method to convert a string into `camelCase` (such as one used for i18n keys).
* @example
* ```ts
* console.log(toCamelCase("BIG_ANGRY_TRAINER")); // returns "bigAngryTrainer"
* ```
* @param {string} str - The string being converted
* @returns {string} The result of converting `str` into camel case.
*/
export function toCamelCase(str) {
return splitWords(str)
.map((word, index) =>
index === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
)
.join("");
}
/**
* Helper method to convert a string into `PascalCase`.
* @example
* ```ts
* console.log(toPascalCase("hi how was your day")); // returns "HiHowWasYourDay"
* ```
* @param {string} str - The string being converted
* @returns {string} The result of converting `str` into pascal case.
*/
export function toPascalCase(str) {
return splitWords(str)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("");
}
/**
* Helper method to convert a string into `kebab-case` (such as one used for filenames).
* @example
* ```ts
* console.log(toKebabCase("not_kebab-caSe String")); // returns "not-kebab-case-string"
* ```
* @param {string} str - The string being converted
* @returns {string} The result of converting `str` into kebab case.
*/
export function toKebabCase(str) {
return splitWords(str)
.map(word => word.toLowerCase())
.join("-");
}
/**
* Helper method to convert a string into `snake_case` (such as one used for filenames).
* @example
* ```ts
* console.log(toSnakeCase("not-in snake_CaSe")); // returns "not_in_snake_case"
* ```
* @param {string} str - The string being converted
* @returns {string} The result of converting `str` into snake case.
*/
export function toSnakeCase(str) {
return splitWords(str)
.map(word => word.toLowerCase())
.join("_");
}
/**
* Helper method to convert a string into `UPPER_SNAKE_CASE`.
* @example
* ```ts
* console.log(toUpperSnakeCase("apples bananas_oranGes-PearS")); // returns "APPLES_BANANAS_ORANGES_PEARS"
* ```
* @param {string} str - The string being converted
* @returns {string} The result of converting `str` into upper snake case.
*/
export function toUpperSnakeCase(str) {
return splitWords(str)
.map(word => word.toUpperCase())
.join("_");
}
/**
* Helper method to convert a string into `Pascal_Snake_Case`.
* @example
* ```ts
* console.log(toPascalSnakeCase("apples-bananas_oranGes Pears")); // returns "Apples_Bananas_Oranges_Pears"
* ```
* @param {string} str - The string being converted
* @returns {string} The result of converting `str` into pascal snake case.
*/
export function toPascalSnakeCase(str) {
return splitWords(str)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("_");
}

View File

@ -0,0 +1,53 @@
/**
* Check if the given trainer class is female.
* @param {Document} document - The HTML document to scrape
* @returns {[gender: boolean, counterpartURLs: string[]]} A 2-length tuple containing:
* 1. The trainer class' gender (female or not)
* 2. A list of all the current class' opposite-gender counterparts (if the trainer has any).
*/
export function checkGenderAndType(document) {
const infoBox = document.getElementsByClassName("infobox")[0];
if (!infoBox) {
return [false, []];
}
// Find the row of the table containing the specified gender
const children = [...infoBox.getElementsByTagName("tr")];
const genderCell = children.find(node => [...node.childNodes].some(c => c.textContent?.includes("Gender")));
const tableBox = genderCell?.querySelector("td");
if (!tableBox) {
return [false, []];
}
const gender = getGender(tableBox);
// CHeck the cell's inner HTML for any `href`s to gender counterparts and scrape them too
const hrefExtractRegex = /href="\/wiki\/(.*?)_\(Trainer_class\)"/g;
const counterpartCell = children.find(node => [...node.childNodes].some(c => c.textContent?.includes("Counterpart")));
const counterpartURLs = [];
for (const url of counterpartCell?.innerHTML?.matchAll(hrefExtractRegex) ?? []) {
counterpartURLs.push(url[1]);
}
return [gender, counterpartURLs];
}
/**
* Retrieve the gender from the given node text.
* @param {HTMLTableCellElement} genderCell - The cell to check
* @returns {boolean} The gender type
* @todo Handle trainers whose gender type has changed across different gens (Artists, etc.)
*/
function getGender(genderCell) {
const gender = genderCell.textContent?.trim().toLowerCase() ?? "";
switch (gender) {
case "female only":
return true;
case "male only":
case "both":
case undefined:
default:
return false;
}
}

View File

@ -0,0 +1,76 @@
/**
* @import { parsedNames } from "./types.js";
*/
/**
* An error code for a bad URL.
*/
export const INVALID_URL = /** @type {const} */ ("bad_url_code");
/**
* Fetch a given trainer's names from the given HTML document.
* @param {HTMLElement | null | undefined} trainerListHeader - The header containing the trainer lists
* @param {boolean} [knownFemale=false] - Whether the class is known to be female; default `false`
* @returns {parsedNames | INVALID_URL}
* An object containing the parsed names. \
* Will instead return with {@linkcode INVALID_URL} if the data is invalid.
*/
export function fetchNames(trainerListHeader, knownFemale = false) {
const trainerNames = /** @type {Set<string>} */ (new Set());
const femaleTrainerNames = /** @type {Set<string>} */ (new Set());
if (!trainerListHeader?.parentElement?.childNodes) {
// Return early if no child nodes (ie tables) can be found
return INVALID_URL;
}
const elements = [...trainerListHeader.parentElement.childNodes];
// Find all elements within the "Trainer Names" header and selectively filter to find the name tables.
const startChildIndex = elements.indexOf(trainerListHeader);
const endChildIndex = elements.findIndex(h => h.nodeName === "H2" && elements.indexOf(h) > startChildIndex);
// Grab all the trainer name tables sorted by generation
const tables = elements.slice(startChildIndex, endChildIndex).filter(
/** @type {(t: ChildNode) => t is Element} */
(
t =>
// Only grab expandable tables within the header block
t.nodeName === "TABLE" && t["className"] === "expandable"
),
);
parseTable(tables, knownFemale, trainerNames, femaleTrainerNames);
return {
male: Array.from(trainerNames),
female: Array.from(femaleTrainerNames),
};
}
/**
* Parse the table in question.
* @param {Element[]} tables - The array of Elements forming the current table
* @param {boolean} isFemale - Whether the trainer is known to be female or not
* @param {Set<string>} trainerNames A Set containing the male trainer names
* @param {Set<string>} femaleTrainerNames - A Set containing the female trainer names
*/
function parseTable(tables, isFemale, trainerNames, femaleTrainerNames) {
for (const table of tables) {
// Grab all rows past the first header with exactly 9 children in them (Name, Battle, Winnings, 6 party slots)
const trainerRows = [...table.querySelectorAll("tr:not(:first-child)")].filter(r => r.children.length === 9);
for (const row of trainerRows) {
const content = row.firstElementChild?.innerHTML;
// Skip empty elements & ones without anchors
if (!content || content?.indexOf(" <a ") === -1) {
continue;
}
/** Whether the name is female */
const female = isFemale || content.includes("♀");
// Grab the plaintext name part with an optional ampersand
const nameMatch = />([a-z]+(?: &amp; [a-z]+)?)<\/a>/i.exec(content);
if (!nameMatch) {
continue;
}
(female ? femaleTrainerNames : trainerNames).add(nameMatch[1].replace("&amp;", "&"));
}
}
}

View File

@ -0,0 +1,16 @@
import chalk from "chalk";
/** Show help/usage text for the `scrape-trainers` CLI. */
export function showHelpText() {
console.log(`
Usage: ${chalk.cyan("pnpm scrape-trainers [options] <names>")}
Note that all option names are ${chalk.bold("case insensitive")}.
${chalk.hex("#8a2be2")("Arguments:")}
${chalk.hex("#7fff00")("names")} The name of one or more trainer classes to parse.
${chalk.hex("#ffa500")("Options:")}
${chalk.blue("-h, --help")} Show this help message.
${chalk.blue("-o, --out, --outfile")} The path to a file to save the output. If not provided, will send directly to stdout.
`);
}

View File

@ -0,0 +1,295 @@
import { existsSync, writeFileSync } from "node:fs";
import { format, inspect } from "node:util";
import chalk from "chalk";
import inquirer from "inquirer";
import { JSDOM } from "jsdom";
import { toCamelCase, toPascalSnakeCase, toTitleCase } from "../helpers/strings.js";
import { checkGenderAndType } from "./check-gender.js";
import { fetchNames, INVALID_URL } from "./fetch-names.js";
import { showHelpText } from "./help-message.js";
/**
* @packageDocumentation
* This script will scrape Bulbapedia for the English names of a given trainer class,
* outputting them as JSON.
* Usage: `pnpm scrape-trainers`
*/
/**
* @import { parsedNames } from "./types.js"
*/
const version = "1.0.0";
const OUTFILE_ALIASES = /** @type {const} */ (["-o", "--outfile", "--outFile"]);
/**
* A large object mapping each "base" trainer name to a list of replacements.
* Used to allow for trainer classes with different `TrainerType`s than in mainline.
* @type {Record<string, string[]>}
*/
const trainerNamesMap = {
pokemonBreeder: ["breeder"],
worker: ["worker", "snowWorker"],
richBoy: ["richKid"],
gentleman: ["rich"],
};
async function main() {
console.log(chalk.hex("#FF7F50")(`🍳 Trainer Name Scraper v${version}`));
const args = process.argv.slice(2);
const out = getOutfile(args);
// Break out if no args remain
if (args.length === 0) {
console.error(
chalk.red.bold(
`✗ Error: No trainer classes provided!\nArgs: ${chalk.hex("#7310fdff")(process.argv.slice(2).join(", "))}`,
),
);
showHelpText();
process.exitCode = 1;
return;
}
const output = await scrapeTrainerNames(args);
await tryWriteFile(out, output);
}
/**
* Get the outfile location from the args array.
* @param {string[]} args - The command line arguments
* @returns {string | undefined} The outfile location, or `undefined` if none is provided
* @remarks
* This will mutate the `args` array by removing the outfile from the list of arguments.
*/
function getOutfile(args) {
let /** @type {string} */ outFile;
// Extract the outfile as either the form "-o=y" or "-o y".
const hasEquals = /^.*=(.+)$/g.exec(args[0]);
if (hasEquals) {
outFile = hasEquals[1];
args.splice(0, 1);
} else if (/** @type {readonly string[]} */ (OUTFILE_ALIASES).includes(args[0])) {
outFile = args[1];
args.splice(0, 2);
} else {
console.log(chalk.hex("#ffa500")("No outfile detected, logging to stdout..."));
return;
}
console.log(chalk.hex("#ffa500")(`Using outfile: ${chalk.blue(outFile)}`));
return outFile;
}
/**
* Scrape the requested trainer names and format the resultant output.
* @param {string[]} classes The names of the trainer classes to retrieve
* @returns {Promise<string>} A Promise that resolves with the finished text.
*/
async function scrapeTrainerNames(classes) {
classes = [...new Set(classes)];
/**
* A Set containing all trainer URLs that have been seen.
* @type {Set<string>}
*/
const seenClasses = new Set();
/**
* A large array of tuples matching each class to their corresponding list of trainer names. \
* Trainer classes with only 1 gender will only contain the single array for that gender.
* @type {[keyName: string, names: string[] | parsedNames][]}
*/
const namesTuples = await Promise.all(
classes.map(async trainerClass => {
try {
const [trainerName, names] = await doFetch(trainerClass, seenClasses);
const namesObj = names.female.length === 0 ? names.male : names;
return /** @type {const} */ ([trainerName, namesObj]);
} catch (e) {
if (!(e instanceof Error)) {
throw new Error(chalk.red.bold("Unrecognized error detected:", inspect(e)));
}
// If the error contains an HTTP status, attempt to parse the code to give a more friendly
// response than JSDOM's "Resource was not loaded"gi
const errCode = /Status: (\d*)/g.exec(e.message)?.[1];
if (!errCode) {
throw e;
}
/** @type {string} */
let reason;
switch (+errCode) {
case 404:
reason = "Page not found";
break;
case 403:
reason = "Access is forbidden";
break;
default:
reason = `Server produced error code of ${+errCode}`;
}
throw new Error(
chalk.red.bold(`Failed to parse URL for ${chalk.hex("#7fff00")(`\"${trainerClass}\"`)}!\nReason: ${reason}`),
);
}
}),
);
// Grab all keys inside the name replacement map and change them accordingly.
const mappedNames = namesTuples.filter(tuple => tuple[0] in trainerNamesMap);
for (const mappedName of mappedNames) {
const namesMapping = trainerNamesMap[mappedName[0]];
namesTuples.splice(
namesTuples.indexOf(mappedName),
1,
...namesMapping.map(
name => /** @type {[keyName: string, names: parsedNames | string[]]} */ ([name, mappedName[1]]),
),
);
}
namesTuples.sort((a, b) => a[0].localeCompare(b[0]));
/** @type {Record<string, string[] | parsedNames>} */
const namesRecord = Object.fromEntries(namesTuples);
// Convert all arrays into objects indexed by numbers
return JSON.stringify(
namesRecord,
(_, v) => {
if (Array.isArray(v)) {
return v.reduce((ret, curr, i) => {
ret[i + 1] = curr; // 1 indexed
return ret;
}, {});
}
return v;
},
2,
);
}
/**
* Recursively scrape names from a given Trainer class and its gender counterparts.
* @param {string} trainerClass - The URL to parse
* @param {Set<string>} seenClasses - A Set containing all seen class URLs, used for record keeping.
* @returns {Promise<[string, parsedNames]>}
* A Promise that resolves with:
* 1. The name to use for the key.
* 2. All fetched names for this trainer class and its gender variants.
*/
async function doFetch(trainerClass, seenClasses) {
let keyName = toCamelCase(trainerClass);
// Bulba URLs are in Pascal_Snake_Case (Pokemon_Breeder)
const classURL = toPascalSnakeCase(trainerClass);
seenClasses.add(classURL);
// Bulbapedia has redirects mapping basically all variant spellings of each trainer name to the corresponding main page.
// We thus rely on it
const { document } = (await JSDOM.fromURL(`https://bulbapedia.bulbagarden.net/wiki/${classURL}`)).window;
const trainerListHeader = document.querySelector("#Trainer_list")?.parentElement;
const [female, counterpartURLs] = checkGenderAndType(document);
const names = fetchNames(trainerListHeader, female);
if (names === INVALID_URL) {
return Promise.reject(
new Error(chalk.red.bold(`URL \"${classURL}\" did not correspond to a valid trainer class!`)),
);
}
// Recurse into all unseen gender counterparts' URLs, using the first male name we find
const counterpartNames = await Promise.all(
counterpartURLs
.filter(url => !seenClasses.has(url))
.map(counterpartURL => {
console.log(chalk.green(`Accessing gender counterpart URL: ${toTitleCase(counterpartURL)}`));
return doFetch(counterpartURL, seenClasses);
}),
);
let overrodeName = false;
for (const [cKeyName, cNameObj] of counterpartNames) {
if (!overrodeName && female) {
overrodeName = true;
console.log(chalk.green(`Using "${cKeyName}" as the name of the JSON key object...`));
keyName = cKeyName;
}
names.male = [...new Set(names.male.concat(cNameObj.male))];
names.female = [...new Set(names.female.concat(cNameObj.female))];
}
return [normalizeDiacritics(keyName), names];
}
/**
* Convert all diacritical marks within a string into their normalized variants.
* @param {string} str - The string to parse
* @returns {string} The string with normalized diacritics
*/
function normalizeDiacritics(str) {
// Normalizing to NFKD splits all diacritics into the base letter + grapheme (à -> a + `),
// which are conveniently all in their own little Unicode block for easy removal
return str.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
}
/**
* Try to write the output to a file (or log it to stdout, as the case may be).
* @param {string | undefined} outFile - The outfile
* @param {string} output - The scraped output to produce
*/
async function tryWriteFile(outFile, output) {
if (!outFile) {
console.log(output);
return;
}
if (existsSync(outFile) && !(await promptExisting(outFile))) {
process.exitCode = 1;
return;
}
try {
writeFileSync(outFile, output);
console.log(chalk.green.bold(`✔ Output written to ${chalk.blue(outFile)} successfully!`));
} catch (e) {
let /** @type {string} */ errStr;
if (!(e instanceof Error)) {
errStr = format("Unknown error occurred: ", e);
} else {
// @ts-expect-error - Node.JS file errors always have codes
switch (e.code) {
case "ENOENT":
errStr = `File not found: ${outFile}`;
break;
case "EACCES":
errStr = `Could not write ${outFile}: Permission denied`;
break;
case "EISDIR":
errStr = `Unable to write to ${outFile} as it is a directory`;
break;
default:
errStr = `Error writing file: ${e.message}`;
}
}
console.error(chalk.red.bold(errStr));
process.exitCode = 1;
return;
}
}
/**
* Confirm overwriting an already-existing file.
* @param {string} outFile - The outfile
* @returns {Promise<boolean>} Whether "Yes" or "No" was selected.
*/
async function promptExisting(outFile) {
return (
await inquirer.prompt([
{
type: "confirm",
name: "continue",
message: `File ${chalk.blue(outFile)} already exists!` + "\nDo you want to replace it?",
default: false,
},
])
).continue;
}
main();

View File

@ -0,0 +1,9 @@
/**
* @typedef {Object}
* parsedNames
* A parsed object containing the desired names.
* @property {string[]} male
* @property {string[]} female
*/
export {};

View File

@ -1,8 +1,10 @@
import type { ArenaTagTypeMap } from "#data/arena-tag";
import type { ArenaTagType } from "#enums/arena-tag-type";
// biome-ignore lint/correctness/noUnusedImports: TSDocs
import type { SessionSaveData } from "#system/game-data";
/** Subset of {@linkcode ArenaTagType}s that apply some negative effect to pokemon that switch in ({@link https://bulbapedia.bulbagarden.net/wiki/List_of_moves_that_cause_entry_hazards#List_of_traps | entry hazards} and Imprison. */
export type ArenaTrapTagType =
export type EntryHazardTagType =
| ArenaTagType.STICKY_WEB
| ArenaTagType.SPIKES
| ArenaTagType.TOXIC_SPIKES
@ -19,6 +21,9 @@ export type TurnProtectArenaTagType =
| ArenaTagType.MAT_BLOCK
| ArenaTagType.CRAFTY_SHIELD;
/** Subset of {@linkcode ArenaTagType}s that create Trick Room-like effects which are removed upon overlap. */
export type RoomArenaTagType = ArenaTagType.TRICK_ROOM;
/** Subset of {@linkcode ArenaTagType}s that cannot persist across turns, and thus should not be serialized in {@linkcode SessionSaveData}. */
export type NonSerializableArenaTagType = ArenaTagType.NONE | TurnProtectArenaTagType | ArenaTagType.ION_DELUGE;

View File

@ -1476,10 +1476,7 @@ export class BattleScene extends SceneBase {
pokemon.resetBattleAndWaveData();
pokemon.resetTera();
applyAbAttrs("PostBattleInitAbAttr", { pokemon });
if (
pokemon.hasSpecies(SpeciesId.TERAPAGOS) ||
(this.gameMode.isClassic && this.currentBattle.waveIndex > 180 && this.currentBattle.waveIndex <= 190)
) {
if (pokemon.hasSpecies(SpeciesId.TERAPAGOS)) {
this.arena.playerTerasUsed = 0;
}
}
@ -3684,15 +3681,8 @@ export class BattleScene extends SceneBase {
) {
return false;
}
if (this.gameMode.modeId === GameModes.CHALLENGE) {
const disallowedChallenges = encounterCandidate.disallowedChallenges;
if (
disallowedChallenges &&
disallowedChallenges.length > 0 &&
this.gameMode.challenges.some(challenge => disallowedChallenges.includes(challenge.id))
) {
return false;
}
if (encounterCandidate.disallowedChallenges?.some(challenge => this.gameMode.hasChallenge(challenge))) {
return false;
}
if (!encounterCandidate.meetsRequirements()) {
return false;

View File

@ -6,7 +6,7 @@ import type { SpeciesFormChangeRevertWeatherFormTrigger } from "#data/form-chang
import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
import type { ArenaTrapTag, SuppressAbilitiesTag } from "#data/arena-tag";
import type { EntryHazardTag, SuppressAbilitiesTag } from "#data/arena-tag";
import type { BattlerTag } from "#data/battler-tags";
import { GroundedTag } from "#data/battler-tags";
import { getBerryEffectFunc } from "#data/berry";
@ -970,6 +970,8 @@ export class MoveImmunityStatStageChangeAbAttr extends MoveImmunityAbAttr {
export interface PostMoveInteractionAbAttrParams extends AugmentMoveInteractionAbAttrParams {
/** Stores the hit result of the move used in the interaction */
readonly hitResult: HitResult;
/** The amount of damage dealt in the interaction */
readonly damage: number;
}
export class PostDefendAbAttr extends AbAttr {
@ -1079,20 +1081,16 @@ export class PostDefendHpGatedStatStageChangeAbAttr extends PostDefendAbAttr {
this.selfTarget = selfTarget;
}
override canApply({ pokemon, opponent: attacker, move }: PostMoveInteractionAbAttrParams): boolean {
override canApply({ pokemon, opponent: attacker, move, damage }: PostMoveInteractionAbAttrParams): boolean {
const hpGateFlat: number = Math.ceil(pokemon.getMaxHp() * this.hpGate);
const lastAttackReceived = pokemon.turnData.attacksReceived[pokemon.turnData.attacksReceived.length - 1];
const damageReceived = lastAttackReceived?.damage || 0;
return (
this.condition(pokemon, attacker, move) && pokemon.hp <= hpGateFlat && pokemon.hp + damageReceived > hpGateFlat
);
return this.condition(pokemon, attacker, move) && pokemon.hp <= hpGateFlat && pokemon.hp + damage > hpGateFlat;
}
override apply({ simulated, pokemon, opponent: attacker }: PostMoveInteractionAbAttrParams): void {
override apply({ simulated, pokemon, opponent }: PostMoveInteractionAbAttrParams): void {
if (!simulated) {
globalScene.phaseManager.unshiftNew(
"StatStageChangePhase",
(this.selfTarget ? pokemon : attacker).getBattlerIndex(),
(this.selfTarget ? pokemon : opponent).getBattlerIndex(),
true,
this.stats,
this.stages,
@ -1113,7 +1111,7 @@ export class PostDefendApplyArenaTrapTagAbAttr extends PostDefendAbAttr {
}
override canApply({ pokemon, opponent: attacker, move }: PostMoveInteractionAbAttrParams): boolean {
const tag = globalScene.arena.getTag(this.arenaTagType) as ArenaTrapTag;
const tag = globalScene.arena.getTag(this.arenaTagType) as EntryHazardTag;
return (
this.condition(pokemon, attacker, move) &&
(!globalScene.arena.getTag(this.arenaTagType) || tag.layers < tag.maxLayers)
@ -1235,7 +1233,7 @@ export class PostDefendContactApplyStatusEffectAbAttr extends PostDefendAbAttr {
// TODO: Probably want to check against simulated here
const effect =
this.effects.length === 1 ? this.effects[0] : this.effects[pokemon.randBattleSeedInt(this.effects.length)];
attacker.trySetStatus(effect, true, pokemon);
attacker.trySetStatus(effect, pokemon);
}
}
@ -1263,17 +1261,17 @@ export class PostDefendContactApplyTagChanceAbAttr extends PostDefendAbAttr {
this.turnCount = turnCount;
}
override canApply({ move, pokemon, opponent: attacker }: PostMoveInteractionAbAttrParams): boolean {
override canApply({ move, pokemon, opponent }: PostMoveInteractionAbAttrParams): boolean {
return (
move.doesFlagEffectApply({ flag: MoveFlags.MAKES_CONTACT, user: attacker, target: pokemon }) &&
move.doesFlagEffectApply({ flag: MoveFlags.MAKES_CONTACT, user: opponent, target: pokemon }) &&
pokemon.randBattleSeedInt(100) < this.chance &&
attacker.canAddTag(this.tagType)
opponent.canAddTag(this.tagType)
);
}
override apply({ simulated, opponent: attacker, move }: PostMoveInteractionAbAttrParams): void {
override apply({ pokemon, simulated, opponent, move }: PostMoveInteractionAbAttrParams): void {
if (!simulated) {
attacker.addTag(this.tagType, this.turnCount, move.id, attacker.id);
opponent.addTag(this.tagType, this.turnCount, move.id, pokemon.id);
}
}
}
@ -1758,7 +1756,7 @@ export class PokemonTypeChangeAbAttr extends PreAttackAbAttr {
* Parameters for abilities that modify the hit count and damage of a move
*/
export interface AddSecondStrikeAbAttrParams extends Omit<AugmentMoveInteractionAbAttrParams, "opponent"> {
/** Holder for the number of hits. May be modified by ability application */
/** Holder for the number of hits. May be modified by ability application */
hitCount?: NumberHolder;
/** Holder for the damage multiplier _of the current hit_ */
multiplier?: NumberHolder;
@ -2228,7 +2226,7 @@ export class PostAttackApplyStatusEffectAbAttr extends PostAttackAbAttr {
apply({ pokemon, opponent }: PostMoveInteractionAbAttrParams): void {
const effect =
this.effects.length === 1 ? this.effects[0] : this.effects[pokemon.randBattleSeedInt(this.effects.length)];
opponent.trySetStatus(effect, true, pokemon);
opponent.trySetStatus(effect, pokemon);
}
}
@ -2383,7 +2381,7 @@ export class SynchronizeStatusAbAttr extends PostSetStatusAbAttr {
*/
override apply({ simulated, effect, sourcePokemon, pokemon }: PostSetStatusAbAttrParams): void {
if (!simulated && sourcePokemon) {
sourcePokemon.trySetStatus(effect, true, pokemon);
sourcePokemon.trySetStatus(effect, pokemon);
}
}
}
@ -3014,41 +3012,44 @@ export class PostSummonFormChangeAbAttr extends PostSummonAbAttr {
}
}
/** Attempts to copy a pokemon's ability */
/**
* Attempts to copy a pokemon's ability
*
* @remarks
* Hardcodes idiosyncrasies specific to trace, so should not be used for other abilities
* that might copy abilities in the future
* @sealed
*/
export class PostSummonCopyAbilityAbAttr extends PostSummonAbAttr {
private target: Pokemon;
private targetAbilityName: string;
override canApply({ pokemon }: AbAttrBaseParams): boolean {
const targets = pokemon.getOpponents();
override canApply({ pokemon, simulated }: AbAttrBaseParams): boolean {
const targets = pokemon
.getOpponents()
.filter(t => t.getAbility().isCopiable || t.getAbility().id === AbilityId.WONDER_GUARD);
if (!targets.length) {
return false;
}
let target: Pokemon;
if (targets.length > 1) {
globalScene.executeWithSeedOffset(() => (target = randSeedItem(targets)), globalScene.currentBattle.waveIndex);
// simulated call always chooses first target so as to not advance RNG
if (targets.length > 1 && !simulated) {
target = targets[randSeedInt(targets.length)];
} else {
target = targets[0];
}
if (
!target!.getAbility().isCopiable &&
// Wonder Guard is normally uncopiable so has the attribute, but Trace specifically can copy it
!(pokemon.hasAbility(AbilityId.TRACE) && target!.getAbility().id === AbilityId.WONDER_GUARD)
) {
return false;
}
this.target = target!;
this.targetAbilityName = allAbilities[target!.getAbility().id].name;
this.target = target;
this.targetAbilityName = allAbilities[target.getAbility().id].name;
return true;
}
override apply({ pokemon, simulated }: AbAttrBaseParams): void {
if (!simulated) {
pokemon.setTempAbility(this.target!.getAbility());
setAbilityRevealed(this.target!);
// Protect against this somehow being called before canApply by ensuring target is defined
if (!simulated && this.target) {
pokemon.setTempAbility(this.target.getAbility());
setAbilityRevealed(this.target);
pokemon.updateInfo();
}
}
@ -3659,7 +3660,8 @@ export class PreSetStatusEffectImmunityAbAttr extends PreSetStatusAbAttr {
protected immuneEffects: StatusEffect[];
/**
* @param immuneEffects - The status effects to which the Pokémon is immune.
* @param immuneEffects - An array of {@linkcode StatusEffect}s to prevent application.
* If none are provided, will block **all** status effects regardless of type.
*/
constructor(...immuneEffects: StatusEffect[]) {
super();
@ -3668,7 +3670,7 @@ export class PreSetStatusEffectImmunityAbAttr extends PreSetStatusAbAttr {
}
override canApply({ effect }: PreSetStatusAbAttrParams): boolean {
return (effect !== StatusEffect.FAINT && this.immuneEffects.length < 1) || this.immuneEffects.includes(effect);
return (this.immuneEffects.length === 0 && effect !== StatusEffect.FAINT) || this.immuneEffects.includes(effect);
}
/**
@ -3720,6 +3722,11 @@ export interface UserFieldStatusEffectImmunityAbAttrParams extends AbAttrBasePar
*/
export class UserFieldStatusEffectImmunityAbAttr extends AbAttr {
protected immuneEffects: StatusEffect[];
/**
* @param immuneEffects - An array of {@linkcode StatusEffect}s to prevent application.
* If none are provided, will block **all** status effects regardless of type.
*/
constructor(...immuneEffects: StatusEffect[]) {
super();
@ -3728,7 +3735,7 @@ export class UserFieldStatusEffectImmunityAbAttr extends AbAttr {
override canApply({ effect, cancelled }: UserFieldStatusEffectImmunityAbAttrParams): boolean {
return (
(!cancelled.value && effect !== StatusEffect.FAINT && this.immuneEffects.length < 1) ||
(!cancelled.value && this.immuneEffects.length === 0 && effect !== StatusEffect.FAINT) ||
this.immuneEffects.includes(effect)
);
}
@ -3754,6 +3761,10 @@ export class ConditionalUserFieldStatusEffectImmunityAbAttr extends UserFieldSta
*/
private condition: (target: Pokemon, source: Pokemon | null) => boolean;
/**
* @param immuneEffects - An array of {@linkcode StatusEffect}s to prevent application.
* If none are provided, will block **all** status effects regardless of type.
*/
constructor(condition: (target: Pokemon, source: Pokemon | null) => boolean, ...immuneEffects: StatusEffect[]) {
super(...immuneEffects);
@ -5814,7 +5825,7 @@ export class NoFusionAbilityAbAttr extends AbAttr {
export interface IgnoreTypeImmunityAbAttrParams extends AbAttrBaseParams {
/** The type of the move being used */
readonly moveType: PokemonType;
/** The type being checked for */
/** The type being checked for */
readonly defenderType: PokemonType;
/** Holds whether the type immunity should be bypassed */
cancelled: BooleanHolder;
@ -7481,8 +7492,7 @@ export function initAbilities() {
.unsuppressable()
.bypassFaint(),
new Ability(AbilityId.CORROSION, 7)
.attr(IgnoreTypeStatusEffectImmunityAbAttr, [ StatusEffect.POISON, StatusEffect.TOXIC ], [ PokemonType.STEEL, PokemonType.POISON ])
.edgeCase(), // Should poison itself with toxic orb.
.attr(IgnoreTypeStatusEffectImmunityAbAttr, [ StatusEffect.POISON, StatusEffect.TOXIC ], [ PokemonType.STEEL, PokemonType.POISON ]),
new Ability(AbilityId.COMATOSE, 7)
.attr(StatusEffectImmunityAbAttr, ...getNonVolatileStatusEffects())
.attr(BattlerTagImmunityAbAttr, BattlerTagType.DROWSY)
@ -7824,22 +7834,26 @@ export function initAbilities() {
new Ability(AbilityId.TOXIC_CHAIN, 9)
.attr(PostAttackApplyStatusEffectAbAttr, false, 30, StatusEffect.TOXIC),
new Ability(AbilityId.EMBODY_ASPECT_TEAL, 9)
.attr(PostTeraFormChangeStatChangeAbAttr, [ Stat.SPD ], 1)
.attr(PostTeraFormChangeStatChangeAbAttr, [ Stat.SPD ], 1) // Activates immediately upon Terastallizing, as well as upon switching in while Terastallized
.conditionalAttr(pokemon => pokemon.isTerastallized, PostSummonStatStageChangeAbAttr, [ Stat.SPD ], 1, true)
.uncopiable()
.unreplaceable() // TODO is this true?
.attr(NoTransformAbilityAbAttr),
new Ability(AbilityId.EMBODY_ASPECT_WELLSPRING, 9)
.attr(PostTeraFormChangeStatChangeAbAttr, [ Stat.SPDEF ], 1)
.conditionalAttr(pokemon => pokemon.isTerastallized, PostSummonStatStageChangeAbAttr, [ Stat.SPDEF ], 1, true)
.uncopiable()
.unreplaceable()
.attr(NoTransformAbilityAbAttr),
new Ability(AbilityId.EMBODY_ASPECT_HEARTHFLAME, 9)
.attr(PostTeraFormChangeStatChangeAbAttr, [ Stat.ATK ], 1)
.conditionalAttr(pokemon => pokemon.isTerastallized, PostSummonStatStageChangeAbAttr, [ Stat.ATK ], 1, true)
.uncopiable()
.unreplaceable()
.attr(NoTransformAbilityAbAttr),
new Ability(AbilityId.EMBODY_ASPECT_CORNERSTONE, 9)
.attr(PostTeraFormChangeStatChangeAbAttr, [ Stat.DEF ], 1)
.conditionalAttr(pokemon => pokemon.isTerastallized, PostSummonStatStageChangeAbAttr, [ Stat.DEF ], 1, true)
.uncopiable()
.unreplaceable()
.attr(NoTransformAbilityAbAttr),

View File

@ -24,11 +24,12 @@ import type { Pokemon } from "#field/pokemon";
import type {
ArenaScreenTagType,
ArenaTagTypeData,
ArenaTrapTagType,
EntryHazardTagType,
RoomArenaTagType,
SerializableArenaTagType,
} from "#types/arena-tags";
import type { Mutable } from "#types/type-helpers";
import { BooleanHolder, NumberHolder, toDmgValue } from "#utils/common";
import { BooleanHolder, type NumberHolder, toDmgValue } from "#utils/common";
import i18next from "i18next";
/**
@ -294,7 +295,8 @@ export abstract class WeakenMoveScreenTag extends SerializableArenaTag {
if (bypassed.value) {
return false;
}
damageMultiplier.value = globalScene.currentBattle.double ? 2732 / 4096 : 0.5;
// Screens are less effective in Double Battles
damageMultiplier.value = globalScene.currentBattle.double ? 2 / 3 : 1 / 2;
return true;
}
return false;
@ -725,42 +727,79 @@ export class IonDelugeTag extends ArenaTag {
}
/**
* Abstract class to implement arena traps.
* Abstract class to implement [entry hazards](https://bulbapedia.bulbagarden.net/wiki/List_of_moves_that_cause_entry_hazards).
* These persistent tags remain on-field across turns and apply effects to any {@linkcode Pokemon} switching in. \
* Uniquely, adding a tag multiple times may stack multiple "layers" of the effect, increasing its severity.
*/
export abstract class ArenaTrapTag extends SerializableArenaTag {
abstract readonly tagType: ArenaTrapTagType;
public layers: number;
public maxLayers: number;
export abstract class EntryHazardTag extends SerializableArenaTag {
public declare abstract readonly tagType: EntryHazardTagType;
/**
* Creates a new instance of the ArenaTrapTag class.
*
* @param tagType - The type of the arena tag.
* @param sourceMove - The move that created the tag.
* @param sourceId - The ID of the source of the tag.
* @param side - The side (player or enemy) the tag affects.
* @param maxLayers - The maximum amount of layers this tag can have.
* The current number of layers this tag has.
* Starts at 1 and increases each time the trap is laid.
*/
constructor(sourceMove: MoveId, sourceId: number | undefined, side: ArenaTagSide, maxLayers: number) {
super(0, sourceMove, sourceId, side);
this.layers = 1;
this.maxLayers = maxLayers;
public layers = 1;
/** The maximum number of layers this tag can have. */
public abstract get maxLayers(): number;
/** Whether this tag should only affect grounded targets; default `true` */
protected get groundedOnly(): boolean {
return true;
}
onOverlap(arena: Arena, _source: Pokemon | null): void {
if (this.layers < this.maxLayers) {
this.layers++;
constructor(sourceMove: MoveId, sourceId: number | undefined, side: ArenaTagSide) {
super(0, sourceMove, sourceId, side);
}
this.onAdd(arena);
// TODO: Add a `canAdd` field to arena tags to remove need for callers to check layer counts
/**
* Display text when this tag is added to the field.
* @param _arena - The {@linkcode Arena} at the time of adding this tag
* @param quiet - Whether to suppress messages during tag creation; default `false`
*/
override onAdd(_arena: Arena, quiet = false): void {
// Here, `quiet=true` means "just add the tag, no questions asked"
if (quiet) {
return;
}
const source = this.getSourcePokemon();
if (!source) {
console.warn(
"Failed to get source Pokemon for AernaTrapTag on add message!" +
`\nTag type: ${this.tagType}` +
`\nPID: ${this.sourceId}`,
);
return;
}
globalScene.phaseManager.queueMessage(this.getAddMessage(source));
}
/**
* Activates the hazard effect onto a Pokemon when it enters the field
* @param _arena the {@linkcode Arena} containing this tag
* @param simulated if `true`, only checks if the hazard would activate.
* @param pokemon the {@linkcode Pokemon} triggering this hazard
* Return the text to be displayed upon adding a new layer to this trap.
* @param source - The {@linkcode Pokemon} having created this tag
* @returns The localized message to be displayed on screen.
*/
protected abstract getAddMessage(source: Pokemon): string;
/**
* Add a new layer to this tag upon overlap, triggering the tag's normal {@linkcode onAdd} effects upon doing so.
* @param arena - The {@linkcode arena} at the time of adding the tag
*/
override onOverlap(arena: Arena): void {
if (this.layers >= this.maxLayers) {
return;
}
this.layers++;
this.onAdd(arena);
}
/**
* Activate the hazard effect onto a Pokemon when it enters the field.
* @param _arena - The {@linkcode Arena} at the time of tag activation
* @param simulated - Whether to suppress activation effects during execution
* @param pokemon - The {@linkcode Pokemon} triggering this hazard
* @returns `true` if this hazard affects the given Pokemon; `false` otherwise.
*/
override apply(_arena: Arena, simulated: boolean, pokemon: Pokemon): boolean {
@ -768,12 +807,21 @@ export abstract class ArenaTrapTag extends SerializableArenaTag {
return false;
}
if (this.groundedOnly && !pokemon.isGrounded()) {
return false;
}
return this.activateTrap(pokemon, simulated);
}
activateTrap(_pokemon: Pokemon, _simulated: boolean): boolean {
return false;
}
/**
* Activate this trap's effects when a Pokemon switches into it.
* @param _pokemon - The {@linkcode Pokemon}
* @param _simulated - Whether the activation is simulated
* @returns Whether the trap activation succeeded
* @todo Do we need the return value? nothing uses it
*/
protected abstract activateTrap(_pokemon: Pokemon, _simulated: boolean): boolean;
getMatchupScoreMultiplier(pokemon: Pokemon): number {
return pokemon.isGrounded()
@ -781,141 +829,186 @@ export abstract class ArenaTrapTag extends SerializableArenaTag {
: Phaser.Math.Linear(0, 1 / Math.pow(2, this.layers), Math.min(pokemon.getHpRatio(), 0.5) * 2);
}
public loadTag<T extends this>(source: BaseArenaTag & Pick<T, "tagType" | "layers" | "maxLayers">): void {
public loadTag<T extends this>(source: BaseArenaTag & Pick<T, "tagType" | "layers">): void {
super.loadTag(source);
this.layers = source.layers;
this.maxLayers = source.maxLayers;
}
}
/**
* Abstract class to implement damaging entry hazards.
* Currently used for {@linkcode SpikesTag} and {@linkcode StealthRockTag}.
*/
abstract class DamagingTrapTag extends EntryHazardTag {
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
// Check for magic guard immunity
const cancelled = new BooleanHolder(false);
applyAbAttrs("BlockNonDirectDamageAbAttr", { pokemon, cancelled });
if (cancelled.value) {
return false;
}
if (simulated) {
return true;
}
// Damage the target and trigger a message
const damageHpRatio = this.getDamageHpRatio(pokemon);
const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio);
globalScene.phaseManager.queueMessage(this.getTriggerMessage(pokemon));
pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT });
pokemon.turnData.damageTaken += damage;
return true;
}
/**
* Return the text to be displayed when this tag deals damage.
* @param _pokemon - The {@linkcode Pokemon} switching in
* @returns The localized trigger message to be displayed on-screen.
*/
protected abstract getTriggerMessage(_pokemon: Pokemon): string;
/**
* Return the amount of damage this tag should deal to the given Pokemon, relative to its maximum HP.
* @param _pokemon - The {@linkcode Pokemon} switching in
* @returns The percentage of max HP to deal upon activation.
*/
protected abstract getDamageHpRatio(_pokemon: Pokemon): number;
}
/**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Spikes_(move) Spikes}.
* Applies up to 3 layers of Spikes, dealing 1/8th, 1/6th, or 1/4th of the the Pokémon's HP
* in damage for 1, 2, or 3 layers of Spikes respectively if they are summoned into this trap.
*/
class SpikesTag extends ArenaTrapTag {
class SpikesTag extends DamagingTrapTag {
public readonly tagType = ArenaTagType.SPIKES;
override get maxLayers() {
return 3 as const;
}
constructor(sourceId: number | undefined, side: ArenaTagSide) {
super(MoveId.SPIKES, sourceId, side, 3);
super(MoveId.SPIKES, sourceId, side);
}
onAdd(arena: Arena, quiet = false): void {
super.onAdd(arena);
// We assume `quiet=true` means "just add the bloody tag no questions asked"
if (quiet) {
return;
}
const source = this.getSourcePokemon();
if (!source) {
console.warn(`Failed to get source Pokemon for SpikesTag on add message; id: ${this.sourceId}`);
return;
}
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:spikesOnAdd", {
moveName: this.getMoveName(),
opponentDesc: source.getOpponentDescriptor(),
}),
);
protected override getAddMessage(source: Pokemon): string {
return i18next.t("arenaTag:spikesOnAdd", {
moveName: this.getMoveName(),
opponentDesc: source.getOpponentDescriptor(),
});
}
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
if (!pokemon.isGrounded()) {
return false;
}
protected override getTriggerMessage(pokemon: Pokemon): string {
return i18next.t("arenaTag:spikesActivateTrap", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
});
}
const cancelled = new BooleanHolder(false);
applyAbAttrs("BlockNonDirectDamageAbAttr", { pokemon, cancelled });
if (simulated || cancelled.value) {
return !cancelled.value;
}
const damageHpRatio = 1 / (10 - 2 * this.layers);
const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio);
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:spikesActivateTrap", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
}),
);
pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT });
pokemon.turnData.damageTaken += damage;
return true;
protected override getDamageHpRatio(_pokemon: Pokemon): number {
// 1/8 for 1 layer, 1/6 for 2, 1/4 for 3
return 1 / (10 - 2 * this.layers);
}
}
/**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Toxic_Spikes_(move) Toxic Spikes}.
* Applies up to 2 layers of Toxic Spikes, poisoning or badly poisoning any Pokémon who is
* summoned into this trap if 1 or 2 layers of Toxic Spikes respectively are up. Poison-type
* Pokémon summoned into this trap remove it entirely.
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Stealth_Rock_(move) | Stealth Rock}.
* Applies up to 1 layer of Stealth Rocks, dealing percentage-based damage to any Pokémon
* who is summoned into the trap based on the Rock type's type effectiveness.
*/
class ToxicSpikesTag extends ArenaTrapTag {
#neutralized: boolean;
public readonly tagType = ArenaTagType.TOXIC_SPIKES;
class StealthRockTag extends DamagingTrapTag {
public readonly tagType = ArenaTagType.STEALTH_ROCK;
public override get maxLayers() {
return 1 as const;
}
protected override get groundedOnly() {
return false;
}
constructor(sourceId: number | undefined, side: ArenaTagSide) {
super(MoveId.TOXIC_SPIKES, sourceId, side, 2);
this.#neutralized = false;
super(MoveId.STEALTH_ROCK, sourceId, side);
}
onAdd(arena: Arena, quiet = false): void {
super.onAdd(arena);
if (quiet) {
// We assume `quiet=true` means "just add the bloody tag no questions asked"
return;
}
const source = this.getSourcePokemon();
if (!source) {
console.warn(`Failed to get source Pokemon for ToxicSpikesTag on add message; id: ${this.sourceId}`);
return;
}
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:toxicSpikesOnAdd", {
moveName: this.getMoveName(),
opponentDesc: source.getOpponentDescriptor(),
}),
);
protected override getAddMessage(source: Pokemon): string {
return i18next.t("arenaTag:stealthRockOnAdd", {
opponentDesc: source.getOpponentDescriptor(),
});
}
onRemove(arena: Arena): void {
protected override getTriggerMessage(pokemon: Pokemon): string {
return i18next.t("arenaTag:stealthRockActivateTrap", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
});
}
protected override getDamageHpRatio(pokemon: Pokemon): number {
const effectiveness = pokemon.getAttackTypeEffectiveness(PokemonType.ROCK, undefined, true);
return 0.125 * effectiveness;
}
getMatchupScoreMultiplier(pokemon: Pokemon): number {
const damageHpRatio = this.getDamageHpRatio(pokemon);
return Phaser.Math.Linear(super.getMatchupScoreMultiplier(pokemon), 1, 1 - Math.pow(damageHpRatio, damageHpRatio));
}
}
/**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Toxic_Spikes_(move) | Toxic Spikes}.
* Applies up to 2 layers of Toxic Spikes, poisoning or badly poisoning any Pokémon switched in
* based on the current layer count. \
* Poison-type Pokémon will remove it entirely upon switch-in.
*/
class ToxicSpikesTag extends EntryHazardTag {
/**
* Whether the tag is currently in the process of being neutralized by a Poison-type.
* @defaultValue `false`
*/
#neutralized = false;
public readonly tagType = ArenaTagType.TOXIC_SPIKES;
override get maxLayers() {
return 2 as const;
}
constructor(sourceId: number | undefined, side: ArenaTagSide) {
super(MoveId.TOXIC_SPIKES, sourceId, side);
}
protected override getAddMessage(source: Pokemon): string {
return i18next.t("arenaTag:toxicSpikesOnAdd", {
moveName: this.getMoveName(),
opponentDesc: source.getOpponentDescriptor(),
});
}
// Override remove function to only display text when not neutralized
override onRemove(arena: Arena): void {
if (!this.#neutralized) {
super.onRemove(arena);
}
}
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
if (pokemon.isGrounded()) {
if (simulated) {
return true;
}
if (pokemon.isOfType(PokemonType.POISON)) {
this.#neutralized = true;
if (globalScene.arena.removeTag(this.tagType)) {
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:toxicSpikesActivateTrapPoison", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
moveName: this.getMoveName(),
}),
);
return true;
}
} else if (!pokemon.status) {
const toxic = this.layers > 1;
if (
pokemon.trySetStatus(!toxic ? StatusEffect.POISON : StatusEffect.TOXIC, true, null, 0, this.getMoveName())
) {
return true;
}
}
if (simulated) {
return true;
}
return false;
if (pokemon.isOfType(PokemonType.POISON)) {
// Neutralize the tag and remove it from the field.
// Message cannot be moved to `onRemove` as that requires a reference to the neutralizing pokemon
this.#neutralized = true;
globalScene.arena.removeTagOnSide(this.tagType, this.side);
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:toxicSpikesActivateTrapPoison", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
moveName: this.getMoveName(),
}),
);
return true;
}
// Attempt to poison the target, suppressing any status effect messages
const effect = this.layers === 1 ? StatusEffect.POISON : StatusEffect.TOXIC;
return pokemon.trySetStatus(effect, null, 0, this.getMoveName(), false, true);
}
getMatchupScoreMultiplier(pokemon: Pokemon): number {
@ -930,71 +1023,37 @@ class ToxicSpikesTag extends ArenaTrapTag {
}
/**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Stealth_Rock_(move) Stealth Rock}.
* Applies up to 1 layer of Stealth Rocks, dealing percentage-based damage to any Pokémon
* who is summoned into the trap, based on the Rock type's type effectiveness.
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Sticky_Web_(move) | Sticky Web}.
* Applies a single-layer trap that lowers the Speed of all grounded Pokémon switching in.
*/
class StealthRockTag extends ArenaTrapTag {
public readonly tagType = ArenaTagType.STEALTH_ROCK;
class StickyWebTag extends EntryHazardTag {
public readonly tagType = ArenaTagType.STICKY_WEB;
public override get maxLayers() {
return 1 as const;
}
constructor(sourceId: number | undefined, side: ArenaTagSide) {
super(MoveId.STEALTH_ROCK, sourceId, side, 1);
super(MoveId.STICKY_WEB, sourceId, side);
}
onAdd(arena: Arena, quiet = false): void {
super.onAdd(arena);
if (quiet) {
return;
}
const source = this.getSourcePokemon();
if (!quiet && source) {
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:stealthRockOnAdd", {
opponentDesc: source.getOpponentDescriptor(),
}),
);
}
}
getDamageHpRatio(pokemon: Pokemon): number {
const effectiveness = pokemon.getAttackTypeEffectiveness(PokemonType.ROCK, undefined, true);
let damageHpRatio = 0;
switch (effectiveness) {
case 0:
damageHpRatio = 0;
break;
case 0.25:
damageHpRatio = 0.03125;
break;
case 0.5:
damageHpRatio = 0.0625;
break;
case 1:
damageHpRatio = 0.125;
break;
case 2:
damageHpRatio = 0.25;
break;
case 4:
damageHpRatio = 0.5;
break;
}
return damageHpRatio;
protected override getAddMessage(source: Pokemon): string {
return i18next.t("arenaTag:stickyWebOnAdd", {
moveName: this.getMoveName(),
opponentDesc: source.getOpponentDescriptor(),
});
}
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
const cancelled = new BooleanHolder(false);
applyAbAttrs("BlockNonDirectDamageAbAttr", { pokemon, cancelled });
if (cancelled.value) {
return false;
}
// TODO: Does this need to pass `simulated` as a parameter?
applyAbAttrs("ProtectStatAbAttr", {
pokemon,
cancelled,
stat: Stat.SPD,
stages: -1,
});
const damageHpRatio = this.getDamageHpRatio(pokemon);
if (!damageHpRatio) {
if (cancelled.value) {
return false;
}
@ -1002,95 +1061,112 @@ class StealthRockTag extends ArenaTrapTag {
return true;
}
const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio);
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:stealthRockActivateTrap", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
i18next.t("arenaTag:stickyWebActivateTrap", {
pokemonName: pokemon.getNameToRender(),
}),
);
pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT });
pokemon.turnData.damageTaken += damage;
return true;
}
getMatchupScoreMultiplier(pokemon: Pokemon): number {
const damageHpRatio = this.getDamageHpRatio(pokemon);
return Phaser.Math.Linear(super.getMatchupScoreMultiplier(pokemon), 1, 1 - Math.pow(damageHpRatio, damageHpRatio));
globalScene.phaseManager.unshiftNew(
"StatStageChangePhase",
pokemon.getBattlerIndex(),
false,
[Stat.SPD],
-1,
true,
false,
true,
null,
false,
true,
);
return true;
}
}
/**
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Sticky_Web_(move) Sticky Web}.
* Applies up to 1 layer of Sticky Web, which lowers the Speed by one stage
* to any Pokémon who is summoned into this trap.
* This arena tag facilitates the application of the move Imprison
* Imprison remains in effect as long as the source Pokemon is active and present on the field.
* Imprison will apply to any opposing Pokemon that switch onto the field as well.
*/
class StickyWebTag extends ArenaTrapTag {
public readonly tagType = ArenaTagType.STICKY_WEB;
class ImprisonTag extends EntryHazardTag {
public readonly tagType = ArenaTagType.IMPRISON;
public override get maxLayers() {
return 1 as const;
}
constructor(sourceId: number | undefined, side: ArenaTagSide) {
super(MoveId.STICKY_WEB, sourceId, side, 1);
super(MoveId.IMPRISON, sourceId, side);
}
onAdd(arena: Arena, quiet = false): void {
super.onAdd(arena);
/**
* Apply the effects of Imprison to all opposing on-field Pokemon.
*/
override onAdd(_arena: Arena, quiet = false) {
super.onAdd(_arena, quiet);
// We assume `quiet=true` means "just add the bloody tag no questions asked"
if (quiet) {
return;
}
const party = this.getAffectedPokemon();
party.forEach(p => {
if (p.isAllowedInBattle()) {
p.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId);
}
});
}
protected override getAddMessage(source: Pokemon): string {
return i18next.t("battlerTags:imprisonOnAdd", {
pokemonNameWithAffix: getPokemonNameWithAffix(source),
});
}
/**
* Checks if the source Pokemon is still active on the field
* @param _arena
* @returns `true` if the source of the tag is still active on the field | `false` if not
*/
override lapse(): boolean {
const source = this.getSourcePokemon();
if (!source) {
console.warn(`Failed to get source Pokemon for SpikesTag on add message; id: ${this.sourceId}`);
return;
}
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:stickyWebOnAdd", {
moveName: this.getMoveName(),
opponentDesc: source.getOpponentDescriptor(),
}),
);
return !!source?.isActive(true);
}
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
if (pokemon.isGrounded()) {
const cancelled = new BooleanHolder(false);
applyAbAttrs("ProtectStatAbAttr", {
pokemon,
cancelled,
stat: Stat.SPD,
stages: -1,
});
if (simulated) {
return !cancelled.value;
}
if (!cancelled.value) {
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:stickyWebActivateTrap", {
pokemonName: pokemon.getNameToRender(),
}),
);
const stages = new NumberHolder(-1);
globalScene.phaseManager.unshiftNew(
"StatStageChangePhase",
pokemon.getBattlerIndex(),
false,
[Stat.SPD],
stages.value,
true,
false,
true,
null,
false,
true,
);
return true;
}
/**
* This applies the effects of Imprison to any opposing Pokemon that switch into the field while the source Pokemon is still active
* @param {Pokemon} pokemon the Pokemon Imprison is applied to
* @returns `true`
*/
override activateTrap(pokemon: Pokemon): boolean {
const source = this.getSourcePokemon();
if (source?.isActive(true) && pokemon.isAllowedInBattle()) {
pokemon.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId);
}
return true;
}
return false;
/**
* When the arena tag is removed, it also attempts to remove any related Battler Tags if they haven't already been removed from the affected Pokemon
* @param arena
*/
override onRemove(): void {
const party = this.getAffectedPokemon();
party.forEach(p => {
p.removeTag(BattlerTagType.IMPRISON);
});
}
}
/**
* Abstract base class for all Room {@linkcode ArenaTag}s, characterized by their immediate removal
* upon overlap.
*/
abstract class RoomArenaTag extends SerializableArenaTag {
declare abstract tagType: RoomArenaTagType;
/**
* Immediately remove this Tag upon overlapping.
* @sealed
*/
override onOverlap(): void {
globalScene.arena.removeTagOnSide(this.tagType, this.side);
}
}
@ -1099,7 +1175,7 @@ class StickyWebTag extends ArenaTrapTag {
* Reverses the Speed stats for all Pokémon on the field as long as this arena tag is up,
* also reversing the turn order for all Pokémon on the field as well.
*/
export class TrickRoomTag extends SerializableArenaTag {
export class TrickRoomTag extends RoomArenaTag {
public readonly tagType = ArenaTagType.TRICK_ROOM;
constructor(turnCount: number, sourceId?: number) {
super(turnCount, MoveId.TRICK_ROOM, sourceId);
@ -1129,7 +1205,7 @@ export class TrickRoomTag extends SerializableArenaTag {
globalScene.phaseManager.queueMessage(
i18next.t("arenaTag:trickRoomOnAdd", {
moveName: this.getMoveName(),
pokemonNameWithAffix: getPokemonNameWithAffix(source),
}),
);
}
@ -1287,75 +1363,6 @@ class NoneTag extends ArenaTag {
}
}
/**
* This arena tag facilitates the application of the move Imprison
* Imprison remains in effect as long as the source Pokemon is active and present on the field.
* Imprison will apply to any opposing Pokemon that switch onto the field as well.
*/
class ImprisonTag extends ArenaTrapTag {
public readonly tagType = ArenaTagType.IMPRISON;
constructor(sourceId: number | undefined, side: ArenaTagSide) {
super(MoveId.IMPRISON, sourceId, side, 1);
}
/**
* Apply the effects of Imprison to all opposing on-field Pokemon.
*/
override onAdd() {
const source = this.getSourcePokemon();
if (!source) {
return;
}
const party = this.getAffectedPokemon();
party.forEach(p => {
if (p.isAllowedInBattle()) {
p.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId);
}
});
globalScene.phaseManager.queueMessage(
i18next.t("battlerTags:imprisonOnAdd", {
pokemonNameWithAffix: getPokemonNameWithAffix(source),
}),
);
}
/**
* Checks if the source Pokemon is still active on the field
* @param _arena
* @returns `true` if the source of the tag is still active on the field | `false` if not
*/
override lapse(): boolean {
const source = this.getSourcePokemon();
return !!source?.isActive(true);
}
/**
* This applies the effects of Imprison to any opposing Pokemon that switch into the field while the source Pokemon is still active
* @param {Pokemon} pokemon the Pokemon Imprison is applied to
* @returns `true`
*/
override activateTrap(pokemon: Pokemon): boolean {
const source = this.getSourcePokemon();
if (source?.isActive(true) && pokemon.isAllowedInBattle()) {
pokemon.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId);
}
return true;
}
/**
* When the arena tag is removed, it also attempts to remove any related Battler Tags if they haven't already been removed from the affected Pokemon
* @param arena
*/
override onRemove(): void {
const party = this.getAffectedPokemon();
party.forEach(p => {
p.removeTag(BattlerTagType.IMPRISON);
});
}
}
/**
* Arena Tag implementing the "sea of fire" effect from the combination
* of {@link https://bulbapedia.bulbagarden.net/wiki/Fire_Pledge_(move) | Fire Pledge}

View File

@ -6,7 +6,7 @@ import { toTitleCase } from "#utils/strings";
export const speciesEggMoves = {
[SpeciesId.BULBASAUR]: [ MoveId.SAPPY_SEED, MoveId.MALIGNANT_CHAIN, MoveId.EARTH_POWER, MoveId.MATCHA_GOTCHA ],
[SpeciesId.CHARMANDER]: [ MoveId.DRAGON_DANCE, MoveId.BITTER_BLADE, MoveId.EARTH_POWER, MoveId.OBLIVION_WING ],
[SpeciesId.CHARMANDER]: [ MoveId.DRAGON_DANCE, MoveId.AEROBLAST, MoveId.EARTH_POWER, MoveId.BITTER_BLADE ],
[SpeciesId.SQUIRTLE]: [ MoveId.FREEZE_DRY, MoveId.ARMOR_CANNON, MoveId.SHORE_UP, MoveId.ORIGIN_PULSE ],
[SpeciesId.CATERPIE]: [ MoveId.SANDSEAR_STORM, MoveId.SILK_TRAP, MoveId.TWIN_BEAM, MoveId.BLEAKWIND_STORM ],
[SpeciesId.WEEDLE]: [ MoveId.THOUSAND_ARROWS, MoveId.NOXIOUS_TORQUE, MoveId.ATTACK_ORDER, MoveId.VICTORY_DANCE ],
@ -29,7 +29,7 @@ export const speciesEggMoves = {
[SpeciesId.GROWLITHE]: [ MoveId.ZING_ZAP, MoveId.DRAGON_DANCE, MoveId.MORNING_SUN, MoveId.SACRED_FIRE ],
[SpeciesId.POLIWAG]: [ MoveId.SLACK_OFF, MoveId.WILDBOLT_STORM, MoveId.DRAIN_PUNCH, MoveId.SURGING_STRIKES ],
[SpeciesId.ABRA]: [ MoveId.AURA_SPHERE, MoveId.BADDY_BAD, MoveId.ICE_BEAM, MoveId.PSYSTRIKE ],
[SpeciesId.MACHOP]: [ MoveId.COMBAT_TORQUE, MoveId.METEOR_MASH, MoveId.MOUNTAIN_GALE, MoveId.FISSURE ],
[SpeciesId.MACHOP]: [ MoveId.DRAIN_PUNCH, MoveId.METEOR_MASH, MoveId.MOUNTAIN_GALE, MoveId.FISSURE ],
[SpeciesId.BELLSPROUT]: [ MoveId.SOLAR_BLADE, MoveId.STRENGTH_SAP, MoveId.FIRE_LASH, MoveId.VICTORY_DANCE ],
[SpeciesId.TENTACOOL]: [ MoveId.BANEFUL_BUNKER, MoveId.MALIGNANT_CHAIN, MoveId.BOUNCY_BUBBLE, MoveId.STRENGTH_SAP ],
[SpeciesId.GEODUDE]: [ MoveId.FLARE_BLITZ, MoveId.HEAD_SMASH, MoveId.SHORE_UP, MoveId.SHELL_SMASH ],
@ -142,7 +142,7 @@ export const speciesEggMoves = {
[SpeciesId.RALTS]: [ MoveId.PSYBLADE, MoveId.BITTER_BLADE, MoveId.NO_RETREAT, MoveId.BOOMBURST ],
[SpeciesId.SURSKIT]: [ MoveId.POLLEN_PUFF, MoveId.FIERY_DANCE, MoveId.BOUNCY_BUBBLE, MoveId.AEROBLAST ],
[SpeciesId.SHROOMISH]: [ MoveId.ACCELEROCK, MoveId.TRAILBLAZE, MoveId.STORM_THROW, MoveId.SAPPY_SEED ],
[SpeciesId.SLAKOTH]: [ MoveId.CRUSH_GRIP, MoveId.DRAIN_PUNCH, MoveId.PARTING_SHOT, MoveId.SKILL_SWAP ],
[SpeciesId.SLAKOTH]: [ MoveId.ENTRAINMENT, MoveId.DRAIN_PUNCH, MoveId.PARTING_SHOT, MoveId.CRUSH_GRIP ],
[SpeciesId.NINCADA]: [ MoveId.BULLDOZE, MoveId.STICKY_WEB, MoveId.SHADOW_BONE, MoveId.SHELL_SMASH ],
[SpeciesId.WHISMUR]: [ MoveId.ALLURING_VOICE, MoveId.SHIFT_GEAR, MoveId.SPARKLING_ARIA, MoveId.TORCH_SONG ],
[SpeciesId.MAKUHITA]: [ MoveId.COMBAT_TORQUE, MoveId.SLACK_OFF, MoveId.HEAT_CRASH, MoveId.DOUBLE_IRON_BASH ],
@ -187,7 +187,7 @@ export const speciesEggMoves = {
[SpeciesId.WYNAUT]: [ MoveId.RECOVER, MoveId.SHED_TAIL, MoveId.TAUNT, MoveId.COMEUPPANCE ],
[SpeciesId.SNORUNT]: [ MoveId.SPARKLY_SWIRL, MoveId.NASTY_PLOT, MoveId.EARTH_POWER, MoveId.BLOOD_MOON ],
[SpeciesId.SPHEAL]: [ MoveId.FLIP_TURN, MoveId.FREEZE_DRY, MoveId.SLACK_OFF, MoveId.STEAM_ERUPTION ],
[SpeciesId.CLAMPERL]: [ MoveId.SHELL_SIDE_ARM, MoveId.BOUNCY_BUBBLE, MoveId.FREEZE_DRY, MoveId.STEAM_ERUPTION ],
[SpeciesId.CLAMPERL]: [ MoveId.SHELL_SIDE_ARM, MoveId.SNIPE_SHOT, MoveId.GIGA_DRAIN, MoveId.BOUNCY_BUBBLE ],
[SpeciesId.RELICANTH]: [ MoveId.DRAGON_DANCE, MoveId.SHORE_UP, MoveId.WAVE_CRASH, MoveId.DIAMOND_STORM ],
[SpeciesId.LUVDISC]: [ MoveId.BATON_PASS, MoveId.HEART_SWAP, MoveId.GLITZY_GLOW, MoveId.REVIVAL_BLESSING ],
[SpeciesId.BAGON]: [ MoveId.HEADLONG_RUSH, MoveId.FIRE_LASH, MoveId.DRAGON_DANCE, MoveId.DRAGON_DARTS ],
@ -229,7 +229,7 @@ export const speciesEggMoves = {
[SpeciesId.MIME_JR]: [ MoveId.CHILLY_RECEPTION, MoveId.MOONBLAST, MoveId.FROST_BREATH, MoveId.LUMINA_CRASH ],
[SpeciesId.HAPPINY]: [ MoveId.COTTON_GUARD, MoveId.SEISMIC_TOSS, MoveId.SIZZLY_SLIDE, MoveId.REVIVAL_BLESSING ],
[SpeciesId.CHATOT]: [ MoveId.SPARKLING_ARIA, MoveId.BOOMBURST, MoveId.BATON_PASS, MoveId.TORCH_SONG ],
[SpeciesId.SPIRITOMB]: [ MoveId.PARTING_SHOT, MoveId.BADDY_BAD, MoveId.STRENGTH_SAP, MoveId.SPECTRAL_THIEF ],
[SpeciesId.SPIRITOMB]: [ MoveId.PARTING_SHOT, MoveId.BADDY_BAD, MoveId.BITTER_MALICE, MoveId.STRENGTH_SAP ],
[SpeciesId.GIBLE]: [ MoveId.METEOR_MASH, MoveId.BITTER_BLADE, MoveId.LANDS_WRATH, MoveId.DRAGON_DANCE ],
[SpeciesId.MUNCHLAX]: [ MoveId.STUFF_CHEEKS, MoveId.GRAV_APPLE, MoveId.SLACK_OFF, MoveId.EXTREME_SPEED ],
[SpeciesId.RIOLU]: [ MoveId.THUNDEROUS_KICK, MoveId.TACHYON_CUTTER, MoveId.TRIPLE_AXEL, MoveId.SUNSTEEL_STRIKE ],
@ -247,7 +247,7 @@ export const speciesEggMoves = {
[SpeciesId.DIALGA]: [ MoveId.CORE_ENFORCER, MoveId.TAKE_HEART, MoveId.RECOVER, MoveId.MAKE_IT_RAIN ],
[SpeciesId.PALKIA]: [ MoveId.MALIGNANT_CHAIN, MoveId.TAKE_HEART, MoveId.RECOVER, MoveId.ORIGIN_PULSE ],
[SpeciesId.HEATRAN]: [ MoveId.ENERGY_BALL, MoveId.RECOVER, MoveId.ERUPTION, MoveId.TACHYON_CUTTER ],
[SpeciesId.REGIGIGAS]: [ MoveId.SKILL_SWAP, MoveId.RECOVER, MoveId.EXTREME_SPEED, MoveId.GIGATON_HAMMER ],
[SpeciesId.REGIGIGAS]: [ MoveId.OBSTRUCT, MoveId.RECOVER, MoveId.STORM_THROW, MoveId.LANDS_WRATH ],
[SpeciesId.GIRATINA]: [ MoveId.DRAGON_DANCE, MoveId.SPECTRAL_THIEF, MoveId.RECOVER, MoveId.COLLISION_COURSE ],
[SpeciesId.CRESSELIA]: [ MoveId.COSMIC_POWER, MoveId.BODY_PRESS, MoveId.SIZZLY_SLIDE, MoveId.LUMINA_CRASH ],
[SpeciesId.PHIONE]: [ MoveId.BOUNCY_BUBBLE, MoveId.FREEZE_DRY, MoveId.STORED_POWER, MoveId.ORIGIN_PULSE ],
@ -318,7 +318,7 @@ export const speciesEggMoves = {
[SpeciesId.MIENFOO]: [ MoveId.GUNK_SHOT, MoveId.SUPERCELL_SLAM, MoveId.MOUNTAIN_GALE, MoveId.WICKED_BLOW ],
[SpeciesId.DRUDDIGON]: [ MoveId.FIRE_LASH, MoveId.MORNING_SUN, MoveId.DRAGON_DARTS, MoveId.CLANGOROUS_SOUL ],
[SpeciesId.GOLETT]: [ MoveId.SHIFT_GEAR, MoveId.DRAIN_PUNCH, MoveId.HEADLONG_RUSH, MoveId.RAGE_FIST ],
[SpeciesId.PAWNIARD]: [ MoveId.SUCKER_PUNCH, MoveId.CEASELESS_EDGE, MoveId.BITTER_BLADE, MoveId.LAST_RESPECTS ],
[SpeciesId.PAWNIARD]: [ MoveId.SUCKER_PUNCH, MoveId.SPIRIT_BREAK, MoveId.LAST_RESPECTS, MoveId.BITTER_BLADE ],
[SpeciesId.BOUFFALANT]: [ MoveId.HORN_LEECH, MoveId.HIGH_JUMP_KICK, MoveId.HEAD_SMASH, MoveId.FLARE_BLITZ ],
[SpeciesId.RUFFLET]: [ MoveId.FLOATY_FALL, MoveId.AURA_SPHERE, MoveId.NO_RETREAT, MoveId.BOLT_BEAK ],
[SpeciesId.VULLABY]: [ MoveId.FOUL_PLAY, MoveId.BODY_PRESS, MoveId.ROOST, MoveId.RUINATION ],
@ -333,7 +333,7 @@ export const speciesEggMoves = {
[SpeciesId.THUNDURUS]: [ MoveId.SANDSEAR_STORM, MoveId.HURRICANE, MoveId.FROST_BREATH, MoveId.ELECTRO_SHOT ],
[SpeciesId.RESHIRAM]: [ MoveId.ENERGY_BALL, MoveId.TAKE_HEART, MoveId.FICKLE_BEAM, MoveId.ERUPTION ],
[SpeciesId.ZEKROM]: [ MoveId.TRIPLE_AXEL, MoveId.THUNDEROUS_KICK, MoveId.DRAGON_HAMMER, MoveId.DRAGON_ENERGY ],
[SpeciesId.LANDORUS]: [ MoveId.STONE_AXE, MoveId.FLOATY_FALL, MoveId.ROOST, MoveId.BLEAKWIND_STORM ],
[SpeciesId.LANDORUS]: [ MoveId.STONE_AXE, MoveId.FLOATY_FALL, MoveId.ROOST, MoveId.THOUSAND_WAVES ],
[SpeciesId.KYUREM]: [ MoveId.DRAGON_DARTS, MoveId.GLACIAL_LANCE, MoveId.NO_RETREAT, MoveId.DRAGON_ENERGY ],
[SpeciesId.KELDEO]: [ MoveId.BOUNCY_BUBBLE, MoveId.THUNDERBOLT, MoveId.ICE_BEAM, MoveId.STEAM_ERUPTION ],
[SpeciesId.MELOETTA]: [ MoveId.BODY_SLAM, MoveId.PSYCHIC_NOISE, MoveId.TRIPLE_ARROWS, MoveId.TORCH_SONG ],
@ -351,7 +351,7 @@ export const speciesEggMoves = {
[SpeciesId.PANCHAM]: [ MoveId.DRAIN_PUNCH, MoveId.SUCKER_PUNCH, MoveId.METEOR_MASH, MoveId.WICKED_BLOW ],
[SpeciesId.FURFROU]: [ MoveId.TIDY_UP, MoveId.SLACK_OFF, MoveId.COMBAT_TORQUE, MoveId.MULTI_ATTACK ],
[SpeciesId.ESPURR]: [ MoveId.LUSTER_PURGE, MoveId.MOONBLAST, MoveId.AURA_SPHERE, MoveId.DARK_VOID ],
[SpeciesId.HONEDGE]: [ MoveId.TACHYON_CUTTER, MoveId.SHADOW_BONE, MoveId.BITTER_BLADE, MoveId.BEHEMOTH_BLADE ],
[SpeciesId.HONEDGE]: [ MoveId.TACHYON_CUTTER, MoveId.SPIRIT_SHACKLE, MoveId.BITTER_BLADE, MoveId.BEHEMOTH_BLADE ],
[SpeciesId.SPRITZEE]: [ MoveId.SPEED_SWAP, MoveId.REVIVAL_BLESSING, MoveId.ROOST, MoveId.TORCH_SONG ],
[SpeciesId.SWIRLIX]: [ MoveId.BELLY_DRUM, MoveId.HEADLONG_RUSH, MoveId.MAGICAL_TORQUE, MoveId.REVIVAL_BLESSING ],
[SpeciesId.INKAY]: [ MoveId.POWER_TRIP, MoveId.SPIN_OUT, MoveId.RECOVER, MoveId.PSYCHO_BOOST ],
@ -429,7 +429,7 @@ export const speciesEggMoves = {
[SpeciesId.MAGEARNA]: [ MoveId.STRENGTH_SAP, MoveId.EARTH_POWER, MoveId.MOONBLAST, MoveId.MAKE_IT_RAIN ],
[SpeciesId.MARSHADOW]: [ MoveId.POWER_UP_PUNCH, MoveId.BONEMERANG, MoveId.METEOR_MASH, MoveId.TRIPLE_AXEL ],
[SpeciesId.POIPOLE]: [ MoveId.MALIGNANT_CHAIN, MoveId.ICE_BEAM, MoveId.ARMOR_CANNON, MoveId.CLANGING_SCALES ],
[SpeciesId.STAKATAKA]: [ MoveId.HEAVY_SLAM, MoveId.SHORE_UP, MoveId.CURSE, MoveId.SALT_CURE ],
[SpeciesId.STAKATAKA]: [ MoveId.HEAVY_SLAM, MoveId.HEAL_ORDER, MoveId.CURSE, MoveId.SALT_CURE ],
[SpeciesId.BLACEPHALON]: [ MoveId.STEEL_BEAM, MoveId.MOONBLAST, MoveId.CHLOROBLAST, MoveId.MOONGEIST_BEAM ],
[SpeciesId.ZERAORA]: [ MoveId.SWORDS_DANCE, MoveId.FIRE_LASH, MoveId.COLLISION_COURSE, MoveId.TRIPLE_AXEL ],
[SpeciesId.MELTAN]: [ MoveId.BULLET_PUNCH, MoveId.DRAIN_PUNCH, MoveId.BULK_UP, MoveId.PLASMA_FISTS ],
@ -482,7 +482,7 @@ export const speciesEggMoves = {
[SpeciesId.ZAMAZENTA]: [ MoveId.BULK_UP, MoveId.BODY_PRESS, MoveId.POWER_TRIP, MoveId.SLACK_OFF ],
[SpeciesId.ETERNATUS]: [ MoveId.BODY_PRESS, MoveId.NASTY_PLOT, MoveId.MALIGNANT_CHAIN, MoveId.DRAGON_ENERGY ],
[SpeciesId.KUBFU]: [ MoveId.METEOR_MASH, MoveId.DRAIN_PUNCH, MoveId.JET_PUNCH, MoveId.DRAGON_DANCE ],
[SpeciesId.ZARUDE]: [ MoveId.SAPPY_SEED, MoveId.MIGHTY_CLEAVE, MoveId.WICKED_BLOW, MoveId.VICTORY_DANCE ],
[SpeciesId.ZARUDE]: [ MoveId.LEAF_BLADE, MoveId.HEADLONG_RUSH, MoveId.WICKED_BLOW, MoveId.VICTORY_DANCE ],
[SpeciesId.REGIELEKI]: [ MoveId.NASTY_PLOT, MoveId.ICE_BEAM, MoveId.EARTH_POWER, MoveId.ELECTRO_DRIFT ],
[SpeciesId.REGIDRAGO]: [ MoveId.SHELL_SIDE_ARM, MoveId.FLAMETHROWER, MoveId.TAKE_HEART, MoveId.DRAGON_DARTS ],
[SpeciesId.GLASTRIER]: [ MoveId.SPEED_SWAP, MoveId.SLACK_OFF, MoveId.HIGH_HORSEPOWER, MoveId.GLACIAL_LANCE ],

View File

@ -208,7 +208,7 @@ export const starterPassiveAbilities: StarterPassiveAbilities = {
[SpeciesId.XATU]: { 0: AbilityId.SHEER_FORCE },
[SpeciesId.MAREEP]: { 0: AbilityId.ELECTROMORPHOSIS },
[SpeciesId.FLAAFFY]: { 0: AbilityId.ELECTROMORPHOSIS },
[SpeciesId.AMPHAROS]: { 0: AbilityId.ELECTROMORPHOSIS, 1: AbilityId.ELECTROMORPHOSIS },
[SpeciesId.AMPHAROS]: { 0: AbilityId.ELECTROMORPHOSIS, 1: AbilityId.FLUFFY },
[SpeciesId.HOPPIP]: { 0: AbilityId.WIND_RIDER },
[SpeciesId.SKIPLOOM]: { 0: AbilityId.WIND_RIDER },
[SpeciesId.JUMPLUFF]: { 0: AbilityId.FLUFFY },
@ -402,7 +402,7 @@ export const starterPassiveAbilities: StarterPassiveAbilities = {
[SpeciesId.SPHEAL]: { 0: AbilityId.UNAWARE },
[SpeciesId.SEALEO]: { 0: AbilityId.UNAWARE },
[SpeciesId.WALREIN]: { 0: AbilityId.UNAWARE },
[SpeciesId.CLAMPERL]: { 0: AbilityId.DAUNTLESS_SHIELD },
[SpeciesId.CLAMPERL]: { 0: AbilityId.OVERCOAT },
[SpeciesId.GOREBYSS]: { 0: AbilityId.ARENA_TRAP },
[SpeciesId.HUNTAIL]: { 0: AbilityId.ARENA_TRAP },
[SpeciesId.RELICANTH]: { 0: AbilityId.PRIMORDIAL_SEA },
@ -416,8 +416,8 @@ export const starterPassiveAbilities: StarterPassiveAbilities = {
[SpeciesId.REGIROCK]: { 0: AbilityId.SAND_STREAM },
[SpeciesId.REGICE]: { 0: AbilityId.SNOW_WARNING },
[SpeciesId.REGISTEEL]: { 0: AbilityId.STEELY_SPIRIT },
[SpeciesId.LATIAS]: { 0: AbilityId.SPEED_BOOST, 1: AbilityId.PRISM_ARMOR },
[SpeciesId.LATIOS]: { 0: AbilityId.SPEED_BOOST, 1: AbilityId.TINTED_LENS },
[SpeciesId.LATIAS]: { 0: AbilityId.PRISM_ARMOR, 1: AbilityId.PRISM_ARMOR },
[SpeciesId.LATIOS]: { 0: AbilityId.NEUROFORCE, 1: AbilityId.NEUROFORCE },
[SpeciesId.KYOGRE]: { 0: AbilityId.MOLD_BREAKER, 1: AbilityId.TERAVOLT },
[SpeciesId.GROUDON]: { 0: AbilityId.MOLD_BREAKER, 1: AbilityId.TURBOBLAZE },
[SpeciesId.RAYQUAZA]: { 0: AbilityId.UNNERVE, 1: AbilityId.UNNERVE },
@ -521,7 +521,7 @@ export const starterPassiveAbilities: StarterPassiveAbilities = {
[SpeciesId.SHAYMIN]: { 0: AbilityId.GRASSY_SURGE, 1: AbilityId.DELTA_STREAM },
[SpeciesId.ARCEUS]: { 0: AbilityId.ADAPTABILITY, 1: AbilityId.ADAPTABILITY, 2: AbilityId.ADAPTABILITY, 3: AbilityId.ADAPTABILITY, 4: AbilityId.ADAPTABILITY, 5: AbilityId.ADAPTABILITY, 6: AbilityId.ADAPTABILITY, 7: AbilityId.ADAPTABILITY, 8: AbilityId.ADAPTABILITY, 9: AbilityId.ADAPTABILITY, 10: AbilityId.ADAPTABILITY, 11: AbilityId.ADAPTABILITY, 12: AbilityId.ADAPTABILITY, 13: AbilityId.ADAPTABILITY, 14: AbilityId.ADAPTABILITY, 15: AbilityId.ADAPTABILITY, 16: AbilityId.ADAPTABILITY, 17: AbilityId.ADAPTABILITY },
[SpeciesId.VICTINI]: { 0: AbilityId.SHEER_FORCE },
[SpeciesId.VICTINI]: { 0: AbilityId.SERENE_GRACE },
[SpeciesId.SNIVY]: { 0: AbilityId.MULTISCALE },
[SpeciesId.SERVINE]: { 0: AbilityId.MULTISCALE },
[SpeciesId.SERPERIOR]: { 0: AbilityId.MULTISCALE },
@ -585,7 +585,7 @@ export const starterPassiveAbilities: StarterPassiveAbilities = {
[SpeciesId.KROKOROK]: { 0: AbilityId.TOUGH_CLAWS },
[SpeciesId.KROOKODILE]: { 0: AbilityId.TOUGH_CLAWS },
[SpeciesId.DARUMAKA]: { 0: AbilityId.GORILLA_TACTICS },
[SpeciesId.DARMANITAN]: { 0: AbilityId.GORILLA_TACTICS, 1: AbilityId.SOLID_ROCK },
[SpeciesId.DARMANITAN]: { 0: AbilityId.GORILLA_TACTICS, 1: AbilityId.PSYCHIC_SURGE },
[SpeciesId.MARACTUS]: { 0: AbilityId.WELL_BAKED_BODY },
[SpeciesId.DWEBBLE]: { 0: AbilityId.ROCKY_PAYLOAD },
[SpeciesId.CRUSTLE]: { 0: AbilityId.ROCKY_PAYLOAD },

View File

@ -1,3 +1,4 @@
import { defaultStarterSpecies } from "#app/constants";
import { globalScene } from "#app/global-scene";
import { speciesStarterCosts } from "#balance/starters";
import { allMoves } from "#data/data-lists";
@ -76,7 +77,8 @@ export enum EvolutionItem {
LEADERS_CREST
}
type TyrogueMove = MoveId.LOW_SWEEP | MoveId.MACH_PUNCH | MoveId.RAPID_SPIN;
const tyrogueMoves = [MoveId.LOW_SWEEP, MoveId.MACH_PUNCH, MoveId.RAPID_SPIN] as const;
type TyrogueMove = typeof tyrogueMoves[number];
/**
* Pokemon Evolution tuple type consisting of:
@ -191,7 +193,7 @@ export class SpeciesEvolutionCondition {
case EvoCondKey.WEATHER:
return cond.weather.includes(globalScene.arena.getWeatherType());
case EvoCondKey.TYROGUE:
return pokemon.getMoveset(true).find(m => m.moveId as TyrogueMove)?.moveId === cond.move;
return pokemon.getMoveset(true).find(m => (tyrogueMoves as readonly MoveId[]) .includes(m.moveId))?.moveId === cond.move;
case EvoCondKey.NATURE:
return cond.nature.includes(pokemon.getNature());
case EvoCondKey.RANDOM_FORM: {
@ -652,7 +654,7 @@ export const pokemonEvolutions: PokemonEvolutions = {
],
[SpeciesId.KIRLIA]: [
new SpeciesEvolution(SpeciesId.GARDEVOIR, 30, null, null),
new SpeciesEvolution(SpeciesId.GALLADE, 1, EvolutionItem.DAWN_STONE, {key: EvoCondKey.GENDER, gender: Gender.MALE})
new SpeciesEvolution(SpeciesId.GALLADE, 1, EvolutionItem.DAWN_STONE, {key: EvoCondKey.GENDER, gender: Gender.MALE}, SpeciesWildEvolutionDelay.LONG),
],
[SpeciesId.SURSKIT]: [
new SpeciesEvolution(SpeciesId.MASQUERAIN, 22, null, null)
@ -741,7 +743,7 @@ export const pokemonEvolutions: PokemonEvolutions = {
],
[SpeciesId.SNORUNT]: [
new SpeciesEvolution(SpeciesId.GLALIE, 42, null, null),
new SpeciesEvolution(SpeciesId.FROSLASS, 1, EvolutionItem.DAWN_STONE, {key: EvoCondKey.GENDER, gender: Gender.FEMALE})
new SpeciesEvolution(SpeciesId.FROSLASS, 1, EvolutionItem.DAWN_STONE, {key: EvoCondKey.GENDER, gender: Gender.FEMALE}, SpeciesWildEvolutionDelay.LONG),
],
[SpeciesId.SPHEAL]: [
new SpeciesEvolution(SpeciesId.SEALEO, 32, null, null)
@ -1591,7 +1593,7 @@ export const pokemonEvolutions: PokemonEvolutions = {
new SpeciesEvolution(SpeciesId.WHIMSICOTT, 1, EvolutionItem.SUN_STONE, null, SpeciesWildEvolutionDelay.LONG)
],
[SpeciesId.PETILIL]: [
new SpeciesEvolution(SpeciesId.HISUI_LILLIGANT, 1, EvolutionItem.SHINY_STONE, null, SpeciesWildEvolutionDelay.LONG),
new SpeciesEvolution(SpeciesId.HISUI_LILLIGANT, 1, EvolutionItem.DAWN_STONE, null, SpeciesWildEvolutionDelay.LONG),
new SpeciesEvolution(SpeciesId.LILLIGANT, 1, EvolutionItem.SUN_STONE, null, SpeciesWildEvolutionDelay.LONG)
],
[SpeciesId.BASCULIN]: [
@ -1883,6 +1885,15 @@ export function initPokemonPrevolutions(): void {
// TODO: This may cause funny business for double starters such as Pichu/Pikachu
export const pokemonStarters: PokemonPrevolutions = {};
/**
* The default species and all their evolutions
*/
export const defaultStarterSpeciesAndEvolutions: SpeciesId[] = defaultStarterSpecies.flatMap(id => {
const stage2ids = pokemonEvolutions[id]?.map(e => e.speciesId) ?? [];
const stage3ids = stage2ids.flatMap(s2id => pokemonEvolutions[s2id]?.map(e => e.speciesId) ?? []);
return [id, ...stage2ids, ...stage3ids];
});
export function initPokemonStarters(): void {
const starterKeys = Object.keys(pokemonPrevolutions);
starterKeys.forEach(pk => {

View File

@ -42,10 +42,10 @@ export const SAME_SPECIES_EGG_HA_RATE = 8;
export const MANAPHY_EGG_MANAPHY_RATE = 8;
export const GACHA_EGG_HA_RATE = 192;
// 1/x for legendary eggs, 1/x*2 for epic eggs, 1/x*4 for rare eggs, and 1/x*8 for common eggs
export const GACHA_DEFAULT_RARE_EGGMOVE_RATE = 6;
export const SAME_SPECIES_EGG_RARE_EGGMOVE_RATE = 3;
export const GACHA_MOVE_UP_RARE_EGGMOVE_RATE = 3;
// Odds are 1/x
// [COMMON, RARE, EPIC/MANAPHY, LEGEND]
export const RARE_EGGMOVE_RATES: readonly number[] = [48, 24, 12, 6];
export const BOOSTED_RARE_EGGMOVE_RATES: readonly number[] = [16, 12, 6, 3];
// #region Variant properties
// The chance x/10 of a shiny being a variant, then of being specifically an epic variant

View File

@ -564,7 +564,7 @@ export class BeakBlastChargingTag extends BattlerTag {
target: pokemon,
})
) {
phaseData.attacker.trySetStatus(StatusEffect.BURN, true, pokemon);
phaseData.attacker.trySetStatus(StatusEffect.BURN, pokemon);
}
return true;
}
@ -1058,8 +1058,7 @@ export class SeedTag extends SerializableBattlerTag {
// Check which opponent to restore HP to
const source = pokemon.getOpponents().find(o => o.getBattlerIndex() === this.sourceIndex);
if (!source) {
console.warn(`Failed to get source Pokemon for SeedTag lapse; id: ${this.sourceId}`);
return false;
return true;
}
const cancelled = new BooleanHolder(false);
@ -1510,7 +1509,7 @@ export class DrowsyTag extends SerializableBattlerTag {
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
if (!super.lapse(pokemon, lapseType)) {
pokemon.trySetStatus(StatusEffect.SLEEP, true);
pokemon.trySetStatus(StatusEffect.SLEEP);
return false;
}
@ -1860,7 +1859,7 @@ export class ContactSetStatusProtectedTag extends DamageProtectedTag {
* @param user - The pokemon that is being attacked and has the tag
*/
override onContact(attacker: Pokemon, user: Pokemon): void {
attacker.trySetStatus(this.#statusEffect, true, user);
attacker.trySetStatus(this.#statusEffect, user);
}
}
@ -2331,10 +2330,10 @@ export class CritBoostTag extends SerializableBattlerTag {
super.onAdd(pokemon);
// Dragon cheer adds +2 crit stages if the pokemon is a Dragon type when the tag is added
if (this.tagType === BattlerTagType.DRAGON_CHEER && pokemon.getTypes(true).includes(PokemonType.DRAGON)) {
(this as Mutable<this>).critStages = 2;
} else {
if (this.tagType === BattlerTagType.DRAGON_CHEER && !pokemon.getTypes(true, true).includes(PokemonType.DRAGON)) {
(this as Mutable<this>).critStages = 1;
} else {
(this as Mutable<this>).critStages = 2;
}
globalScene.phaseManager.queueMessage(
@ -2804,7 +2803,7 @@ export class GulpMissileTag extends SerializableBattlerTag {
if (this.tagType === BattlerTagType.GULP_MISSILE_ARROKUDA) {
globalScene.phaseManager.unshiftNew("StatStageChangePhase", attacker.getBattlerIndex(), false, [Stat.DEF], -1);
} else {
attacker.trySetStatus(StatusEffect.PARALYSIS, true, pokemon);
attacker.trySetStatus(StatusEffect.PARALYSIS, pokemon);
}
}
return false;

View File

@ -1,11 +1,13 @@
import type { FixedBattleConfig } from "#app/battle";
import { getRandomTrainerFunc } from "#app/battle";
import { defaultStarterSpecies } from "#app/constants";
import { defaultStarterSpeciesAndEvolutions } from "#balance/pokemon-evolutions";
import { speciesStarterCosts } from "#balance/starters";
import type { PokemonSpecies } from "#data/pokemon-species";
import { AbilityAttr } from "#enums/ability-attr";
import { BattleType } from "#enums/battle-type";
import { Challenges } from "#enums/challenges";
import { TypeColor, TypeShadow } from "#enums/color";
import { DexAttr } from "#enums/dex-attr";
import { ClassicFixedBossWaves } from "#enums/fixed-boss-waves";
import { ModifierTier } from "#enums/modifier-tier";
import { MoveId } from "#enums/move-id";
@ -19,8 +21,9 @@ import type { EnemyPokemon, PlayerPokemon, Pokemon } from "#field/pokemon";
import { Trainer } from "#field/trainer";
import type { ModifierTypeOption } from "#modifiers/modifier-type";
import { PokemonMove } from "#moves/pokemon-move";
import type { DexAttrProps, GameData } from "#system/game-data";
import type { DexAttrProps, GameData, StarterDataEntry } from "#system/game-data";
import { RibbonData, type RibbonFlag } from "#system/ribbons/ribbon-data";
import type { DexEntry } from "#types/dex-data";
import { type BooleanHolder, isBetween, type NumberHolder, randSeedItem } from "#utils/common";
import { deepCopy } from "#utils/data";
import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils";
@ -237,6 +240,15 @@ export abstract class Challenge {
return false;
}
/**
* An apply function for STARTER_SELECT_MODIFY challenges. Derived classes should alter this.
* @param _pokemon {@link Pokemon} The starter pokemon to modify.
* @returns {@link boolean} Whether this function did anything.
*/
applyStarterSelectModify(_speciesId: SpeciesId, _dexEntry: DexEntry, _starterDataEntry: StarterDataEntry): boolean {
return false;
}
/**
* An apply function for STARTER_MODIFY challenges. Derived classes should alter this.
* @param _pokemon {@link Pokemon} The starter pokemon to modify.
@ -785,7 +797,7 @@ export class FreshStartChallenge extends Challenge {
}
applyStarterChoice(pokemon: PokemonSpecies, valid: BooleanHolder): boolean {
if (this.value === 1 && !defaultStarterSpecies.includes(pokemon.speciesId)) {
if (this.value === 1 && !defaultStarterSpeciesAndEvolutions.includes(pokemon.speciesId)) {
valid.value = false;
return true;
}
@ -797,10 +809,61 @@ export class FreshStartChallenge extends Challenge {
return true;
}
applyStarterSelectModify(speciesId: SpeciesId, dexEntry: DexEntry, starterDataEntry: StarterDataEntry): boolean {
// Remove all egg moves
starterDataEntry.eggMoves = 0;
// Remove hidden and passive ability
const defaultAbilities = AbilityAttr.ABILITY_1 | AbilityAttr.ABILITY_2;
starterDataEntry.abilityAttr &= defaultAbilities;
starterDataEntry.passiveAttr = 0;
// Remove cost reduction
starterDataEntry.valueReduction = 0;
// Remove natures except for the default ones
const neutralNaturesAttr =
(1 << (Nature.HARDY + 1)) |
(1 << (Nature.DOCILE + 1)) |
(1 << (Nature.SERIOUS + 1)) |
(1 << (Nature.BASHFUL + 1)) |
(1 << (Nature.QUIRKY + 1));
dexEntry.natureAttr &= neutralNaturesAttr;
// Cap all ivs at 15
for (let i = 0; i < 6; i++) {
dexEntry.ivs[i] = Math.min(dexEntry.ivs[i], 15);
}
// Removes shiny and variants
dexEntry.caughtAttr &= ~DexAttr.SHINY;
dexEntry.caughtAttr &= ~(DexAttr.VARIANT_2 | DexAttr.VARIANT_3);
// Remove unlocked forms for specific species
if (speciesId === SpeciesId.ZYGARDE) {
// Sets ability from power construct to aura break
const formMask = (DexAttr.DEFAULT_FORM << 2n) - 1n;
dexEntry.caughtAttr &= formMask;
} else if (
[
SpeciesId.PIKACHU,
SpeciesId.EEVEE,
SpeciesId.PICHU,
SpeciesId.ROTOM,
SpeciesId.MELOETTA,
SpeciesId.FROAKIE,
].includes(speciesId)
) {
const formMask = (DexAttr.DEFAULT_FORM << 1n) - 1n; // These mons are set to form 0 because they're meant to be unlocks or mid-run form changes
dexEntry.caughtAttr &= formMask;
}
return true;
}
applyStarterModify(pokemon: Pokemon): boolean {
pokemon.abilityIndex = pokemon.abilityIndex % 2; // Always base ability, if you set it to hidden it wraps to first ability
pokemon.passive = false; // Passive isn't unlocked
pokemon.nature = Nature.HARDY; // Neutral nature
let validMoves = pokemon.species
.getLevelMoves()
.filter(m => isBetween(m[0], 1, 5))
@ -827,12 +890,14 @@ export class FreshStartChallenge extends Challenge {
SpeciesId.ROTOM,
SpeciesId.MELOETTA,
SpeciesId.FROAKIE,
SpeciesId.ROCKRUFF,
].includes(pokemon.species.speciesId)
) {
pokemon.formIndex = 0; // These mons are set to form 0 because they're meant to be unlocks or mid-run form changes
}
pokemon.ivs = [15, 15, 15, 15, 15, 15]; // Default IVs of 15 for all stats (Updated to 15 from 10 in 1.2.0)
// Cap all ivs at 15
for (let i = 0; i < 6; i++) {
pokemon.ivs[i] = Math.min(pokemon.ivs[i], 15);
}
pokemon.teraType = pokemon.species.type1; // Always primary tera type
return true;
}

View File

@ -3,17 +3,16 @@ import { globalScene } from "#app/global-scene";
import Overrides from "#app/overrides";
import { pokemonPrevolutions } from "#balance/pokemon-evolutions";
import {
BOOSTED_RARE_EGGMOVE_RATES,
EGG_PITY_EPIC_THRESHOLD,
EGG_PITY_LEGENDARY_THRESHOLD,
EGG_PITY_RARE_THRESHOLD,
GACHA_DEFAULT_COMMON_EGG_THRESHOLD,
GACHA_DEFAULT_EPIC_EGG_THRESHOLD,
GACHA_DEFAULT_RARE_EGG_THRESHOLD,
GACHA_DEFAULT_RARE_EGGMOVE_RATE,
GACHA_DEFAULT_SHINY_RATE,
GACHA_EGG_HA_RATE,
GACHA_LEGENDARY_UP_THRESHOLD_OFFSET,
GACHA_MOVE_UP_RARE_EGGMOVE_RATE,
GACHA_SHINY_UP_SHINY_RATE,
HATCH_WAVES_COMMON_EGG,
HATCH_WAVES_EPIC_EGG,
@ -21,8 +20,8 @@ import {
HATCH_WAVES_MANAPHY_EGG,
HATCH_WAVES_RARE_EGG,
MANAPHY_EGG_MANAPHY_RATE,
RARE_EGGMOVE_RATES,
SAME_SPECIES_EGG_HA_RATE,
SAME_SPECIES_EGG_RARE_EGGMOVE_RATE,
SAME_SPECIES_EGG_SHINY_RATE,
SHINY_EPIC_CHANCE,
SHINY_VARIANT_CHANCE,
@ -355,21 +354,22 @@ export class Egg {
// #region Private methods
////
/**
* Rolls which egg move slot the egg will have.
* 1/x chance for rare, (x-1)/3 chance for each common move.
* x is determined by Egg Tier. Boosted rates used for eggs obtained through Move Up Gacha and Candy.
* @returns the slot for the egg move
*/
private rollEggMoveIndex() {
let baseChance = GACHA_DEFAULT_RARE_EGGMOVE_RATE;
switch (this._sourceType) {
case EggSourceType.SAME_SPECIES_EGG:
baseChance = SAME_SPECIES_EGG_RARE_EGGMOVE_RATE;
break;
case EggSourceType.GACHA_MOVE:
baseChance = GACHA_MOVE_UP_RARE_EGGMOVE_RATE;
break;
default:
break;
const tierNum = this.isManaphyEgg() ? 2 : this.tier;
let baseChance: number;
if (this._sourceType === EggSourceType.SAME_SPECIES_EGG || this._sourceType === EggSourceType.GACHA_MOVE) {
baseChance = BOOSTED_RARE_EGGMOVE_RATES[tierNum];
} else {
baseChance = RARE_EGGMOVE_RATES[tierNum];
}
const tierMultiplier = this.isManaphyEgg() ? 2 : Math.pow(2, 3 - this.tier);
return randSeedInt(baseChance * tierMultiplier) ? randSeedInt(3) : 3;
return randSeedInt(baseChance) ? randSeedInt(3) : 3;
}
private getEggTierDefaultHatchWaves(eggTier?: EggTier): number {

View File

@ -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 { ArenaTrapTag } from "#data/arena-tag";
import type { EntryHazardTag } from "#data/arena-tag";
import { WeakenMoveTypeTag } from "#data/arena-tag";
import { MoveChargeAnim } from "#data/battle-anims";
import {
@ -88,7 +88,7 @@ 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, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common";
import { BooleanHolder, coerceArray, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { toCamelCase, toTitleCase } from "#utils/strings";
import i18next from "i18next";
@ -1190,8 +1190,9 @@ export abstract class MoveAttr {
}
/**
* @virtual
* @returns the {@linkcode MoveCondition} or {@linkcode MoveConditionFunc} for this {@linkcode Move}
* Return this `MoveAttr`'s associated {@linkcode MoveCondition} or {@linkcode MoveConditionFunc}.
* The specified condition will be added to all {@linkcode Move}s with this attribute,
* and moves **will fail upon use** if _at least 1_ of their attached conditions returns `false`.
*/
getCondition(): MoveCondition | MoveConditionFunc | null {
return null;
@ -1304,15 +1305,21 @@ export class MoveEffectAttr extends MoveAttr {
}
/**
* Determines whether the {@linkcode Move}'s effects are valid to {@linkcode apply}
* @virtual
* @param user {@linkcode Pokemon} using the move
* @param target {@linkcode Pokemon} target of the move
* @param move {@linkcode Move} with this attribute
* @param args Set of unique arguments needed by this attribute
* @returns true if basic application of the ability attribute should be possible
* Determine whether this {@linkcode MoveAttr}'s effects are able to {@linkcode apply | be applied} to the target.
*
* Will **NOT** cause the move to fail upon returning `false` (unlike {@linkcode getCondition};
* merely that the effect for this attribute will be nullified.
* @param user - The {@linkcode Pokemon} using the move
* @param target - The {@linkcode Pokemon} being targeted by the move, or {@linkcode user} if the move is
* {@linkcode selfTarget | self-targeting}
* @param move - The {@linkcode Move} being used
* @param _args - Set of unique arguments needed by this attribute
* @returns `true` if basic application of this `MoveAttr`s effects should be possible
*/
canApply(user: Pokemon, target: Pokemon, move: Move, args?: any[]) {
// TODO: Decouple this check from the `apply` step
// TODO: Make non-damaging moves fail by default if none of their attributes can apply
canApply(user: Pokemon, target: Pokemon, move: Move, _args?: any[]) {
// TODO: These checks seem redundant
return !! (this.selfTarget ? user.hp && !user.getTag(BattlerTagType.FRENZY) : target.hp)
&& (this.selfTarget || !target.getTag(BattlerTagType.PROTECTED) ||
move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target }));
@ -1961,19 +1968,17 @@ export class AddSubstituteAttr extends MoveEffectAttr {
* @see {@linkcode apply}
*/
export class HealAttr extends MoveEffectAttr {
/** The percentage of {@linkcode Stat.HP} to heal */
private healRatio: number;
/** Should an animation be shown? */
private showAnim: boolean;
constructor(healRatio?: number, showAnim?: boolean, selfTarget?: boolean) {
super(selfTarget === undefined || selfTarget);
this.healRatio = healRatio || 1;
this.showAnim = !!showAnim;
constructor(
/** The percentage of {@linkcode Stat.HP} to heal. */
private healRatio: number,
/** Whether to display a healing animation when healing the target; default `false` */
private showAnim = false,
selfTarget = true
) {
super(selfTarget);
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
override apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean {
this.addHealPhase(this.selfTarget ? user : target, this.healRatio);
return true;
}
@ -1982,15 +1987,69 @@ export class HealAttr extends MoveEffectAttr {
* Creates a new {@linkcode PokemonHealPhase}.
* This heals the target and shows the appropriate message.
*/
addHealPhase(target: Pokemon, healRatio: number) {
protected addHealPhase(target: Pokemon, healRatio: number) {
globalScene.phaseManager.unshiftNew("PokemonHealPhase", target.getBattlerIndex(),
toDmgValue(target.getMaxHp() * healRatio), i18next.t("moveTriggers:healHp", { pokemonName: getPokemonNameWithAffix(target) }), true, !this.showAnim);
}
getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number {
override getTargetBenefitScore(user: Pokemon, target: Pokemon, _move: Move): number {
const score = ((1 - (this.selfTarget ? user : target).getHpRatio()) * 20) - this.healRatio * 10;
return Math.round(score / (1 - this.healRatio / 2));
}
// TODO: Change to fail move
override canApply(user: Pokemon, target: Pokemon, _move: Move, _args?: any[]): boolean {
if (!super.canApply(user, target, _move, _args)) {
return false;
}
const healedPokemon = this.selfTarget ? user : target;
if (healedPokemon.isFullHp()) {
// Ensure the fail message isn't displayed when checking the move conditions outside of the move execution
// TOOD: Fix this in PR#6276
if (globalScene.phaseManager.getCurrentPhase()?.is("MovePhase")) {
globalScene.phaseManager.queueMessage(i18next.t("battle:hpIsFull", {
pokemonName: getPokemonNameWithAffix(healedPokemon),
}))
}
return false;
}
return true;
}
}
/**
* Attribute to put the user to sleep for a fixed duration, fully heal them and cure their status.
* Used for {@linkcode MoveId.REST}.
*/
export class RestAttr extends HealAttr {
private duration: number;
constructor(duration: number) {
super(1, true);
this.duration = duration;
}
override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const wasSet = user.trySetStatus(StatusEffect.SLEEP, user, this.duration, null, true, true,
i18next.t("moveTriggers:restBecameHealthy", {
pokemonName: getPokemonNameWithAffix(user),
}));
return wasSet && super.apply(user, target, move, args);
}
override addHealPhase(user: Pokemon): void {
globalScene.phaseManager.unshiftNew("PokemonHealPhase", user.getBattlerIndex(), user.getMaxHp(), null)
}
// TODO: change after HealAttr is changed to fail move
override getCondition(): MoveConditionFunc {
return (user, target, move) =>
super.canApply(user, target, move, [])
// Intentionally suppress messages here as we display generic fail msg
// TODO: This might have order-of-operation jank
&& user.canSetStatus(StatusEffect.SLEEP, true, true, user)
}
}
/**
@ -2262,20 +2321,16 @@ export class BoostHealAttr extends HealAttr {
* @see {@linkcode apply}
*/
export class HealOnAllyAttr extends HealAttr {
/**
* @param user {@linkcode Pokemon} using the move
* @param target {@linkcode Pokemon} target of the move
* @param move {@linkcode Move} with this attribute
* @param args N/A
* @returns true if the function succeeds
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (user.getAlly() === target) {
super.apply(user, target, move, args);
return true;
}
override canApply(user: Pokemon, target: Pokemon, _move: Move, _args?: any[]): boolean {
// Don't trigger if not targeting an ally
return target === user.getAlly() && super.canApply(user, target, _move, _args);
}
return false;
override apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean {
if (user.isOpponent(target)) {
return false;
}
return super.apply(user, target, _move, _args);
}
}
@ -2286,6 +2341,7 @@ export class HealOnAllyAttr extends HealAttr {
* @see {@linkcode apply}
* @see {@linkcode getUserBenefitScore}
*/
// TODO: Make Strength Sap its own attribute that extends off of this one
export class HitHealAttr extends MoveEffectAttr {
private healRatio: number;
private healStat: EffectiveStat | null;
@ -2536,49 +2592,50 @@ export class WaterShurikenMultiHitTypeAttr extends ChangeMultiHitTypeAttr {
export class StatusEffectAttr extends MoveEffectAttr {
public effect: StatusEffect;
public turnsRemaining?: number;
public overrideStatus: boolean = false;
constructor(effect: StatusEffect, selfTarget?: boolean, turnsRemaining?: number, overrideStatus: boolean = false) {
constructor(effect: StatusEffect, selfTarget = false) {
super(selfTarget);
this.effect = effect;
this.turnsRemaining = turnsRemaining;
this.overrideStatus = overrideStatus;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true);
const statusCheck = moveChance < 0 || moveChance === 100 || user.randBattleSeedInt(100) < moveChance;
if (!statusCheck) {
return false;
}
// non-status moves don't play sound effects for failures
const quiet = move.category !== MoveCategory.STATUS;
if (statusCheck) {
const pokemon = this.selfTarget ? user : target;
if (user !== target && move.category === MoveCategory.STATUS && !target.canSetStatus(this.effect, quiet, false, user, true)) {
return false;
}
if (((!pokemon.status || this.overrideStatus) || (pokemon.status.effect === this.effect && moveChance < 0))
&& pokemon.trySetStatus(this.effect, true, user, this.turnsRemaining, null, this.overrideStatus, quiet)) {
applyAbAttrs("ConfusionOnStatusEffectAbAttr", {pokemon: user, opponent: target, move, effect: this.effect});
return true;
}
if (
target.trySetStatus(this.effect, user, undefined, null, false, quiet)
) {
applyAbAttrs("ConfusionOnStatusEffectAbAttr", {pokemon: user, opponent: target, move, effect: this.effect});
return true;
}
return false;
}
getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number {
const moveChance = this.getMoveChance(user, target, move, this.selfTarget, false);
const score = (moveChance < 0) ? -10 : Math.floor(moveChance * -0.1);
const score = moveChance < 0 ? -10 : Math.floor(moveChance * -0.1);
const pokemon = this.selfTarget ? user : target;
return !pokemon.status && pokemon.canSetStatus(this.effect, true, false, user) ? score : 0;
return pokemon.canSetStatus(this.effect, true, false, user) ? score : 0;
}
}
/**
* Attribute to randomly apply one of several statuses to the target.
* Used for {@linkcode Moves.TRI_ATTACK} and {@linkcode Moves.DIRE_CLAW}.
*/
export class MultiStatusEffectAttr extends StatusEffectAttr {
public effects: StatusEffect[];
constructor(effects: StatusEffect[], selfTarget?: boolean, turnsRemaining?: number, overrideStatus?: boolean) {
super(effects[0], selfTarget, turnsRemaining, overrideStatus);
constructor(effects: StatusEffect[], selfTarget?: boolean) {
super(effects[0], selfTarget);
this.effects = effects;
}
@ -2611,26 +2668,41 @@ export class PsychoShiftEffectAttr extends MoveEffectAttr {
* @returns - Whether the effect was successfully applied to the target.
*/
apply(user: Pokemon, target: Pokemon, _move: Move, _args: any[]): boolean {
const statusToApply: StatusEffect | undefined = user.status?.effect ?? (user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : undefined);
const statusToApply = user.status?.effect ??
(user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : StatusEffect.NONE);
if (target.status || !statusToApply) {
// Bang is justified as condition func returns early if no status is found
if (!target.trySetStatus(statusToApply, user)) {
return false;
} else {
const canSetStatus = target.canSetStatus(statusToApply, true, false, user);
const trySetStatus = canSetStatus ? target.trySetStatus(statusToApply, true, user) : false;
}
if (trySetStatus && user.status) {
// PsychoShiftTag is added to the user if move succeeds so that the user is healed of its status effect after its move
user.addTag(BattlerTagType.PSYCHO_SHIFT);
if (user.status) {
// Add tag to user to heal its status effect after the move ends (unless we have comatose);
// occurs after move use to ensure correct Synchronize timing
user.addTag(BattlerTagType.PSYCHO_SHIFT)
}
return true;
}
getCondition(): MoveConditionFunc {
return (user, target) => {
if (target.status?.effect) {
return false;
}
return trySetStatus;
const statusToApply = user.status?.effect ?? (user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : StatusEffect.NONE);
return !!statusToApply && target.canSetStatus(statusToApply, false, false, user);
}
}
getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number {
const statusToApply = user.status?.effect ?? (user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : undefined);
return !target.status && statusToApply && target.canSetStatus(statusToApply, true, false, user) ? -10 : 0;
const statusToApply =
user.status?.effect ??
(user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : StatusEffect.NONE);
// TODO: Give this a positive user benefit score
return !target.status?.effect && statusToApply && target.canSetStatus(statusToApply, true, false, user) ? -10 : 0;
}
}
@ -2690,7 +2762,7 @@ export class StealHeldItemChanceAttr extends MoveEffectAttr {
* Used for Incinerate and Knock Off.
* Not Implemented Cases: (Same applies for Thief)
* "If the user faints due to the target's Ability (Rough Skin or Iron Barbs) or held Rocky Helmet, it cannot remove the target's held item."
* "If the Pokémon is knocked out by the attack, Sticky Hold does not protect the held item.""
* "If the Pokémon is knocked out by the attack, Sticky Hold does not protect the held item."
*/
export class RemoveHeldItemAttr extends MoveEffectAttr {
@ -2900,7 +2972,7 @@ export class HealStatusEffectAttr extends MoveEffectAttr {
*/
constructor(selfTarget: boolean, effects: StatusEffect | StatusEffect[]) {
super(selfTarget, { lastHitOnly: true });
this.effects = [ effects ].flat(1);
this.effects = coerceArray(effects)
}
/**
@ -3205,7 +3277,6 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr {
)
)
user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode, turn: globalScene.currentBattle.turn})
user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode, turn: globalScene.currentBattle.turn})
// Queue up an attack on the given slot.
globalScene.arena.positionalTagManager.addTag<PositionalTagType.DELAYED_ATTACK>({
@ -3939,22 +4010,36 @@ export class BeatUpAttr extends VariablePowerAttr {
}
}
const doublePowerChanceMessageFunc = (user: Pokemon, target: Pokemon, move: Move) => {
let message: string = "";
globalScene.executeWithSeedOffset(() => {
const rand = randSeedInt(100);
if (rand < move.chance) {
message = i18next.t("moveTriggers:goingAllOutForAttack", { pokemonName: getPokemonNameWithAffix(user) });
}
}, globalScene.currentBattle.turn << 6, globalScene.waveSeed);
return message;
};
/**
* Message function for {@linkcode MoveId.FICKLE_BEAM} that shows a message before move use if
* the move's power would be boosted.
* @todo Find another way to synchronize the RNG calls of Fickle Beam with its message
* than using a seed offset
*/
function doublePowerChanceMessageFunc(chance: number) {
return (user: Pokemon, target: Pokemon, move: Move) => {
let message: string = "";
globalScene.executeWithSeedOffset(() => {
const rand = randSeedInt(100);
if (rand < chance) {
message = i18next.t("moveTriggers:goingAllOutForAttack", { pokemonName: getPokemonNameWithAffix(user) });
}
}, globalScene.currentBattle.turn << 6, globalScene.waveSeed);
return message;
};
}
export class DoublePowerChanceAttr extends VariablePowerAttr {
private chance: number;
constructor(chance: number) {
super(false)
this.chance = chance
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
let rand: number;
let rand = 0;
globalScene.executeWithSeedOffset(() => rand = randSeedInt(100), globalScene.currentBattle.turn << 6, globalScene.waveSeed);
if (rand! < move.chance) {
if (rand < this.chance) {
const power = args[0] as NumberHolder;
power.value *= 2;
return true;
@ -4427,6 +4512,10 @@ export class SpitUpPowerAttr extends VariablePowerAttr {
* Does NOT remove stockpiled stacks.
*/
export class SwallowHealAttr extends HealAttr {
constructor() {
super(1)
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const stockpilingTag = user.getTag(StockpilingTag);
@ -6083,7 +6172,7 @@ export class AddArenaTrapTagAttr extends AddArenaTagAttr {
getCondition(): MoveConditionFunc {
return (user, target, move) => {
const side = (this.selfSideTarget !== user.isPlayer()) ? ArenaTagSide.ENEMY : ArenaTagSide.PLAYER;
const tag = globalScene.arena.getTagOnSide(this.tagType, side) as ArenaTrapTag;
const tag = globalScene.arena.getTagOnSide(this.tagType, side) as EntryHazardTag;
if (!tag) {
return true;
}
@ -6107,7 +6196,7 @@ export class AddArenaTrapTagHitAttr extends AddArenaTagAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true);
const side = (this.selfSideTarget ? user : target).isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
const tag = globalScene.arena.getTagOnSide(this.tagType, side) as ArenaTrapTag;
const tag = globalScene.arena.getTagOnSide(this.tagType, side) as EntryHazardTag;
if ((moveChance < 0 || moveChance === 100 || user.randBattleSeedInt(100) < moveChance) && user.getLastXMoves(1)[0]?.result === MoveResult.SUCCESS) {
globalScene.arena.addTag(this.tagType, 0, move.id, user.id, side);
if (!tag) {
@ -7909,7 +7998,7 @@ export class StatusIfBoostedAttr extends MoveEffectAttr {
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (target.turnData.statStagesIncreased) {
target.trySetStatus(this.effect, true, user);
target.trySetStatus(this.effect, user);
}
return true;
}
@ -8056,11 +8145,11 @@ const failIfDampCondition: MoveConditionFunc = (user, target, move) => {
return !cancelled.value;
};
const userSleptOrComatoseCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => user.status?.effect === StatusEffect.SLEEP || user.hasAbility(AbilityId.COMATOSE);
const userSleptOrComatoseCondition: MoveConditionFunc = (user) => user.status?.effect === StatusEffect.SLEEP || user.hasAbility(AbilityId.COMATOSE);
const targetSleptOrComatoseCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => target.status?.effect === StatusEffect.SLEEP || target.hasAbility(AbilityId.COMATOSE);
const targetSleptOrComatoseCondition: MoveConditionFunc = (_user: Pokemon, target: Pokemon, _move: Move) => target.status?.effect === StatusEffect.SLEEP || target.hasAbility(AbilityId.COMATOSE);
const failIfLastCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => globalScene.phaseManager.phaseQueue.find(phase => phase.is("MovePhase")) !== undefined;
const failIfLastCondition: MoveConditionFunc = () => globalScene.phaseManager.findPhase(phase => phase.is("MovePhase")) !== undefined;
const failIfLastInPartyCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => {
const party: Pokemon[] = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty();
@ -8483,8 +8572,6 @@ const MoveAttrs = Object.freeze({
/** Map of of move attribute names to their constructors */
export type MoveAttrConstructorMap = typeof MoveAttrs;
export const selfStatLowerMoves: MoveId[] = [];
export function initMoves() {
allMoves.push(
new SelfStatusMove(MoveId.NONE, PokemonType.NORMAL, MoveCategory.STATUS, -1, -1, 0, 1),
@ -8938,9 +9025,7 @@ export function initMoves() {
.attr(MultiHitAttr, MultiHitType._2)
.makesContact(false),
new SelfStatusMove(MoveId.REST, PokemonType.PSYCHIC, -1, 5, -1, 0, 1)
.attr(StatusEffectAttr, StatusEffect.SLEEP, true, 3, true)
.attr(HealAttr, 1, true)
.condition((user, target, move) => !user.isFullHp() && user.canSetStatus(StatusEffect.SLEEP, true, true, user))
.attr(RestAttr, 3)
.triageMove(),
new AttackMove(MoveId.ROCK_SLIDE, PokemonType.ROCK, MoveCategory.PHYSICAL, 75, 90, 10, 30, 0, 1)
.attr(FlinchAttr)
@ -9286,14 +9371,16 @@ export function initMoves() {
.condition(user => (user.getTag(StockpilingTag)?.stockpiledCount ?? 0) < 3)
.attr(AddBattlerTagAttr, BattlerTagType.STOCKPILING, true),
new AttackMove(MoveId.SPIT_UP, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, 100, 10, -1, 0, 3)
.condition(hasStockpileStacksCondition)
.attr(SpitUpPowerAttr, 100)
.condition(hasStockpileStacksCondition)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true),
new SelfStatusMove(MoveId.SWALLOW, PokemonType.NORMAL, -1, 10, -1, 0, 3)
.condition(hasStockpileStacksCondition)
.attr(SwallowHealAttr)
.condition(hasStockpileStacksCondition)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.STOCKPILING ], true)
.triageMove(),
.triageMove()
// TODO: Verify if using Swallow at full HP still consumes stacks or not
.edgeCase(),
new AttackMove(MoveId.HEAT_WAVE, PokemonType.FIRE, MoveCategory.SPECIAL, 95, 90, 10, 10, 0, 3)
.attr(HealStatusEffectAttr, true, StatusEffect.FREEZE)
.attr(StatusEffectAttr, StatusEffect.BURN)
@ -9679,14 +9766,8 @@ export function initMoves() {
.unimplemented(),
new StatusMove(MoveId.PSYCHO_SHIFT, PokemonType.PSYCHIC, 100, 10, -1, 0, 4)
.attr(PsychoShiftEffectAttr)
.condition((user, target, move) => {
let statusToApply = user.hasAbility(AbilityId.COMATOSE) ? StatusEffect.SLEEP : undefined;
if (user.status?.effect && isNonVolatileStatusEffect(user.status.effect)) {
statusToApply = user.status.effect;
}
return !!statusToApply && target.canSetStatus(statusToApply, false, false, user);
}
),
// TODO: Verify status applied if a statused pokemon obtains Comatose (via Transform) and uses Psycho Shift
.edgeCase(),
new AttackMove(MoveId.TRUMP_CARD, PokemonType.NORMAL, MoveCategory.SPECIAL, -1, -1, 5, -1, 0, 4)
.makesContact()
.attr(LessPPMorePowerAttr),
@ -11387,9 +11468,9 @@ export function initMoves() {
new AttackMove(MoveId.RUINATION, PokemonType.DARK, MoveCategory.SPECIAL, -1, 90, 10, -1, 0, 9)
.attr(TargetHalfHpDamageAttr),
new AttackMove(MoveId.COLLISION_COURSE, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 100, 100, 5, -1, 0, 9)
.attr(MovePowerMultiplierAttr, (user, target, move) => target.getAttackTypeEffectiveness(move.type, user) >= 2 ? 5461 / 4096 : 1),
.attr(MovePowerMultiplierAttr, (user, target, move) => target.getAttackTypeEffectiveness(move.type, user) >= 2 ? 4 / 3 : 1),
new AttackMove(MoveId.ELECTRO_DRIFT, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 100, 100, 5, -1, 0, 9)
.attr(MovePowerMultiplierAttr, (user, target, move) => target.getAttackTypeEffectiveness(move.type, user) >= 2 ? 5461 / 4096 : 1)
.attr(MovePowerMultiplierAttr, (user, target, move) => target.getAttackTypeEffectiveness(move.type, user) >= 2 ? 4 / 3 : 1)
.makesContact(),
new SelfStatusMove(MoveId.SHED_TAIL, PokemonType.NORMAL, -1, 10, -1, 0, 9)
.attr(AddSubstituteAttr, 0.5, true)
@ -11494,10 +11575,9 @@ export function initMoves() {
.attr(TeraStarstormTypeAttr)
.attr(VariableTargetAttr, (user, target, move) => user.hasSpecies(SpeciesId.TERAPAGOS) && (user.isTerastallized || globalScene.currentBattle.preTurnCommands[user.getFieldIndex()]?.command === Command.TERA) ? MoveTarget.ALL_NEAR_ENEMIES : MoveTarget.NEAR_OTHER)
.partial(), /** Does not ignore abilities that affect stats, relevant in determining the move's category {@see TeraMoveCategoryAttr} */
new AttackMove(MoveId.FICKLE_BEAM, PokemonType.DRAGON, MoveCategory.SPECIAL, 80, 100, 5, 30, 0, 9)
.attr(PreMoveMessageAttr, doublePowerChanceMessageFunc)
.attr(DoublePowerChanceAttr)
.edgeCase(), // Should not interact with Sheer Force
new AttackMove(MoveId.FICKLE_BEAM, PokemonType.DRAGON, MoveCategory.SPECIAL, 80, 100, 5, -1, 0, 9)
.attr(PreMoveMessageAttr, doublePowerChanceMessageFunc(30))
.attr(DoublePowerChanceAttr, 30),
new SelfStatusMove(MoveId.BURNING_BULWARK, PokemonType.FIRE, -1, 10, -1, 4, 9)
.attr(ProtectAttr, BattlerTagType.BURNING_BULWARK)
.condition(failIfLastCondition),
@ -11540,9 +11620,4 @@ export function initMoves() {
new AttackMove(MoveId.MALIGNANT_CHAIN, PokemonType.POISON, MoveCategory.SPECIAL, 100, 100, 5, 50, 0, 9)
.attr(StatusEffectAttr, StatusEffect.TOXIC)
);
allMoves.map(m => {
if (m.getAttrs("StatStageChangeAttr").some(a => a.selfTarget && a.stages < 0)) {
selfStatLowerMoves.push(m.id);
}
});
}

View File

@ -237,7 +237,7 @@ export const BerriesAboundEncounter: MysteryEncounter = MysteryEncounterBuilder.
const config = globalScene.currentBattle.mysteryEncounter!.enemyPartyConfigs[0];
config.pokemonConfigs![0].tags = [BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON];
config.pokemonConfigs![0].mysteryEncounterBattleEffects = (pokemon: Pokemon) => {
queueEncounterMessage(`${namespace}:option.2.boss_enraged`);
queueEncounterMessage(`${namespace}:option.2.bossEnraged`);
globalScene.phaseManager.unshiftNew(
"StatStageChangePhase",
pokemon.getBattlerIndex(),

View File

@ -243,8 +243,9 @@ export const FieryFalloutEncounter: MysteryEncounter = MysteryEncounterBuilder.w
if (burnable?.length > 0) {
const roll = randSeedInt(burnable.length);
const chosenPokemon = burnable[roll];
if (chosenPokemon.trySetStatus(StatusEffect.BURN)) {
if (chosenPokemon.canSetStatus(StatusEffect.BURN, true)) {
// Burn applied
chosenPokemon.doSetStatus(StatusEffect.BURN);
encounter.setDialogueToken("burnedPokemon", chosenPokemon.getNameToRender());
encounter.setDialogueToken("abilityName", allAbilities[AbilityId.HEATPROOF].name);
queueEncounterMessage(`${namespace}:option.2.targetBurned`);

View File

@ -44,7 +44,10 @@ 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 { getEnumKeys } from "#utils/enums";
import { getRandomLocaleEntry } from "#utils/i18n";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { toCamelCase } from "#utils/strings";
import i18next from "i18next";
/** the i18n namespace for the encounter */
@ -984,14 +987,17 @@ function doTradeReceivedSequence(
}
function generateRandomTraderName() {
const length = TrainerType.YOUNGSTER - TrainerType.ACE_TRAINER + 1;
// +1 avoids TrainerType.UNKNOWN
const classKey = `trainersCommon:${TrainerType[randInt(length) + 1]}`;
// Some trainers have 2 gendered pools, some do not
const genderKey = i18next.exists(`${classKey}.MALE`) ? (randInt(2) === 0 ? ".MALE" : ".FEMALE") : "";
const trainerNameKey = randSeedItem(Object.keys(i18next.t(`${classKey}${genderKey}`, { returnObjects: true })));
const trainerNameString = i18next.t(`${classKey}${genderKey}.${trainerNameKey}`);
// Some names have an '&' symbol and need to be trimmed to a single name instead of a double name
const trainerNames = trainerNameString.split(" & ");
return trainerNames[randInt(trainerNames.length)];
const allTrainerNames = getEnumKeys(TrainerType);
// Exclude TrainerType.UNKNOWN and everything after Ace Trainers (grunts and unique trainers)
const eligibleNames = allTrainerNames.slice(
1,
allTrainerNames.indexOf(TrainerType[TrainerType.YOUNGSTER] as keyof typeof TrainerType),
);
const randomTrainer = toCamelCase(randSeedItem(eligibleNames));
const classKey = `trainersCommon:${randomTrainer}`;
// Pick a random gender for ones with gendered pools, or access the raw object for ones without.
const genderKey = i18next.exists(`${classKey}.male`) ? randSeedItem([".male", ".female"]) : "";
const trainerNameString = getRandomLocaleEntry(`${classKey}${genderKey}`)[1];
// Split the string by &s (for duo trainers)
return randSeedItem(trainerNameString.split(" & "));
}

View File

@ -4,6 +4,7 @@ import { modifierTypes } from "#data/data-lists";
import type { IEggOptions } from "#data/egg";
import { getPokeballTintColor } from "#data/pokeball";
import { BiomeId } from "#enums/biome-id";
import { Challenges } from "#enums/challenges";
import { EggSourceType } from "#enums/egg-source-types";
import { EggTier } from "#enums/egg-type";
import { MoveId } from "#enums/move-id";
@ -130,6 +131,7 @@ export const TheExpertPokemonBreederEncounter: MysteryEncounter = MysteryEncount
MysteryEncounterType.THE_EXPERT_POKEMON_BREEDER,
)
.withEncounterTier(MysteryEncounterTier.ULTRA)
.withDisallowedChallenges(Challenges.HARDCORE)
.withSceneWaveRangeRequirement(25, 180)
.withScenePartySizeRequirement(4, 6, true) // Must have at least 4 legal pokemon in party
.withIntroSpriteConfigs([]) // These are set in onInit()

View File

@ -153,7 +153,7 @@ export const TrashToTreasureEncounter: MysteryEncounter = MysteryEncounterBuilde
doGarbageDig();
})
.withOptionPhase(async () => {
// Gain 2 Leftovers and 1 Shell Bell
// Gain 1 Leftovers and 1 Shell Bell
await transitionMysteryEncounterIntroVisuals();
await tryApplyDigRewardItems();
@ -231,21 +231,7 @@ async function tryApplyDigRewardItems() {
const party = globalScene.getPlayerParty();
// Iterate over the party until an item was successfully given
// First leftovers
for (const pokemon of party) {
const heldItems = globalScene.findModifiers(
m => m instanceof PokemonHeldItemModifier && m.pokemonId === pokemon.id,
true,
) as PokemonHeldItemModifier[];
const existingLeftovers = heldItems.find(m => m instanceof TurnHealModifier) as TurnHealModifier;
if (!existingLeftovers || existingLeftovers.getStackCount() < existingLeftovers.getMaxStackCount()) {
await applyModifierTypeToPlayerPokemon(pokemon, leftovers);
break;
}
}
// Second leftovers
// Only Leftovers
for (const pokemon of party) {
const heldItems = globalScene.findModifiers(
m => m instanceof PokemonHeldItemModifier && m.pokemonId === pokemon.id,
@ -263,7 +249,7 @@ async function tryApplyDigRewardItems() {
await showEncounterText(
i18next.t("battle:rewardGainCount", {
modifierName: leftovers.name,
count: 2,
count: 1,
}),
null,
undefined,

View File

@ -2,6 +2,7 @@ import { globalScene } from "#app/global-scene";
import { allSpecies, modifierTypes } from "#data/data-lists";
import { getLevelTotalExp } from "#data/exp";
import type { PokemonSpecies } from "#data/pokemon-species";
import { AbilityId } from "#enums/ability-id";
import { Challenges } from "#enums/challenges";
import { ModifierTier } from "#enums/modifier-tier";
import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode";
@ -10,8 +11,9 @@ import { MysteryEncounterType } from "#enums/mystery-encounter-type";
import { Nature } from "#enums/nature";
import { PartyMemberStrength } from "#enums/party-member-strength";
import { PlayerGender } from "#enums/player-gender";
import { PokemonType } from "#enums/pokemon-type";
import { MAX_POKEMON_TYPE, PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { TrainerType } from "#enums/trainer-type";
import type { PlayerPokemon, Pokemon } from "#field/pokemon";
import type { PokemonHeldItemModifier } from "#modifiers/modifier";
@ -219,6 +221,7 @@ export const WeirdDreamEncounter: MysteryEncounter = MysteryEncounterBuilder.wit
await showEncounterText(`${namespace}:option.1.dreamComplete`);
await doNewTeamPostProcess(transformations);
globalScene.phaseManager.unshiftNew("PartyHealPhase", true);
setEncounterRewards({
guaranteedModifierTypeFuncs: [
modifierTypes.MEMORY_MUSHROOM,
@ -230,7 +233,7 @@ export const WeirdDreamEncounter: MysteryEncounter = MysteryEncounterBuilder.wit
],
fillRemaining: false,
});
leaveEncounterWithoutBattle(true);
leaveEncounterWithoutBattle(false);
})
.build(),
)
@ -431,6 +434,8 @@ function getTeamTransformations(): PokemonTransformation[] {
newAbilityIndex,
undefined,
);
transformation.newPokemon.teraType = randSeedInt(MAX_POKEMON_TYPE);
}
return pokemonTransformations;
@ -440,6 +445,8 @@ async function doNewTeamPostProcess(transformations: PokemonTransformation[]) {
let atLeastOneNewStarter = false;
for (const transformation of transformations) {
const previousPokemon = transformation.previousPokemon;
const oldHpRatio = previousPokemon.getHpRatio(true);
const oldStatus = previousPokemon.status;
const newPokemon = transformation.newPokemon;
const speciesRootForm = newPokemon.species.getRootSpeciesId();
@ -462,6 +469,19 @@ async function doNewTeamPostProcess(transformations: PokemonTransformation[]) {
}
newPokemon.calculateStats();
if (oldHpRatio > 0) {
newPokemon.hp = Math.ceil(oldHpRatio * newPokemon.getMaxHp());
// Assume that the `status` instance can always safely be transferred to the new pokemon
// This is the case (as of version 1.10.4)
// Safeguard against COMATOSE here
if (!newPokemon.hasAbility(AbilityId.COMATOSE, false, true)) {
newPokemon.status = oldStatus;
}
} else {
newPokemon.hp = 0;
newPokemon.doSetStatus(StatusEffect.FAINT);
}
await newPokemon.updateInfo();
}

View File

@ -309,7 +309,7 @@ export function getRandomSpeciesByStarterCost(
*/
export function koPlayerPokemon(pokemon: PlayerPokemon) {
pokemon.hp = 0;
pokemon.trySetStatus(StatusEffect.FAINT);
pokemon.doSetStatus(StatusEffect.FAINT);
pokemon.updateInfo();
queueEncounterMessage(
i18next.t("battle:fainted", {

View File

@ -44,6 +44,34 @@ export abstract class PhasePriorityQueue {
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;
}
}
/**

View File

@ -795,7 +795,7 @@ export class PokemonSpecies extends PokemonSpeciesForm implements Localizable {
return Gender.GENDERLESS;
}
if (randSeedFloat() <= this.malePercent) {
if (randSeedFloat() * 100 <= this.malePercent) {
return Gender.MALE;
}
return Gender.FEMALE;

View File

@ -11,6 +11,7 @@ import type { MoveId } from "#enums/move-id";
import type { Nature } from "#enums/nature";
import type { PokemonType } from "#enums/pokemon-type";
import type { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import type { AttackMoveResult } from "#types/attack-move-result";
import type { IllusionData } from "#types/illusion-data";
import type { TurnMove } from "#types/turn-move";
@ -326,6 +327,14 @@ export class PokemonTurnData {
public switchedInThisTurn = false;
public failedRunAway = false;
public joinedRound = false;
/** Tracker for a pending status effect
*
* @remarks
* Set whenever {@linkcode Pokemon#trySetStatus} succeeds in order to prevent subsequent status effects
* from being applied. Necessary because the status is not actually set until the {@linkcode ObtainStatusEffectPhase} runs,
* which may not happen before another status effect is attempted to be applied.
*/
public pendingStatus: StatusEffect = StatusEffect.NONE;
/**
* The amount of times this Pokemon has acted again and used a move in the current turn.
* Used to make sure multi-hits occur properly when the user is

View File

@ -1,165 +0,0 @@
import { TrainerType } from "#enums/trainer-type";
import { toPascalSnakeCase } from "#utils/strings";
class TrainerNameConfig {
public urls: string[];
public femaleUrls: string[] | null;
constructor(type: TrainerType, ...urls: string[]) {
this.urls = urls.length ? urls : [toPascalSnakeCase(TrainerType[type])];
}
hasGenderVariant(...femaleUrls: string[]): TrainerNameConfig {
this.femaleUrls = femaleUrls.length ? femaleUrls : null;
return this;
}
}
interface TrainerNameConfigs {
[key: number]: TrainerNameConfig;
}
// used in a commented code
// biome-ignore lint/correctness/noUnusedVariables: Used by commented code
const trainerNameConfigs: TrainerNameConfigs = {
[TrainerType.ACE_TRAINER]: new TrainerNameConfig(TrainerType.ACE_TRAINER),
[TrainerType.ARTIST]: new TrainerNameConfig(TrainerType.ARTIST),
[TrainerType.BACKERS]: new TrainerNameConfig(TrainerType.BACKERS),
[TrainerType.BACKPACKER]: new TrainerNameConfig(TrainerType.BACKPACKER),
[TrainerType.BAKER]: new TrainerNameConfig(TrainerType.BAKER),
[TrainerType.BEAUTY]: new TrainerNameConfig(TrainerType.BEAUTY),
[TrainerType.BIKER]: new TrainerNameConfig(TrainerType.BIKER),
[TrainerType.BLACK_BELT]: new TrainerNameConfig(TrainerType.BLACK_BELT).hasGenderVariant("Battle_Girl"),
[TrainerType.BREEDER]: new TrainerNameConfig(TrainerType.BREEDER, "Pokémon_Breeder"),
[TrainerType.CLERK]: new TrainerNameConfig(TrainerType.CLERK),
[TrainerType.CYCLIST]: new TrainerNameConfig(TrainerType.CYCLIST),
[TrainerType.DANCER]: new TrainerNameConfig(TrainerType.DANCER),
[TrainerType.DEPOT_AGENT]: new TrainerNameConfig(TrainerType.DEPOT_AGENT),
[TrainerType.DOCTOR]: new TrainerNameConfig(TrainerType.DOCTOR).hasGenderVariant("Nurse"),
[TrainerType.FIREBREATHER]: new TrainerNameConfig(TrainerType.FIREBREATHER),
[TrainerType.FISHERMAN]: new TrainerNameConfig(TrainerType.FISHERMAN),
[TrainerType.GUITARIST]: new TrainerNameConfig(TrainerType.GUITARIST),
[TrainerType.HARLEQUIN]: new TrainerNameConfig(TrainerType.HARLEQUIN),
[TrainerType.HIKER]: new TrainerNameConfig(TrainerType.HIKER),
[TrainerType.HOOLIGANS]: new TrainerNameConfig(TrainerType.HOOLIGANS),
[TrainerType.HOOPSTER]: new TrainerNameConfig(TrainerType.HOOPSTER),
[TrainerType.INFIELDER]: new TrainerNameConfig(TrainerType.INFIELDER),
[TrainerType.JANITOR]: new TrainerNameConfig(TrainerType.JANITOR),
[TrainerType.LINEBACKER]: new TrainerNameConfig(TrainerType.LINEBACKER),
[TrainerType.MAID]: new TrainerNameConfig(TrainerType.MAID),
[TrainerType.MUSICIAN]: new TrainerNameConfig(TrainerType.MUSICIAN),
[TrainerType.HEX_MANIAC]: new TrainerNameConfig(TrainerType.HEX_MANIAC),
[TrainerType.NURSERY_AIDE]: new TrainerNameConfig(TrainerType.NURSERY_AIDE),
[TrainerType.OFFICER]: new TrainerNameConfig(TrainerType.OFFICER),
[TrainerType.PARASOL_LADY]: new TrainerNameConfig(TrainerType.PARASOL_LADY),
[TrainerType.PILOT]: new TrainerNameConfig(TrainerType.PILOT),
[TrainerType.POKEFAN]: new TrainerNameConfig(TrainerType.POKEFAN, "Poké_Fan"),
[TrainerType.PRESCHOOLER]: new TrainerNameConfig(TrainerType.PRESCHOOLER),
[TrainerType.PSYCHIC]: new TrainerNameConfig(TrainerType.PSYCHIC),
[TrainerType.RANGER]: new TrainerNameConfig(TrainerType.RANGER),
[TrainerType.RICH]: new TrainerNameConfig(TrainerType.RICH, "Gentleman").hasGenderVariant("Madame"),
[TrainerType.RICH_KID]: new TrainerNameConfig(TrainerType.RICH_KID, "Rich_Boy").hasGenderVariant("Lady"),
[TrainerType.ROUGHNECK]: new TrainerNameConfig(TrainerType.ROUGHNECK),
[TrainerType.SAILOR]: new TrainerNameConfig(TrainerType.SAILOR),
[TrainerType.SCIENTIST]: new TrainerNameConfig(TrainerType.SCIENTIST),
[TrainerType.SMASHER]: new TrainerNameConfig(TrainerType.SMASHER),
[TrainerType.SNOW_WORKER]: new TrainerNameConfig(TrainerType.SNOW_WORKER, "Worker"),
[TrainerType.STRIKER]: new TrainerNameConfig(TrainerType.STRIKER),
[TrainerType.SCHOOL_KID]: new TrainerNameConfig(TrainerType.SCHOOL_KID, "School_Kid"),
[TrainerType.SWIMMER]: new TrainerNameConfig(TrainerType.SWIMMER),
[TrainerType.TWINS]: new TrainerNameConfig(TrainerType.TWINS),
[TrainerType.VETERAN]: new TrainerNameConfig(TrainerType.VETERAN),
[TrainerType.WAITER]: new TrainerNameConfig(TrainerType.WAITER).hasGenderVariant("Waitress"),
[TrainerType.WORKER]: new TrainerNameConfig(TrainerType.WORKER),
[TrainerType.YOUNGSTER]: new TrainerNameConfig(TrainerType.YOUNGSTER).hasGenderVariant("Lass"),
};
// function used in a commented code
// biome-ignore lint/correctness/noUnusedVariables: TODO make this into a script instead of having it be in src/data...
function fetchAndPopulateTrainerNames(
url: string,
parser: DOMParser,
trainerNames: Set<string>,
femaleTrainerNames: Set<string>,
forceFemale = false,
) {
return new Promise<void>(resolve => {
fetch(`https://bulbapedia.bulbagarden.net/wiki/${url}_(Trainer_class)`)
.then(response => response.text())
.then(html => {
console.log(url);
const htmlDoc = parser.parseFromString(html, "text/html");
const trainerListHeader = htmlDoc.querySelector("#Trainer_list")?.parentElement;
if (!trainerListHeader) {
return [];
}
const elements = [...(trainerListHeader?.parentElement?.childNodes ?? [])];
const startChildIndex = elements.indexOf(trainerListHeader);
const endChildIndex = elements.findIndex(h => h.nodeName === "H2" && elements.indexOf(h) > startChildIndex);
const tables = elements
.filter(t => {
if (t.nodeName !== "TABLE" || t["className"] !== "expandable") {
return false;
}
const childIndex = elements.indexOf(t);
return childIndex > startChildIndex && childIndex < endChildIndex;
})
.map(t => t as Element);
console.log(url, tables);
for (const table of tables) {
const trainerRows = [...table.querySelectorAll("tr:not(:first-child)")].filter(r => r.children.length === 9);
for (const row of trainerRows) {
const nameCell = row.firstElementChild;
if (!nameCell) {
continue;
}
const content = nameCell.innerHTML;
if (content.indexOf(" <a ") > -1) {
const female = /♀/.test(content);
if (url === "Twins") {
console.log(content);
}
const nameMatch = />([a-z]+(?: &amp; [a-z]+)?)<\/a>/i.exec(content);
if (nameMatch) {
(female || forceFemale ? femaleTrainerNames : trainerNames).add(nameMatch[1].replace("&amp;", "&"));
}
}
}
}
resolve();
});
});
}
/*export function scrapeTrainerNames() {
const parser = new DOMParser();
const trainerTypeNames = {};
const populateTrainerNamePromises: Promise<void>[] = [];
for (let t of Object.keys(trainerNameConfigs)) {
populateTrainerNamePromises.push(new Promise<void>(resolve => {
const trainerType = t;
trainerTypeNames[trainerType] = [];
const config = trainerNameConfigs[t] as TrainerNameConfig;
const trainerNames = new Set<string>();
const femaleTrainerNames = new Set<string>();
console.log(config.urls, config.femaleUrls)
const trainerClassRequests = config.urls.map(u => fetchAndPopulateTrainerNames(u, parser, trainerNames, femaleTrainerNames));
if (config.femaleUrls)
trainerClassRequests.push(...config.femaleUrls.map(u => fetchAndPopulateTrainerNames(u, parser, null, femaleTrainerNames, true)));
Promise.all(trainerClassRequests).then(() => {
console.log(trainerNames, femaleTrainerNames)
trainerTypeNames[trainerType] = !femaleTrainerNames.size ? Array.from(trainerNames) : [ Array.from(trainerNames), Array.from(femaleTrainerNames) ];
resolve();
});
}));
}
Promise.all(populateTrainerNamePromises).then(() => {
let output = 'export const trainerNamePools = {';
Object.keys(trainerTypeNames).forEach(t => {
output += `\n\t[TrainerType.${TrainerType[t]}]: ${JSON.stringify(trainerTypeNames[t])},`;
});
output += `\n};`;
console.log(output);
});
}*/

View File

@ -1346,11 +1346,12 @@ export const trainerConfigs: TrainerConfigs = {
[TrainerPoolTier.RARE]: [
SpeciesId.YANMA,
SpeciesId.NINJASK,
SpeciesId.WHIRLIPEDE,
SpeciesId.VENIPEDE,
SpeciesId.EMOLGA,
SpeciesId.SKIDDO,
SpeciesId.ROLYCOLY,
],
[TrainerPoolTier.SUPER_RARE]: [SpeciesId.ACCELGOR, SpeciesId.DREEPY],
[TrainerPoolTier.SUPER_RARE]: [SpeciesId.SHELMET, SpeciesId.DREEPY],
}),
[TrainerType.DANCER]: new TrainerConfig(++t)
.setMoneyMultiplier(1.55)
@ -1396,7 +1397,6 @@ export const trainerConfigs: TrainerConfigs = {
trainerPartyTemplates.ONE_AVG,
trainerPartyTemplates.THREE_WEAK_SAME,
trainerPartyTemplates.ONE_STRONG,
trainerPartyTemplates.SIX_WEAKER,
)
.setSpeciesPools({
[TrainerPoolTier.COMMON]: [
@ -1510,7 +1510,6 @@ export const trainerConfigs: TrainerConfigs = {
.setMoneyMultiplier(1.1)
.setEncounterBgm(TrainerType.POKEFAN)
.setPartyTemplates(
trainerPartyTemplates.FOUR_WEAKER,
trainerPartyTemplates.THREE_WEAK,
trainerPartyTemplates.TWO_WEAK_ONE_AVG,
trainerPartyTemplates.TWO_AVG,
@ -1550,7 +1549,7 @@ export const trainerConfigs: TrainerConfigs = {
],
[TrainerPoolTier.UNCOMMON]: [SpeciesId.HOUNDOUR, SpeciesId.ROCKRUFF, SpeciesId.MASCHIFF],
[TrainerPoolTier.RARE]: [SpeciesId.JOLTEON, SpeciesId.RIOLU],
[TrainerPoolTier.SUPER_RARE]: [],
[TrainerPoolTier.SUPER_RARE]: [SpeciesId.SLAKOTH],
[TrainerPoolTier.ULTRA_RARE]: [SpeciesId.ENTEI, SpeciesId.SUICUNE, SpeciesId.RAIKOU],
}),
[TrainerType.PARASOL_LADY]: new TrainerConfig(++t)
@ -1595,13 +1594,10 @@ export const trainerConfigs: TrainerConfigs = {
.setHasDouble("Pokéfan Family")
.setEncounterBgm(TrainerType.POKEFAN)
.setPartyTemplates(
trainerPartyTemplates.SIX_WEAKER,
trainerPartyTemplates.FOUR_WEAK,
trainerPartyTemplates.TWO_AVG,
trainerPartyTemplates.ONE_STRONG,
trainerPartyTemplates.FOUR_WEAK_SAME,
trainerPartyTemplates.FIVE_WEAK,
trainerPartyTemplates.SIX_WEAKER_SAME,
)
.setSpeciesFilter(s => tmSpecies[MoveId.HELPING_HAND].indexOf(s.speciesId) > -1),
[TrainerType.PRESCHOOLER]: new TrainerConfig(++t)
@ -1609,12 +1605,7 @@ export const trainerConfigs: TrainerConfigs = {
.setEncounterBgm(TrainerType.YOUNGSTER)
.setHasGenders("Preschooler Female", "lass")
.setHasDouble("Preschoolers")
.setPartyTemplates(
trainerPartyTemplates.THREE_WEAK,
trainerPartyTemplates.FOUR_WEAKER,
trainerPartyTemplates.TWO_WEAK_SAME_ONE_AVG,
trainerPartyTemplates.FIVE_WEAKER,
)
.setPartyTemplates(trainerPartyTemplates.THREE_WEAK, trainerPartyTemplates.TWO_WEAK_SAME_ONE_AVG)
.setSpeciesPools({
[TrainerPoolTier.COMMON]: [
SpeciesId.CATERPIE,
@ -1748,11 +1739,7 @@ export const trainerConfigs: TrainerConfigs = {
.setHasGenders("Lady")
.setHasDouble("Rich Kids")
.setEncounterBgm(TrainerType.RICH)
.setPartyTemplates(
trainerPartyTemplates.FOUR_WEAKER,
trainerPartyTemplates.THREE_WEAK_SAME,
trainerPartyTemplates.TWO_WEAK_SAME_ONE_AVG,
)
.setPartyTemplates(trainerPartyTemplates.THREE_WEAK_SAME, trainerPartyTemplates.TWO_WEAK_SAME_ONE_AVG)
.setSpeciesFilter(s => s.baseTotal <= 460),
[TrainerType.ROUGHNECK]: new TrainerConfig(++t)
.setMoneyMultiplier(1.4)
@ -1866,6 +1853,7 @@ export const trainerConfigs: TrainerConfigs = {
SpeciesId.METAPOD,
SpeciesId.LEDYBA,
SpeciesId.CLEFFA,
SpeciesId.MAREEP,
SpeciesId.WOOPER,
SpeciesId.TEDDIURSA,
SpeciesId.REMORAID,
@ -1877,6 +1865,7 @@ export const trainerConfigs: TrainerConfigs = {
SpeciesId.BONSLY,
SpeciesId.PETILIL,
SpeciesId.SPRITZEE,
SpeciesId.BOUNSWEET,
SpeciesId.MILCERY,
SpeciesId.PICHU,
]),
@ -1888,6 +1877,7 @@ export const trainerConfigs: TrainerConfigs = {
SpeciesId.KAKUNA,
SpeciesId.SPINARAK,
SpeciesId.IGGLYBUFF,
SpeciesId.MAREEP,
SpeciesId.PALDEA_WOOPER,
SpeciesId.PHANPY,
SpeciesId.MANTYKE,
@ -1899,6 +1889,7 @@ export const trainerConfigs: TrainerConfigs = {
SpeciesId.MIME_JR,
SpeciesId.COTTONEE,
SpeciesId.SWIRLIX,
SpeciesId.FOMANTIS,
SpeciesId.FIDOUGH,
SpeciesId.EEVEE,
],
@ -3836,7 +3827,7 @@ export const trainerConfigs: TrainerConfigs = {
p.setBoss(true, 2);
}),
)
.setInstantTera(2), // Tera Fire Arcanine, Tera Grass Exeggutor, Tera Water Gyarados
.setInstantTera(1), // Tera Fire Arcanine, Tera Grass Exeggutor, Tera Water Gyarados
[TrainerType.RED]: new TrainerConfig(++t)
.initForChampion(true)
.setBattleBgm("battle_johto_champion")
@ -4025,19 +4016,14 @@ export const trainerConfigs: TrainerConfigs = {
.setBattleBgm("battle_sinnoh_champion")
.setMixedBattleBgm("battle_sinnoh_champion")
.setPartyMemberFunc(0, getRandomPartyMemberFunc([SpeciesId.SPIRITOMB]))
.setPartyMemberFunc(1, getRandomPartyMemberFunc([SpeciesId.MILOTIC, SpeciesId.ROSERADE, SpeciesId.HISUI_ARCANINE]))
.setPartyMemberFunc(
1,
getRandomPartyMemberFunc(
[SpeciesId.MILOTIC, SpeciesId.ROSERADE, SpeciesId.HISUI_ARCANINE],
TrainerSlot.TRAINER,
true,
p => {
p.generateAndPopulateMoveset();
p.teraType = p.species.type1;
},
),
2,
getRandomPartyMemberFunc([SpeciesId.TOGEKISS], TrainerSlot.TRAINER, true, p => {
p.generateAndPopulateMoveset();
p.teraType = p.species.type1;
}),
)
.setPartyMemberFunc(2, getRandomPartyMemberFunc([SpeciesId.TOGEKISS]))
.setPartyMemberFunc(3, getRandomPartyMemberFunc([SpeciesId.LUCARIO]))
.setPartyMemberFunc(
4,
@ -4056,7 +4042,7 @@ export const trainerConfigs: TrainerConfigs = {
p.setBoss(true, 2);
}),
)
.setInstantTera(1), // Tera Water Milotic / Grass Roserade / Fire Hisuian Arcanine
.setInstantTera(2), // Tera Fairy Togekiss
[TrainerType.ALDER]: new TrainerConfig(++t)
.initForChampion(true)
.setHasDouble("alder_iris_double")
@ -5809,7 +5795,7 @@ export const trainerConfigs: TrainerConfigs = {
p.generateName();
}),
)
.setInstantTera(3), // Tera Fairy Sylveon
.setInstantTera(4), // Tera Fairy Sylveon
[TrainerType.PENNY_2]: new TrainerConfig(++t)
.setName("Cassiopeia")
.initForEvilTeamLeader("Star Boss", [], true)

View File

@ -1,5 +1,5 @@
/**
* The index of a given Pokemon on-field.
* The index of a given Pokemon on-field. \
* Used as an index into `globalScene.getField`, as well as for most target-specifying effects.
*/
export enum BattlerIndex {

View File

@ -18,6 +18,11 @@ export enum ChallengeType {
* @see {@link Challenge.applyStarterPointCost}
*/
STARTER_COST,
/**
* Challenges which modify the starter data in starter select
* @see {@link Challenge.applyStarterSelectModify}
*/
STARTER_SELECT_MODIFY,
/**
* Challenges which modify your starters in some way
* @see {@link Challenge.applyStarterModify}

View File

@ -20,3 +20,6 @@ export enum PokemonType {
FAIRY,
STELLAR
}
/** The largest legal value for a {@linkcode PokemonType} (includes Stellar) */
export const MAX_POKEMON_TYPE = PokemonType.STELLAR;

View File

@ -1,3 +1,5 @@
/** Enum representing all non-volatile status effects. */
// TODO: Remove StatusEffect.FAINT
export enum StatusEffect {
NONE,
POISON,

View File

@ -8,7 +8,7 @@ import Overrides from "#app/overrides";
import type { BiomeTierTrainerPools, PokemonPools } from "#balance/biomes";
import { BiomePoolTier, biomePokemonPools, biomeTrainerPools } from "#balance/biomes";
import type { ArenaTag } from "#data/arena-tag";
import { ArenaTrapTag, getArenaTag } from "#data/arena-tag";
import { EntryHazardTag, getArenaTag } from "#data/arena-tag";
import { SpeciesFormChangeRevertWeatherFormTrigger, SpeciesFormChangeWeatherTrigger } from "#data/form-change-triggers";
import type { PokemonSpecies } from "#data/pokemon-species";
import { PositionalTagManager } from "#data/positional-tags/positional-tag-manager";
@ -709,8 +709,8 @@ export class Arena {
if (existingTag) {
existingTag.onOverlap(this, globalScene.getPokemonById(sourceId));
if (existingTag instanceof ArenaTrapTag) {
const { tagType, side, turnCount, layers, maxLayers } = existingTag as ArenaTrapTag;
if (existingTag instanceof EntryHazardTag) {
const { tagType, side, turnCount, layers, maxLayers } = existingTag as EntryHazardTag;
this.eventTarget.dispatchEvent(new TagAddedEvent(tagType, side, turnCount, layers, maxLayers));
}
@ -723,7 +723,7 @@ export class Arena {
newTag.onAdd(this, quiet);
this.tags.push(newTag);
const { layers = 0, maxLayers = 0 } = newTag instanceof ArenaTrapTag ? newTag : {};
const { layers = 0, maxLayers = 0 } = newTag instanceof EntryHazardTag ? newTag : {};
this.eventTarget.dispatchEvent(
new TagAddedEvent(newTag.tagType, newTag.side, newTag.turnCount, layers, maxLayers),

View File

@ -237,6 +237,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
public ivs: number[];
public nature: Nature;
public moveset: PokemonMove[];
/**
* This Pokemon's current {@link https://m.bulbapedia.bulbagarden.net/wiki/Status_condition#Non-volatile_status | non-volatile status condition},
* or `null` if none exist.
* @todo Make private
*/
public status: Status | null;
public friendship: number;
public metLevel: number;
@ -449,7 +454,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
getNameToRender(useIllusion = true) {
const illusion = this.summonData.illusion;
const name = useIllusion ? (illusion?.name ?? this.name) : this.name;
const nickname: string | undefined = useIllusion ? illusion?.nickname : this.nickname;
const nickname: string | undefined = useIllusion ? (illusion?.nickname ?? this.nickname) : this.nickname;
try {
if (nickname) {
return decodeURIComponent(escape(atob(nickname))); // TODO: Remove `atob` and `escape`... eventually...
@ -1647,19 +1652,6 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
return precise ? this.hp / this.getMaxHp() : Math.round((this.hp / this.getMaxHp()) * 100) / 100;
}
generateGender(): void {
if (this.species.malePercent === null) {
this.gender = Gender.GENDERLESS;
} else {
const genderChance = (this.id % 256) * 0.390625;
if (genderChance < this.species.malePercent) {
this.gender = Gender.MALE;
} else {
this.gender = Gender.FEMALE;
}
}
}
/**
* Return this Pokemon's {@linkcode Gender}.
* @param ignoreOverride - Whether to ignore any overrides caused by {@linkcode MoveId.TRANSFORM | Transform}; default `false`
@ -1776,7 +1768,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* @returns Whether this Pokemon is currently fused with another species.
*/
isFusion(useIllusion = false): boolean {
return useIllusion ? !!this.summonData.illusion?.fusionSpecies : !!this.fusionSpecies;
return !!(useIllusion ? (this.summonData.illusion?.fusionSpecies ?? this.fusionSpecies) : this.fusionSpecies);
}
/**
@ -2229,8 +2221,16 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
return this.hasPassive() && (!canApply || this.canApplyAbility(true)) && this.getPassiveAbility().hasAttr(attrType);
}
public getAbilityPriorities(): [number, number] {
return [this.getAbility().postSummonPriority, this.getPassiveAbility().postSummonPriority];
/**
* Return the ability priorities of the pokemon's ability and, if enabled, its passive ability
* @returns A tuple containing the ability priorities of the pokemon
*/
public getAbilityPriorities(): [number] | [activePriority: number, passivePriority: number] {
const abilityPriority = this.getAbility().postSummonPriority;
if (this.hasPassive()) {
return [abilityPriority, this.getPassiveAbility().postSummonPriority];
}
return [abilityPriority];
}
/**
@ -3070,14 +3070,17 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
if (this.level < levelMove[0]) {
break;
}
let weight = levelMove[0];
let weight = levelMove[0] + 20;
// Evolution Moves
if (weight === EVOLVE_MOVE) {
weight = 50;
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 ((weight === 1 && allMoves[levelMove[1]].power >= 80) || (weight === RELEARN_MOVE && this.hasTrainer())) {
weight = 40;
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]);
@ -3107,11 +3110,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
}
if (compatible && !movePool.some(m => m[0] === moveId) && !allMoves[moveId].name.endsWith(" (N)")) {
if (tmPoolTiers[moveId] === ModifierTier.COMMON && this.level >= 15) {
movePool.push([moveId, 4]);
movePool.push([moveId, 24]);
} else if (tmPoolTiers[moveId] === ModifierTier.GREAT && this.level >= 30) {
movePool.push([moveId, 8]);
movePool.push([moveId, 28]);
} else if (tmPoolTiers[moveId] === ModifierTier.ULTRA && this.level >= 50) {
movePool.push([moveId, 14]);
movePool.push([moveId, 34]);
}
}
}
@ -3121,7 +3124,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
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, 40]);
movePool.push([moveId, 60]);
}
}
const moveId = speciesEggMoves[this.species.getRootSpeciesId()][3];
@ -3132,13 +3135,13 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
!allMoves[moveId].name.endsWith(" (N)") &&
!this.isBoss()
) {
movePool.push([moveId, 30]);
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, 40]);
movePool.push([moveId, 60]);
}
}
const moveId = speciesEggMoves[this.fusionSpecies.getRootSpeciesId()][3];
@ -3149,7 +3152,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
!allMoves[moveId].name.endsWith(" (N)") &&
!this.isBoss()
) {
movePool.push([moveId, 30]);
movePool.push([moveId, 50]);
}
}
}
@ -3230,6 +3233,18 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
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);
if (attackMovePool.length) {
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) {
@ -4746,7 +4761,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* @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.
* Defaults to "other".
* Default `"other"`
*/
queueStatusImmuneMessage(
quiet: boolean,
@ -4775,15 +4790,20 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
}
/**
* Checks if a status effect can be applied to the Pokemon.
* Check if a status effect can be applied to this {@linkcode Pokemon}.
*
* @param effect The {@linkcode StatusEffect} whose applicability is being checked
* @param quiet Whether in-battle messages should trigger or not
* @param overrideStatus Whether the Pokemon's current status can be overriden
* @param sourcePokemon The Pokemon that is setting the status effect
* @param ignoreField Whether any field effects (weather, terrain, etc.) should be considered
* @param effect - The {@linkcode StatusEffect} whose applicability is being checked
* @param quiet - Whether to suppress in-battle messages for status checks; default `false`
* @param overrideStatus - Whether to allow overriding the Pokemon's current status with a different one; default `false`
* @param sourcePokemon - The {@linkcode Pokemon} applying the status effect to the target,
* or `null` if the status is applied from a non-Pokemon source (hazards, etc.); default `null`
* @param ignoreField - Whether to ignore field effects (weather, terrain, etc.) preventing status application;
* default `false`
* @returns Whether {@linkcode effect} can be applied to this Pokemon.
*/
canSetStatus(
// TODO: Review and verify the message order precedence in mainline if multiple status-blocking effects are present at once
// TODO: Make argument order consistent with `trySetStatus`
public canSetStatus(
effect: StatusEffect,
quiet = false,
overrideStatus = false,
@ -4791,7 +4811,9 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
ignoreField = false,
): boolean {
if (effect !== StatusEffect.FAINT) {
if (overrideStatus ? this.status?.effect === effect : this.status) {
// Status-overriding moves (i.e. Rest) fail if their respective status already exists;
// all other moves fail if the target already has _any_ status
if (overrideStatus ? this.status?.effect === effect : this.status || this.turnData.pendingStatus) {
this.queueStatusImmuneMessage(quiet, overrideStatus ? "overlap" : "other"); // having different status displays generic fail message
return false;
}
@ -4803,73 +4825,62 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
const types = this.getTypes(true, true);
/* Whether the target is immune to the specific status being applied. */
let isImmune = false;
/** The reason for a potential blockage; default "other" for type-based. */
let reason: "other" | Exclude<TerrainType, TerrainType.NONE> = "other";
switch (effect) {
case StatusEffect.POISON:
case StatusEffect.TOXIC: {
// Check if the Pokemon is immune to Poison/Toxic or if the source pokemon is canceling the immunity
const poisonImmunity = types.map(defType => {
// Check if the Pokemon is not immune to Poison/Toxic
case StatusEffect.TOXIC:
// Check for type based immunities and/or Corrosion from the applier.
isImmune = types.some(defType => {
// only 1 immunity needed to block
if (defType !== PokemonType.POISON && defType !== PokemonType.STEEL) {
return false;
}
// Check if the source Pokemon has an ability that cancels the Poison/Toxic immunity
// No source (such as from Toxic Spikes) = blocked by default
if (!sourcePokemon) {
return true;
}
const cancelImmunity = new BooleanHolder(false);
// TODO: Determine if we need to pass `quiet` as the value for simulated in this call
if (sourcePokemon) {
applyAbAttrs("IgnoreTypeStatusEffectImmunityAbAttr", {
pokemon: sourcePokemon,
cancelled: cancelImmunity,
statusEffect: effect,
defenderType: defType,
});
if (cancelImmunity.value) {
return false;
}
}
return true;
applyAbAttrs("IgnoreTypeStatusEffectImmunityAbAttr", {
pokemon: sourcePokemon,
cancelled: cancelImmunity,
statusEffect: effect,
defenderType: defType,
});
return !cancelImmunity.value;
});
if (this.isOfType(PokemonType.POISON) || this.isOfType(PokemonType.STEEL)) {
if (poisonImmunity.includes(true)) {
this.queueStatusImmuneMessage(quiet);
return false;
}
}
break;
}
case StatusEffect.PARALYSIS:
if (this.isOfType(PokemonType.ELECTRIC)) {
this.queueStatusImmuneMessage(quiet);
return false;
}
isImmune = this.isOfType(PokemonType.ELECTRIC);
break;
case StatusEffect.SLEEP:
if (this.isGrounded() && globalScene.arena.terrain?.terrainType === TerrainType.ELECTRIC) {
this.queueStatusImmuneMessage(quiet, TerrainType.ELECTRIC);
return false;
}
isImmune = this.isGrounded() && globalScene.arena.getTerrainType() === TerrainType.ELECTRIC;
reason = TerrainType.ELECTRIC;
break;
case StatusEffect.FREEZE:
if (
case StatusEffect.FREEZE: {
const weatherType = globalScene.arena.getWeatherType();
isImmune =
this.isOfType(PokemonType.ICE) ||
(!ignoreField &&
globalScene?.arena?.weather?.weatherType &&
[WeatherType.SUNNY, WeatherType.HARSH_SUN].includes(globalScene.arena.weather.weatherType))
) {
this.queueStatusImmuneMessage(quiet);
return false;
}
(!ignoreField && (weatherType === WeatherType.SUNNY || weatherType === WeatherType.HARSH_SUN));
break;
}
case StatusEffect.BURN:
if (this.isOfType(PokemonType.FIRE)) {
this.queueStatusImmuneMessage(quiet);
return false;
}
isImmune = this.isOfType(PokemonType.FIRE);
break;
}
if (isImmune) {
this.queueStatusImmuneMessage(quiet, reason);
return false;
}
// Check for cancellations from self/ally abilities
const cancelled = new BooleanHolder(false);
applyAbAttrs("StatusEffectImmunityAbAttr", { pokemon: this, effect, cancelled, simulated: quiet });
if (cancelled.value) {
@ -4886,14 +4897,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
source: sourcePokemon,
});
if (cancelled.value) {
break;
return false;
}
}
if (cancelled.value) {
return false;
}
// Perform safeguard checks
if (sourcePokemon && sourcePokemon !== this && this.isSafeguarded(sourcePokemon)) {
if (!quiet) {
globalScene.phaseManager.queueMessage(
@ -4906,18 +4914,36 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
return true;
}
trySetStatus(
effect?: StatusEffect,
asPhase = false,
/**
* Attempt to set this Pokemon's status to the specified condition.
* Enqueues a new `ObtainStatusEffectPhase` to trigger animations, etc.
* @param effect - The {@linkcode StatusEffect} to set
* @param sourcePokemon - The {@linkcode Pokemon} applying the status effect to the target,
* or `null` if the status is applied from a non-Pokemon source (hazards, etc.); default `null`
* @param sleepTurnsRemaining - The number of turns to set {@linkcode StatusEffect.SLEEP} for;
* defaults to a random number between 2 and 4 and is unused for non-Sleep statuses
* @param sourceText - The text to show for the source of the status effect, if any; default `null`
* @param overrideStatus - Whether to allow overriding the Pokemon's current status with a different one; default `false`
* @param quiet - Whether to suppress in-battle messages for status checks; default `true`
* @param overrideMessage - String containing text to be displayed upon status setting; defaults to normal key for status
* and is used exclusively for Rest
* @returns Whether the status effect phase was successfully created.
* @see {@linkcode doSetStatus} - alternate function that sets status immediately (albeit without condition checks).
*/
public trySetStatus(
effect: StatusEffect,
sourcePokemon: Pokemon | null = null,
turnsRemaining = 0,
sleepTurnsRemaining?: number,
sourceText: string | null = null,
overrideStatus?: boolean,
quiet = true,
overrideMessage?: string,
): boolean {
// TODO: This needs to propagate failure status for status moves
if (!effect) {
return false;
}
if (!this.canSetStatus(effect, quiet, overrideStatus, sourcePokemon)) {
return false;
}
@ -4937,50 +4963,114 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
}
}
if (asPhase) {
if (overrideStatus) {
this.resetStatus(false);
}
globalScene.phaseManager.unshiftNew(
"ObtainStatusEffectPhase",
this.getBattlerIndex(),
effect,
turnsRemaining,
sourceText,
sourcePokemon,
);
return true;
if (overrideStatus) {
this.resetStatus(false);
} else {
this.turnData.pendingStatus = effect;
}
let sleepTurnsRemaining: NumberHolder;
if (effect === StatusEffect.SLEEP) {
sleepTurnsRemaining = new NumberHolder(this.randBattleSeedIntRange(2, 4));
this.setFrameRate(4);
// If the user is invulnerable, lets remove their invulnerability when they fall asleep
const invulnerableTags = [
BattlerTagType.UNDERGROUND,
BattlerTagType.UNDERWATER,
BattlerTagType.HIDDEN,
BattlerTagType.FLYING,
];
const tag = invulnerableTags.find(t => this.getTag(t));
if (tag) {
this.removeTag(tag);
this.getMoveQueue().pop();
}
}
sleepTurnsRemaining = sleepTurnsRemaining!; // tell TS compiler it's defined
this.status = new Status(effect, 0, sleepTurnsRemaining?.value);
globalScene.phaseManager.unshiftNew(
"ObtainStatusEffectPhase",
this.getBattlerIndex(),
effect,
sourcePokemon,
sleepTurnsRemaining,
sourceText,
overrideMessage,
);
return true;
}
/**
* Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect.
* @param effect - The {@linkcode StatusEffect} to set
* @remarks
* Clears this pokemon's `pendingStatus` in its {@linkcode Pokemon.turnData | turnData}.
*
* This method does **not** check for feasibility; that is the responsibility of the caller.
*/
doSetStatus(effect: Exclude<StatusEffect, StatusEffect.SLEEP>): void;
/**
* Set this Pokemon's {@linkcode status | non-volatile status condition} to the specified effect.
* @param effect - {@linkcode StatusEffect.SLEEP}
* @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4
* @remarks
* Clears this pokemon's `pendingStatus` in its {@linkcode Pokemon#turnData}.
*
* This method does **not** check for feasibility; that is the responsibility of the caller.
*/
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
* @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4
* and is unused for all non-sleep Statuses
* @remarks
* Clears this pokemon's `pendingStatus` in its {@linkcode Pokemon#turnData}.
*
* This method does **not** check for feasibility; that is the responsibility of the caller.
*/
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
* @param sleepTurnsRemaining - The number of turns to inflict sleep for; defaults to a random number between 2 and 4
* and is unused for all non-sleep Statuses
* @remarks
* Clears this pokemon's `pendingStatus` in its {@linkcode Pokemon#turnData}.
*
* 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(
effect: StatusEffect,
sleepTurnsRemaining = effect !== StatusEffect.SLEEP ? 0 : this.randBattleSeedIntRange(2, 4),
): void {
// Reset any pending status
this.turnData.pendingStatus = StatusEffect.NONE;
switch (effect) {
case StatusEffect.POISON:
case StatusEffect.TOXIC:
this.setFrameRate(8);
break;
case StatusEffect.PARALYSIS:
this.setFrameRate(5);
break;
case StatusEffect.SLEEP: {
this.setFrameRate(3);
// If the user is semi-invulnerable when put asleep (such as due to Yawm),
// remove their invulnerability and cancel the upcoming move from the queue
const invulnTagTypes = [
BattlerTagType.FLYING,
BattlerTagType.UNDERGROUND,
BattlerTagType.UNDERWATER,
BattlerTagType.HIDDEN,
];
if (this.findTag(t => invulnTagTypes.includes(t.tagType))) {
this.findAndRemoveTags(t => invulnTagTypes.includes(t.tagType));
this.getMoveQueue().shift();
}
break;
}
case StatusEffect.FREEZE:
this.setFrameRate(0);
break;
case StatusEffect.BURN:
this.setFrameRate(14);
break;
case StatusEffect.FAINT:
break;
default:
effect satisfies StatusEffect.NONE;
break;
}
this.status = new Status(effect, 0, sleepTurnsRemaining);
}
/**
* Resets the status of a pokemon.
* @param revive Whether revive should be cured; defaults to true.
@ -5009,8 +5099,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
public clearStatus(confusion: boolean, reloadAssets: boolean) {
const lastStatus = this.status?.effect;
this.status = null;
this.setFrameRate(10);
if (lastStatus === StatusEffect.SLEEP) {
this.setFrameRate(10);
if (this.getTag(BattlerTagType.NIGHTMARE)) {
this.lapseTag(BattlerTagType.NIGHTMARE);
}

View File

@ -1,7 +1,7 @@
import { globalScene } from "#app/global-scene";
import { pokemonPrevolutions } from "#balance/pokemon-evolutions";
import { signatureSpecies } from "#balance/signature-species";
import { ArenaTrapTag } from "#data/arena-tag";
import { EntryHazardTag } from "#data/arena-tag";
import type { PokemonSpecies } from "#data/pokemon-species";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { PartyMemberStrength } from "#enums/party-member-strength";
@ -16,12 +16,9 @@ import type { PersistentModifier } from "#modifiers/modifier";
import { getIsInitialized, initI18n } from "#plugins/i18n";
import type { TrainerConfig } from "#trainers/trainer-config";
import { trainerConfigs } from "#trainers/trainer-config";
import {
TrainerPartyCompoundTemplate,
type TrainerPartyTemplate,
trainerPartyTemplates,
} from "#trainers/trainer-party-template";
import { TrainerPartyCompoundTemplate, type TrainerPartyTemplate } from "#trainers/trainer-party-template";
import { randSeedInt, randSeedItem, randSeedWeightedItem } from "#utils/common";
import { getRandomLocaleEntry } from "#utils/i18n";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { toCamelCase } from "#utils/strings";
import i18next from "i18next";
@ -35,6 +32,18 @@ export class Trainer extends Phaser.GameObjects.Container {
public partnerNameKey: string | undefined;
public originalIndexes: { [key: number]: number } = {};
/**
* Create a new Trainer.
* @param trainerType - The {@linkcode TrainerType} for this trainer, used to determine
* name, sprite, party contents and other details.
* @param variant - The {@linkcode TrainerVariant} for this trainer (if any are available)
* @param partyTemplateIndex - If provided, will override the trainer's party template with the given
* version.
* @param nameKey - If provided, will override the name key of the trainer
* @param partnerNameKey - If provided, will override the
* @param trainerConfigOverride - If provided, will override the trainer config for the given trainer type
* @todo Review how many of these parameters we actually need
*/
constructor(
trainerType: TrainerType,
variant: TrainerVariant,
@ -44,13 +53,11 @@ export class Trainer extends Phaser.GameObjects.Container {
trainerConfigOverride?: TrainerConfig,
) {
super(globalScene, -72, 80);
this.config = trainerConfigs.hasOwnProperty(trainerType)
? trainerConfigs[trainerType]
: trainerConfigs[TrainerType.ACE_TRAINER];
if (trainerConfigOverride) {
this.config = trainerConfigOverride;
}
this.config =
trainerConfigOverride ??
(trainerConfigs.hasOwnProperty(trainerType)
? trainerConfigs[trainerType]
: trainerConfigs[TrainerType.ACE_TRAINER]);
this.variant = variant;
this.partyTemplateIndex = Math.min(
@ -59,20 +66,21 @@ export class Trainer extends Phaser.GameObjects.Container {
: randSeedWeightedItem(this.config.partyTemplates.map((_, i) => i)),
this.config.partyTemplates.length - 1,
);
const classKey = `trainersCommon:${TrainerType[trainerType]}`;
// TODO: Rework this and add actual error handling for missing names
const classKey = `trainersCommon:${toCamelCase(TrainerType[trainerType])}`;
if (i18next.exists(classKey, { returnObjects: true })) {
if (nameKey) {
this.nameKey = nameKey;
this.name = i18next.t(nameKey);
} else {
const genderKey = i18next.exists(`${classKey}.MALE`)
const genderKey = i18next.exists(`${classKey}.male`)
? variant === TrainerVariant.FEMALE
? ".FEMALE"
: ".MALE"
? ".female"
: ".male"
: "";
const trainerKey = randSeedItem(Object.keys(i18next.t(`${classKey}${genderKey}`, { returnObjects: true })));
this.nameKey = `${classKey}${genderKey}.${trainerKey}`;
[this.nameKey, this.name] = getRandomLocaleEntry(`${classKey}${genderKey}`);
}
this.name = i18next.t(this.nameKey);
if (variant === TrainerVariant.DOUBLE) {
if (this.config.doubleOnly) {
if (partnerNameKey) {
@ -82,16 +90,8 @@ export class Trainer extends Phaser.GameObjects.Container {
[this.name, this.partnerName] = this.name.split(" & ");
}
} else {
const partnerGenderKey = i18next.exists(`${classKey}.FEMALE`) ? ".FEMALE" : "";
const partnerTrainerKey = randSeedItem(
Object.keys(
i18next.t(`${classKey}${partnerGenderKey}`, {
returnObjects: true,
}),
),
);
this.partnerNameKey = `${classKey}${partnerGenderKey}.${partnerTrainerKey}`;
this.partnerName = i18next.t(this.partnerNameKey);
const partnerGenderKey = i18next.exists(`${classKey}.female`) ? ".female" : "";
[this.partnerNameKey, this.partnerName] = getRandomLocaleEntry(`${classKey}${partnerGenderKey}`);
}
}
}
@ -109,10 +109,6 @@ export class Trainer extends Phaser.GameObjects.Container {
break;
}
console.log(
Object.keys(trainerPartyTemplates)[Object.values(trainerPartyTemplates).indexOf(this.getPartyTemplate())],
);
const getSprite = (hasShadow?: boolean, forceFemale?: boolean) => {
const ret = globalScene.addFieldSprite(
0,
@ -157,9 +153,9 @@ export class Trainer extends Phaser.GameObjects.Container {
/**
* Returns the name of the trainer based on the provided trainer slot and the option to include a title.
* @param {TrainerSlot} trainerSlot - The slot to determine which name to use. Defaults to TrainerSlot.NONE.
* @param {boolean} includeTitle - Whether to include the title in the returned name. Defaults to false.
* @returns {string} - The formatted name of the trainer.
* @param trainerSlot - The slot to determine which name to use; default `TrainerSlot.NONE`
* @param includeTitle - Whether to include the title in the returned name; default `false`
* @returns - The formatted name of the trainer
*/
getName(trainerSlot: TrainerSlot = TrainerSlot.NONE, includeTitle = false): string {
// Get the base title based on the trainer slot and variant.
@ -584,8 +580,8 @@ export class Trainer extends Phaser.GameObjects.Container {
score /= playerField.length;
if (forSwitch && !p.isOnField()) {
globalScene.arena
.findTagsOnSide(t => t instanceof ArenaTrapTag, ArenaTagSide.ENEMY)
.map(t => (score *= (t as ArenaTrapTag).getMatchupScoreMultiplier(p)));
.findTagsOnSide(t => t instanceof EntryHazardTag, ArenaTagSide.ENEMY)
.map(t => (score *= (t as EntryHazardTag).getMatchupScoreMultiplier(p)));
}
}

Some files were not shown because too many files have changed in this diff Show More