Compare commits

...

9 Commits

Author SHA1 Message Date
Bertie690
381e0302f9
Merge a29161c2ed into b2990aaa15 2025-08-14 19:42:54 -04:00
Sirz Benjie
b2990aaa15
[Bug] [Beta] Fix renaming runs (#6268)
Rename run name field, don't encrypt before updating
2025-08-14 16:57:01 -05:00
Bertie690
ee4950633e
[Test] Added toHaveArenaTagMatcher + fixed prior matchers (#6205)
* [Test] Added `toHaveArenaTagMatcher` + fixed prior matchers

* Fixed imports and stuff

* Removed accidental test file addition

* More improvements and minor fixes

* More semantic changes

* Shuffled a few funcs around

* More fixups to strings

* Added `toHavePositionalTag` matcher

* Applied reviews and fixed my godawful penmanship

* Fix vitest.d.ts

* Fix imports in `vitest.d.ts`

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
2025-08-14 13:16:23 -07:00
Sirz Benjie
30058ed70e
[Feature] Add per-species tracking for ribbons, show nuzlocke ribbon (#6246)
* Add tracking for nuzlocke completion

* Add ribbon to legacy ui folder

* Add tracking for friendship ribbon

* fix overlapping flag set

* Replace mass getters with a single method

* Add tracking for each generational ribbon

* Add ribbons for each challenge

* Apply Kev's suggestions from code review

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
2025-08-14 13:20:48 -05:00
Wlowscha
140e4ab142
[UI/UX] Party slots refactor (#6199)
* constants for position of discard button

* Moved transfer/discard button up in doubles

* Fixed the various `.setOrigin(0,0)`

* Small clean up

* Added `isBenched` property to slots; x origin of `slotBg` is now 0

* Also set y origin to 0

* Offsets are relevant to the same thing

* Introducing const object to store ui magic numbers

* More magic numbers in const

* Laid out numbers for slot positions

* Added smaller main slots for transfer mode in doubles

* Changed background to fit new slot disposition

* Apply suggestions from code review

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>

* Optimized PNGs

* Updated comment

* Removed "magicNumbers" container, added multiple comments

* Update src/ui/party-ui-handler.ts

Co-authored-by: Amani H. <109637146+xsn34kzx@users.noreply.github.com>

* Fainted pkmn slots displaying correctly

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>
Co-authored-by: Adri1 <adrien.grivel@hotmail.fr>
Co-authored-by: Amani H. <109637146+xsn34kzx@users.noreply.github.com>
Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
2025-08-14 13:10:15 -05:00
fabske0
76d8357d0b
[Dev] Rename OPP_ overrides to ENEMY_ (#6255)
rename `OPP_` to `ENEMY_`
2025-08-14 18:06:24 +00:00
Bertie690
a29161c2ed Removed bogus PB test 2025-08-05 22:12:35 -04:00
Bertie690
314f46a22b Fixed tests and such 2025-08-05 18:10:34 -04:00
Bertie690
4885a6abc5 Added toBeConfused matcher 2025-08-05 17:50:02 -04:00
80 changed files with 2344 additions and 1272 deletions

View File

@ -81,7 +81,7 @@ For example, here is how you could test a scenario where the player Pokemon has
```typescript ```typescript
const overrides = { const overrides = {
ABILITY_OVERRIDE: AbilityId.DROUGHT, ABILITY_OVERRIDE: AbilityId.DROUGHT,
OPP_MOVESET_OVERRIDE: MoveId.WATER_GUN, ENEMY_MOVESET_OVERRIDE: MoveId.WATER_GUN,
} satisfies Partial<InstanceType<typeof DefaultOverrides>>; } satisfies Partial<InstanceType<typeof DefaultOverrides>>;
``` ```

View File

@ -29,6 +29,7 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.0.0", "@biomejs/biome": "2.0.0",
"@ls-lint/ls-lint": "2.3.1", "@ls-lint/ls-lint": "2.3.1",
"@types/crypto-js": "^4.2.0",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"@types/node": "^22.16.5", "@types/node": "^22.16.5",
"@vitest/coverage-istanbul": "^3.2.4", "@vitest/coverage-istanbul": "^3.2.4",

View File

@ -48,6 +48,9 @@ importers:
'@ls-lint/ls-lint': '@ls-lint/ls-lint':
specifier: 2.3.1 specifier: 2.3.1
version: 2.3.1 version: 2.3.1
'@types/crypto-js':
specifier: ^4.2.0
version: 4.2.2
'@types/jsdom': '@types/jsdom':
specifier: ^21.1.7 specifier: ^21.1.7
version: 21.1.7 version: 21.1.7
@ -718,6 +721,9 @@ packages:
'@types/cookie@0.6.0': '@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
'@types/crypto-js@4.2.2':
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
'@types/deep-eql@4.0.2': '@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@ -2525,6 +2531,8 @@ snapshots:
'@types/cookie@0.6.0': {} '@types/cookie@0.6.0': {}
'@types/crypto-js@4.2.2': {}
'@types/deep-eql@4.0.2': {} '@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 837 B

After

Width:  |  Height:  |  Size: 799 B

View File

@ -0,0 +1,146 @@
{
"textures": [
{
"image": "party_slot_main_short.png",
"format": "RGBA8888",
"size": {
"w": 110,
"h": 294
},
"scale": 1,
"frames": [
{
"filename": "party_slot_main_short",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 110,
"h": 41
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 110,
"h": 41
},
"frame": {
"x": 0,
"y": 0,
"w": 110,
"h": 41
}
},
{
"filename": "party_slot_main_short_sel",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 110,
"h": 41
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 110,
"h": 41
},
"frame": {
"x": 0,
"y": 41,
"w": 110,
"h": 41
}
},
{
"filename": "party_slot_main_short_fnt",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 110,
"h": 41
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 110,
"h": 41
},
"frame": {
"x": 0,
"y": 82,
"w": 110,
"h": 41
}
},
{
"filename": "party_slot_main_short_fnt_sel",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 110,
"h": 41
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 110,
"h": 41
},
"frame": {
"x": 0,
"y": 123,
"w": 110,
"h": 41
}
},
{
"filename": "party_slot_main_short_swap",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 110,
"h": 41
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 110,
"h": 41
},
"frame": {
"x": 0,
"y": 164,
"w": 110,
"h": 41
}
},
{
"filename": "party_slot_main_short_swap_sel",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 110,
"h": 41
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 110,
"h": 41
},
"frame": {
"x": 0,
"y": 205,
"w": 110,
"h": 41
}
}
]
}
],
"meta": {
"app": "https://www.codeandweb.com/texturepacker",
"version": "3.0",
"smartupdate": "$TexturePacker:SmartUpdate:29685f2f538901cf5bf7f0ed2ea867c3:a080ea6c8cccd1e03244214053e79796:565f7afc5ca419b6ba8dbce51ea30818$"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,3 +1,5 @@
import type { RibbonData } from "#system/ribbons/ribbon-data";
export interface DexData { export interface DexData {
[key: number]: DexEntry; [key: number]: DexEntry;
} }
@ -10,4 +12,5 @@ export interface DexEntry {
caughtCount: number; caughtCount: number;
hatchedCount: number; hatchedCount: number;
ivs: number[]; ivs: number[];
ribbons: RibbonData;
} }

View File

@ -103,3 +103,12 @@ export type CoerceNullPropertiesToUndefined<T extends object> = {
* @typeParam T - The type to render partial * @typeParam T - The type to render partial
*/ */
export type AtLeastOne<T extends object> = Partial<T> & ObjectValues<{ [K in keyof T]: Pick<Required<T>, K> }>; export type AtLeastOne<T extends object> = Partial<T> & ObjectValues<{ [K in keyof T]: Pick<Required<T>, K> }>;
/** Type helper that adds a brand to a type, used for nominal typing.
*
* @remarks
* Brands should be either a string or unique symbol. This prevents overlap with other types.
*/
export declare class Brander<B> {
private __brand: B;
}

View File

@ -17,45 +17,42 @@ export function initLoggedInUser(): void {
}; };
} }
export function updateUserInfo(): Promise<[boolean, number]> { export async function updateUserInfo(): Promise<[boolean, number]> {
return new Promise<[boolean, number]>(resolve => { if (bypassLogin) {
if (bypassLogin) { loggedInUser = {
loggedInUser = { username: "Guest",
username: "Guest", lastSessionSlot: -1,
lastSessionSlot: -1, discordId: "",
discordId: "", googleId: "",
googleId: "", hasAdminRole: false,
hasAdminRole: false, };
}; let lastSessionSlot = -1;
let lastSessionSlot = -1; for (let s = 0; s < 5; s++) {
for (let s = 0; s < 5; s++) { if (localStorage.getItem(`sessionData${s ? s : ""}_${loggedInUser.username}`)) {
if (localStorage.getItem(`sessionData${s ? s : ""}_${loggedInUser.username}`)) { lastSessionSlot = s;
lastSessionSlot = s; break;
break;
}
} }
loggedInUser.lastSessionSlot = lastSessionSlot;
// Migrate old data from before the username was appended
["data", "sessionData", "sessionData1", "sessionData2", "sessionData3", "sessionData4"].map(d => {
const lsItem = localStorage.getItem(d);
if (lsItem && !!loggedInUser?.username) {
const lsUserItem = localStorage.getItem(`${d}_${loggedInUser.username}`);
if (lsUserItem) {
localStorage.setItem(`${d}_${loggedInUser.username}_bak`, lsUserItem);
}
localStorage.setItem(`${d}_${loggedInUser.username}`, lsItem);
localStorage.removeItem(d);
}
});
return resolve([true, 200]);
} }
pokerogueApi.account.getInfo().then(([accountInfo, status]) => { loggedInUser.lastSessionSlot = lastSessionSlot;
if (!accountInfo) { // Migrate old data from before the username was appended
resolve([false, status]); ["data", "sessionData", "sessionData1", "sessionData2", "sessionData3", "sessionData4"].forEach(d => {
return; const lsItem = localStorage.getItem(d);
if (lsItem && !!loggedInUser?.username) {
const lsUserItem = localStorage.getItem(`${d}_${loggedInUser.username}`);
if (lsUserItem) {
localStorage.setItem(`${d}_${loggedInUser.username}_bak`, lsUserItem);
}
localStorage.setItem(`${d}_${loggedInUser.username}`, lsItem);
localStorage.removeItem(d);
} }
loggedInUser = accountInfo;
resolve([true, 200]);
}); });
}); return [true, 200];
}
const [accountInfo, status] = await pokerogueApi.account.getInfo();
if (!accountInfo) {
return [false, status];
}
loggedInUser = accountInfo;
return [true, 200];
} }

View File

@ -944,17 +944,17 @@ export class BattleScene extends SceneBase {
dataSource?: PokemonData, dataSource?: PokemonData,
postProcess?: (enemyPokemon: EnemyPokemon) => void, postProcess?: (enemyPokemon: EnemyPokemon) => void,
): EnemyPokemon { ): EnemyPokemon {
if (Overrides.OPP_LEVEL_OVERRIDE > 0) { if (Overrides.ENEMY_LEVEL_OVERRIDE > 0) {
level = Overrides.OPP_LEVEL_OVERRIDE; level = Overrides.ENEMY_LEVEL_OVERRIDE;
} }
if (Overrides.OPP_SPECIES_OVERRIDE) { if (Overrides.ENEMY_SPECIES_OVERRIDE) {
species = getPokemonSpecies(Overrides.OPP_SPECIES_OVERRIDE); species = getPokemonSpecies(Overrides.ENEMY_SPECIES_OVERRIDE);
// The fact that a Pokemon is a boss or not can change based on its Species and level // The fact that a Pokemon is a boss or not can change based on its Species and level
boss = this.getEncounterBossSegments(this.currentBattle.waveIndex, level, species) > 1; boss = this.getEncounterBossSegments(this.currentBattle.waveIndex, level, species) > 1;
} }
const pokemon = new EnemyPokemon(species, level, trainerSlot, boss, shinyLock, dataSource); const pokemon = new EnemyPokemon(species, level, trainerSlot, boss, shinyLock, dataSource);
if (Overrides.OPP_FUSION_OVERRIDE) { if (Overrides.ENEMY_FUSION_OVERRIDE) {
pokemon.generateFusionSpecies(); pokemon.generateFusionSpecies();
} }
@ -1766,10 +1766,10 @@ export class BattleScene extends SceneBase {
} }
getEncounterBossSegments(waveIndex: number, level: number, species?: PokemonSpecies, forceBoss = false): number { getEncounterBossSegments(waveIndex: number, level: number, species?: PokemonSpecies, forceBoss = false): number {
if (Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE > 1) { if (Overrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE > 1) {
return Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE; return Overrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE;
} }
if (Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE === 1) { if (Overrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE === 1) {
// The rest of the code expects to be returned 0 and not 1 if the enemy is not a boss // The rest of the code expects to be returned 0 and not 1 if the enemy is not a boss
return 0; return 0;
} }

View File

@ -101,3 +101,9 @@ export const ANTI_VARIANCE_WEIGHT_MODIFIER = 15;
* Default: `10000` (0.01%) * Default: `10000` (0.01%)
*/ */
export const FAKE_TITLE_LOGO_CHANCE = 10000; export const FAKE_TITLE_LOGO_CHANCE = 10000;
/**
* The ceiling on friendship amount that can be reached through the use of rare candies.
* Using rare candies will never increase friendship beyond this value.
*/
export const RARE_CANDY_FRIENDSHIP_CAP = 200;

View File

@ -1152,13 +1152,16 @@ export class GravityTag extends SerializableArenaTag {
onAdd(_arena: Arena): void { onAdd(_arena: Arena): void {
globalScene.phaseManager.queueMessage(i18next.t("arenaTag:gravityOnAdd")); globalScene.phaseManager.queueMessage(i18next.t("arenaTag:gravityOnAdd"));
// Remove all flying-related effects from all on-field Pokemon.
globalScene.getField(true).forEach(pokemon => { globalScene.getField(true).forEach(pokemon => {
if (pokemon !== null) { pokemon.removeTag(BattlerTagType.FLOATING);
pokemon.removeTag(BattlerTagType.FLOATING); pokemon.removeTag(BattlerTagType.TELEKINESIS);
pokemon.removeTag(BattlerTagType.TELEKINESIS); if (pokemon.getTag(BattlerTagType.FLYING)) {
if (pokemon.getTag(BattlerTagType.FLYING)) { pokemon.removeTag(BattlerTagType.FLYING);
pokemon.addTag(BattlerTagType.INTERRUPTED); // TODO: This is an extremely poor way of handling move interruption
}
pokemon.addTag(BattlerTagType.INTERRUPTED);
} }
}); });
} }

View File

@ -736,6 +736,10 @@ export class FlinchedTag extends BattlerTag {
} }
} }
/**
* Tag to cancel the target's action when knocked out of a flying move by Smack Down or Gravity.
*/
// TODO: This is not a very good way to cancel a semi invulnerable turn
export class InterruptedTag extends BattlerTag { export class InterruptedTag extends BattlerTag {
public override readonly tagType = BattlerTagType.INTERRUPTED; public override readonly tagType = BattlerTagType.INTERRUPTED;
constructor(sourceMove: MoveId) { constructor(sourceMove: MoveId) {
@ -765,7 +769,7 @@ export class InterruptedTag extends BattlerTag {
} }
/** /**
* BattlerTag that represents the {@link https://bulbapedia.bulbagarden.net/wiki/Confusion_(status_condition) Confusion} status condition * BattlerTag that represents the {@link https://bulbapedia.bulbagarden.net/wiki/Confusion_(status_condition) | Confusion} status condition
*/ */
export class ConfusedTag extends SerializableBattlerTag { export class ConfusedTag extends SerializableBattlerTag {
public override readonly tagType = BattlerTagType.CONFUSED; public override readonly tagType = BattlerTagType.CONFUSED;
@ -774,8 +778,9 @@ export class ConfusedTag extends SerializableBattlerTag {
} }
canAdd(pokemon: Pokemon): boolean { canAdd(pokemon: Pokemon): boolean {
const blockedByTerrain = pokemon.isGrounded() && globalScene.arena.terrain?.terrainType === TerrainType.MISTY; const blockedByTerrain = pokemon.isGrounded() && globalScene.arena.getTerrainType() === TerrainType.MISTY;
if (blockedByTerrain) { if (blockedByTerrain) {
// TODO: this should not trigger if the current move is an attacking move
pokemon.queueStatusImmuneMessage(false, TerrainType.MISTY); pokemon.queueStatusImmuneMessage(false, TerrainType.MISTY);
return false; return false;
} }

View File

@ -20,6 +20,7 @@ import { Trainer } from "#field/trainer";
import type { ModifierTypeOption } from "#modifiers/modifier-type"; import type { ModifierTypeOption } from "#modifiers/modifier-type";
import { PokemonMove } from "#moves/pokemon-move"; import { PokemonMove } from "#moves/pokemon-move";
import type { DexAttrProps, GameData } from "#system/game-data"; import type { DexAttrProps, GameData } from "#system/game-data";
import { RibbonData, type RibbonFlag } from "#system/ribbons/ribbon-data";
import { type BooleanHolder, isBetween, type NumberHolder, randSeedItem } from "#utils/common"; import { type BooleanHolder, isBetween, type NumberHolder, randSeedItem } from "#utils/common";
import { deepCopy } from "#utils/data"; import { deepCopy } from "#utils/data";
import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils"; import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils";
@ -42,6 +43,15 @@ export abstract class Challenge {
public conditions: ChallengeCondition[]; public conditions: ChallengeCondition[];
/**
* The Ribbon awarded on challenge completion, or 0 if the challenge has no ribbon or is not enabled
*
* @defaultValue 0
*/
public get ribbonAwarded(): RibbonFlag {
return 0 as RibbonFlag;
}
/** /**
* @param id {@link Challenges} The enum value for the challenge * @param id {@link Challenges} The enum value for the challenge
*/ */
@ -423,6 +433,12 @@ type ChallengeCondition = (data: GameData) => boolean;
* Implements a mono generation challenge. * Implements a mono generation challenge.
*/ */
export class SingleGenerationChallenge extends Challenge { export class SingleGenerationChallenge extends Challenge {
public override get ribbonAwarded(): RibbonFlag {
// NOTE: This logic will not work for the eventual mono gen 10 ribbon, as
// as its flag will not be in sequence with the other mono gen ribbons.
return this.value ? ((RibbonData.MONO_GEN_1 << (this.value - 1)) as RibbonFlag) : 0;
}
constructor() { constructor() {
super(Challenges.SINGLE_GENERATION, 9); super(Challenges.SINGLE_GENERATION, 9);
} }
@ -686,6 +702,12 @@ interface monotypeOverride {
* Implements a mono type challenge. * Implements a mono type challenge.
*/ */
export class SingleTypeChallenge extends Challenge { export class SingleTypeChallenge extends Challenge {
public override get ribbonAwarded(): RibbonFlag {
// `this.value` represents the 1-based index of pokemon type
// `RibbonData.MONO_NORMAL` starts the flag position for the types,
// and we shift it by 1 for the specific type.
return this.value ? ((RibbonData.MONO_NORMAL << (this.value - 1)) as RibbonFlag) : 0;
}
private static TYPE_OVERRIDES: monotypeOverride[] = [ private static TYPE_OVERRIDES: monotypeOverride[] = [
{ species: SpeciesId.CASTFORM, type: PokemonType.NORMAL, fusion: false }, { species: SpeciesId.CASTFORM, type: PokemonType.NORMAL, fusion: false },
]; ];
@ -755,6 +777,9 @@ export class SingleTypeChallenge extends Challenge {
* Implements a fresh start challenge. * Implements a fresh start challenge.
*/ */
export class FreshStartChallenge extends Challenge { export class FreshStartChallenge extends Challenge {
public override get ribbonAwarded(): RibbonFlag {
return this.value ? RibbonData.FRESH_START : 0;
}
constructor() { constructor() {
super(Challenges.FRESH_START, 2); super(Challenges.FRESH_START, 2);
} }
@ -828,6 +853,9 @@ export class FreshStartChallenge extends Challenge {
* Implements an inverse battle challenge. * Implements an inverse battle challenge.
*/ */
export class InverseBattleChallenge extends Challenge { export class InverseBattleChallenge extends Challenge {
public override get ribbonAwarded(): RibbonFlag {
return this.value ? RibbonData.INVERSE : 0;
}
constructor() { constructor() {
super(Challenges.INVERSE_BATTLE, 1); super(Challenges.INVERSE_BATTLE, 1);
} }
@ -861,6 +889,9 @@ export class InverseBattleChallenge extends Challenge {
* Implements a flip stat challenge. * Implements a flip stat challenge.
*/ */
export class FlipStatChallenge extends Challenge { export class FlipStatChallenge extends Challenge {
public override get ribbonAwarded(): RibbonFlag {
return this.value ? RibbonData.FLIP_STATS : 0;
}
constructor() { constructor() {
super(Challenges.FLIP_STAT, 1); super(Challenges.FLIP_STAT, 1);
} }
@ -941,6 +972,9 @@ export class LowerStarterPointsChallenge extends Challenge {
* Implements a No Support challenge * Implements a No Support challenge
*/ */
export class LimitedSupportChallenge extends Challenge { export class LimitedSupportChallenge extends Challenge {
public override get ribbonAwarded(): RibbonFlag {
return this.value ? ((RibbonData.NO_HEAL << (this.value - 1)) as RibbonFlag) : 0;
}
constructor() { constructor() {
super(Challenges.LIMITED_SUPPORT, 3); super(Challenges.LIMITED_SUPPORT, 3);
} }
@ -973,6 +1007,9 @@ export class LimitedSupportChallenge extends Challenge {
* Implements a Limited Catch challenge * Implements a Limited Catch challenge
*/ */
export class LimitedCatchChallenge extends Challenge { export class LimitedCatchChallenge extends Challenge {
public override get ribbonAwarded(): RibbonFlag {
return this.value ? RibbonData.LIMITED_CATCH : 0;
}
constructor() { constructor() {
super(Challenges.LIMITED_CATCH, 1); super(Challenges.LIMITED_CATCH, 1);
} }
@ -997,6 +1034,9 @@ export class LimitedCatchChallenge extends Challenge {
* Implements a Permanent Faint challenge * Implements a Permanent Faint challenge
*/ */
export class HardcoreChallenge extends Challenge { export class HardcoreChallenge extends Challenge {
public override get ribbonAwarded(): RibbonFlag {
return this.value ? RibbonData.HARDCORE : 0;
}
constructor() { constructor() {
super(Challenges.HARDCORE, 1); super(Challenges.HARDCORE, 1);
} }

View File

@ -47,6 +47,7 @@ export class EggHatchData {
caughtCount: currDexEntry.caughtCount, caughtCount: currDexEntry.caughtCount,
hatchedCount: currDexEntry.hatchedCount, hatchedCount: currDexEntry.hatchedCount,
ivs: [...currDexEntry.ivs], ivs: [...currDexEntry.ivs],
ribbons: currDexEntry.ribbons,
}; };
this.starterDataEntryBeforeUpdate = { this.starterDataEntryBeforeUpdate = {
moveset: currStarterDataEntry.moveset, moveset: currStarterDataEntry.moveset,

View File

@ -1536,6 +1536,7 @@ export class CritOnlyAttr extends MoveAttr {
} }
} }
// TODO: Fix subclasses to actually extend from `getDamage`
export class FixedDamageAttr extends MoveAttr { export class FixedDamageAttr extends MoveAttr {
private damage: number; private damage: number;
@ -5372,13 +5373,11 @@ export class VariableMoveTypeMultiplierAttr extends MoveAttr {
} }
export class NeutralDamageAgainstFlyingTypeMultiplierAttr extends VariableMoveTypeMultiplierAttr { export class NeutralDamageAgainstFlyingTypeMultiplierAttr extends VariableMoveTypeMultiplierAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { apply(user: Pokemon, target: Pokemon, move: Move, args: [NumberHolder]): boolean {
if (!target.getTag(BattlerTagType.IGNORE_FLYING)) { if (!target.isGrounded(true) && target.isOfType(PokemonType.FLYING)) {
const multiplier = args[0] as NumberHolder; const multiplier = args[0];
//When a flying type is hit, the first hit is always 1x multiplier. // When a flying type is hit, the first hit is always 1x multiplier.
if (target.isOfType(PokemonType.FLYING)) { multiplier.value = 1;
multiplier.value = 1;
}
return true; return true;
} }
@ -5617,13 +5616,13 @@ export class AddBattlerTagAttr extends MoveEffectAttr {
protected cancelOnFail: boolean; protected cancelOnFail: boolean;
private failOnOverlap: boolean; private failOnOverlap: boolean;
constructor(tagType: BattlerTagType, selfTarget: boolean = false, failOnOverlap: boolean = false, turnCountMin: number = 0, turnCountMax?: number, lastHitOnly: boolean = false) { constructor(tagType: BattlerTagType, selfTarget = false, failOnOverlap = false, turnCountMin: number = 0, turnCountMax = turnCountMin, lastHitOnly = false) {
super(selfTarget, { lastHitOnly: lastHitOnly }); super(selfTarget, { lastHitOnly: lastHitOnly });
this.tagType = tagType; this.tagType = tagType;
this.turnCountMin = turnCountMin; this.turnCountMin = turnCountMin;
this.turnCountMax = turnCountMax !== undefined ? turnCountMax : turnCountMin; this.turnCountMax = turnCountMax;
this.failOnOverlap = !!failOnOverlap; this.failOnOverlap = failOnOverlap;
} }
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
@ -5633,13 +5632,14 @@ export class AddBattlerTagAttr extends MoveEffectAttr {
const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true); const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true);
if (moveChance < 0 || moveChance === 100 || user.randBattleSeedInt(100) < moveChance) { if (moveChance < 0 || moveChance === 100 || user.randBattleSeedInt(100) < moveChance) {
return (this.selfTarget ? user : target).addTag(this.tagType, user.randBattleSeedIntRange(this.turnCountMin, this.turnCountMax), move.id, user.id); return (this.selfTarget ? user : target).addTag(this.tagType, user.randBattleSeedIntRange(this.turnCountMin, this.turnCountMax), move.id, user.id);
} }
return false; return false;
} }
getCondition(): MoveConditionFunc | null { getCondition(): MoveConditionFunc | null {
// TODO: This should consider whether the tag can be added
return this.failOnOverlap return this.failOnOverlap
? (user, target, move) => !(this.selfTarget ? user : target).getTag(this.tagType) ? (user, target, move) => !(this.selfTarget ? user : target).getTag(this.tagType)
: null; : null;
@ -5717,8 +5717,10 @@ export class LeechSeedAttr extends AddBattlerTagAttr {
} }
/** /**
* Adds the appropriate battler tag for Smack Down and Thousand arrows * Attribute to add the {@linkcode BattlerTagType.IGNORE_FLYING | IGNORE_FLYING} battler tag to the target
* @extends AddBattlerTagAttr * and remove any prior sources of ungroundedness.
*
* Does nothing if the target was not already ungrounded.
*/ */
export class FallDownAttr extends AddBattlerTagAttr { export class FallDownAttr extends AddBattlerTagAttr {
constructor() { constructor() {
@ -5726,18 +5728,35 @@ export class FallDownAttr extends AddBattlerTagAttr {
} }
/** /**
* Adds Grounded Tag to the target and checks if fallDown message should be displayed * Add `GroundedTag` to the target, remove all prior sources of ungroundedness
* @param user the {@linkcode Pokemon} using the move * and display a message.
* @param target the {@linkcode Pokemon} targeted by the move * @param user - The {@linkcode Pokemon} using the move
* @param move the {@linkcode Move} invoking this effect * @param target - The {@linkcode Pokemon} targeted by the move
* @param move - The {@linkcode Move} invoking this effect
* @param args n/a * @param args n/a
* @returns `true` if the effect successfully applies; `false` otherwise * @returns Whether the target was successfully brought down to earth.
*
*/ */
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean {
if (!target.isGrounded()) { // Smack down and similar only add their tag if the target is already ungrounded,
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:fallDown", { targetPokemonName: getPokemonNameWithAffix(target) })); // barring any prior semi-invulnerability.
if (target.isGrounded(true)) {
return false;
} }
return super.apply(user, target, move, args);
// Remove the target's prior sources of ungroundedness.
// NB: These effects cannot simply be part of the tag's `onAdd` effect as Ingrain also adds the tag
// but does not remove Telekinesis' accuracy boost
target.removeTag(BattlerTagType.FLOATING);
target.removeTag(BattlerTagType.TELEKINESIS);
if (target.getTag(BattlerTagType.FLYING)) {
target.removeTag(BattlerTagType.FLYING);
// TODO: This is an extremely poor way of handling move interruption
target.addTag(BattlerTagType.INTERRUPTED);
}
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:fallDown", { targetPokemonName: getPokemonNameWithAffix(target) }));
return super.apply(user, target, move, _args);
} }
} }
@ -5841,6 +5860,7 @@ export class CurseAttr extends MoveEffectAttr {
} }
} }
// TODO: Delete this and make mortal spin use `RemoveBattlerTagAttr`
export class LapseBattlerTagAttr extends MoveEffectAttr { export class LapseBattlerTagAttr extends MoveEffectAttr {
public tagTypes: BattlerTagType[]; public tagTypes: BattlerTagType[];
@ -8040,7 +8060,12 @@ const phaseForcedSlower = (phase: MovePhase, target: Pokemon, trickRoom: boolean
return phase.isForcedLast() && slower; return phase.isForcedLast() && slower;
}; };
const failOnGravityCondition: MoveConditionFunc = (user, target, move) => !globalScene.arena.getTag(ArenaTagType.GRAVITY); // #region Condition functions
// TODO: This needs to become unselectable, not merely fail
const failOnGravityCondition: MoveConditionFunc = () => !globalScene.arena.getTag(ArenaTagType.GRAVITY);
const failOnGroundedCondition: MoveConditionFunc = (_user, target) => !target.getTag(BattlerTagType.IGNORE_FLYING);
const failOnBossCondition: MoveConditionFunc = (user, target, move) => !target.isBossImmune(); const failOnBossCondition: MoveConditionFunc = (user, target, move) => !target.isBossImmune();
@ -8071,6 +8096,10 @@ const failIfGhostTypeCondition: MoveConditionFunc = (user: Pokemon, target: Poke
const failIfNoTargetHeldItemsCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => target.getHeldItems().filter(i => i.isTransferable)?.length > 0; const failIfNoTargetHeldItemsCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => target.getHeldItems().filter(i => i.isTransferable)?.length > 0;
const unknownTypeCondition: MoveConditionFunc = (user, target, move) => !user.getTypes().includes(PokemonType.UNKNOWN);
// #endregion Condition functions
const attackedByItemMessageFunc = (user: Pokemon, target: Pokemon, move: Move) => { const attackedByItemMessageFunc = (user: Pokemon, target: Pokemon, move: Move) => {
const heldItems = target.getHeldItems().filter(i => i.isTransferable); const heldItems = target.getHeldItems().filter(i => i.isTransferable);
if (heldItems.length === 0) { if (heldItems.length === 0) {
@ -8252,9 +8281,6 @@ export class ExposedMoveAttr extends AddBattlerTagAttr {
} }
} }
const unknownTypeCondition: MoveConditionFunc = (user, target, move) => !user.getTypes().includes(PokemonType.UNKNOWN);
export type MoveTargetSet = { export type MoveTargetSet = {
targets: BattlerIndex[]; targets: BattlerIndex[];
multiple: boolean; multiple: boolean;
@ -8901,7 +8927,6 @@ export function initMoves() {
.ignoresProtect() .ignoresProtect()
/* Transform: /* Transform:
* Does not copy the target's rage fist hit count * Does not copy the target's rage fist hit count
* Does not copy the target's volatile status conditions (ie BattlerTags)
* Renders user typeless when copying typeless opponent (should revert to original typing) * Renders user typeless when copying typeless opponent (should revert to original typing)
*/ */
.edgeCase(), .edgeCase(),
@ -9356,6 +9381,7 @@ export function initMoves() {
.attr(RandomMovesetMoveAttr, invalidAssistMoves, true), .attr(RandomMovesetMoveAttr, invalidAssistMoves, true),
new SelfStatusMove(MoveId.INGRAIN, PokemonType.GRASS, -1, 20, -1, 0, 3) new SelfStatusMove(MoveId.INGRAIN, PokemonType.GRASS, -1, 20, -1, 0, 3)
.attr(AddBattlerTagAttr, BattlerTagType.INGRAIN, true, true) .attr(AddBattlerTagAttr, BattlerTagType.INGRAIN, true, true)
// NB: We add IGNORE_FLYING and remove floating tag directly to avoid removing Telekinesis' accuracy boost
.attr(AddBattlerTagAttr, BattlerTagType.IGNORE_FLYING, true, true) .attr(AddBattlerTagAttr, BattlerTagType.IGNORE_FLYING, true, true)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.FLOATING ], true), .attr(RemoveBattlerTagAttr, [ BattlerTagType.FLOATING ], true),
new AttackMove(MoveId.SUPERPOWER, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 3) new AttackMove(MoveId.SUPERPOWER, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 3)
@ -9613,9 +9639,9 @@ export function initMoves() {
.attr(AddBattlerTagAttr, BattlerTagType.ROOSTED, true, false) .attr(AddBattlerTagAttr, BattlerTagType.ROOSTED, true, false)
.triageMove(), .triageMove(),
new StatusMove(MoveId.GRAVITY, PokemonType.PSYCHIC, -1, 5, -1, 0, 4) new StatusMove(MoveId.GRAVITY, PokemonType.PSYCHIC, -1, 5, -1, 0, 4)
.ignoresProtect()
.attr(AddArenaTagAttr, ArenaTagType.GRAVITY, 5) .attr(AddArenaTagAttr, ArenaTagType.GRAVITY, 5)
.target(MoveTarget.BOTH_SIDES), .target(MoveTarget.BOTH_SIDES)
.ignoresProtect(),
new StatusMove(MoveId.MIRACLE_EYE, PokemonType.PSYCHIC, -1, 40, -1, 0, 4) new StatusMove(MoveId.MIRACLE_EYE, PokemonType.PSYCHIC, -1, 40, -1, 0, 4)
.attr(ExposedMoveAttr, BattlerTagType.IGNORE_DARK) .attr(ExposedMoveAttr, BattlerTagType.IGNORE_DARK)
.ignoresSubstitute() .ignoresSubstitute()
@ -9745,7 +9771,8 @@ export function initMoves() {
.attr(AddBattlerTagAttr, BattlerTagType.AQUA_RING, true, true), .attr(AddBattlerTagAttr, BattlerTagType.AQUA_RING, true, true),
new SelfStatusMove(MoveId.MAGNET_RISE, PokemonType.ELECTRIC, -1, 10, -1, 0, 4) new SelfStatusMove(MoveId.MAGNET_RISE, PokemonType.ELECTRIC, -1, 10, -1, 0, 4)
.attr(AddBattlerTagAttr, BattlerTagType.FLOATING, true, true, 5) .attr(AddBattlerTagAttr, BattlerTagType.FLOATING, true, true, 5)
.condition((user, target, move) => !globalScene.arena.getTag(ArenaTagType.GRAVITY) && [ BattlerTagType.FLOATING, BattlerTagType.IGNORE_FLYING, BattlerTagType.INGRAIN ].every((tag) => !user.getTag(tag))), .condition(failOnGravityCondition)
.condition(failOnGroundedCondition),
new AttackMove(MoveId.FLARE_BLITZ, PokemonType.FIRE, MoveCategory.PHYSICAL, 120, 100, 15, 10, 0, 4) new AttackMove(MoveId.FLARE_BLITZ, PokemonType.FIRE, MoveCategory.PHYSICAL, 120, 100, 15, 10, 0, 4)
.attr(RecoilAttr, false, 0.33) .attr(RecoilAttr, false, 0.33)
.attr(HealStatusEffectAttr, true, StatusEffect.FREEZE) .attr(HealStatusEffectAttr, true, StatusEffect.FREEZE)
@ -9974,12 +10001,12 @@ export function initMoves() {
.powderMove() .powderMove()
.attr(AddBattlerTagAttr, BattlerTagType.CENTER_OF_ATTENTION, true), .attr(AddBattlerTagAttr, BattlerTagType.CENTER_OF_ATTENTION, true),
new StatusMove(MoveId.TELEKINESIS, PokemonType.PSYCHIC, -1, 15, -1, 0, 5) new StatusMove(MoveId.TELEKINESIS, PokemonType.PSYCHIC, -1, 15, -1, 0, 5)
.condition(failOnGravityCondition)
.condition((_user, target, _move) => ![ SpeciesId.DIGLETT, SpeciesId.DUGTRIO, SpeciesId.ALOLA_DIGLETT, SpeciesId.ALOLA_DUGTRIO, SpeciesId.SANDYGAST, SpeciesId.PALOSSAND, SpeciesId.WIGLETT, SpeciesId.WUGTRIO ].includes(target.species.speciesId))
.condition((_user, target, _move) => !(target.species.speciesId === SpeciesId.GENGAR && target.getFormKey() === "mega"))
.condition((_user, target, _move) => isNullOrUndefined(target.getTag(BattlerTagType.INGRAIN)) && isNullOrUndefined(target.getTag(BattlerTagType.IGNORE_FLYING)))
.attr(AddBattlerTagAttr, BattlerTagType.TELEKINESIS, false, true, 3) .attr(AddBattlerTagAttr, BattlerTagType.TELEKINESIS, false, true, 3)
.attr(AddBattlerTagAttr, BattlerTagType.FLOATING, false, true, 3) .attr(AddBattlerTagAttr, BattlerTagType.FLOATING, false, true, 3)
.condition((_user, target, _move) => ![ SpeciesId.DIGLETT, SpeciesId.DUGTRIO, SpeciesId.ALOLA_DIGLETT, SpeciesId.ALOLA_DUGTRIO, SpeciesId.SANDYGAST, SpeciesId.PALOSSAND, SpeciesId.WIGLETT, SpeciesId.WUGTRIO ].includes(target.species.speciesId))
.condition((_user, target, _move) => !(target.species.speciesId === SpeciesId.GENGAR && target.getFormKey() === "mega"))
.condition(failOnGravityCondition)
.condition(failOnGroundedCondition)
.reflectable(), .reflectable(),
new StatusMove(MoveId.MAGIC_ROOM, PokemonType.PSYCHIC, -1, 10, -1, 0, 5) new StatusMove(MoveId.MAGIC_ROOM, PokemonType.PSYCHIC, -1, 10, -1, 0, 5)
.ignoresProtect() .ignoresProtect()
@ -9987,8 +10014,6 @@ export function initMoves() {
.unimplemented(), .unimplemented(),
new AttackMove(MoveId.SMACK_DOWN, PokemonType.ROCK, MoveCategory.PHYSICAL, 50, 100, 15, -1, 0, 5) new AttackMove(MoveId.SMACK_DOWN, PokemonType.ROCK, MoveCategory.PHYSICAL, 50, 100, 15, -1, 0, 5)
.attr(FallDownAttr) .attr(FallDownAttr)
.attr(AddBattlerTagAttr, BattlerTagType.INTERRUPTED)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.FLYING, BattlerTagType.FLOATING, BattlerTagType.TELEKINESIS ])
.attr(HitsTagAttr, BattlerTagType.FLYING) .attr(HitsTagAttr, BattlerTagType.FLYING)
.makesContact(false), .makesContact(false),
new AttackMove(MoveId.STORM_THROW, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 5) new AttackMove(MoveId.STORM_THROW, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 5)
@ -10450,8 +10475,6 @@ export function initMoves() {
.attr(FallDownAttr) .attr(FallDownAttr)
.attr(HitsTagAttr, BattlerTagType.FLYING) .attr(HitsTagAttr, BattlerTagType.FLYING)
.attr(HitsTagAttr, BattlerTagType.FLOATING) .attr(HitsTagAttr, BattlerTagType.FLOATING)
.attr(AddBattlerTagAttr, BattlerTagType.INTERRUPTED)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.FLYING, BattlerTagType.FLOATING, BattlerTagType.TELEKINESIS ])
.makesContact(false) .makesContact(false)
.target(MoveTarget.ALL_NEAR_ENEMIES), .target(MoveTarget.ALL_NEAR_ENEMIES),
new AttackMove(MoveId.THOUSAND_WAVES, PokemonType.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, 100, 0, 6) new AttackMove(MoveId.THOUSAND_WAVES, PokemonType.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, 100, 0, 6)

View File

@ -11,7 +11,7 @@ import { BooleanHolder, toDmgValue } from "#utils/common";
* These are the moves assigned to a {@linkcode Pokemon} object. * These are the moves assigned to a {@linkcode Pokemon} object.
* It links to {@linkcode Move} class via the move ID. * It links to {@linkcode Move} class via the move ID.
* Compared to {@linkcode Move}, this class also tracks things like * Compared to {@linkcode Move}, this class also tracks things like
* PP Ups recieved, PP used, etc. * PP Ups received, PP used, etc.
* @see {@linkcode isUsable} - checks if move is restricted, out of PP, or not implemented. * @see {@linkcode isUsable} - checks if move is restricted, out of PP, or not implemented.
* @see {@linkcode getMove} - returns {@linkcode Move} object by looking it up via ID. * @see {@linkcode getMove} - returns {@linkcode Move} object by looking it up via ID.
* @see {@linkcode usePp} - removes a point of PP from the move. * @see {@linkcode usePp} - removes a point of PP from the move.

View File

@ -23,17 +23,21 @@ export class Terrain {
public terrainType: TerrainType; public terrainType: TerrainType;
public turnsLeft: number; public turnsLeft: number;
constructor(terrainType: TerrainType, turnsLeft?: number) { constructor(terrainType: TerrainType, turnsLeft = 0) {
this.terrainType = terrainType; this.terrainType = terrainType;
this.turnsLeft = turnsLeft || 0; this.turnsLeft = turnsLeft;
} }
/**
* Tick down this terrain's duration.
* @returns Whether the current terrain should remain active (`turnsLeft > 0`)
*/
lapse(): boolean { lapse(): boolean {
if (this.turnsLeft) { // TODO: Add separate flag for infinite duration terrains
return !!--this.turnsLeft; if (this.turnsLeft <= 0) {
return true;
} }
return --this.turnsLeft > 0;
return true;
} }
getAttackTypeMultiplier(attackType: PokemonType): number { getAttackTypeMultiplier(attackType: PokemonType): number {

View File

@ -20,20 +20,25 @@ export class Weather {
public weatherType: WeatherType; public weatherType: WeatherType;
public turnsLeft: number; public turnsLeft: number;
constructor(weatherType: WeatherType, turnsLeft?: number) { constructor(weatherType: WeatherType, turnsLeft = 0) {
this.weatherType = weatherType; this.weatherType = weatherType;
this.turnsLeft = !this.isImmutable() ? turnsLeft || 0 : 0; this.turnsLeft = this.isImmutable() ? 0 : turnsLeft;
} }
/**
* Tick down this weather's duration.
* @returns Whether the current weather should remain active (`turnsLeft > 0`)
*/
lapse(): boolean { lapse(): boolean {
if (this.isImmutable()) { if (this.isImmutable()) {
return true; return true;
} }
if (this.turnsLeft) {
return !!--this.turnsLeft; if (this.turnsLeft <= 0) {
return true;
} }
return true; return --this.turnsLeft > 0;
} }
isImmutable(): boolean { isImmutable(): boolean {
@ -127,6 +132,7 @@ export class Weather {
} }
} }
// TODO: These functions should return empty strings instead of `null` - requires bangs
export function getWeatherStartMessage(weatherType: WeatherType): string | null { export function getWeatherStartMessage(weatherType: WeatherType): string | null {
switch (weatherType) { switch (weatherType) {
case WeatherType.SUNNY: case WeatherType.SUNNY:

View File

@ -288,20 +288,18 @@ export class Arena {
} }
/** /**
* Sets weather to the override specified in overrides.ts * Sets weather to the override specified in overrides.ts`
* @param weather new {@linkcode WeatherType} to set
* @returns true to force trySetWeather to return true
*/ */
trySetWeatherOverride(weather: WeatherType): boolean { private overrideWeather(): void {
const weather = Overrides.WEATHER_OVERRIDE;
this.weather = new Weather(weather, 0); this.weather = new Weather(weather, 0);
globalScene.phaseManager.unshiftNew("CommonAnimPhase", undefined, undefined, CommonAnim.SUNNY + (weather - 1)); globalScene.phaseManager.unshiftNew("CommonAnimPhase", undefined, undefined, CommonAnim.SUNNY + (weather - 1));
globalScene.phaseManager.queueMessage(getWeatherStartMessage(weather)!); // TODO: is this bang correct? globalScene.phaseManager.queueMessage(getWeatherStartMessage(weather)!); // TODO: is this bang correct?
return true;
} }
/** Returns weather or not the weather can be changed to {@linkcode weather} */ /** Returns weather or not the weather can be changed to {@linkcode weather} */
canSetWeather(weather: WeatherType): boolean { canSetWeather(weather: WeatherType): boolean {
return !(this.weather?.weatherType === (weather || undefined)); return this.getWeatherType() !== weather;
} }
/** /**
@ -312,14 +310,15 @@ export class Arena {
*/ */
trySetWeather(weather: WeatherType, user?: Pokemon): boolean { trySetWeather(weather: WeatherType, user?: Pokemon): boolean {
if (Overrides.WEATHER_OVERRIDE) { if (Overrides.WEATHER_OVERRIDE) {
return this.trySetWeatherOverride(Overrides.WEATHER_OVERRIDE); this.overrideWeather();
return true;
} }
if (!this.canSetWeather(weather)) { if (!this.canSetWeather(weather)) {
return false; return false;
} }
const oldWeatherType = this.weather?.weatherType || WeatherType.NONE; const oldWeatherType = this.getWeatherType();
if ( if (
this.weather?.isImmutable() && this.weather?.isImmutable() &&
@ -343,7 +342,7 @@ export class Arena {
globalScene.applyModifier(FieldEffectModifier, user.isPlayer(), user, weatherDuration); globalScene.applyModifier(FieldEffectModifier, user.isPlayer(), user, weatherDuration);
} }
this.weather = weather ? new Weather(weather, weatherDuration.value) : null; this.weather = weather === WeatherType.NONE ? null : new Weather(weather, weatherDuration.value);
this.eventTarget.dispatchEvent( this.eventTarget.dispatchEvent(
new WeatherChangedEvent(oldWeatherType, this.weather?.weatherType!, this.weather?.turnsLeft!), new WeatherChangedEvent(oldWeatherType, this.weather?.weatherType!, this.weather?.turnsLeft!),
); // TODO: is this bang correct? ); // TODO: is this bang correct?
@ -404,25 +403,24 @@ export class Arena {
}); });
} }
/** Returns whether or not the terrain can be set to {@linkcode terrain} */ /** Return whether or not the terrain can be set to {@linkcode terrain} */
canSetTerrain(terrain: TerrainType): boolean { canSetTerrain(terrain: TerrainType): boolean {
return !(this.terrain?.terrainType === (terrain || undefined)); return this.getTerrainType() !== terrain;
} }
/** /**
* Attempts to set a new terrain effect to the battle * Attempt to set the current terrain to the specified type.
* @param terrain {@linkcode TerrainType} new {@linkcode TerrainType} to set * @param terrain - The {@linkcode TerrainType} to try and set.
* @param ignoreAnim boolean if the terrain animation should be ignored * @param ignoreAnim - Whether to prevent showing an the animation; default `false`
* @param user {@linkcode Pokemon} that caused the terrain effect * @param user - The {@linkcode Pokemon} creating the terrain (if any)
* @returns true if new terrain set, false if no terrain provided or attempting to set the same terrain as currently in use * @returns Whether the terrain was successfully set.
*/ */
trySetTerrain(terrain: TerrainType, ignoreAnim = false, user?: Pokemon): boolean { trySetTerrain(terrain: TerrainType, ignoreAnim = false, user?: Pokemon): boolean {
if (!this.canSetTerrain(terrain)) { if (!this.canSetTerrain(terrain)) {
return false; return false;
} }
const oldTerrainType = this.terrain?.terrainType || TerrainType.NONE; const oldTerrainType = this.getTerrainType();
const terrainDuration = new NumberHolder(0); const terrainDuration = new NumberHolder(0);
if (!isNullOrUndefined(user)) { if (!isNullOrUndefined(user)) {
@ -430,7 +428,7 @@ export class Arena {
globalScene.applyModifier(FieldEffectModifier, user.isPlayer(), user, terrainDuration); globalScene.applyModifier(FieldEffectModifier, user.isPlayer(), user, terrainDuration);
} }
this.terrain = terrain ? new Terrain(terrain, terrainDuration.value) : null; this.terrain = terrain === TerrainType.NONE ? null : new Terrain(terrain, terrainDuration.value);
this.eventTarget.dispatchEvent( this.eventTarget.dispatchEvent(
new TerrainChangedEvent(oldTerrainType, this.terrain?.terrainType!, this.terrain?.turnsLeft!), new TerrainChangedEvent(oldTerrainType, this.terrain?.terrainType!, this.terrain?.turnsLeft!),
@ -464,6 +462,24 @@ export class Arena {
return true; return true;
} }
/** Attempt to override the terrain to the value set inside {@linkcode Overrides.STARTING_TERRAIN_OVERRIDE}. */
tryOverrideTerrain(): void {
const terrain = Overrides.STARTING_TERRAIN_OVERRIDE;
if (terrain === TerrainType.NONE) {
return;
}
// TODO: Add a flag for permanent terrains
this.terrain = new Terrain(terrain, 0);
globalScene.phaseManager.unshiftNew(
"CommonAnimPhase",
undefined,
undefined,
CommonAnim.MISTY_TERRAIN + (terrain - 1),
);
globalScene.phaseManager.queueMessage(getTerrainStartMessage(terrain) ?? ""); // TODO: Remove `?? ""` when terrain-fail-msg branch removes `null` from these signatures
}
public isMoveWeatherCancelled(user: Pokemon, move: Move): boolean { public isMoveWeatherCancelled(user: Pokemon, move: Move): boolean {
return !!this.weather && !this.weather.isEffectSuppressed() && this.weather.isMoveWeatherCancelled(user, move); return !!this.weather && !this.weather.isEffectSuppressed() && this.weather.isMoveWeatherCancelled(user, move);
} }

View File

@ -1,7 +1,7 @@
import type { Ability, PreAttackModifyDamageAbAttrParams } from "#abilities/ability"; import type { Ability, PreAttackModifyDamageAbAttrParams } from "#abilities/ability";
import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs"; import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs";
import type { AnySound, BattleScene } from "#app/battle-scene"; import type { AnySound, BattleScene } from "#app/battle-scene";
import { PLAYER_PARTY_MAX_SIZE } from "#app/constants"; import { PLAYER_PARTY_MAX_SIZE, RARE_CANDY_FRIENDSHIP_CAP } from "#app/constants";
import { timedEventManager } from "#app/global-event-manager"; import { timedEventManager } from "#app/global-event-manager";
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages"; import { getPokemonNameWithAffix } from "#app/messages";
@ -139,6 +139,8 @@ import { populateVariantColors, variantColorCache, variantData } from "#sprites/
import { achvs } from "#system/achv"; import { achvs } from "#system/achv";
import type { StarterDataEntry, StarterMoveset } from "#system/game-data"; import type { StarterDataEntry, StarterMoveset } from "#system/game-data";
import type { PokemonData } from "#system/pokemon-data"; import type { PokemonData } from "#system/pokemon-data";
import { RibbonData } from "#system/ribbons/ribbon-data";
import { awardRibbonsToSpeciesLine } from "#system/ribbons/ribbon-methods";
import type { AbAttrMap, AbAttrString, TypeMultiplierAbAttrParams } from "#types/ability-types"; import type { AbAttrMap, AbAttrString, TypeMultiplierAbAttrParams } from "#types/ability-types";
import type { DamageCalculationResult, DamageResult } from "#types/damage-result"; import type { DamageCalculationResult, DamageResult } from "#types/damage-result";
import type { IllusionData } from "#types/illusion-data"; import type { IllusionData } from "#types/illusion-data";
@ -1825,7 +1827,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
// Overrides moveset based on arrays specified in overrides.ts // Overrides moveset based on arrays specified in overrides.ts
let overrideArray: MoveId | Array<MoveId> = this.isPlayer() let overrideArray: MoveId | Array<MoveId> = this.isPlayer()
? Overrides.MOVESET_OVERRIDE ? Overrides.MOVESET_OVERRIDE
: Overrides.OPP_MOVESET_OVERRIDE; : Overrides.ENEMY_MOVESET_OVERRIDE;
overrideArray = coerceArray(overrideArray); overrideArray = coerceArray(overrideArray);
if (overrideArray.length > 0) { if (overrideArray.length > 0) {
if (!this.isPlayer()) { if (!this.isPlayer()) {
@ -2030,8 +2032,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
if (Overrides.ABILITY_OVERRIDE && this.isPlayer()) { if (Overrides.ABILITY_OVERRIDE && this.isPlayer()) {
return allAbilities[Overrides.ABILITY_OVERRIDE]; return allAbilities[Overrides.ABILITY_OVERRIDE];
} }
if (Overrides.OPP_ABILITY_OVERRIDE && this.isEnemy()) { if (Overrides.ENEMY_ABILITY_OVERRIDE && this.isEnemy()) {
return allAbilities[Overrides.OPP_ABILITY_OVERRIDE]; return allAbilities[Overrides.ENEMY_ABILITY_OVERRIDE];
} }
if (this.isFusion()) { if (this.isFusion()) {
if (!isNullOrUndefined(this.fusionCustomPokemonData?.ability) && this.fusionCustomPokemonData.ability !== -1) { if (!isNullOrUndefined(this.fusionCustomPokemonData?.ability) && this.fusionCustomPokemonData.ability !== -1) {
@ -2060,8 +2062,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
if (Overrides.PASSIVE_ABILITY_OVERRIDE && this.isPlayer()) { if (Overrides.PASSIVE_ABILITY_OVERRIDE && this.isPlayer()) {
return allAbilities[Overrides.PASSIVE_ABILITY_OVERRIDE]; return allAbilities[Overrides.PASSIVE_ABILITY_OVERRIDE];
} }
if (Overrides.OPP_PASSIVE_ABILITY_OVERRIDE && this.isEnemy()) { if (Overrides.ENEMY_PASSIVE_ABILITY_OVERRIDE && this.isEnemy()) {
return allAbilities[Overrides.OPP_PASSIVE_ABILITY_OVERRIDE]; return allAbilities[Overrides.ENEMY_PASSIVE_ABILITY_OVERRIDE];
} }
if (!isNullOrUndefined(this.customPokemonData.passive) && this.customPokemonData.passive !== -1) { if (!isNullOrUndefined(this.customPokemonData.passive) && this.customPokemonData.passive !== -1) {
return allAbilities[this.customPokemonData.passive]; return allAbilities[this.customPokemonData.passive];
@ -2128,14 +2130,14 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
// returns override if valid for current case // returns override if valid for current case
if ( if (
(Overrides.HAS_PASSIVE_ABILITY_OVERRIDE === false && this.isPlayer()) || (Overrides.HAS_PASSIVE_ABILITY_OVERRIDE === false && this.isPlayer()) ||
(Overrides.OPP_HAS_PASSIVE_ABILITY_OVERRIDE === false && this.isEnemy()) (Overrides.ENEMY_HAS_PASSIVE_ABILITY_OVERRIDE === false && this.isEnemy())
) { ) {
return false; return false;
} }
if ( if (
((Overrides.PASSIVE_ABILITY_OVERRIDE !== AbilityId.NONE || Overrides.HAS_PASSIVE_ABILITY_OVERRIDE) && ((Overrides.PASSIVE_ABILITY_OVERRIDE !== AbilityId.NONE || Overrides.HAS_PASSIVE_ABILITY_OVERRIDE) &&
this.isPlayer()) || this.isPlayer()) ||
((Overrides.OPP_PASSIVE_ABILITY_OVERRIDE !== AbilityId.NONE || Overrides.OPP_HAS_PASSIVE_ABILITY_OVERRIDE) && ((Overrides.ENEMY_PASSIVE_ABILITY_OVERRIDE !== AbilityId.NONE || Overrides.ENEMY_HAS_PASSIVE_ABILITY_OVERRIDE) &&
this.isEnemy()) this.isEnemy())
) { ) {
return true; return true;
@ -2280,13 +2282,29 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
return this.teraType; return this.teraType;
} }
public isGrounded(): boolean { /**
* Return whether this Pokemon is currently on the ground.
*
* To be considered grounded, a Pokemon must either:
* * Be {@linkcode GroundedTag | forcibly grounded} from an effect like Smack Down or Ingrain
* * Be under the effects of {@linkcode ArenaTagType.GRAVITY | harsh gravity}
* * **Not** be all of the following things:
* * {@linkcode PokemonType.FLYING | Flying-type}
* * {@linkcode AbilityId.LEVITATE | Levitating}
* * {@linkcode BattlerTagType.FLOATING | Floating} from Magnet Rise or Telekinesis.
* * {@linkcode SemiInvulnerableTag | Semi-invulnerable} with `ignoreSemiInvulnerable` set to `false`
* @param ignoreSemiInvulnerable - Whether to ignore the target's semi-invulnerable state when determining groundedness;
default `false`
* @returns Whether this pokemon is currently grounded, as described above.
*/
public isGrounded(ignoreSemiInvulnerable = false): boolean {
return ( return (
!!this.getTag(GroundedTag) || !!this.getTag(GroundedTag) ||
globalScene.arena.hasTag(ArenaTagType.GRAVITY) ||
(!this.isOfType(PokemonType.FLYING, true, true) && (!this.isOfType(PokemonType.FLYING, true, true) &&
!this.hasAbility(AbilityId.LEVITATE) && !this.hasAbility(AbilityId.LEVITATE) &&
!this.getTag(BattlerTagType.FLOATING) && !this.getTag(BattlerTagType.FLOATING) &&
!this.getTag(SemiInvulnerableTag)) (ignoreSemiInvulnerable || !this.getTag(SemiInvulnerableTag)))
); );
} }
@ -2486,7 +2504,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
// Handle flying v ground type immunity without removing flying type so effective types are still effective // Handle flying v ground type immunity without removing flying type so effective types are still effective
// Related to https://github.com/pagefaultgames/pokerogue/issues/524 // Related to https://github.com/pagefaultgames/pokerogue/issues/524
if (moveType === PokemonType.GROUND && (this.isGrounded() || arena.hasTag(ArenaTagType.GRAVITY))) { if (moveType === PokemonType.GROUND && this.isGrounded()) {
const flyingIndex = types.indexOf(PokemonType.FLYING); const flyingIndex = types.indexOf(PokemonType.FLYING);
if (flyingIndex > -1) { if (flyingIndex > -1) {
types.splice(flyingIndex, 1); types.splice(flyingIndex, 1);
@ -3001,8 +3019,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
if (forStarter && this.isPlayer() && Overrides.STARTER_FUSION_SPECIES_OVERRIDE) { if (forStarter && this.isPlayer() && Overrides.STARTER_FUSION_SPECIES_OVERRIDE) {
fusionOverride = getPokemonSpecies(Overrides.STARTER_FUSION_SPECIES_OVERRIDE); fusionOverride = getPokemonSpecies(Overrides.STARTER_FUSION_SPECIES_OVERRIDE);
} else if (this.isEnemy() && Overrides.OPP_FUSION_SPECIES_OVERRIDE) { } else if (this.isEnemy() && Overrides.ENEMY_FUSION_SPECIES_OVERRIDE) {
fusionOverride = getPokemonSpecies(Overrides.OPP_FUSION_SPECIES_OVERRIDE); fusionOverride = getPokemonSpecies(Overrides.ENEMY_FUSION_SPECIES_OVERRIDE);
} }
this.fusionSpecies = this.fusionSpecies =
@ -3753,6 +3771,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
const isPhysical = moveCategory === MoveCategory.PHYSICAL; const isPhysical = moveCategory === MoveCategory.PHYSICAL;
/** Combined damage multiplier from field effects such as weather, terrain, etc. */ /** Combined damage multiplier from field effects such as weather, terrain, etc. */
// TODO: This should be applied directly to base power
const arenaAttackTypeMultiplier = new NumberHolder( const arenaAttackTypeMultiplier = new NumberHolder(
globalScene.arena.getAttackTypeMultiplier(moveType, source.isGrounded()), globalScene.arena.getAttackTypeMultiplier(moveType, source.isGrounded()),
); );
@ -5822,45 +5841,59 @@ export class PlayerPokemon extends Pokemon {
); );
}); });
} }
/**
addFriendship(friendship: number): void { * Add friendship to this Pokemon
if (friendship > 0) { *
const starterSpeciesId = this.species.getRootSpeciesId(); * @remarks
const fusionStarterSpeciesId = this.isFusion() && this.fusionSpecies ? this.fusionSpecies.getRootSpeciesId() : 0; * This adds friendship to the pokemon's friendship stat (used for evolution, return, etc.) and candy progress.
const starterData = [ * For fusions, candy progress for each species in the fusion is halved.
globalScene.gameData.starterData[starterSpeciesId], *
fusionStarterSpeciesId ? globalScene.gameData.starterData[fusionStarterSpeciesId] : null, * @param friendship - The amount of friendship to add. Negative values will reduce friendship, though not below 0.
].filter(d => !!d); * @param capped - If true, don't allow the friendship gain to exceed 200. Used to cap friendship gains from rare candies.
const amount = new NumberHolder(friendship); */
globalScene.applyModifier(PokemonFriendshipBoosterModifier, true, this, amount); addFriendship(friendship: number, capped = false): void {
const candyFriendshipMultiplier = globalScene.gameMode.isClassic // Short-circuit friendship loss, which doesn't impact candy friendship
? timedEventManager.getClassicFriendshipMultiplier() if (friendship <= 0) {
: 1;
const fusionReduction = fusionStarterSpeciesId
? timedEventManager.areFusionsBoosted()
? 1.5 // Divide candy gain for fusions by 1.5 during events
: 2 // 2 for fusions outside events
: 1; // 1 for non-fused mons
const starterAmount = new NumberHolder(Math.floor((amount.value * candyFriendshipMultiplier) / fusionReduction));
// Add friendship to this PlayerPokemon
this.friendship = Math.min(this.friendship + amount.value, 255);
if (this.friendship === 255) {
globalScene.validateAchv(achvs.MAX_FRIENDSHIP);
}
// Add to candy progress for this mon's starter species and its fused species (if it has one)
starterData.forEach((sd: StarterDataEntry, i: number) => {
const speciesId = !i ? starterSpeciesId : (fusionStarterSpeciesId as SpeciesId);
sd.friendship = (sd.friendship || 0) + starterAmount.value;
if (sd.friendship >= getStarterValueFriendshipCap(speciesStarterCosts[speciesId])) {
globalScene.gameData.addStarterCandy(getPokemonSpecies(speciesId), 1);
sd.friendship = 0;
}
});
} else {
// Lose friendship upon fainting
this.friendship = Math.max(this.friendship + friendship, 0); this.friendship = Math.max(this.friendship + friendship, 0);
return;
} }
const starterSpeciesId = this.species.getRootSpeciesId();
const fusionStarterSpeciesId = this.isFusion() && this.fusionSpecies ? this.fusionSpecies.getRootSpeciesId() : 0;
const starterGameData = globalScene.gameData.starterData;
const starterData: [StarterDataEntry, SpeciesId][] = [[starterGameData[starterSpeciesId], starterSpeciesId]];
if (fusionStarterSpeciesId) {
starterData.push([starterGameData[fusionStarterSpeciesId], fusionStarterSpeciesId]);
}
const amount = new NumberHolder(friendship);
globalScene.applyModifier(PokemonFriendshipBoosterModifier, true, this, amount);
friendship = amount.value;
const newFriendship = this.friendship + friendship;
// If capped is true, only adjust friendship if the new friendship is less than or equal to 200.
if (!capped || newFriendship <= RARE_CANDY_FRIENDSHIP_CAP) {
this.friendship = Math.min(newFriendship, 255);
if (newFriendship >= 255) {
globalScene.validateAchv(achvs.MAX_FRIENDSHIP);
awardRibbonsToSpeciesLine(this.species.speciesId, RibbonData.FRIENDSHIP);
}
}
let candyFriendshipMultiplier = globalScene.gameMode.isClassic
? timedEventManager.getClassicFriendshipMultiplier()
: 1;
if (fusionStarterSpeciesId) {
candyFriendshipMultiplier /= timedEventManager.areFusionsBoosted() ? 1.5 : 2;
}
const candyFriendshipAmount = Math.floor(friendship * candyFriendshipMultiplier);
// Add to candy progress for this mon's starter species and its fused species (if it has one)
starterData.forEach(([sd, id]: [StarterDataEntry, SpeciesId]) => {
sd.friendship = (sd.friendship || 0) + candyFriendshipAmount;
if (sd.friendship >= getStarterValueFriendshipCap(speciesStarterCosts[id])) {
globalScene.gameData.addStarterCandy(getPokemonSpecies(id), 1);
sd.friendship = 0;
}
});
} }
getPossibleEvolution(evolution: SpeciesFormEvolution | null): Promise<Pokemon> { getPossibleEvolution(evolution: SpeciesFormEvolution | null): Promise<Pokemon> {
@ -6241,22 +6274,22 @@ export class EnemyPokemon extends Pokemon {
this.setBoss(boss, dataSource?.bossSegments); this.setBoss(boss, dataSource?.bossSegments);
} }
if (Overrides.OPP_STATUS_OVERRIDE) { if (Overrides.ENEMY_STATUS_OVERRIDE) {
this.status = new Status(Overrides.OPP_STATUS_OVERRIDE, 0, 4); this.status = new Status(Overrides.ENEMY_STATUS_OVERRIDE, 0, 4);
} }
if (Overrides.OPP_GENDER_OVERRIDE !== null) { if (Overrides.ENEMY_GENDER_OVERRIDE !== null) {
this.gender = Overrides.OPP_GENDER_OVERRIDE; this.gender = Overrides.ENEMY_GENDER_OVERRIDE;
} }
const speciesId = this.species.speciesId; const speciesId = this.species.speciesId;
if ( if (
speciesId in Overrides.OPP_FORM_OVERRIDES && speciesId in Overrides.ENEMY_FORM_OVERRIDES &&
!isNullOrUndefined(Overrides.OPP_FORM_OVERRIDES[speciesId]) && !isNullOrUndefined(Overrides.ENEMY_FORM_OVERRIDES[speciesId]) &&
this.species.forms[Overrides.OPP_FORM_OVERRIDES[speciesId]] this.species.forms[Overrides.ENEMY_FORM_OVERRIDES[speciesId]]
) { ) {
this.formIndex = Overrides.OPP_FORM_OVERRIDES[speciesId]; this.formIndex = Overrides.ENEMY_FORM_OVERRIDES[speciesId];
} else if (globalScene.gameMode.isDaily && globalScene.gameMode.isWaveFinal(globalScene.currentBattle.waveIndex)) { } else if (globalScene.gameMode.isDaily && globalScene.gameMode.isWaveFinal(globalScene.currentBattle.waveIndex)) {
const eventBoss = getDailyEventSeedBoss(globalScene.seed); const eventBoss = getDailyEventSeedBoss(globalScene.seed);
if (!isNullOrUndefined(eventBoss)) { if (!isNullOrUndefined(eventBoss)) {
@ -6266,21 +6299,21 @@ export class EnemyPokemon extends Pokemon {
if (!dataSource) { if (!dataSource) {
this.generateAndPopulateMoveset(); this.generateAndPopulateMoveset();
if (shinyLock || Overrides.OPP_SHINY_OVERRIDE === false) { if (shinyLock || Overrides.ENEMY_SHINY_OVERRIDE === false) {
this.shiny = false; this.shiny = false;
} else { } else {
this.trySetShiny(); this.trySetShiny();
} }
if (!this.shiny && Overrides.OPP_SHINY_OVERRIDE) { if (!this.shiny && Overrides.ENEMY_SHINY_OVERRIDE) {
this.shiny = true; this.shiny = true;
this.initShinySparkle(); this.initShinySparkle();
} }
if (this.shiny) { if (this.shiny) {
this.variant = this.generateShinyVariant(); this.variant = this.generateShinyVariant();
if (Overrides.OPP_VARIANT_OVERRIDE !== null) { if (Overrides.ENEMY_VARIANT_OVERRIDE !== null) {
this.variant = Overrides.OPP_VARIANT_OVERRIDE; this.variant = Overrides.ENEMY_VARIANT_OVERRIDE;
} }
} }

View File

@ -90,6 +90,7 @@ export class LoadingScene extends SceneBase {
this.loadAtlas("shiny_icons", "ui"); this.loadAtlas("shiny_icons", "ui");
this.loadImage("ha_capsule", "ui", "ha_capsule.png"); this.loadImage("ha_capsule", "ui", "ha_capsule.png");
this.loadImage("champion_ribbon", "ui", "champion_ribbon.png"); this.loadImage("champion_ribbon", "ui", "champion_ribbon.png");
this.loadImage("champion_ribbon_emerald", "ui", "champion_ribbon_emerald.png");
this.loadImage("icon_spliced", "ui"); this.loadImage("icon_spliced", "ui");
this.loadImage("icon_lock", "ui", "icon_lock.png"); this.loadImage("icon_lock", "ui", "icon_lock.png");
this.loadImage("icon_stop", "ui", "icon_stop.png"); this.loadImage("icon_stop", "ui", "icon_stop.png");
@ -122,6 +123,7 @@ export class LoadingScene extends SceneBase {
this.loadImage("party_bg_double", "ui"); this.loadImage("party_bg_double", "ui");
this.loadImage("party_bg_double_manage", "ui"); this.loadImage("party_bg_double_manage", "ui");
this.loadAtlas("party_slot_main", "ui"); this.loadAtlas("party_slot_main", "ui");
this.loadAtlas("party_slot_main_short", "ui");
this.loadAtlas("party_slot", "ui"); this.loadAtlas("party_slot", "ui");
this.loadImage("party_slot_overlay_lv", "ui"); this.loadImage("party_slot_overlay_lv", "ui");
this.loadImage("party_slot_hp_bar", "ui"); this.loadImage("party_slot_hp_bar", "ui");

View File

@ -2304,7 +2304,7 @@ export class PokemonLevelIncrementModifier extends ConsumablePokemonModifier {
playerPokemon.levelExp = 0; playerPokemon.levelExp = 0;
} }
playerPokemon.addFriendship(FRIENDSHIP_GAIN_FROM_RARE_CANDY); playerPokemon.addFriendship(FRIENDSHIP_GAIN_FROM_RARE_CANDY, true);
globalScene.phaseManager.unshiftNew( globalScene.phaseManager.unshiftNew(
"LevelUpPhase", "LevelUpPhase",
@ -3755,7 +3755,7 @@ export class EnemyFusionChanceModifier extends EnemyPersistentModifier {
export function overrideModifiers(isPlayer = true): void { export function overrideModifiers(isPlayer = true): void {
const modifiersOverride: ModifierOverride[] = isPlayer const modifiersOverride: ModifierOverride[] = isPlayer
? Overrides.STARTING_MODIFIER_OVERRIDE ? Overrides.STARTING_MODIFIER_OVERRIDE
: Overrides.OPP_MODIFIER_OVERRIDE; : Overrides.ENEMY_MODIFIER_OVERRIDE;
if (!modifiersOverride || modifiersOverride.length === 0 || !globalScene) { if (!modifiersOverride || modifiersOverride.length === 0 || !globalScene) {
return; return;
} }
@ -3797,7 +3797,7 @@ export function overrideModifiers(isPlayer = true): void {
export function overrideHeldItems(pokemon: Pokemon, isPlayer = true): void { export function overrideHeldItems(pokemon: Pokemon, isPlayer = true): void {
const heldItemsOverride: ModifierOverride[] = isPlayer const heldItemsOverride: ModifierOverride[] = isPlayer
? Overrides.STARTING_HELD_ITEMS_OVERRIDE ? Overrides.STARTING_HELD_ITEMS_OVERRIDE
: Overrides.OPP_HELD_ITEMS_OVERRIDE; : Overrides.ENEMY_HELD_ITEMS_OVERRIDE;
if (!heldItemsOverride || heldItemsOverride.length === 0 || !globalScene) { if (!heldItemsOverride || heldItemsOverride.length === 0 || !globalScene) {
return; return;
} }

View File

@ -1,4 +1,5 @@
import { type PokeballCounts } from "#app/battle-scene"; import { type PokeballCounts } from "#app/battle-scene";
import { TerrainType } from "#app/data/terrain";
import { EvolutionItem } from "#balance/pokemon-evolutions"; import { EvolutionItem } from "#balance/pokemon-evolutions";
import { Gender } from "#data/gender"; import { Gender } from "#data/gender";
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
@ -61,6 +62,12 @@ class DefaultOverrides {
readonly SEED_OVERRIDE: string = ""; readonly SEED_OVERRIDE: string = "";
readonly DAILY_RUN_SEED_OVERRIDE: string | null = null; readonly DAILY_RUN_SEED_OVERRIDE: string | null = null;
readonly WEATHER_OVERRIDE: WeatherType = WeatherType.NONE; readonly WEATHER_OVERRIDE: WeatherType = WeatherType.NONE;
/**
* If set, will override the in-game terrain at the start of each biome transition.
*
* Lasts until cleared or replaced by another effect, and is refreshed at the start of each new biome.
*/
readonly STARTING_TERRAIN_OVERRIDE: TerrainType = TerrainType.NONE;
/** /**
* If `null`, ignore this override. * If `null`, ignore this override.
* *
@ -179,25 +186,24 @@ class DefaultOverrides {
// -------------------------- // --------------------------
// OPPONENT / ENEMY OVERRIDES // OPPONENT / ENEMY OVERRIDES
// -------------------------- // --------------------------
// TODO: rename `OPP_` to `ENEMY_` readonly ENEMY_SPECIES_OVERRIDE: SpeciesId | number = 0;
readonly OPP_SPECIES_OVERRIDE: SpeciesId | number = 0;
/** /**
* This will make all opponents fused Pokemon * This will make all opponents fused Pokemon
*/ */
readonly OPP_FUSION_OVERRIDE: boolean = false; readonly ENEMY_FUSION_OVERRIDE: boolean = false;
/** /**
* This will override the species of the fusion only when the opponent is already a fusion * This will override the species of the fusion only when the opponent is already a fusion
*/ */
readonly OPP_FUSION_SPECIES_OVERRIDE: SpeciesId | number = 0; readonly ENEMY_FUSION_SPECIES_OVERRIDE: SpeciesId | number = 0;
readonly OPP_LEVEL_OVERRIDE: number = 0; readonly ENEMY_LEVEL_OVERRIDE: number = 0;
readonly OPP_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE; readonly ENEMY_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE;
readonly OPP_PASSIVE_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE; readonly ENEMY_PASSIVE_ABILITY_OVERRIDE: AbilityId = AbilityId.NONE;
readonly OPP_HAS_PASSIVE_ABILITY_OVERRIDE: boolean | null = null; readonly ENEMY_HAS_PASSIVE_ABILITY_OVERRIDE: boolean | null = null;
readonly OPP_STATUS_OVERRIDE: StatusEffect = StatusEffect.NONE; readonly ENEMY_STATUS_OVERRIDE: StatusEffect = StatusEffect.NONE;
readonly OPP_GENDER_OVERRIDE: Gender | null = null; readonly ENEMY_GENDER_OVERRIDE: Gender | null = null;
readonly OPP_MOVESET_OVERRIDE: MoveId | Array<MoveId> = []; readonly ENEMY_MOVESET_OVERRIDE: MoveId | Array<MoveId> = [];
readonly OPP_SHINY_OVERRIDE: boolean | null = null; readonly ENEMY_SHINY_OVERRIDE: boolean | null = null;
readonly OPP_VARIANT_OVERRIDE: Variant | null = null; readonly ENEMY_VARIANT_OVERRIDE: Variant | null = null;
/** /**
* Overrides the IVs of enemy pokemon. Values must never be outside the range `0` to `31`! * Overrides the IVs of enemy pokemon. Values must never be outside the range `0` to `31`!
* - If set to a number between `0` and `31`, set all IVs of all enemy pokemon to that number. * - If set to a number between `0` and `31`, set all IVs of all enemy pokemon to that number.
@ -207,7 +213,7 @@ class DefaultOverrides {
readonly ENEMY_IVS_OVERRIDE: number | number[] | null = null; readonly ENEMY_IVS_OVERRIDE: number | number[] | null = null;
/** Override the nature of all enemy pokemon to the specified nature. Disabled if `null`. */ /** Override the nature of all enemy pokemon to the specified nature. Disabled if `null`. */
readonly ENEMY_NATURE_OVERRIDE: Nature | null = null; readonly ENEMY_NATURE_OVERRIDE: Nature | null = null;
readonly OPP_FORM_OVERRIDES: Partial<Record<SpeciesId, number>> = {}; readonly ENEMY_FORM_OVERRIDES: Partial<Record<SpeciesId, number>> = {};
/** /**
* Override to give the enemy Pokemon a given amount of health segments * Override to give the enemy Pokemon a given amount of health segments
* *
@ -215,7 +221,7 @@ class DefaultOverrides {
* 1: the Pokemon will have a single health segment and therefore will not be a boss * 1: the Pokemon will have a single health segment and therefore will not be a boss
* 2+: the Pokemon will be a boss with the given number of health segments * 2+: the Pokemon will be a boss with the given number of health segments
*/ */
readonly OPP_HEALTH_SEGMENTS_OVERRIDE: number = 0; readonly ENEMY_HEALTH_SEGMENTS_OVERRIDE: number = 0;
// ------------- // -------------
// EGG OVERRIDES // EGG OVERRIDES
@ -277,12 +283,12 @@ class DefaultOverrides {
* *
* Note that any previous modifiers are cleared. * Note that any previous modifiers are cleared.
*/ */
readonly OPP_MODIFIER_OVERRIDE: ModifierOverride[] = []; readonly ENEMY_MODIFIER_OVERRIDE: ModifierOverride[] = [];
/** Override array of {@linkcode ModifierOverride}s used to provide held items to first party member when starting a new game. */ /** Override array of {@linkcode ModifierOverride}s used to provide held items to first party member when starting a new game. */
readonly STARTING_HELD_ITEMS_OVERRIDE: ModifierOverride[] = []; readonly STARTING_HELD_ITEMS_OVERRIDE: ModifierOverride[] = [];
/** Override array of {@linkcode ModifierOverride}s used to provide held items to enemies on spawn. */ /** Override array of {@linkcode ModifierOverride}s used to provide held items to enemies on spawn. */
readonly OPP_HELD_ITEMS_OVERRIDE: ModifierOverride[] = []; readonly ENEMY_HELD_ITEMS_OVERRIDE: ModifierOverride[] = [];
/** /**
* Override array of {@linkcode ModifierOverride}s used to replace the generated item rolls after a wave. * Override array of {@linkcode ModifierOverride}s used to replace the generated item rolls after a wave.

View File

@ -229,7 +229,7 @@ export class EncounterPhase extends BattlePhase {
}), }),
); );
} else { } else {
const overridedBossSegments = Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE > 1; const overridedBossSegments = Overrides.ENEMY_HEALTH_SEGMENTS_OVERRIDE > 1;
// for double battles, reduce the health segments for boss Pokemon unless there is an override // for double battles, reduce the health segments for boss Pokemon unless there is an override
if (!overridedBossSegments && battle.enemyParty.filter(p => p.isBoss()).length > 1) { if (!overridedBossSegments && battle.enemyParty.filter(p => p.isBoss()).length > 1) {
for (const enemyPokemon of battle.enemyParty) { for (const enemyPokemon of battle.enemyParty) {
@ -690,6 +690,7 @@ export class EncounterPhase extends BattlePhase {
trySetWeatherIfNewBiome(): void { trySetWeatherIfNewBiome(): void {
if (!this.loaded) { if (!this.loaded) {
globalScene.arena.trySetWeather(getRandomWeatherType(globalScene.arena)); globalScene.arena.trySetWeather(getRandomWeatherType(globalScene.arena));
globalScene.arena.tryOverrideTerrain();
} }
} }
} }

View File

@ -19,8 +19,11 @@ import { ChallengeData } from "#system/challenge-data";
import type { SessionSaveData } from "#system/game-data"; import type { SessionSaveData } from "#system/game-data";
import { ModifierData as PersistentModifierData } from "#system/modifier-data"; import { ModifierData as PersistentModifierData } from "#system/modifier-data";
import { PokemonData } from "#system/pokemon-data"; import { PokemonData } from "#system/pokemon-data";
import { RibbonData, type RibbonFlag } from "#system/ribbons/ribbon-data";
import { awardRibbonsToSpeciesLine } from "#system/ribbons/ribbon-methods";
import { TrainerData } from "#system/trainer-data"; import { TrainerData } from "#system/trainer-data";
import { trainerConfigs } from "#trainers/trainer-config"; import { trainerConfigs } from "#trainers/trainer-config";
import { checkSpeciesValidForChallenge, isNuzlockeChallenge } from "#utils/challenge-utils";
import { isLocal, isLocalServerConnected } from "#utils/common"; import { isLocal, isLocalServerConnected } from "#utils/common";
import { getPokemonSpecies } from "#utils/pokemon-utils"; import { getPokemonSpecies } from "#utils/pokemon-utils";
import i18next from "i18next"; import i18next from "i18next";
@ -111,6 +114,40 @@ export class GameOverPhase extends BattlePhase {
} }
} }
/**
* Submethod of {@linkcode handleGameOver} that awards ribbons to Pokémon in the player's party based on the current
* game mode and challenges.
*/
private awardRibbons(): void {
let ribbonFlags = 0;
if (globalScene.gameMode.isClassic) {
ribbonFlags |= RibbonData.CLASSIC;
}
if (isNuzlockeChallenge()) {
ribbonFlags |= RibbonData.NUZLOCKE;
}
for (const challenge of globalScene.gameMode.challenges) {
const ribbon = challenge.ribbonAwarded;
if (challenge.value && ribbon) {
ribbonFlags |= ribbon;
}
}
// Award ribbons to all Pokémon in the player's party that are considered valid
// for the current game mode and challenges.
for (const pokemon of globalScene.getPlayerParty()) {
const species = pokemon.species;
if (
checkSpeciesValidForChallenge(
species,
globalScene.gameData.getSpeciesDexAttrProps(species, pokemon.getDexAttr()),
false,
)
) {
awardRibbonsToSpeciesLine(species.speciesId, ribbonFlags as RibbonFlag);
}
}
}
handleGameOver(): void { handleGameOver(): void {
const doGameOver = (newClear: boolean) => { const doGameOver = (newClear: boolean) => {
globalScene.disableMenu = true; globalScene.disableMenu = true;
@ -122,12 +159,12 @@ export class GameOverPhase extends BattlePhase {
globalScene.validateAchv(achvs.UNEVOLVED_CLASSIC_VICTORY); globalScene.validateAchv(achvs.UNEVOLVED_CLASSIC_VICTORY);
globalScene.gameData.gameStats.sessionsWon++; globalScene.gameData.gameStats.sessionsWon++;
for (const pokemon of globalScene.getPlayerParty()) { for (const pokemon of globalScene.getPlayerParty()) {
this.awardRibbon(pokemon); this.awardFirstClassicCompletion(pokemon);
if (pokemon.species.getRootSpeciesId() !== pokemon.species.getRootSpeciesId(true)) { if (pokemon.species.getRootSpeciesId() !== pokemon.species.getRootSpeciesId(true)) {
this.awardRibbon(pokemon, true); this.awardFirstClassicCompletion(pokemon, true);
} }
} }
this.awardRibbons();
} else if (globalScene.gameMode.isDaily && newClear) { } else if (globalScene.gameMode.isDaily && newClear) {
globalScene.gameData.gameStats.dailyRunSessionsWon++; globalScene.gameData.gameStats.dailyRunSessionsWon++;
} }
@ -263,7 +300,7 @@ export class GameOverPhase extends BattlePhase {
} }
} }
awardRibbon(pokemon: Pokemon, forStarter = false): void { awardFirstClassicCompletion(pokemon: Pokemon, forStarter = false): void {
const speciesId = getPokemonSpecies(pokemon.species.speciesId); const speciesId = getPokemonSpecies(pokemon.species.speciesId);
const speciesRibbonCount = globalScene.gameData.incrementRibbonCount(speciesId, forStarter); const speciesRibbonCount = globalScene.gameData.incrementRibbonCount(speciesId, forStarter);
// first time classic win, award voucher // first time classic win, award voucher

View File

@ -12,7 +12,7 @@ import { BooleanHolder, toDmgValue } from "#utils/common";
export class WeatherEffectPhase extends CommonAnimPhase { export class WeatherEffectPhase extends CommonAnimPhase {
public readonly phaseName = "WeatherEffectPhase"; public readonly phaseName = "WeatherEffectPhase";
public weather: Weather | null; public weather: Weather | null; // TODO: This should not be `null`
constructor() { constructor() {
super( super(

View File

@ -56,15 +56,15 @@ export class PokerogueSessionSavedataApi extends ApiBase {
/** /**
* Update a session savedata. * Update a session savedata.
* @param params The {@linkcode UpdateSessionSavedataRequest} to send * @param params - The request to send
* @param rawSavedata The raw savedata (as `string`) * @param rawSavedata - The raw, unencrypted savedata
* @returns An error message if something went wrong * @returns An error message if something went wrong
*/ */
public async update(params: UpdateSessionSavedataRequest, rawSavedata: string) { public async update(params: UpdateSessionSavedataRequest, rawSavedata: string): Promise<string> {
try { try {
const urlSearchParams = this.toUrlSearchParams(params); const urlSearchParams = this.toUrlSearchParams(params);
const response = await this.doPost(`/savedata/session/update?${urlSearchParams}`, rawSavedata);
const response = await this.doPost(`/savedata/session/update?${urlSearchParams}`, rawSavedata);
return await response.text(); return await response.text();
} catch (err) { } catch (err) {
console.warn("Could not update session savedata!", err); console.warn("Could not update session savedata!", err);

View File

@ -5,7 +5,6 @@ import {
FlipStatChallenge, FlipStatChallenge,
FreshStartChallenge, FreshStartChallenge,
InverseBattleChallenge, InverseBattleChallenge,
LimitedCatchChallenge,
SingleGenerationChallenge, SingleGenerationChallenge,
SingleTypeChallenge, SingleTypeChallenge,
} from "#data/challenge"; } from "#data/challenge";
@ -14,6 +13,7 @@ import { PlayerGender } from "#enums/player-gender";
import { getShortenedStatKey, Stat } from "#enums/stat"; import { getShortenedStatKey, Stat } from "#enums/stat";
import { TurnHeldItemTransferModifier } from "#modifiers/modifier"; import { TurnHeldItemTransferModifier } from "#modifiers/modifier";
import type { ConditionFn } from "#types/common"; import type { ConditionFn } from "#types/common";
import { isNuzlockeChallenge } from "#utils/challenge-utils";
import { NumberHolder } from "#utils/common"; import { NumberHolder } from "#utils/common";
import i18next from "i18next"; import i18next from "i18next";
import type { Modifier } from "typescript"; import type { Modifier } from "typescript";
@ -924,18 +924,7 @@ export const achvs = {
globalScene.gameMode.challenges.some(c => c.id === Challenges.INVERSE_BATTLE && c.value > 0), globalScene.gameMode.challenges.some(c => c.id === Challenges.INVERSE_BATTLE && c.value > 0),
).setSecret(), ).setSecret(),
// TODO: Decide on icon // TODO: Decide on icon
NUZLOCKE: new ChallengeAchv( NUZLOCKE: new ChallengeAchv("NUZLOCKE", "", "NUZLOCKE.description", "leaf_stone", 100, isNuzlockeChallenge),
"NUZLOCKE",
"",
"NUZLOCKE.description",
"leaf_stone",
100,
c =>
c instanceof LimitedCatchChallenge &&
c.value > 0 &&
globalScene.gameMode.challenges.some(c => c.id === Challenges.HARDCORE && c.value > 0) &&
globalScene.gameMode.challenges.some(c => c.id === Challenges.FRESH_START && c.value > 0),
),
BREEDERS_IN_SPACE: new Achv("BREEDERS_IN_SPACE", "", "BREEDERS_IN_SPACE.description", "moon_stone", 50).setSecret(), BREEDERS_IN_SPACE: new Achv("BREEDERS_IN_SPACE", "", "BREEDERS_IN_SPACE.description", "moon_stone", 50).setSecret(),
}; };

View File

@ -48,6 +48,7 @@ import { EggData } from "#system/egg-data";
import { GameStats } from "#system/game-stats"; import { GameStats } from "#system/game-stats";
import { ModifierData as PersistentModifierData } from "#system/modifier-data"; import { ModifierData as PersistentModifierData } from "#system/modifier-data";
import { PokemonData } from "#system/pokemon-data"; import { PokemonData } from "#system/pokemon-data";
import { RibbonData } from "#system/ribbons/ribbon-data";
import { resetSettings, SettingKeys, setSetting } from "#system/settings"; import { resetSettings, SettingKeys, setSetting } from "#system/settings";
import { SettingGamepad, setSettingGamepad, settingGamepadDefaults } from "#system/settings-gamepad"; import { SettingGamepad, setSettingGamepad, settingGamepadDefaults } from "#system/settings-gamepad";
import type { SettingKeyboard } from "#system/settings-keyboard"; import type { SettingKeyboard } from "#system/settings-keyboard";
@ -127,7 +128,8 @@ export interface SessionSaveData {
battleType: BattleType; battleType: BattleType;
trainer: TrainerData; trainer: TrainerData;
gameVersion: string; gameVersion: string;
runNameText: string; /** The player-chosen name of the run */
name: string;
timestamp: number; timestamp: number;
challenges: ChallengeData[]; challenges: ChallengeData[];
mysteryEncounterType: MysteryEncounterType | -1; // Only defined when current wave is ME, mysteryEncounterType: MysteryEncounterType | -1; // Only defined when current wave is ME,
@ -402,121 +404,121 @@ export class GameData {
} }
public initSystem(systemDataStr: string, cachedSystemDataStr?: string): Promise<boolean> { public initSystem(systemDataStr: string, cachedSystemDataStr?: string): Promise<boolean> {
return new Promise<boolean>(resolve => { const { promise, resolve } = Promise.withResolvers<boolean>();
try { try {
let systemData = this.parseSystemData(systemDataStr); let systemData = this.parseSystemData(systemDataStr);
if (cachedSystemDataStr) { if (cachedSystemDataStr) {
const cachedSystemData = this.parseSystemData(cachedSystemDataStr); const cachedSystemData = this.parseSystemData(cachedSystemDataStr);
if (cachedSystemData.timestamp > systemData.timestamp) { if (cachedSystemData.timestamp > systemData.timestamp) {
console.debug("Use cached system"); console.debug("Use cached system");
systemData = cachedSystemData; systemData = cachedSystemData;
systemDataStr = cachedSystemDataStr; systemDataStr = cachedSystemDataStr;
} else {
this.clearLocalData();
}
}
console.debug(systemData);
localStorage.setItem(`data_${loggedInUser?.username}`, encrypt(systemDataStr, bypassLogin));
const lsItemKey = `runHistoryData_${loggedInUser?.username}`;
const lsItem = localStorage.getItem(lsItemKey);
if (!lsItem) {
localStorage.setItem(lsItemKey, "");
}
applySystemVersionMigration(systemData);
this.trainerId = systemData.trainerId;
this.secretId = systemData.secretId;
this.gender = systemData.gender;
this.saveSetting(SettingKeys.Player_Gender, systemData.gender === PlayerGender.FEMALE ? 1 : 0);
if (!systemData.starterData) {
this.initStarterData();
if (systemData["starterMoveData"]) {
const starterMoveData = systemData["starterMoveData"];
for (const s of Object.keys(starterMoveData)) {
this.starterData[s].moveset = starterMoveData[s];
}
}
if (systemData["starterEggMoveData"]) {
const starterEggMoveData = systemData["starterEggMoveData"];
for (const s of Object.keys(starterEggMoveData)) {
this.starterData[s].eggMoves = starterEggMoveData[s];
}
}
this.migrateStarterAbilities(systemData, this.starterData);
const starterIds = Object.keys(this.starterData).map(s => Number.parseInt(s) as SpeciesId);
for (const s of starterIds) {
this.starterData[s].candyCount += systemData.dexData[s].caughtCount;
this.starterData[s].candyCount += systemData.dexData[s].hatchedCount * 2;
if (systemData.dexData[s].caughtAttr & DexAttr.SHINY) {
this.starterData[s].candyCount += 4;
}
}
} else { } else {
this.starterData = systemData.starterData; this.clearLocalData();
} }
if (systemData.gameStats) {
this.gameStats = systemData.gameStats;
}
if (systemData.unlocks) {
for (const key of Object.keys(systemData.unlocks)) {
if (this.unlocks.hasOwnProperty(key)) {
this.unlocks[key] = systemData.unlocks[key];
}
}
}
if (systemData.achvUnlocks) {
for (const a of Object.keys(systemData.achvUnlocks)) {
if (achvs.hasOwnProperty(a)) {
this.achvUnlocks[a] = systemData.achvUnlocks[a];
}
}
}
if (systemData.voucherUnlocks) {
for (const v of Object.keys(systemData.voucherUnlocks)) {
if (vouchers.hasOwnProperty(v)) {
this.voucherUnlocks[v] = systemData.voucherUnlocks[v];
}
}
}
if (systemData.voucherCounts) {
getEnumKeys(VoucherType).forEach(key => {
const index = VoucherType[key];
this.voucherCounts[index] = systemData.voucherCounts[index] || 0;
});
}
this.eggs = systemData.eggs ? systemData.eggs.map(e => e.toEgg()) : [];
this.eggPity = systemData.eggPity ? systemData.eggPity.slice(0) : [0, 0, 0, 0];
this.unlockPity = systemData.unlockPity ? systemData.unlockPity.slice(0) : [0, 0, 0, 0];
this.dexData = Object.assign(this.dexData, systemData.dexData);
this.consolidateDexData(this.dexData);
this.defaultDexData = null;
resolve(true);
} catch (err) {
console.error(err);
resolve(false);
} }
});
console.debug(systemData);
localStorage.setItem(`data_${loggedInUser?.username}`, encrypt(systemDataStr, bypassLogin));
const lsItemKey = `runHistoryData_${loggedInUser?.username}`;
const lsItem = localStorage.getItem(lsItemKey);
if (!lsItem) {
localStorage.setItem(lsItemKey, "");
}
applySystemVersionMigration(systemData);
this.trainerId = systemData.trainerId;
this.secretId = systemData.secretId;
this.gender = systemData.gender;
this.saveSetting(SettingKeys.Player_Gender, systemData.gender === PlayerGender.FEMALE ? 1 : 0);
if (!systemData.starterData) {
this.initStarterData();
if (systemData["starterMoveData"]) {
const starterMoveData = systemData["starterMoveData"];
for (const s of Object.keys(starterMoveData)) {
this.starterData[s].moveset = starterMoveData[s];
}
}
if (systemData["starterEggMoveData"]) {
const starterEggMoveData = systemData["starterEggMoveData"];
for (const s of Object.keys(starterEggMoveData)) {
this.starterData[s].eggMoves = starterEggMoveData[s];
}
}
this.migrateStarterAbilities(systemData, this.starterData);
const starterIds = Object.keys(this.starterData).map(s => Number.parseInt(s) as SpeciesId);
for (const s of starterIds) {
this.starterData[s].candyCount += systemData.dexData[s].caughtCount;
this.starterData[s].candyCount += systemData.dexData[s].hatchedCount * 2;
if (systemData.dexData[s].caughtAttr & DexAttr.SHINY) {
this.starterData[s].candyCount += 4;
}
}
} else {
this.starterData = systemData.starterData;
}
if (systemData.gameStats) {
this.gameStats = systemData.gameStats;
}
if (systemData.unlocks) {
for (const key of Object.keys(systemData.unlocks)) {
if (this.unlocks.hasOwnProperty(key)) {
this.unlocks[key] = systemData.unlocks[key];
}
}
}
if (systemData.achvUnlocks) {
for (const a of Object.keys(systemData.achvUnlocks)) {
if (achvs.hasOwnProperty(a)) {
this.achvUnlocks[a] = systemData.achvUnlocks[a];
}
}
}
if (systemData.voucherUnlocks) {
for (const v of Object.keys(systemData.voucherUnlocks)) {
if (vouchers.hasOwnProperty(v)) {
this.voucherUnlocks[v] = systemData.voucherUnlocks[v];
}
}
}
if (systemData.voucherCounts) {
getEnumKeys(VoucherType).forEach(key => {
const index = VoucherType[key];
this.voucherCounts[index] = systemData.voucherCounts[index] || 0;
});
}
this.eggs = systemData.eggs ? systemData.eggs.map(e => e.toEgg()) : [];
this.eggPity = systemData.eggPity ? systemData.eggPity.slice(0) : [0, 0, 0, 0];
this.unlockPity = systemData.unlockPity ? systemData.unlockPity.slice(0) : [0, 0, 0, 0];
this.dexData = Object.assign(this.dexData, systemData.dexData);
this.consolidateDexData(this.dexData);
this.defaultDexData = null;
resolve(true);
} catch (err) {
console.error(err);
resolve(false);
}
return promise;
} }
/** /**
@ -627,6 +629,9 @@ export class GameData {
} }
return ret; return ret;
} }
if (k === "ribbons") {
return RibbonData.fromJSON(v);
}
return k.endsWith("Attr") && !["natureAttr", "abilityAttr", "passiveAttr"].includes(k) ? BigInt(v ?? 0) : v; return k.endsWith("Attr") && !["natureAttr", "abilityAttr", "passiveAttr"].includes(k) ? BigInt(v ?? 0) : v;
}) as SystemSaveData; }) as SystemSaveData;
@ -982,51 +987,45 @@ export class GameData {
} }
async renameSession(slotId: number, newName: string): Promise<boolean> { async renameSession(slotId: number, newName: string): Promise<boolean> {
return new Promise(async resolve => { if (slotId < 0) {
if (slotId < 0) { return false;
return resolve(false); }
} if (newName === "") {
const sessionData: SessionSaveData | null = await this.getSession(slotId); return true;
}
const sessionData: SessionSaveData | null = await this.getSession(slotId);
if (!sessionData) { if (!sessionData) {
return resolve(false); return false;
} }
if (newName === "") { sessionData.name = newName;
return resolve(true); // update timestamp by 1 to ensure the session is saved
} sessionData.timestamp += 1;
const updatedDataStr = JSON.stringify(sessionData);
const encrypted = encrypt(updatedDataStr, bypassLogin);
const secretId = this.secretId;
const trainerId = this.trainerId;
sessionData.runNameText = newName; if (bypassLogin) {
const updatedDataStr = JSON.stringify(sessionData); localStorage.setItem(
const encrypted = encrypt(updatedDataStr, bypassLogin); `sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`,
const secretId = this.secretId; encrypt(updatedDataStr, bypassLogin),
const trainerId = this.trainerId; );
return true;
}
if (bypassLogin) { const response = await pokerogueApi.savedata.session.update(
localStorage.setItem( { slot: slotId, trainerId, secretId, clientSessionId },
`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, updatedDataStr,
encrypt(updatedDataStr, bypassLogin), );
);
resolve(true); if (response) {
return; return false;
} }
pokerogueApi.savedata.session localStorage.setItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, encrypted);
.update({ slot: slotId, trainerId, secretId, clientSessionId }, encrypted) const success = await updateUserInfo();
.then(error => { return !(success !== null && !success);
if (error) {
console.error("Failed to update session name:", error);
resolve(false);
} else {
localStorage.setItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, encrypted);
updateUserInfo().then(success => {
if (success !== null && !success) {
return resolve(false);
}
});
resolve(true);
}
});
});
} }
loadSession(slotId: number, sessionData?: SessionSaveData): Promise<boolean> { loadSession(slotId: number, sessionData?: SessionSaveData): Promise<boolean> {
@ -1634,6 +1633,7 @@ export class GameData {
caughtCount: 0, caughtCount: 0,
hatchedCount: 0, hatchedCount: 0,
ivs: [0, 0, 0, 0, 0, 0], ivs: [0, 0, 0, 0, 0, 0],
ribbons: new RibbonData(0),
}; };
} }
@ -1878,6 +1878,12 @@ export class GameData {
}); });
} }
/**
* Increase the number of classic ribbons won with this species.
* @param species - The species to increment the ribbon count for
* @param forStarter - If true, will increment the ribbon count for the root species of the given species
* @returns The number of classic wins after incrementing.
*/
incrementRibbonCount(species: PokemonSpecies, forStarter = false): number { incrementRibbonCount(species: PokemonSpecies, forStarter = false): number {
const speciesIdToIncrement: SpeciesId = species.getRootSpeciesId(forStarter); const speciesIdToIncrement: SpeciesId = species.getRootSpeciesId(forStarter);
@ -2177,6 +2183,9 @@ export class GameData {
if (!entry.hasOwnProperty("natureAttr") || (entry.caughtAttr && !entry.natureAttr)) { if (!entry.hasOwnProperty("natureAttr") || (entry.caughtAttr && !entry.natureAttr)) {
entry.natureAttr = this.defaultDexData?.[k].natureAttr || 1 << randInt(25, 1); entry.natureAttr = this.defaultDexData?.[k].natureAttr || 1 << randInt(25, 1);
} }
if (!entry.hasOwnProperty("ribbons")) {
entry.ribbons = new RibbonData(0);
}
} }
} }

View File

@ -0,0 +1,148 @@
import type { Brander } from "#types/type-helpers";
export type RibbonFlag = (number & Brander<"RibbonFlag">) | 0;
/**
* Class for ribbon data management. Usually constructed via the {@linkcode fromJSON} method.
*
* @remarks
* Stores information about the ribbons earned by a species using a bitfield.
*/
export class RibbonData {
/** Internal bitfield storing the unlock state for each ribbon */
private payload: number;
//#region Ribbons
//#region Monotype challenge ribbons
/** Ribbon for winning the normal monotype challenge */
public static readonly MONO_NORMAL = 0x1 as RibbonFlag;
/** Ribbon for winning the fighting monotype challenge */
public static readonly MONO_FIGHTING = 0x2 as RibbonFlag;
/** Ribbon for winning the flying monotype challenge */
public static readonly MONO_FLYING = 0x4 as RibbonFlag;
/** Ribbon for winning the poision monotype challenge */
public static readonly MONO_POISON = 0x8 as RibbonFlag;
/** Ribbon for winning the ground monotype challenge */
public static readonly MONO_GROUND = 0x10 as RibbonFlag;
/** Ribbon for winning the rock monotype challenge */
public static readonly MONO_ROCK = 0x20 as RibbonFlag;
/** Ribbon for winning the bug monotype challenge */
public static readonly MONO_BUG = 0x40 as RibbonFlag;
/** Ribbon for winning the ghost monotype challenge */
public static readonly MONO_GHOST = 0x80 as RibbonFlag;
/** Ribbon for winning the steel monotype challenge */
public static readonly MONO_STEEL = 0x100 as RibbonFlag;
/** Ribbon for winning the fire monotype challenge */
public static readonly MONO_FIRE = 0x200 as RibbonFlag;
/** Ribbon for winning the water monotype challenge */
public static readonly MONO_WATER = 0x400 as RibbonFlag;
/** Ribbon for winning the grass monotype challenge */
public static readonly MONO_GRASS = 0x800 as RibbonFlag;
/** Ribbon for winning the electric monotype challenge */
public static readonly MONO_ELECTRIC = 0x1000 as RibbonFlag;
/** Ribbon for winning the psychic monotype challenge */
public static readonly MONO_PSYCHIC = 0x2000 as RibbonFlag;
/** Ribbon for winning the ice monotype challenge */
public static readonly MONO_ICE = 0x4000 as RibbonFlag;
/** Ribbon for winning the dragon monotype challenge */
public static readonly MONO_DRAGON = 0x8000 as RibbonFlag;
/** Ribbon for winning the dark monotype challenge */
public static readonly MONO_DARK = 0x10000 as RibbonFlag;
/** Ribbon for winning the fairy monotype challenge */
public static readonly MONO_FAIRY = 0x20000 as RibbonFlag;
//#endregion Monotype ribbons
//#region Monogen ribbons
/** Ribbon for winning the the mono gen 1 challenge */
public static readonly MONO_GEN_1 = 0x40000 as RibbonFlag;
/** Ribbon for winning the the mono gen 2 challenge */
public static readonly MONO_GEN_2 = 0x80000 as RibbonFlag;
/** Ribbon for winning the mono gen 3 challenge */
public static readonly MONO_GEN_3 = 0x100000 as RibbonFlag;
/** Ribbon for winning the mono gen 4 challenge */
public static readonly MONO_GEN_4 = 0x200000 as RibbonFlag;
/** Ribbon for winning the mono gen 5 challenge */
public static readonly MONO_GEN_5 = 0x400000 as RibbonFlag;
/** Ribbon for winning the mono gen 6 challenge */
public static readonly MONO_GEN_6 = 0x800000 as RibbonFlag;
/** Ribbon for winning the mono gen 7 challenge */
public static readonly MONO_GEN_7 = 0x1000000 as RibbonFlag;
/** Ribbon for winning the mono gen 8 challenge */
public static readonly MONO_GEN_8 = 0x2000000 as RibbonFlag;
/** Ribbon for winning the mono gen 9 challenge */
public static readonly MONO_GEN_9 = 0x4000000 as RibbonFlag;
//#endregion Monogen ribbons
/** Ribbon for winning classic */
public static readonly CLASSIC = 0x8000000 as RibbonFlag;
/** Ribbon for winning the nuzzlocke challenge */
public static readonly NUZLOCKE = 0x10000000 as RibbonFlag;
/** Ribbon for reaching max friendship */
public static readonly FRIENDSHIP = 0x20000000 as RibbonFlag;
/** Ribbon for winning the flip stats challenge */
public static readonly FLIP_STATS = 0x40000000 as RibbonFlag;
/** Ribbon for winning the inverse challenge */
public static readonly INVERSE = 0x80000000 as RibbonFlag;
/** Ribbon for winning the fresh start challenge */
public static readonly FRESH_START = 0x100000000 as RibbonFlag;
/** Ribbon for winning the hardcore challenge */
public static readonly HARDCORE = 0x200000000 as RibbonFlag;
/** Ribbon for winning the limited catch challenge */
public static readonly LIMITED_CATCH = 0x400000000 as RibbonFlag;
/** Ribbon for winning the limited support challenge set to no heal */
public static readonly NO_HEAL = 0x800000000 as RibbonFlag;
/** Ribbon for winning the limited uspport challenge set to no shop */
public static readonly NO_SHOP = 0x1000000000 as RibbonFlag;
/** Ribbon for winning the limited support challenge set to both*/
public static readonly NO_SUPPORT = 0x2000000000 as RibbonFlag;
// NOTE: max possible ribbon flag is 0x20000000000000 (53 total ribbons)
// Once this is exceeded, bitfield needs to be changed to a bigint or even a uint array
// Note that this has no impact on serialization as it is stored in hex.
//#endregion Ribbons
/** Create a new instance of RibbonData. Generally, {@linkcode fromJSON} is used instead. */
constructor(value: number) {
this.payload = value;
}
/** Serialize the bitfield payload as a hex encoded string */
public toJSON(): string {
return this.payload.toString(16);
}
/**
* Decode a hexadecimal string representation of the bitfield into a `RibbonData` instance
*
* @param value - Hexadecimal string representation of the bitfield (without the leading 0x)
* @returns A new instance of `RibbonData` initialized with the provided bitfield.
*/
public static fromJSON(value: string): RibbonData {
try {
return new RibbonData(Number.parseInt(value, 16));
} catch {
return new RibbonData(0);
}
}
/**
* Award one or more ribbons to the ribbon data by setting the corresponding flags in the bitfield.
*
* @param flags - The flags to set. Can be a single flag or multiple flags.
*/
public award(...flags: [RibbonFlag, ...RibbonFlag[]]): void {
for (const f of flags) {
this.payload |= f;
}
}
/**
* Check if a specific ribbon has been awarded
* @param flag - The ribbon to check
* @returns Whether the specified flag has been awarded
*/
public has(flag: RibbonFlag): boolean {
return !!(this.payload & flag);
}
}

View File

@ -0,0 +1,20 @@
import { globalScene } from "#app/global-scene";
import { pokemonPrevolutions } from "#balance/pokemon-evolutions";
import type { SpeciesId } from "#enums/species-id";
import type { RibbonFlag } from "#system/ribbons/ribbon-data";
import { isNullOrUndefined } from "#utils/common";
/**
* Award one or more ribbons to a species and its pre-evolutions
*
* @param id - The ID of the species to award ribbons to
* @param ribbons - The ribbon(s) to award (use bitwise OR to combine multiple)
*/
export function awardRibbonsToSpeciesLine(id: SpeciesId, ribbons: RibbonFlag): void {
const dexData = globalScene.gameData.dexData;
dexData[id].ribbons.award(ribbons);
// Mark all pre-evolutions of the Pokémon with the same ribbon flags.
for (let prevoId = pokemonPrevolutions[id]; !isNullOrUndefined(prevoId); prevoId = pokemonPrevolutions[prevoId]) {
dexData[id].ribbons.award(ribbons);
}
}

View File

@ -31,6 +31,11 @@ import { toTitleCase } from "#utils/strings";
import i18next from "i18next"; import i18next from "i18next";
import type BBCodeText from "phaser3-rex-plugins/plugins/bbcodetext"; import type BBCodeText from "phaser3-rex-plugins/plugins/bbcodetext";
const DISCARD_BUTTON_X = 60;
const DISCARD_BUTTON_X_DOUBLES = 64;
const DISCARD_BUTTON_Y = -73;
const DISCARD_BUTTON_Y_DOUBLES = -58;
const defaultMessage = i18next.t("partyUiHandler:choosePokemon"); const defaultMessage = i18next.t("partyUiHandler:choosePokemon");
/** /**
@ -301,7 +306,7 @@ export class PartyUiHandler extends MessageUiHandler {
const partyMessageText = addTextObject(10, 8, defaultMessage, TextStyle.WINDOW, { maxLines: 2 }); const partyMessageText = addTextObject(10, 8, defaultMessage, TextStyle.WINDOW, { maxLines: 2 });
partyMessageText.setName("text-party-msg"); partyMessageText.setName("text-party-msg");
partyMessageText.setOrigin(0, 0); partyMessageText.setOrigin(0);
partyMessageBoxContainer.add(partyMessageText); partyMessageBoxContainer.add(partyMessageText);
this.message = partyMessageText; this.message = partyMessageText;
@ -317,10 +322,8 @@ export class PartyUiHandler extends MessageUiHandler {
this.iconAnimHandler = new PokemonIconAnimHandler(); this.iconAnimHandler = new PokemonIconAnimHandler();
this.iconAnimHandler.setup(); this.iconAnimHandler.setup();
const partyDiscardModeButton = new PartyDiscardModeButton(60, -globalScene.game.canvas.height / 15 - 1, this); const partyDiscardModeButton = new PartyDiscardModeButton(DISCARD_BUTTON_X, DISCARD_BUTTON_Y, this);
partyContainer.add(partyDiscardModeButton); partyContainer.add(partyDiscardModeButton);
this.partyDiscardModeButton = partyDiscardModeButton; this.partyDiscardModeButton = partyDiscardModeButton;
// prepare move overlay // prepare move overlay
@ -1233,7 +1236,7 @@ export class PartyUiHandler extends MessageUiHandler {
} }
if (!this.optionsCursorObj) { if (!this.optionsCursorObj) {
this.optionsCursorObj = globalScene.add.image(0, 0, "cursor"); this.optionsCursorObj = globalScene.add.image(0, 0, "cursor");
this.optionsCursorObj.setOrigin(0, 0); this.optionsCursorObj.setOrigin(0);
this.optionsContainer.add(this.optionsCursorObj); this.optionsContainer.add(this.optionsCursorObj);
} }
this.optionsCursorObj.setPosition( this.optionsCursorObj.setPosition(
@ -1605,7 +1608,7 @@ export class PartyUiHandler extends MessageUiHandler {
optionText.setColor("#40c8f8"); optionText.setColor("#40c8f8");
optionText.setShadowColor("#006090"); optionText.setShadowColor("#006090");
} }
optionText.setOrigin(0, 0); optionText.setOrigin(0);
/** For every item that has stack bigger than 1, display the current quantity selection */ /** For every item that has stack bigger than 1, display the current quantity selection */
const itemModifiers = this.getItemModifiers(pokemon); const itemModifiers = this.getItemModifiers(pokemon);
@ -1802,6 +1805,7 @@ class PartySlot extends Phaser.GameObjects.Container {
private selected: boolean; private selected: boolean;
private transfer: boolean; private transfer: boolean;
private slotIndex: number; private slotIndex: number;
private isBenched: boolean;
private pokemon: PlayerPokemon; private pokemon: PlayerPokemon;
private slotBg: Phaser.GameObjects.Image; private slotBg: Phaser.GameObjects.Image;
@ -1812,6 +1816,7 @@ class PartySlot extends Phaser.GameObjects.Container {
public slotHpText: Phaser.GameObjects.Text; public slotHpText: Phaser.GameObjects.Text;
public slotDescriptionLabel: Phaser.GameObjects.Text; // this is used to show text instead of the HP bar i.e. for showing "Able"/"Not Able" for TMs when you try to learn them public slotDescriptionLabel: Phaser.GameObjects.Text; // this is used to show text instead of the HP bar i.e. for showing "Able"/"Not Able" for TMs when you try to learn them
private slotBgKey: string;
private pokemonIcon: Phaser.GameObjects.Container; private pokemonIcon: Phaser.GameObjects.Container;
private iconAnimHandler: PokemonIconAnimHandler; private iconAnimHandler: PokemonIconAnimHandler;
@ -1822,19 +1827,34 @@ class PartySlot extends Phaser.GameObjects.Container {
partyUiMode: PartyUiMode, partyUiMode: PartyUiMode,
tmMoveId: MoveId, tmMoveId: MoveId,
) { ) {
super( const isBenched = slotIndex >= globalScene.currentBattle.getBattlerCount();
globalScene, const isDoubleBattle = globalScene.currentBattle.double;
slotIndex >= globalScene.currentBattle.getBattlerCount() ? 230.5 : 64, const isItemManageMode = partyUiMode === PartyUiMode.MODIFIER_TRANSFER || partyUiMode === PartyUiMode.DISCARD;
slotIndex >= globalScene.currentBattle.getBattlerCount()
? -184 + /*
(globalScene.currentBattle.double ? -40 : 0) + * Here we determine the position of the slot.
(28 + (globalScene.currentBattle.double ? 8 : 0)) * slotIndex * The x coordinate depends on whether the pokemon is on the field or in the bench.
: partyUiMode === PartyUiMode.MODIFIER_TRANSFER * The y coordinate depends on various factors, such as the number of pokémon on the field,
? -124 + (globalScene.currentBattle.double ? -20 : 0) + slotIndex * 55 * and whether the transfer/discard button is also on the screen.
: -124 + (globalScene.currentBattle.double ? -8 : 0) + slotIndex * 64, */
); const slotPositionX = isBenched ? 143 : 9;
let slotPositionY: number;
if (isBenched) {
slotPositionY = -196 + (isDoubleBattle ? -40 : 0);
slotPositionY += (28 + (isDoubleBattle ? 8 : 0)) * slotIndex;
} else {
slotPositionY = -148.5;
if (isDoubleBattle) {
slotPositionY += isItemManageMode ? -20 : -8;
}
slotPositionY += (isItemManageMode ? (isDoubleBattle ? 47 : 55) : 64) * slotIndex;
}
super(globalScene, slotPositionX, slotPositionY);
this.slotIndex = slotIndex; this.slotIndex = slotIndex;
this.isBenched = isBenched;
this.pokemon = pokemon; this.pokemon = pokemon;
this.iconAnimHandler = iconAnimHandler; this.iconAnimHandler = iconAnimHandler;
@ -1848,27 +1868,75 @@ class PartySlot extends Phaser.GameObjects.Container {
setup(partyUiMode: PartyUiMode, tmMoveId: MoveId) { setup(partyUiMode: PartyUiMode, tmMoveId: MoveId) {
const currentLanguage = i18next.resolvedLanguage ?? "en"; const currentLanguage = i18next.resolvedLanguage ?? "en";
const offsetJa = currentLanguage === "ja"; const offsetJa = currentLanguage === "ja";
const isItemManageMode = partyUiMode === PartyUiMode.MODIFIER_TRANSFER || partyUiMode === PartyUiMode.DISCARD;
const battlerCount = globalScene.currentBattle.getBattlerCount(); this.slotBgKey = this.isBenched
? "party_slot"
: isItemManageMode && globalScene.currentBattle.double
? "party_slot_main_short"
: "party_slot_main";
const fullSlotBgKey = this.pokemon.hp ? this.slotBgKey : `${this.slotBgKey}${"_fnt"}`;
this.slotBg = globalScene.add.sprite(0, 0, this.slotBgKey, fullSlotBgKey);
this.slotBg.setOrigin(0);
this.add(this.slotBg);
const slotKey = `party_slot${this.slotIndex >= battlerCount ? "" : "_main"}`; const genderSymbol = getGenderSymbol(this.pokemon.getGender(true));
const isFusion = this.pokemon.isFusion();
const slotBg = globalScene.add.sprite(0, 0, slotKey, `${slotKey}${this.pokemon.hp ? "" : "_fnt"}`); // Here we define positions and offsets
this.slotBg = slotBg; // Base values are for the active pokemon; they are changed for benched pokemon,
// or for active pokemon if in a double battle in item management mode.
this.add(slotBg); // icon position relative to slot background
let slotPb = { x: 4, y: 4 };
// name position relative to slot background
let namePosition = { x: 24, y: 10 + (offsetJa ? 2 : 0) };
// maximum allowed length of name; must accomodate fusion symbol
let maxNameTextWidth = 76 - (isFusion ? 8 : 0);
// "Lv." label position relative to slot background
let levelLabelPosition = { x: 24 + 8, y: 10 + 12 };
// offset from "Lv." to the level number; should not be changed.
const levelTextToLevelLabelOffset = { x: 9, y: offsetJa ? 1.5 : 0 };
// offests from "Lv." to gender, spliced and status icons, these depend on the type of slot.
let genderTextToLevelLabelOffset = { x: 68 - (isFusion ? 8 : 0), y: -9 };
let splicedIconToLevelLabelOffset = { x: 68, y: 3.5 - 12 };
let statusIconToLevelLabelOffset = { x: 55, y: 0 };
// offset from the name to the shiny icon (on the left); should not be changed.
const shinyIconToNameOffset = { x: -9, y: 3 };
// hp bar position relative to slot background
let hpBarPosition = { x: 8, y: 31 };
// offsets of hp bar overlay (showing the remaining hp) and number; should not be changed.
const hpOverlayToBarOffset = { x: 16, y: 2 };
const hpTextToBarOffset = { x: -3, y: -2 + (offsetJa ? 2 : 0) };
// description position relative to slot background
let descriptionLabelPosition = { x: 32, y: 46 };
const slotPb = globalScene.add.sprite( // If in item management mode, the active slots are shorter
this.slotIndex >= battlerCount ? -85.5 : -51, if (isItemManageMode && globalScene.currentBattle.double && !this.isBenched) {
this.slotIndex >= battlerCount ? 0 : -20.5, namePosition.y -= 8;
"party_pb", levelLabelPosition.y -= 8;
); hpBarPosition.y -= 8;
this.slotPb = slotPb; descriptionLabelPosition.y -= 8;
}
this.add(slotPb); // Benched slots have significantly different parameters
if (this.isBenched) {
slotPb = { x: 2, y: 12 };
namePosition = { x: 21, y: 2 + (offsetJa ? 2 : 0) };
maxNameTextWidth = 52;
levelLabelPosition = { x: 21 + 8, y: 2 + 12 };
genderTextToLevelLabelOffset = { x: 36, y: 0 };
splicedIconToLevelLabelOffset = { x: 36 + (genderSymbol ? 8 : 0), y: 0.5 };
statusIconToLevelLabelOffset = { x: 43, y: 0 };
hpBarPosition = { x: 72, y: 6 };
descriptionLabelPosition = { x: 94, y: 16 };
}
this.pokemonIcon = globalScene.addPokemonIcon(this.pokemon, slotPb.x, slotPb.y, 0.5, 0.5, true); this.slotPb = globalScene.add.sprite(0, 0, "party_pb");
this.slotPb.setPosition(slotPb.x, slotPb.y);
this.add(this.slotPb);
this.pokemonIcon = globalScene.addPokemonIcon(this.pokemon, this.slotPb.x, this.slotPb.y, 0.5, 0.5, true);
this.add(this.pokemonIcon); this.add(this.pokemonIcon);
this.iconAnimHandler.addOrUpdate(this.pokemonIcon, PokemonIconAnimMode.PASSIVE); this.iconAnimHandler.addOrUpdate(this.pokemonIcon, PokemonIconAnimMode.PASSIVE);
@ -1882,7 +1950,7 @@ class PartySlot extends Phaser.GameObjects.Container {
const nameSizeTest = addTextObject(0, 0, displayName, TextStyle.PARTY); const nameSizeTest = addTextObject(0, 0, displayName, TextStyle.PARTY);
nameTextWidth = nameSizeTest.displayWidth; nameTextWidth = nameSizeTest.displayWidth;
while (nameTextWidth > (this.slotIndex >= battlerCount ? 52 : 76 - (this.pokemon.fusionSpecies ? 8 : 0))) { while (nameTextWidth > maxNameTextWidth) {
displayName = `${displayName.slice(0, displayName.endsWith(".") ? -2 : -1).trimEnd()}.`; displayName = `${displayName.slice(0, displayName.endsWith(".") ? -2 : -1).trimEnd()}.`;
nameSizeTest.setText(displayName); nameSizeTest.setText(displayName);
nameTextWidth = nameSizeTest.displayWidth; nameTextWidth = nameSizeTest.displayWidth;
@ -1891,78 +1959,59 @@ class PartySlot extends Phaser.GameObjects.Container {
nameSizeTest.destroy(); nameSizeTest.destroy();
this.slotName = addTextObject(0, 0, displayName, TextStyle.PARTY); this.slotName = addTextObject(0, 0, displayName, TextStyle.PARTY);
this.slotName.setPositionRelative( this.slotName.setPositionRelative(this.slotBg, namePosition.x, namePosition.y);
slotBg, this.slotName.setOrigin(0);
this.slotIndex >= battlerCount ? 21 : 24,
(this.slotIndex >= battlerCount ? 2 : 10) + (offsetJa ? 2 : 0),
);
this.slotName.setOrigin(0, 0);
const slotLevelLabel = globalScene.add.image(0, 0, "party_slot_overlay_lv"); const slotLevelLabel = globalScene.add
slotLevelLabel.setPositionRelative( .image(0, 0, "party_slot_overlay_lv")
slotBg, .setPositionRelative(this.slotBg, levelLabelPosition.x, levelLabelPosition.y)
(this.slotIndex >= battlerCount ? 21 : 24) + 8, .setOrigin(0);
(this.slotIndex >= battlerCount ? 2 : 10) + 12,
);
slotLevelLabel.setOrigin(0, 0);
const slotLevelText = addTextObject( const slotLevelText = addTextObject(
0, 0,
0, 0,
this.pokemon.level.toString(), this.pokemon.level.toString(),
this.pokemon.level < globalScene.getMaxExpLevel() ? TextStyle.PARTY : TextStyle.PARTY_RED, this.pokemon.level < globalScene.getMaxExpLevel() ? TextStyle.PARTY : TextStyle.PARTY_RED,
); )
slotLevelText.setPositionRelative(slotLevelLabel, 9, offsetJa ? 1.5 : 0); .setPositionRelative(slotLevelLabel, levelTextToLevelLabelOffset.x, levelTextToLevelLabelOffset.y)
slotLevelText.setOrigin(0, 0.25); .setOrigin(0, 0.25);
slotInfoContainer.add([this.slotName, slotLevelLabel, slotLevelText]); slotInfoContainer.add([this.slotName, slotLevelLabel, slotLevelText]);
const genderSymbol = getGenderSymbol(this.pokemon.getGender(true));
if (genderSymbol) { if (genderSymbol) {
const slotGenderText = addTextObject(0, 0, genderSymbol, TextStyle.PARTY); const slotGenderText = addTextObject(0, 0, genderSymbol, TextStyle.PARTY)
slotGenderText.setColor(getGenderColor(this.pokemon.getGender(true))); .setColor(getGenderColor(this.pokemon.getGender(true)))
slotGenderText.setShadowColor(getGenderColor(this.pokemon.getGender(true), true)); .setShadowColor(getGenderColor(this.pokemon.getGender(true), true))
if (this.slotIndex >= battlerCount) { .setPositionRelative(slotLevelLabel, genderTextToLevelLabelOffset.x, genderTextToLevelLabelOffset.y)
slotGenderText.setPositionRelative(slotLevelLabel, 36, 0); .setOrigin(0, 0.25);
} else {
slotGenderText.setPositionRelative(this.slotName, 76 - (this.pokemon.fusionSpecies ? 8 : 0), 3);
}
slotGenderText.setOrigin(0, 0.25);
slotInfoContainer.add(slotGenderText); slotInfoContainer.add(slotGenderText);
} }
if (this.pokemon.fusionSpecies) { if (isFusion) {
const splicedIcon = globalScene.add.image(0, 0, "icon_spliced"); const splicedIcon = globalScene.add
splicedIcon.setScale(0.5); .image(0, 0, "icon_spliced")
splicedIcon.setOrigin(0, 0); .setScale(0.5)
if (this.slotIndex >= battlerCount) { .setOrigin(0)
splicedIcon.setPositionRelative(slotLevelLabel, 36 + (genderSymbol ? 8 : 0), 0.5); .setPositionRelative(slotLevelLabel, splicedIconToLevelLabelOffset.x, splicedIconToLevelLabelOffset.y);
} else {
splicedIcon.setPositionRelative(this.slotName, 76, 3.5);
}
slotInfoContainer.add(splicedIcon); slotInfoContainer.add(splicedIcon);
} }
if (this.pokemon.status) { if (this.pokemon.status) {
const statusIndicator = globalScene.add.sprite(0, 0, getLocalizedSpriteKey("statuses")); const statusIndicator = globalScene.add
statusIndicator.setFrame(StatusEffect[this.pokemon.status?.effect].toLowerCase()); .sprite(0, 0, getLocalizedSpriteKey("statuses"))
statusIndicator.setOrigin(0, 0); .setFrame(StatusEffect[this.pokemon.status?.effect].toLowerCase())
statusIndicator.setPositionRelative(slotLevelLabel, this.slotIndex >= battlerCount ? 43 : 55, 0); .setOrigin(0)
.setPositionRelative(slotLevelLabel, statusIconToLevelLabelOffset.x, statusIconToLevelLabelOffset.y);
slotInfoContainer.add(statusIndicator); slotInfoContainer.add(statusIndicator);
} }
if (this.pokemon.isShiny()) { if (this.pokemon.isShiny()) {
const doubleShiny = this.pokemon.isDoubleShiny(false); const doubleShiny = this.pokemon.isDoubleShiny(false);
const shinyStar = globalScene.add.image(0, 0, `shiny_star_small${doubleShiny ? "_1" : ""}`); const shinyStar = globalScene.add
shinyStar.setOrigin(0, 0); .image(0, 0, `shiny_star_small${doubleShiny ? "_1" : ""}`)
shinyStar.setPositionRelative(this.slotName, -9, 3); .setOrigin(0)
shinyStar.setTint(getVariantTint(this.pokemon.getBaseVariant())); .setPositionRelative(this.slotName, shinyIconToNameOffset.x, shinyIconToNameOffset.y)
.setTint(getVariantTint(this.pokemon.getBaseVariant()));
slotInfoContainer.add(shinyStar); slotInfoContainer.add(shinyStar);
if (doubleShiny) { if (doubleShiny) {
@ -1971,50 +2020,38 @@ class PartySlot extends Phaser.GameObjects.Container {
.setOrigin(0) .setOrigin(0)
.setPosition(shinyStar.x, shinyStar.y) .setPosition(shinyStar.x, shinyStar.y)
.setTint(getVariantTint(this.pokemon.fusionVariant)); .setTint(getVariantTint(this.pokemon.fusionVariant));
slotInfoContainer.add(fusionShinyStar); slotInfoContainer.add(fusionShinyStar);
} }
} }
this.slotHpBar = globalScene.add.image(0, 0, "party_slot_hp_bar"); this.slotHpBar = globalScene.add
this.slotHpBar.setPositionRelative( .image(0, 0, "party_slot_hp_bar")
slotBg, .setOrigin(0)
this.slotIndex >= battlerCount ? 72 : 8, .setVisible(false)
this.slotIndex >= battlerCount ? 6 : 31, .setPositionRelative(this.slotBg, hpBarPosition.x, hpBarPosition.y);
);
this.slotHpBar.setOrigin(0, 0);
this.slotHpBar.setVisible(false);
const hpRatio = this.pokemon.getHpRatio(); const hpRatio = this.pokemon.getHpRatio();
this.slotHpOverlay = globalScene.add.sprite( this.slotHpOverlay = globalScene.add
0, .sprite(0, 0, "party_slot_hp_overlay", hpRatio > 0.5 ? "high" : hpRatio > 0.25 ? "medium" : "low")
0, .setOrigin(0)
"party_slot_hp_overlay", .setPositionRelative(this.slotHpBar, hpOverlayToBarOffset.x, hpOverlayToBarOffset.y)
hpRatio > 0.5 ? "high" : hpRatio > 0.25 ? "medium" : "low", .setScale(hpRatio, 1)
); .setVisible(false);
this.slotHpOverlay.setPositionRelative(this.slotHpBar, 16, 2);
this.slotHpOverlay.setOrigin(0, 0);
this.slotHpOverlay.setScale(hpRatio, 1);
this.slotHpOverlay.setVisible(false);
this.slotHpText = addTextObject(0, 0, `${this.pokemon.hp}/${this.pokemon.getMaxHp()}`, TextStyle.PARTY); this.slotHpText = addTextObject(0, 0, `${this.pokemon.hp}/${this.pokemon.getMaxHp()}`, TextStyle.PARTY)
this.slotHpText.setPositionRelative( .setOrigin(1, 0)
this.slotHpBar, .setPositionRelative(
this.slotHpBar.width - 3, this.slotHpBar,
this.slotHpBar.height - 2 + (offsetJa ? 2 : 0), this.slotHpBar.width + hpTextToBarOffset.x,
); this.slotHpBar.height + hpTextToBarOffset.y,
this.slotHpText.setOrigin(1, 0); ) // TODO: annoying because it contains the width
this.slotHpText.setVisible(false); .setVisible(false);
this.slotDescriptionLabel = addTextObject(0, 0, "", TextStyle.MESSAGE); this.slotDescriptionLabel = addTextObject(0, 0, "", TextStyle.MESSAGE)
this.slotDescriptionLabel.setPositionRelative( .setOrigin(0, 1)
slotBg, .setVisible(false)
this.slotIndex >= battlerCount ? 94 : 32, .setPositionRelative(this.slotBg, descriptionLabelPosition.x, descriptionLabelPosition.y);
this.slotIndex >= battlerCount ? 16 : 46,
);
this.slotDescriptionLabel.setOrigin(0, 1);
this.slotDescriptionLabel.setVisible(false);
slotInfoContainer.add([this.slotHpBar, this.slotHpOverlay, this.slotHpText, this.slotDescriptionLabel]); slotInfoContainer.add([this.slotHpBar, this.slotHpOverlay, this.slotHpText, this.slotDescriptionLabel]);
@ -2076,10 +2113,9 @@ class PartySlot extends Phaser.GameObjects.Container {
} }
private updateSlotTexture(): void { private updateSlotTexture(): void {
const battlerCount = globalScene.currentBattle.getBattlerCount();
this.slotBg.setTexture( this.slotBg.setTexture(
`party_slot${this.slotIndex >= battlerCount ? "" : "_main"}`, this.slotBgKey,
`party_slot${this.slotIndex >= battlerCount ? "" : "_main"}${this.transfer ? "_swap" : this.pokemon.hp ? "" : "_fnt"}${this.selected ? "_sel" : ""}`, `${this.slotBgKey}${this.transfer ? "_swap" : this.pokemon.hp ? "" : "_fnt"}${this.selected ? "_sel" : ""}`,
); );
} }
} }
@ -2198,10 +2234,6 @@ class PartyDiscardModeButton extends Phaser.GameObjects.Container {
this.discardIcon.setVisible(false); this.discardIcon.setVisible(false);
this.textBox.setVisible(true); this.textBox.setVisible(true);
this.textBox.setText(i18next.t("partyUiHandler:TRANSFER")); this.textBox.setText(i18next.t("partyUiHandler:TRANSFER"));
this.setPosition(
globalScene.currentBattle.double ? 64 : 60,
globalScene.currentBattle.double ? -48 : -globalScene.game.canvas.height / 15 - 1,
);
this.transferIcon.displayWidth = this.textBox.text.length * 9 + 3; this.transferIcon.displayWidth = this.textBox.text.length * 9 + 3;
break; break;
case PartyUiMode.DISCARD: case PartyUiMode.DISCARD:
@ -2209,13 +2241,13 @@ class PartyDiscardModeButton extends Phaser.GameObjects.Container {
this.discardIcon.setVisible(true); this.discardIcon.setVisible(true);
this.textBox.setVisible(true); this.textBox.setVisible(true);
this.textBox.setText(i18next.t("partyUiHandler:DISCARD")); this.textBox.setText(i18next.t("partyUiHandler:DISCARD"));
this.setPosition(
globalScene.currentBattle.double ? 64 : 60,
globalScene.currentBattle.double ? -48 : -globalScene.game.canvas.height / 15 - 1,
);
this.discardIcon.displayWidth = this.textBox.text.length * 9 + 3; this.discardIcon.displayWidth = this.textBox.text.length * 9 + 3;
break; break;
} }
this.setPosition(
globalScene.currentBattle.double ? DISCARD_BUTTON_X_DOUBLES : DISCARD_BUTTON_X,
globalScene.currentBattle.double ? DISCARD_BUTTON_Y_DOUBLES : DISCARD_BUTTON_Y,
);
} }
clear() { clear() {

View File

@ -208,7 +208,7 @@ export class RunInfoUiHandler extends UiHandler {
headerText.setOrigin(0, 0); headerText.setOrigin(0, 0);
headerText.setPositionRelative(headerBg, 8, 4); headerText.setPositionRelative(headerBg, 8, 4);
this.runContainer.add(headerText); this.runContainer.add(headerText);
const runName = addTextObject(0, 0, this.runInfo.runNameText, TextStyle.WINDOW); const runName = addTextObject(0, 0, this.runInfo.name, TextStyle.WINDOW);
runName.setOrigin(0, 0); runName.setOrigin(0, 0);
runName.setPositionRelative(headerBg, 60, 4); runName.setPositionRelative(headerBg, 60, 4);
this.runContainer.add(runName); this.runContainer.add(runName);

View File

@ -377,7 +377,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
"select_cursor_highlight_thick", "select_cursor_highlight_thick",
undefined, undefined,
294, 294,
this.sessionSlots[prevSlotIndex ?? 0]?.saveData?.runNameText ? 50 : 60, this.sessionSlots[prevSlotIndex ?? 0]?.saveData?.name ? 50 : 60,
6, 6,
6, 6,
6, 6,
@ -553,10 +553,10 @@ class SessionSlot extends Phaser.GameObjects.Container {
} }
async setupWithData(data: SessionSaveData) { async setupWithData(data: SessionSaveData) {
const hasName = data?.runNameText; const hasName = data?.name;
this.remove(this.loadingLabel, true); this.remove(this.loadingLabel, true);
if (hasName) { if (hasName) {
const nameLabel = addTextObject(8, 5, data.runNameText, TextStyle.WINDOW); const nameLabel = addTextObject(8, 5, data.name, TextStyle.WINDOW);
this.add(nameLabel); this.add(nameLabel);
} else { } else {
const fallbackName = this.decideFallback(data); const fallbackName = this.decideFallback(data);

View File

@ -45,6 +45,7 @@ import type { Variant } from "#sprites/variant";
import { getVariantIcon, getVariantTint } from "#sprites/variant"; import { getVariantIcon, getVariantTint } from "#sprites/variant";
import { achvs } from "#system/achv"; import { achvs } from "#system/achv";
import type { DexAttrProps, StarterAttributes, StarterMoveset } from "#system/game-data"; import type { DexAttrProps, StarterAttributes, StarterMoveset } from "#system/game-data";
import { RibbonData } from "#system/ribbons/ribbon-data";
import { SettingKeyboard } from "#system/settings-keyboard"; import { SettingKeyboard } from "#system/settings-keyboard";
import type { DexEntry } from "#types/dex-data"; import type { DexEntry } from "#types/dex-data";
import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler"; import type { OptionSelectItem } from "#ui/abstract-option-select-ui-handler";
@ -3226,6 +3227,8 @@ export class StarterSelectUiHandler extends MessageUiHandler {
onScreenFirstIndex + maxRows * maxColumns - 1, onScreenFirstIndex + maxRows * maxColumns - 1,
); );
const gameData = globalScene.gameData;
this.starterSelectScrollBar.setScrollCursor(this.scrollCursor); this.starterSelectScrollBar.setScrollCursor(this.scrollCursor);
let pokerusCursorIndex = 0; let pokerusCursorIndex = 0;
@ -3265,9 +3268,9 @@ export class StarterSelectUiHandler extends MessageUiHandler {
container.label.setVisible(true); container.label.setVisible(true);
const speciesVariants = const speciesVariants =
speciesId && globalScene.gameData.dexData[speciesId].caughtAttr & DexAttr.SHINY speciesId && gameData.dexData[speciesId].caughtAttr & DexAttr.SHINY
? [DexAttr.DEFAULT_VARIANT, DexAttr.VARIANT_2, DexAttr.VARIANT_3].filter( ? [DexAttr.DEFAULT_VARIANT, DexAttr.VARIANT_2, DexAttr.VARIANT_3].filter(
v => !!(globalScene.gameData.dexData[speciesId].caughtAttr & v), v => !!(gameData.dexData[speciesId].caughtAttr & v),
) )
: []; : [];
for (let v = 0; v < 3; v++) { for (let v = 0; v < 3; v++) {
@ -3282,12 +3285,15 @@ export class StarterSelectUiHandler extends MessageUiHandler {
} }
} }
container.starterPassiveBgs.setVisible(!!globalScene.gameData.starterData[speciesId].passiveAttr); container.starterPassiveBgs.setVisible(!!gameData.starterData[speciesId].passiveAttr);
container.hiddenAbilityIcon.setVisible( container.hiddenAbilityIcon.setVisible(
!!globalScene.gameData.dexData[speciesId].caughtAttr && !!gameData.dexData[speciesId].caughtAttr && !!(gameData.starterData[speciesId].abilityAttr & 4),
!!(globalScene.gameData.starterData[speciesId].abilityAttr & 4),
); );
container.classicWinIcon.setVisible(globalScene.gameData.starterData[speciesId].classicWinCount > 0); container.classicWinIcon
.setVisible(gameData.starterData[speciesId].classicWinCount > 0)
.setTexture(
gameData.dexData[speciesId].ribbons.has(RibbonData.NUZLOCKE) ? "champion_ribbon_emerald" : "champion_ribbon",
);
container.favoriteIcon.setVisible(this.starterPreferences[speciesId]?.favorite ?? false); container.favoriteIcon.setVisible(this.starterPreferences[speciesId]?.favorite ?? false);
// 'Candy Icon' mode // 'Candy Icon' mode

View File

@ -4,6 +4,7 @@ import { pokemonEvolutions } from "#balance/pokemon-evolutions";
import { pokemonFormChanges } from "#data/pokemon-forms"; import { pokemonFormChanges } from "#data/pokemon-forms";
import type { PokemonSpecies } from "#data/pokemon-species"; import type { PokemonSpecies } from "#data/pokemon-species";
import { ChallengeType } from "#enums/challenge-type"; import { ChallengeType } from "#enums/challenge-type";
import { Challenges } from "#enums/challenges";
import type { MoveId } from "#enums/move-id"; import type { MoveId } from "#enums/move-id";
import type { MoveSourceType } from "#enums/move-source-type"; import type { MoveSourceType } from "#enums/move-source-type";
import type { SpeciesId } from "#enums/species-id"; import type { SpeciesId } from "#enums/species-id";
@ -378,7 +379,7 @@ export function checkStarterValidForChallenge(species: PokemonSpecies, props: De
* @param soft - If `true`, allow it if it could become valid through a form change. * @param soft - If `true`, allow it if it could become valid through a form change.
* @returns `true` if the species is considered valid. * @returns `true` if the species is considered valid.
*/ */
function checkSpeciesValidForChallenge(species: PokemonSpecies, props: DexAttrProps, soft: boolean) { export function checkSpeciesValidForChallenge(species: PokemonSpecies, props: DexAttrProps, soft: boolean) {
const isValidForChallenge = new BooleanHolder(true); const isValidForChallenge = new BooleanHolder(true);
applyChallenges(ChallengeType.STARTER_CHOICE, species, isValidForChallenge, props); applyChallenges(ChallengeType.STARTER_CHOICE, species, isValidForChallenge, props);
if (!soft || !pokemonFormChanges.hasOwnProperty(species.speciesId)) { if (!soft || !pokemonFormChanges.hasOwnProperty(species.speciesId)) {
@ -407,3 +408,28 @@ function checkSpeciesValidForChallenge(species: PokemonSpecies, props: DexAttrPr
}); });
return result; return result;
} }
/** @returns Whether the current game mode meets the criteria to be considered a Nuzlocke challenge */
export function isNuzlockeChallenge(): boolean {
let isFreshStart = false;
let isLimitedCatch = false;
let isHardcore = false;
for (const challenge of globalScene.gameMode.challenges) {
// value is 0 if challenge is not active
if (!challenge.value) {
continue;
}
switch (challenge.id) {
case Challenges.FRESH_START:
isFreshStart = true;
break;
case Challenges.LIMITED_CATCH:
isLimitedCatch = true;
break;
case Challenges.HARDCORE:
isHardcore = true;
break;
}
}
return isFreshStart && isLimitedCatch && isHardcore;
}

View File

@ -45,17 +45,17 @@ export function deepMergeSpriteData(dest: object, source: object) {
} }
export function encrypt(data: string, bypassLogin: boolean): string { export function encrypt(data: string, bypassLogin: boolean): string {
return (bypassLogin if (bypassLogin) {
? (data: string) => btoa(encodeURIComponent(data)) return btoa(encodeURIComponent(data));
: (data: string) => AES.encrypt(data, saveKey))(data) as unknown as string; // TODO: is this correct? }
return AES.encrypt(data, saveKey).toString();
} }
export function decrypt(data: string, bypassLogin: boolean): string { export function decrypt(data: string, bypassLogin: boolean): string {
return ( if (bypassLogin) {
bypassLogin return decodeURIComponent(atob(data));
? (data: string) => decodeURIComponent(atob(data)) }
: (data: string) => AES.decrypt(data, saveKey).toString(enc.Utf8) return AES.decrypt(data, saveKey).toString(enc.Utf8);
)(data);
} }
// the latest data saved/loaded for the Starter Preferences. Required to reduce read/writes. Initialize as "{}", since this is the default value and no data needs to be stored if present. // the latest data saved/loaded for the Starter Preferences. Required to reduce read/writes. Initialize as "{}", since this is the default value and no data needs to be stored if present.

View File

@ -0,0 +1,27 @@
import type { AtLeastOne, NonFunctionPropertiesRecursive as nonFunc } from "#types/type-helpers";
/**
* Helper type to admit an object containing the given properties
* _and_ at least 1 other non-function property.
* @example
* ```ts
* type foo = {
* qux: 1 | 2 | 3,
* bar: number,
* baz: string
* quux: () => void; // ignored!
* }
*
* type quxAndSomethingElse = OneOther<foo, "qux">
*
* const good1: quxAndSomethingElse = {qux: 1, bar: 3} // OK!
* const good2: quxAndSomethingElse = {qux: 2, baz: "4", bar: 12} // OK!
* const bad1: quxAndSomethingElse = {baz: "4", bar: 12} // Errors because `qux` is required
* const bad2: quxAndSomethingElse = {qux: 1} // Errors because at least 1 thing _other_ than `qux` is required
* ```
* @typeParam O - The object to source keys from
* @typeParam K - One or more of O's keys to render mandatory
*/
export type OneOther<O extends object, K extends keyof O> = AtLeastOne<Omit<nonFunc<O>, K>> & {
[key in K]: O[K];
};

View File

@ -1,23 +1,32 @@
import type { TerrainType } from "#app/data/terrain"; import type { TerrainType } from "#app/data/terrain";
import type Overrides from "#app/overrides";
import type { ArenaTag } from "#data/arena-tag";
import type { PositionalTag } from "#data/positional-tags/positional-tag";
import type { AbilityId } from "#enums/ability-id"; import type { AbilityId } from "#enums/ability-id";
import type { ArenaTagSide } from "#enums/arena-tag-side";
import type { ArenaTagType } from "#enums/arena-tag-type";
import type { BattlerTagType } from "#enums/battler-tag-type"; import type { BattlerTagType } from "#enums/battler-tag-type";
import type { MoveId } from "#enums/move-id"; import type { MoveId } from "#enums/move-id";
import type { PokemonType } from "#enums/pokemon-type"; import type { PokemonType } from "#enums/pokemon-type";
import type { PositionalTagType } from "#enums/positional-tag-type";
import type { BattleStat, EffectiveStat, Stat } from "#enums/stat"; import type { BattleStat, EffectiveStat, Stat } from "#enums/stat";
import type { StatusEffect } from "#enums/status-effect"; import type { StatusEffect } from "#enums/status-effect";
import type { WeatherType } from "#enums/weather-type"; import type { WeatherType } from "#enums/weather-type";
import type { Arena } from "#field/arena";
import type { Pokemon } from "#field/pokemon"; import type { Pokemon } from "#field/pokemon";
import type { ToHaveEffectiveStatMatcherOptions } from "#test/test-utils/matchers/to-have-effective-stat"; import type { PokemonMove } from "#moves/pokemon-move";
import type { toHaveArenaTagOptions } from "#test/test-utils/matchers/to-have-arena-tag";
import type { toHaveEffectiveStatOptions } from "#test/test-utils/matchers/to-have-effective-stat";
import type { toHavePositionalTagOptions } from "#test/test-utils/matchers/to-have-positional-tag";
import type { expectedStatusType } from "#test/test-utils/matchers/to-have-status-effect"; import type { expectedStatusType } from "#test/test-utils/matchers/to-have-status-effect";
import type { toHaveTypesOptions } from "#test/test-utils/matchers/to-have-types"; import type { toHaveTypesOptions } from "#test/test-utils/matchers/to-have-types";
import type { TurnMove } from "#types/turn-move"; import type { TurnMove } from "#types/turn-move";
import type { AtLeastOne } from "#types/type-helpers"; import type { AtLeastOne } from "#types/type-helpers";
import type { toDmgValue } from "utils/common";
import type { expect } from "vitest"; import type { expect } from "vitest";
import type Overrides from "#app/overrides";
import type { PokemonMove } from "#moves/pokemon-move";
declare module "vitest" { declare module "vitest" {
interface Assertion { interface Assertion<T> {
/** /**
* Check whether an array contains EXACTLY the given items (in any order). * Check whether an array contains EXACTLY the given items (in any order).
* *
@ -27,45 +36,9 @@ declare module "vitest" {
* @param expected - The expected contents of the array, in any order * @param expected - The expected contents of the array, in any order
* @see {@linkcode expect.arrayContaining} * @see {@linkcode expect.arrayContaining}
*/ */
toEqualArrayUnsorted<E>(expected: E[]): void; toEqualArrayUnsorted(expected: T[]): void;
/** // #region Arena Matchers
* Check whether a {@linkcode Pokemon}'s current typing includes the given types.
*
* @param expected - The expected types (in any order)
* @param options - The options passed to the matcher
*/
toHaveTypes(expected: [PokemonType, ...PokemonType[]], options?: toHaveTypesOptions): void;
/**
* Matcher to check the contents of a {@linkcode Pokemon}'s move history.
*
* @param expectedValue - The expected value; can be a {@linkcode MoveId} or a partially filled {@linkcode TurnMove}
* containing the desired properties to check
* @param index - The index of the move history entry to check, in order from most recent to least recent.
* Default `0` (last used move)
* @see {@linkcode Pokemon.getLastXMoves}
*/
toHaveUsedMove(expected: MoveId | AtLeastOne<TurnMove>, index?: number): void;
/**
* Check whether a {@linkcode Pokemon}'s effective stat is as expected
* (checked after all stat value modifications).
*
* @param stat - The {@linkcode EffectiveStat} to check
* @param expectedValue - The expected value of {@linkcode stat}
* @param options - (Optional) The {@linkcode ToHaveEffectiveStatMatcherOptions}
* @remarks
* If you want to check the stat **before** modifiers are applied, use {@linkcode Pokemon.getStat} instead.
*/
toHaveEffectiveStat(stat: EffectiveStat, expectedValue: number, options?: ToHaveEffectiveStatMatcherOptions): void;
/**
* Check whether a {@linkcode Pokemon} has taken a specific amount of damage.
* @param expectedDamageTaken - The expected amount of damage taken
* @param roundDown - Whether to round down {@linkcode expectedDamageTaken} with {@linkcode toDmgValue}; default `true`
*/
toHaveTakenDamage(expectedDamageTaken: number, roundDown?: boolean): void;
/** /**
* Check whether the current {@linkcode WeatherType} is as expected. * Check whether the current {@linkcode WeatherType} is as expected.
@ -80,9 +53,60 @@ declare module "vitest" {
toHaveTerrain(expectedTerrainType: TerrainType): void; toHaveTerrain(expectedTerrainType: TerrainType): void;
/** /**
* Check whether a {@linkcode Pokemon} is at full HP. * Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}.
* @param expectedTag - A partially-filled {@linkcode ArenaTag} containing the desired properties
*/ */
toHaveFullHp(): void; toHaveArenaTag<A extends ArenaTagType>(expectedTag: toHaveArenaTagOptions<A>): void;
/**
* Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}.
* @param expectedType - The {@linkcode ArenaTagType} of the desired tag
* @param side - The {@linkcode ArenaTagSide | side(s) of the field} the tag should affect; default {@linkcode ArenaTagSide.BOTH}
*/
toHaveArenaTag(expectedType: ArenaTagType, side?: ArenaTagSide): void;
/**
* Check whether the current {@linkcode Arena} contains the given {@linkcode PositionalTag}.
* @param expectedTag - A partially-filled `PositionalTag` containing the desired properties
*/
toHavePositionalTag<P extends PositionalTagType>(expectedTag: toHavePositionalTagOptions<P>): void;
/**
* Check whether the current {@linkcode Arena} contains the given number of {@linkcode PositionalTag}s.
* @param expectedType - The {@linkcode PositionalTagType} of the desired tag
* @param count - The number of instances of {@linkcode expectedType} that should be active;
* defaults to `1` and must be within the range `[0, 4]`
*/
toHavePositionalTag(expectedType: PositionalTagType, count?: number): void;
// #endregion Arena Matchers
// #region Pokemon Matchers
/**
* Check whether a {@linkcode Pokemon}'s current typing includes the given types.
* @param expectedTypes - The expected {@linkcode PokemonType}s to check against; must have length `>0`
* @param options - The {@linkcode toHaveTypesOptions | options} passed to the matcher
*/
toHaveTypes(expectedTypes: PokemonType[], options?: toHaveTypesOptions): void;
/**
* Check whether a {@linkcode Pokemon} has used a move matching the given criteria.
* @param expectedMove - The {@linkcode MoveId} the Pokemon is expected to have used,
* or a partially filled {@linkcode TurnMove} containing the desired properties to check
* @param index - The index of the move history entry to check, in order from most recent to least recent; default `0`
* @see {@linkcode Pokemon.getLastXMoves}
*/
toHaveUsedMove(expectedMove: MoveId | AtLeastOne<TurnMove>, index?: number): void;
/**
* Check whether a {@linkcode Pokemon}'s effective stat is as expected
* (checked after all stat value modifications).
* @param stat - The {@linkcode EffectiveStat} to check
* @param expectedValue - The expected value of {@linkcode stat}
* @param options - The {@linkcode toHaveEffectiveStatOptions | options} passed to the matcher
* @remarks
* If you want to check the stat **before** modifiers are applied, use {@linkcode Pokemon.getStat} instead.
*/
toHaveEffectiveStat(stat: EffectiveStat, expectedValue: number, options?: toHaveEffectiveStatOptions): void;
/** /**
* Check whether a {@linkcode Pokemon} has a specific {@linkcode StatusEffect | non-volatile status effect}. * Check whether a {@linkcode Pokemon} has a specific {@linkcode StatusEffect | non-volatile status effect}.
@ -106,7 +130,7 @@ declare module "vitest" {
/** /**
* Check whether a {@linkcode Pokemon} has applied a specific {@linkcode AbilityId}. * Check whether a {@linkcode Pokemon} has applied a specific {@linkcode AbilityId}.
* @param expectedAbilityId - The expected {@linkcode AbilityId} * @param expectedAbilityId - The `AbilityId` to check for
*/ */
toHaveAbilityApplied(expectedAbilityId: AbilityId): void; toHaveAbilityApplied(expectedAbilityId: AbilityId): void;
@ -116,24 +140,36 @@ declare module "vitest" {
*/ */
toHaveHp(expectedHp: number): void; toHaveHp(expectedHp: number): void;
/**
* Check whether a {@linkcode Pokemon} has taken a specific amount of damage.
* @param expectedDamageTaken - The expected amount of damage taken
* @param roundDown - Whether to round down `expectedDamageTaken` with {@linkcode toDmgValue}; default `true`
*/
toHaveTakenDamage(expectedDamageTaken: number, roundDown?: boolean): void;
/** /**
* Check whether a {@linkcode Pokemon} is currently fainted (as determined by {@linkcode Pokemon.isFainted}). * Check whether a {@linkcode Pokemon} is currently fainted (as determined by {@linkcode Pokemon.isFainted}).
* @remarks * @remarks
* When checking whether an enemy wild Pokemon is fainted, one must reference it in a variable _before_ the fainting effect occurs * When checking whether an enemy wild Pokemon is fainted, one must store a reference to it in a variable _before_ the fainting effect occurs.
* as otherwise the Pokemon will be GC'ed and rendered `undefined`. * Otherwise, the Pokemon will be removed from the field and garbage collected.
*/ */
toHaveFainted(): void; toHaveFainted(): void;
/**
* Check whether a {@linkcode Pokemon} is at full HP.
*/
toHaveFullHp(): void;
/** /**
* Check whether a {@linkcode Pokemon} has consumed the given amount of PP for one of its moves. * Check whether a {@linkcode Pokemon} has consumed the given amount of PP for one of its moves.
* @param expectedValue - The {@linkcode MoveId} of the {@linkcode PokemonMove} that should have consumed PP * @param moveId - The {@linkcode MoveId} corresponding to the {@linkcode PokemonMove} that should have consumed PP
* @param ppUsed - The numerical amount of PP that should have been consumed, * @param ppUsed - The numerical amount of PP that should have been consumed,
* or `all` to indicate the move should be _out_ of PP * or `all` to indicate the move should be _out_ of PP
* @remarks * @remarks
* If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.OPP_MOVESET_OVERRIDE}, * If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.ENEMY_MOVESET_OVERRIDE}
* does not contain {@linkcode expectedMove} * or does not contain exactly one copy of `moveId`, this will fail the test.
* or contains the desired move more than once, this will fail the test.
*/ */
toHaveUsedPP(expectedMove: MoveId, ppUsed: number | "all"): void; toHaveUsedPP(moveId: MoveId, ppUsed: number | "all"): void;
// #endregion Pokemon Matchers
} }
} }

View File

@ -278,27 +278,6 @@ describe("Abilities - Parental Bond", () => {
expect(enemyPokemon.getTag(BattlerTagType.TRAPPED)).toBeDefined(); expect(enemyPokemon.getTag(BattlerTagType.TRAPPED)).toBeDefined();
}); });
it("Smack Down boosted by this ability should only ground the target after the second hit", async () => {
game.override.moveset([MoveId.SMACK_DOWN]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const leadPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.SMACK_DOWN);
await game.move.forceHit();
await game.phaseInterceptor.to("DamageAnimPhase");
expect(leadPokemon.turnData.hitCount).toBe(2);
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined();
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
});
it("U-turn boosted by this ability should strike twice before forcing a switch", async () => { it("U-turn boosted by this ability should strike twice before forcing a switch", async () => {
game.override.moveset([MoveId.U_TURN]); game.override.moveset([MoveId.U_TURN]);

View File

@ -1,5 +1,6 @@
import { Status } from "#data/status-effect"; import { Status } from "#data/status-effect";
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerTagType } from "#enums/battler-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
@ -104,27 +105,26 @@ describe("Abilities - SHIELDS DOWN", () => {
expect(game.field.getPlayerPokemon().status).toBe(undefined); expect(game.field.getPlayerPokemon().status).toBe(undefined);
}); });
// toxic spikes currently does not poison flying types when gravity is in effect it("should be poisoned by toxic spikes when Gravity is active before changing forms", async () => {
test.todo("should become poisoned by toxic spikes when grounded", async () => {
game.override
.enemyMoveset([MoveId.GRAVITY, MoveId.TOXIC_SPIKES, MoveId.SPLASH])
.moveset([MoveId.GRAVITY, MoveId.SPLASH]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MINIOR]); await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MINIOR]);
// turn 1 // Change minior to core form in a state where it would revert on switch
game.move.select(MoveId.GRAVITY); const minior = game.scene.getPlayerParty()[1];
await game.move.selectEnemyMove(MoveId.TOXIC_SPIKES); minior.formIndex = redCoreForm;
game.move.use(MoveId.GRAVITY);
await game.move.forceEnemyMove(MoveId.TOXIC_SPIKES);
await game.toNextTurn(); await game.toNextTurn();
// turn 2 expect(game).toHaveArenaTag(ArenaTagType.GRAVITY);
game.doSwitchPokemon(1); game.doSwitchPokemon(1);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.toNextTurn(); await game.toNextTurn();
expect(game.field.getPlayerPokemon().species.speciesId).toBe(SpeciesId.MINIOR); expect(minior.species.speciesId).toBe(SpeciesId.MINIOR);
expect(game.field.getPlayerPokemon().species.formIndex).toBe(0); expect(minior.formIndex).toBe(0);
expect(game.field.getPlayerPokemon().status?.effect).toBe(StatusEffect.POISON); expect(minior.isGrounded()).toBe(true);
expect(minior).toHaveStatusEffect(StatusEffect.POISON);
}); });
test("should ignore yawn", async () => { test("should ignore yawn", async () => {

View File

@ -1,8 +1,6 @@
import { allMoves } from "#data/data-lists"; import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
import { ArenaTagType } from "#enums/arena-tag-type"; import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
@ -27,130 +25,49 @@ describe("Arena - Gravity", () => {
game = new GameManager(phaserGame); game = new GameManager(phaserGame);
game.override game.override
.battleStyle("single") .battleStyle("single")
.moveset([MoveId.TACKLE, MoveId.GRAVITY, MoveId.FISSURE])
.ability(AbilityId.UNNERVE) .ability(AbilityId.UNNERVE)
.enemyAbility(AbilityId.BALL_FETCH) .enemyAbility(AbilityId.BALL_FETCH)
.enemySpecies(SpeciesId.SHUCKLE) .enemySpecies(SpeciesId.MAGIKARP)
.enemyMoveset(MoveId.SPLASH)
.enemyLevel(5); .enemyLevel(5);
}); });
// Reference: https://bulbapedia.bulbagarden.net/wiki/Gravity_(move) // Reference: https://bulbapedia.bulbagarden.net/wiki/Gravity_(move)
it("non-OHKO move accuracy is multiplied by 1.67", async () => { it("should multiply all non-OHKO move accuracy by 1.67x", async () => {
const moveToCheck = allMoves[MoveId.TACKLE]; const accSpy = vi.spyOn(allMoves[MoveId.TACKLE], "calculateBattleAccuracy");
vi.spyOn(moveToCheck, "calculateBattleAccuracy");
// Setup Gravity on first turn
await game.classicMode.startBattle([SpeciesId.PIKACHU]); await game.classicMode.startBattle([SpeciesId.PIKACHU]);
game.move.select(MoveId.GRAVITY);
await game.phaseInterceptor.to("TurnEndPhase"); game.move.use(MoveId.GRAVITY);
await game.move.forceEnemyMove(MoveId.TACKLE);
await game.toEndOfTurn();
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined(); expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
expect(accSpy).toHaveLastReturnedWith(allMoves[MoveId.TACKLE].accuracy * 1.67);
// Use non-OHKO move on second turn
await game.toNextTurn();
game.move.select(MoveId.TACKLE);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(moveToCheck.calculateBattleAccuracy).toHaveLastReturnedWith(100 * 1.67);
}); });
it("OHKO move accuracy is not affected", async () => { it("should not affect OHKO move accuracy", async () => {
/** See Fissure {@link https://bulbapedia.bulbagarden.net/wiki/Fissure_(move)} */ const accSpy = vi.spyOn(allMoves[MoveId.FISSURE], "calculateBattleAccuracy");
const moveToCheck = allMoves[MoveId.FISSURE];
vi.spyOn(moveToCheck, "calculateBattleAccuracy");
// Setup Gravity on first turn
await game.classicMode.startBattle([SpeciesId.PIKACHU]); await game.classicMode.startBattle([SpeciesId.PIKACHU]);
game.move.select(MoveId.GRAVITY);
await game.phaseInterceptor.to("TurnEndPhase"); game.move.use(MoveId.GRAVITY);
await game.move.forceEnemyMove(MoveId.FISSURE);
await game.toEndOfTurn();
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined(); expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
expect(accSpy).toHaveLastReturnedWith(allMoves[MoveId.FISSURE].accuracy);
// Use OHKO move on second turn
await game.toNextTurn();
game.move.select(MoveId.FISSURE);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(moveToCheck.calculateBattleAccuracy).toHaveLastReturnedWith(30);
}); });
describe("Against flying types", () => { it("should forcibly ground all Pokemon for the duration of the effect", async () => {
it("can be hit by ground-type moves now", async () => { await game.classicMode.startBattle([SpeciesId.PIKACHU]);
game.override.enemySpecies(SpeciesId.PIDGEOT).moveset([MoveId.GRAVITY, MoveId.EARTHQUAKE]);
await game.classicMode.startBattle([SpeciesId.PIKACHU]); const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
const pidgeot = game.field.getEnemyPokemon();
vi.spyOn(pidgeot, "getAttackTypeEffectiveness");
// Try earthquake on 1st turn (fails!);
game.move.select(MoveId.EARTHQUAKE);
await game.phaseInterceptor.to("TurnEndPhase");
expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(0);
// Setup Gravity on 2nd turn
await game.toNextTurn();
game.move.select(MoveId.GRAVITY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use ground move on 3rd turn
await game.toNextTurn();
game.move.select(MoveId.EARTHQUAKE);
await game.phaseInterceptor.to("TurnEndPhase");
expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(1);
});
it("keeps super-effective moves super-effective after using gravity", async () => {
game.override.enemySpecies(SpeciesId.PIDGEOT).moveset([MoveId.GRAVITY, MoveId.THUNDERBOLT]);
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
const pidgeot = game.field.getEnemyPokemon();
vi.spyOn(pidgeot, "getAttackTypeEffectiveness");
// Setup Gravity on 1st turn
game.move.select(MoveId.GRAVITY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use electric move on 2nd turn
await game.toNextTurn();
game.move.select(MoveId.THUNDERBOLT);
await game.phaseInterceptor.to("TurnEndPhase");
expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(2);
});
});
it("cancels Fly if its user is semi-invulnerable", async () => {
game.override.enemySpecies(SpeciesId.SNORLAX).enemyMoveset(MoveId.FLY).moveset([MoveId.GRAVITY, MoveId.SPLASH]);
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const charizard = game.field.getPlayerPokemon();
const snorlax = game.field.getEnemyPokemon();
game.move.select(MoveId.SPLASH);
game.move.use(MoveId.GRAVITY);
await game.toNextTurn(); await game.toNextTurn();
expect(snorlax.getTag(BattlerTagType.FLYING)).toBeDefined();
game.move.select(MoveId.GRAVITY); expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); expect(player.isGrounded()).toBe(true);
expect(enemy.isGrounded()).toBe(true);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(snorlax.getTag(BattlerTagType.INTERRUPTED)).toBeDefined();
await game.phaseInterceptor.to("TurnEndPhase");
expect(charizard.hp).toBe(charizard.getMaxHp());
}); });
}); });

View File

@ -1,69 +0,0 @@
import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Arena - Grassy Terrain", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.criticalHits(false)
.enemyLevel(1)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyAbility(AbilityId.STURDY)
.enemyMoveset(MoveId.FLY)
.moveset([MoveId.GRASSY_TERRAIN, MoveId.EARTHQUAKE])
.ability(AbilityId.NO_GUARD);
});
it("halves the damage of Earthquake", async () => {
await game.classicMode.startBattle([SpeciesId.TAUROS]);
const eq = allMoves[MoveId.EARTHQUAKE];
vi.spyOn(eq, "calculateBattlePower");
game.move.select(MoveId.EARTHQUAKE);
await game.toNextTurn();
expect(eq.calculateBattlePower).toHaveReturnedWith(100);
game.move.select(MoveId.GRASSY_TERRAIN);
await game.toNextTurn();
game.move.select(MoveId.EARTHQUAKE);
await game.phaseInterceptor.to("BerryPhase");
expect(eq.calculateBattlePower).toHaveReturnedWith(50);
});
it("Does not halve the damage of Earthquake if opponent is not grounded", async () => {
await game.classicMode.startBattle([SpeciesId.NINJASK]);
const eq = allMoves[MoveId.EARTHQUAKE];
vi.spyOn(eq, "calculateBattlePower");
game.move.select(MoveId.GRASSY_TERRAIN);
await game.toNextTurn();
game.move.select(MoveId.EARTHQUAKE);
await game.phaseInterceptor.to("BerryPhase");
expect(eq.calculateBattlePower).toHaveReturnedWith(100);
});
});

View File

@ -1,59 +0,0 @@
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { WeatherType } from "#enums/weather-type";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Arena - Psychic Terrain", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.criticalHits(false)
.enemyLevel(1)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyAbility(AbilityId.STURDY)
.enemyMoveset(MoveId.SPLASH)
.moveset([MoveId.PSYCHIC_TERRAIN, MoveId.RAIN_DANCE, MoveId.DARK_VOID])
.ability(AbilityId.NO_GUARD);
});
it("Dark Void with Prankster is not blocked", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.PSYCHIC_TERRAIN);
await game.toNextTurn();
game.move.select(MoveId.DARK_VOID);
await game.toEndOfTurn();
expect(game.field.getEnemyPokemon().status?.effect).toBe(StatusEffect.SLEEP);
});
it("Rain Dance with Prankster is not blocked", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.PSYCHIC_TERRAIN);
await game.toNextTurn();
game.move.select(MoveId.RAIN_DANCE);
await game.toEndOfTurn();
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.RAIN);
});
});

400
test/arena/terrain.test.ts Normal file
View File

@ -0,0 +1,400 @@
import { allMoves } from "#app/data/data-lists";
import { getTerrainName, TerrainType } from "#app/data/terrain";
import { getPokemonNameWithAffix } from "#app/messages";
import { randSeedInt } from "#app/utils/common";
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { WeatherType } from "#enums/weather-type";
import { GameManager } from "#test/test-utils/game-manager";
import { toTitleCase } from "#utils/strings";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Terrain -", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.criticalHits(false)
.startingLevel(100)
.enemyLevel(100)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyAbility(AbilityId.STURDY)
.passiveAbility(AbilityId.NO_GUARD);
});
// TODO: Terrain boosts currently apply to damage dealt, not base power
describe.todo.each<{ name: string; type: PokemonType; terrain: TerrainType; move: MoveId }>([
{ name: "Electric", type: PokemonType.ELECTRIC, terrain: TerrainType.ELECTRIC, move: MoveId.THUNDERBOLT },
{ name: "Psychic", type: PokemonType.PSYCHIC, terrain: TerrainType.PSYCHIC, move: MoveId.PSYCHIC },
{ name: "Grassy", type: PokemonType.GRASS, terrain: TerrainType.GRASSY, move: MoveId.ENERGY_BALL },
{ name: "Misty", type: PokemonType.FAIRY, terrain: TerrainType.MISTY, move: MoveId.DRAGON_BREATH },
])("Common Tests - $name Terrain", ({ type, terrain, move }) => {
// biome-ignore lint/suspicious/noDuplicateTestHooks: This is a TODO test case
beforeEach(() => {
game.override.terrain(terrain).enemyPassiveAbility(AbilityId.LEVITATE);
});
const typeStr = toTitleCase(PokemonType[type]);
it.skipIf(terrain === TerrainType.MISTY)(
`should boost power of grounded ${typeStr}-type moves by 1.3x, even against ungrounded targets`,
async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
const powerSpy = vi.spyOn(allMoves[move], "calculateBattlePower");
game.move.use(move);
await game.move.forceEnemyMove(move);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
// Player grounded attack got boosted while enemy ungrounded attack didn't
expect(powerSpy).toHaveLastReturnedWith(allMoves[move].power * 1.3);
expect(powerSpy).toHaveNthReturnedWith(1, allMoves[move].power);
},
);
it.runIf(terrain === TerrainType.MISTY)(
"should cut power of grounded Dragon-type moves in half, even from ungrounded users",
async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
const powerSpy = vi.spyOn(allMoves[move], "calculateBattlePower");
game.move.use(move);
await game.move.forceEnemyMove(move);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
// Enemy dragon breath got nerfed against grounded player; player dragon breath did not
expect(powerSpy).toHaveLastReturnedWith(allMoves[move].power);
expect(powerSpy).toHaveNthReturnedWith(1, allMoves[move].power * 0.5);
},
);
// TODO: Move to a dedicated terrain pulse test file
it(`should change Terrain Pulse into a ${typeStr}-type move and double its base power`, async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
const powerSpy = vi.spyOn(allMoves[MoveId.TERRAIN_PULSE], "calculateBattlePower");
const playerTypeSpy = vi.spyOn(game.field.getPlayerPokemon(), "getMoveType");
const enemyTypeSpy = vi.spyOn(game.field.getEnemyPokemon(), "getMoveType");
game.move.use(MoveId.TERRAIN_PULSE);
await game.move.forceEnemyMove(MoveId.TERRAIN_PULSE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
// player grounded terrain pulse was boosted & type converted; enemy ungrounded one wasn't
expect(powerSpy).toHaveLastReturnedWith(
allMoves[MoveId.TERRAIN_PULSE].power * (terrain === TerrainType.MISTY ? 2 : 2.6),
); // 2 * 1.3
expect(playerTypeSpy).toHaveLastReturnedWith(type);
expect(powerSpy).toHaveNthReturnedWith(1, allMoves[MoveId.TERRAIN_PULSE].power);
expect(enemyTypeSpy).toHaveNthReturnedWith(1, allMoves[MoveId.TERRAIN_PULSE].type);
});
});
describe("Grassy Terrain", () => {
beforeEach(() => {
game.override.terrain(TerrainType.GRASSY);
});
it("should heal all grounded, non semi-invulnerable Pokemon for 1/16th max HP at end of turn", async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
// blissey is grounded, shuckle isn't
const blissey = game.field.getPlayerPokemon();
blissey.hp /= 2;
const shuckle = game.field.getEnemyPokemon();
game.field.mockAbility(shuckle, AbilityId.LEVITATE);
shuckle.hp /= 2;
game.move.use(MoveId.SPLASH);
await game.toNextTurn();
expect(game.phaseInterceptor.log).toContain("PokemonHealPhase");
expect(blissey.getHpRatio()).toBeCloseTo(0.5625, 1);
expect(shuckle.getHpRatio()).toBeCloseTo(0.5, 1);
game.move.use(MoveId.DIG);
await game.toNextTurn();
// shuckle is airborne and blissey is semi-invulnerable, so nobody gets healed
expect(blissey.getHpRatio()).toBeCloseTo(0.5625, 1);
expect(shuckle.getHpRatio()).toBeCloseTo(0.5, 1);
});
// TODO: Enable once magnitude to return a specific power rating
it.todo.each<{ name: string; move: MoveId; basePower?: number }>([
{ name: "Bulldoze", move: MoveId.BULLDOZE },
{ name: "Earthquake", move: MoveId.EARTHQUAKE },
{ name: "Magnitude", move: MoveId.MAGNITUDE, basePower: 150 }, // magnitude 10
])(
"should halve $name's base power against grounded, on-field targets",
async ({ move, basePower = allMoves[move].power }) => {
await game.classicMode.startBattle([SpeciesId.TAUROS]);
// force high rolls for guaranteed magnitude 10s
vi.fn(randSeedInt).mockReturnValue(100);
const powerSpy = vi.spyOn(allMoves[move], "calculateBattlePower");
const enemy = game.field.getEnemyPokemon();
// Turn 1: attack with grassy terrain active; 0.5x
game.move.use(move);
await game.toNextTurn();
expect(powerSpy).toHaveLastReturnedWith(basePower / 2);
// Turn 2: Give enemy levitate to make ungrounded and attack; 1x
// (hits due to no guard)
game.field.mockAbility(game.field.getEnemyPokemon(), AbilityId.LEVITATE);
game.move.use(move);
await game.toNextTurn();
expect(powerSpy).toHaveLastReturnedWith(basePower);
// Turn 3: Remove levitate and make enemy semi-invulnerable; 1x
game.field.mockAbility(game.field.getEnemyPokemon(), AbilityId.BALL_FETCH);
game.move.use(move);
await game.move.forceEnemyMove(MoveId.FLY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(enemy.getLastXMoves()[0].move).toBe(MoveId.FLY);
expect(powerSpy).toHaveLastReturnedWith(basePower);
},
);
});
describe("Electric Terrain", () => {
beforeEach(() => {
game.override.terrain(TerrainType.ELECTRIC);
});
it("should prevent all grounded Pokemon from being put to sleep", async () => {
await game.classicMode.startBattle([SpeciesId.PIDGEOT]);
game.move.use(MoveId.SPORE);
await game.move.forceEnemyMove(MoveId.SPORE);
await game.toEndOfTurn();
const pidgeot = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
expect(pidgeot.status?.effect).toBe(StatusEffect.SLEEP);
expect(shuckle.status?.effect).toBeUndefined();
// TODO: These don't work due to how move failures are propagated
// expect(pidgeot.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
// expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:defaultBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
terrainName: getTerrainName(TerrainType.ELECTRIC),
}),
);
});
it("should prevent attack moves from applying sleep without showing text/failing move", async () => {
vi.spyOn(allMoves[MoveId.RELIC_SONG], "chance", "get").mockReturnValue(100);
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
const shuckle = game.field.getEnemyPokemon();
const statusSpy = vi.spyOn(shuckle, "canSetStatus");
game.move.use(MoveId.RELIC_SONG);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
expect(shuckle.status?.effect).toBeUndefined();
expect(statusSpy).toHaveLastReturnedWith(false);
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.textInterceptor.logs).not.toContain(
i18next.t("terrain:defaultBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
terrainName: getTerrainName(TerrainType.ELECTRIC),
}),
);
});
});
describe("Misty Terrain", () => {
beforeEach(() => {
game.override.terrain(TerrainType.MISTY).enemyPassiveAbility(AbilityId.LEVITATE);
});
it("should prevent all grounded Pokemon from gaining non-volatile statuses", async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(MoveId.TOXIC);
await game.move.forceEnemyMove(MoveId.TOXIC);
await game.toNextTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
// blissey is grounded & protected, shuckle isn't
expect(blissey.status?.effect).toBeUndefined();
expect(shuckle.status?.effect).toBe(StatusEffect.TOXIC);
// TODO: These don't work due to how move failures are propagated
// expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
// expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:mistyBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(blissey),
}),
);
});
it("should block confusion and display message", async () => {
game.override.confusionActivation(false); // prevent self hits from cancelling move
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(MoveId.CONFUSE_RAY);
await game.move.forceEnemyMove(MoveId.CONFUSE_RAY);
await game.toNextTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
// blissey is grounded & protected, shuckle isn't
expect(blissey).not.toHaveBattlerTag(BattlerTagType.CONFUSED);
expect(shuckle).toHaveBattlerTag(BattlerTagType.CONFUSED);
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:mistyBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(blissey),
}),
);
});
it.each<{ status: string; move: MoveId }>([
{ status: "Sleep", move: MoveId.RELIC_SONG },
{ status: "Burn", move: MoveId.SACRED_FIRE },
{ status: "Freeze", move: MoveId.ICE_BEAM },
{ status: "Paralysis", move: MoveId.NUZZLE },
{ status: "Poison", move: MoveId.SLUDGE_BOMB },
{ status: "Toxic Poison", move: MoveId.MALIGNANT_CHAIN },
// TODO: Confusion currently displays terrain block message even from damaging moves
// { status: "Confusion", move: MoveId.MAGICAL_TORQUE },
])("should prevent attack moves from applying $status without showing text/failing move", async ({ move }) => {
vi.spyOn(allMoves[move], "chance", "get").mockReturnValue(100);
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(move);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
// Blissey was grounded and protected from effect, but still took damage
expect(blissey).not.toHaveFullHp();
expect(blissey).not.toHaveBattlerTag(BattlerTagType.CONFUSED);
expect(blissey.status?.effect).toBe(StatusEffect.NONE);
expect(shuckle).toHaveUsedMove({ result: MoveResult.SUCCESS });
expect(game.textInterceptor.logs).not.toContain(
i18next.t("terrain:mistyBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(blissey),
}),
);
});
});
describe("Psychic Terrain", () => {
beforeEach(() => {
game.override.terrain(TerrainType.PSYCHIC).ability(AbilityId.GALE_WINGS).enemyAbility(AbilityId.PRANKSTER);
});
it("should block all opponent-targeted priority moves", async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(MoveId.FAKE_OUT);
await game.move.forceEnemyMove(MoveId.FOLLOW_ME);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:defaultBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
terrainName: getTerrainName(TerrainType.PSYCHIC),
}),
);
});
it("should affect moves that only become priority due to abilities", async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(MoveId.FEATHER_DANCE);
await game.move.forceEnemyMove(MoveId.SWORDS_DANCE);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:defaultBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
terrainName: getTerrainName(TerrainType.PSYCHIC),
}),
);
});
it.each<{ category: string; move: MoveId; effect: () => void }>([
{
category: "Field-targeted",
move: MoveId.RAIN_DANCE,
effect: () => {
expect(game.scene.arena.getWeatherType()).toBe(WeatherType.RAIN);
},
},
{
category: "Enemy-targeting spread",
move: MoveId.DARK_VOID,
effect: () => {
expect(game.field.getEnemyPokemon().status?.effect).toBe(StatusEffect.SLEEP);
},
},
])("should not block $category moves that become priority", async ({ move, effect }) => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(move);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
effect();
});
});
});

View File

@ -1,10 +1,12 @@
import { toEqualArrayUnsorted } from "#test/test-utils/matchers/to-equal-array-unsorted"; import { toEqualArrayUnsorted } from "#test/test-utils/matchers/to-equal-array-unsorted";
import { toHaveAbilityApplied } from "#test/test-utils/matchers/to-have-ability-applied"; import { toHaveAbilityApplied } from "#test/test-utils/matchers/to-have-ability-applied";
import { toHaveArenaTag } from "#test/test-utils/matchers/to-have-arena-tag";
import { toHaveBattlerTag } from "#test/test-utils/matchers/to-have-battler-tag"; import { toHaveBattlerTag } from "#test/test-utils/matchers/to-have-battler-tag";
import { toHaveEffectiveStat } from "#test/test-utils/matchers/to-have-effective-stat"; import { toHaveEffectiveStat } from "#test/test-utils/matchers/to-have-effective-stat";
import { toHaveFainted } from "#test/test-utils/matchers/to-have-fainted"; import { toHaveFainted } from "#test/test-utils/matchers/to-have-fainted";
import { toHaveFullHp } from "#test/test-utils/matchers/to-have-full-hp"; import { toHaveFullHp } from "#test/test-utils/matchers/to-have-full-hp";
import { toHaveHp } from "#test/test-utils/matchers/to-have-hp"; import { toHaveHp } from "#test/test-utils/matchers/to-have-hp";
import { toHavePositionalTag } from "#test/test-utils/matchers/to-have-positional-tag";
import { toHaveStatStage } from "#test/test-utils/matchers/to-have-stat-stage"; import { toHaveStatStage } from "#test/test-utils/matchers/to-have-stat-stage";
import { toHaveStatusEffect } from "#test/test-utils/matchers/to-have-status-effect"; import { toHaveStatusEffect } from "#test/test-utils/matchers/to-have-status-effect";
import { toHaveTakenDamage } from "#test/test-utils/matchers/to-have-taken-damage"; import { toHaveTakenDamage } from "#test/test-utils/matchers/to-have-taken-damage";
@ -22,18 +24,20 @@ import { expect } from "vitest";
expect.extend({ expect.extend({
toEqualArrayUnsorted, toEqualArrayUnsorted,
toHaveWeather,
toHaveTerrain,
toHaveArenaTag,
toHavePositionalTag,
toHaveTypes, toHaveTypes,
toHaveUsedMove, toHaveUsedMove,
toHaveEffectiveStat, toHaveEffectiveStat,
toHaveTakenDamage,
toHaveWeather,
toHaveTerrain,
toHaveFullHp,
toHaveStatusEffect, toHaveStatusEffect,
toHaveStatStage, toHaveStatStage,
toHaveBattlerTag, toHaveBattlerTag,
toHaveAbilityApplied, toHaveAbilityApplied,
toHaveHp, toHaveHp,
toHaveTakenDamage,
toHaveFullHp,
toHaveFainted, toHaveFainted,
toHaveUsedPP, toHaveUsedPP,
}); });

View File

@ -0,0 +1,132 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Fly and Bounce", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.ability(AbilityId.COMPOUND_EYES)
.enemySpecies(SpeciesId.SNORLAX)
.startingLevel(100)
.enemyLevel(100)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.TACKLE);
});
// TODO: Move to a global "charging moves" test file
it.each([
{ name: "Fly", move: MoveId.FLY },
{ name: "Bounce", move: MoveId.BOUNCE },
])("should make the user semi-invulnerable, then attack over 2 turns", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.FLY);
await game.toEndOfTurn();
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
expect(player).toHaveBattlerTag(BattlerTagType.FLYING);
expect(enemy.getLastXMoves(1)[0].result).toBe(MoveResult.MISS);
expect(player.hp).toBe(player.getMaxHp());
expect(enemy.hp).toBe(enemy.getMaxHp());
expect(player.getMoveQueue()[0].move).toBe(MoveId.FLY);
await game.toEndOfTurn();
expect(player).not.toHaveBattlerTag(BattlerTagType.FLYING);
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
expect(player.getMoveHistory()).toHaveLength(2);
const playerFly = player.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
expect(playerFly?.ppUsed).toBe(1);
});
// TODO: Move to a No Guard test file
it("should not allow the user to evade attacks from Pokemon with No Guard", async () => {
game.override.enemyAbility(AbilityId.NO_GUARD);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.use(MoveId.FLY);
await game.toEndOfTurn();
expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp());
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
});
it("should not expend PP when the attack phase is cancelled", async () => {
game.override.enemyAbility(AbilityId.NO_GUARD).enemyMoveset(MoveId.SPORE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
game.move.use(MoveId.FLY);
await game.toEndOfTurn();
expect(playerPokemon).not.toHaveBattlerTag(BattlerTagType.FLYING);
expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP);
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
expect(playerFly?.ppUsed).toBe(0);
});
// TODO: We currently cancel Fly/Bounce in a really scuffed way
it.todo.each<{ name: string; move: MoveId }>([
{ name: "Smack Down", move: MoveId.SMACK_DOWN },
{ name: "Thousand Arrows", move: MoveId.THOUSAND_ARROWS },
{ name: "Gravity", move: MoveId.GRAVITY },
])("should be cancelled immediately when $name is used", async ({ move }) => {
await game.classicMode.startBattle([SpeciesId.AZURILL]);
game.move.use(MoveId.BOUNCE);
await game.move.forceEnemyMove(move);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEndPhase");
// Bounce should've worked until hit
const azurill = game.field.getPlayerPokemon();
expect(azurill).toHaveBattlerTag(BattlerTagType.FLYING);
expect(azurill).not.toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
await game.phaseInterceptor.to("MoveEndPhase");
expect(azurill).not.toHaveBattlerTag(BattlerTagType.FLYING);
expect(azurill).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
expect(azurill.getMoveQueue()).toHaveLength(0);
expect(azurill.visible).toBe(true);
if (move !== MoveId.GRAVITY) {
expect(azurill.hp).toBeLessThan(azurill.getMaxHp());
}
await game.toEndOfTurn();
const snorlax = game.field.getEnemyPokemon();
expect(snorlax.hp).toBe(snorlax.getMaxHp());
});
});

View File

@ -1,120 +0,0 @@
import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Fly", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset(MoveId.FLY)
.battleStyle("single")
.startingLevel(100)
.enemySpecies(SpeciesId.SNORLAX)
.enemyLevel(100)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.TACKLE);
vi.spyOn(allMoves[MoveId.FLY], "accuracy", "get").mockReturnValue(100);
});
it("should make the user semi-invulnerable, then attack over 2 turns", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.FLY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeDefined();
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.MISS);
expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp());
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveQueue()[0].move).toBe(MoveId.FLY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeUndefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveHistory()).toHaveLength(2);
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
expect(playerFly?.ppUsed).toBe(1);
});
it("should not allow the user to evade attacks from Pokemon with No Guard", async () => {
game.override.enemyAbility(AbilityId.NO_GUARD);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.FLY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp());
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
});
it("should not expend PP when the attack phase is cancelled", async () => {
game.override.enemyAbility(AbilityId.NO_GUARD).enemyMoveset(MoveId.SPORE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
game.move.select(MoveId.FLY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeUndefined();
expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP);
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
expect(playerFly?.ppUsed).toBe(0);
});
it("should be cancelled when another Pokemon uses Gravity", async () => {
game.override.enemyMoveset([MoveId.SPLASH, MoveId.GRAVITY]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.FLY);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
await game.move.selectEnemyMove(MoveId.GRAVITY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
expect(playerFly?.ppUsed).toBe(0);
});
});

View File

@ -1,3 +1,4 @@
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
@ -28,30 +29,16 @@ describe("Moves - Magnet Rise", () => {
.enemyLevel(1); .enemyLevel(1);
}); });
it("should make the user immune to ground-type moves", async () => { it("should make the user ungrounded when used", async () => {
await game.classicMode.startBattle([SpeciesId.MAGNEZONE]); await game.classicMode.startBattle([SpeciesId.MAGNEZONE]);
game.move.use(MoveId.MAGNET_RISE); game.move.use(MoveId.MAGNET_RISE);
await game.toEndOfTurn(); await game.toEndOfTurn();
// magnezone levitated and was not hit by earthquake
const magnezone = game.field.getPlayerPokemon(); const magnezone = game.field.getPlayerPokemon();
expect(magnezone.hp).toBe(magnezone.getMaxHp()); expect(magnezone.getTag(BattlerTagType.FLOATING)).toBeDefined();
expect(magnezone.isGrounded()).toBe(false); expect(magnezone.isGrounded()).toBe(false);
});
it("should be removed by gravity", async () => {
await game.classicMode.startBattle([SpeciesId.MAGNEZONE]);
game.move.use(MoveId.MAGNET_RISE);
await game.toNextTurn();
const magnezone = game.field.getPlayerPokemon();
expect(magnezone.hp).toBe(magnezone.getMaxHp()); expect(magnezone.hp).toBe(magnezone.getMaxHp());
game.move.use(MoveId.GRAVITY);
await game.toEndOfTurn();
expect(magnezone.hp).toBeLessThan(magnezone.getMaxHp());
expect(magnezone.isGrounded()).toBe(true);
}); });
}); });

View File

@ -0,0 +1,124 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import type { MoveEffectPhase } from "#phases/move-effect-phase";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Smack Down and Thousand Arrows", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.enemySpecies(SpeciesId.EELEKTROSS)
.startingLevel(100)
.enemyLevel(50)
.criticalHits(false)
.ability(AbilityId.COMPOUND_EYES)
.enemyAbility(AbilityId.STURDY)
.enemyMoveset(MoveId.SPLASH);
});
it.each([
{ name: "Smack Down", move: MoveId.SMACK_DOWN },
{ name: "Thousand Arrows", move: MoveId.THOUSAND_ARROWS },
])("$name should hit and ground ungrounded targets", async ({ move }) => {
game.override.enemySpecies(SpeciesId.TORNADUS);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemy = game.field.getEnemyPokemon();
expect(enemy.isGrounded()).toBe(false);
game.move.use(move);
await game.toEndOfTurn();
expect(enemy).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
expect(enemy.isGrounded()).toBe(true);
});
it("should affect targets with Levitate", async () => {
game.override.enemyPassiveAbility(AbilityId.LEVITATE);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const eelektross = game.field.getEnemyPokemon();
expect(eelektross.isGrounded()).toBe(false);
game.move.use(MoveId.THOUSAND_ARROWS);
await game.toEndOfTurn();
expect(eelektross).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
expect(eelektross.hp).toBeLessThan(eelektross.getMaxHp());
expect(eelektross.isGrounded()).toBe(true);
});
it.each([
{ name: "TELEKINESIS", tag: BattlerTagType.TELEKINESIS },
{ name: "FLOATING", tag: BattlerTagType.FLOATING },
])("should cancel the effects of BattlerTagType.$name", async ({ tag }) => {
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const eelektross = game.field.getEnemyPokemon();
eelektross.addTag(tag);
game.move.use(MoveId.SMACK_DOWN);
await game.toEndOfTurn();
expect(eelektross).not.toHaveBattlerTag(tag);
expect(eelektross).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
});
// NB: This test might sound useless, but semi-invulnerable pokemon are technically considered "ungrounded"
// by most things
it("should not ground semi-invulnerable targets hit via No Guard unless already ungrounded", async () => {
game.override.ability(AbilityId.NO_GUARD);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
game.move.use(MoveId.THOUSAND_ARROWS);
await game.move.forceEnemyMove(MoveId.DIG);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
// Eelektross took damage but was not forcibly grounded
const eelektross = game.field.getEnemyPokemon();
expect(eelektross.isGrounded()).toBe(true);
expect(eelektross).not.toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
expect(eelektross.hp).toBeLessThan(eelektross.getMaxHp());
});
// TODO: Sky drop is currently partially implemented
it.todo("should hit midair targets from Sky Drop without interrupting");
describe("Thousand Arrows", () => {
it("should deal a fixed 1x damage to ungrounded flying-types", async () => {
game.override.enemySpecies(SpeciesId.ARCHEOPS);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const archeops = game.field.getEnemyPokemon();
game.move.use(MoveId.THOUSAND_ARROWS);
await game.phaseInterceptor.to("MoveEffectPhase", false);
const hitSpy = vi.spyOn(game.scene.phaseManager.getCurrentPhase() as MoveEffectPhase, "hitCheck");
await game.toEndOfTurn();
expect(hitSpy).toHaveReturnedWith([expect.anything(), 1]);
expect(archeops).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
expect(archeops.isGrounded()).toBe(true);
expect(archeops.hp).toBeLessThan(archeops.getMaxHp());
});
});
});

View File

@ -1,10 +1,13 @@
import { allMoves } from "#data/data-lists"; import { TerrainType } from "#app/data/terrain";
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index"; import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type";
import { HitCheckResult } from "#enums/hit-check-result";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result"; import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import type { MoveEffectPhase } from "#phases/move-effect-phase";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@ -26,114 +29,117 @@ describe("Moves - Telekinesis", () => {
beforeEach(() => { beforeEach(() => {
game = new GameManager(phaserGame); game = new GameManager(phaserGame);
game.override game.override
.moveset([MoveId.TELEKINESIS, MoveId.TACKLE, MoveId.MUD_SHOT, MoveId.SMACK_DOWN])
.battleStyle("single") .battleStyle("single")
.enemySpecies(SpeciesId.SNORLAX) .enemySpecies(SpeciesId.SNORLAX)
.enemyLevel(60) .enemyLevel(60)
.enemyAbility(AbilityId.BALL_FETCH) .enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset([MoveId.SPLASH]); .enemyMoveset(MoveId.SPLASH);
}); });
it("Telekinesis makes the affected vulnerable to most attacking moves regardless of accuracy", async () => { it("should cause opposing non-OHKO moves to always hit the target", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]); await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemyOpponent = game.field.getEnemyPokemon(); const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
game.move.use(MoveId.TELEKINESIS);
await game.toNextTurn(); await game.toNextTurn();
vi.spyOn(allMoves[MoveId.TACKLE], "accuracy", "get").mockReturnValue(0);
game.move.select(MoveId.TACKLE); expect(enemy).toHaveBattlerTag(BattlerTagType.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase"); expect(enemy).toHaveBattlerTag(BattlerTagType.FLOATING);
expect(enemyOpponent.isFullHp()).toBe(false);
game.move.use(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.move.forceMiss();
await game.toEndOfTurn();
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
expect(player.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
}); });
it("Telekinesis makes the affected airborne and immune to most Ground-moves", async () => { it("should forcibly unground the target", async () => {
game.override.terrain(TerrainType.ELECTRIC);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]); await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemyOpponent = game.field.getEnemyPokemon(); const enemy = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS); game.move.use(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase"); await game.toNextTurn();
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined(); // Use Earthquake - should be ineffective
game.move.use(MoveId.EARTHQUAKE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase", false);
const hitSpy = vi.spyOn(game.scene.phaseManager.getCurrentPhase() as MoveEffectPhase, "hitCheck");
await game.toNextTurn(); await game.toNextTurn();
vi.spyOn(allMoves[MoveId.MUD_SHOT], "accuracy", "get").mockReturnValue(100);
game.move.select(MoveId.MUD_SHOT); expect(enemy.hp).toBe(enemy.getMaxHp());
await game.phaseInterceptor.to("TurnEndPhase"); expect(hitSpy).toHaveLastReturnedWith([HitCheckResult.NO_EFFECT, 0]);
expect(enemyOpponent.isFullHp()).toBe(true);
// Use Spore - should succeed due to being ungrounded
game.move.use(MoveId.SPORE);
await game.toEndOfTurn();
expect(enemy.status?.effect).toBe(StatusEffect.SLEEP);
}); });
it("Telekinesis can still affect Pokemon that have been transformed into invalid Pokemon", async () => { // TODO: Make an it.each testing the invalid species for Telekinesis
game.override.enemyMoveset(MoveId.TRANSFORM); it.todo.each([])("should fail if used on $name", () => {});
it("should still affect enemies transformed into invalid Pokemon", async () => {
game.override.enemyAbility(AbilityId.IMPOSTER);
await game.classicMode.startBattle([SpeciesId.DIGLETT]); await game.classicMode.startBattle([SpeciesId.DIGLETT]);
const enemyOpponent = game.field.getEnemyPokemon(); const enemyOpponent = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS); game.move.use(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase"); await game.move.forceEnemyMove(MoveId.SPLASH);
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined(); await game.toNextTurn();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
expect(enemyOpponent).toHaveBattlerTag(BattlerTagType.TELEKINESIS);
expect(enemyOpponent).toHaveBattlerTag(BattlerTagType.FLOATING);
expect(enemyOpponent.summonData.speciesForm?.speciesId).toBe(SpeciesId.DIGLETT); expect(enemyOpponent.summonData.speciesForm?.speciesId).toBe(SpeciesId.DIGLETT);
}); });
it("Moves like Smack Down and 1000 Arrows remove all effects of Telekinesis from the target Pokemon", async () => { it("should become grounded when Ingrain is used, but not remove the guaranteed hit effect", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemyOpponent = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
await game.toNextTurn();
game.move.select(MoveId.SMACK_DOWN);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeUndefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeUndefined();
});
it("Ingrain will remove the floating effect of Telekinesis, but not the 100% hit", async () => {
game.override.enemyMoveset([MoveId.SPLASH, MoveId.INGRAIN]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]); await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon(); const playerPokemon = game.field.getPlayerPokemon();
const enemyOpponent = game.field.getEnemyPokemon(); const enemy = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
game.move.use(MoveId.TELEKINESIS);
await game.toNextTurn(); await game.toNextTurn();
vi.spyOn(allMoves[MoveId.MUD_SHOT], "accuracy", "get").mockReturnValue(0);
game.move.select(MoveId.MUD_SHOT); game.move.use(MoveId.MUD_SHOT);
await game.move.selectEnemyMove(MoveId.INGRAIN); await game.move.forceEnemyMove(MoveId.INGRAIN);
await game.phaseInterceptor.to("TurnEndPhase"); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined(); await game.phaseInterceptor.to("MoveEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.INGRAIN)).toBeDefined(); await game.move.forceMiss();
expect(enemyOpponent.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined(); await game.toEndOfTurn();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeUndefined();
expect(playerPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); expect(enemy).toHaveBattlerTag(BattlerTagType.TELEKINESIS);
expect(enemy).toHaveBattlerTag(BattlerTagType.INGRAIN);
expect(enemy).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
expect(enemy).not.toHaveBattlerTag(BattlerTagType.FLOATING);
expect(enemy.isGrounded()).toBe(true);
expect(playerPokemon).toHaveUsedMove({ move: MoveId.MUD_SHOT, result: MoveResult.SUCCESS });
}); });
it("should not be baton passed onto a mega gengar", async () => { it("should not be baton passable onto a mega gengar", async () => {
game.override game.override.starterForms({ [SpeciesId.GENGAR]: 1 });
.moveset([MoveId.BATON_PASS])
.enemyMoveset([MoveId.TELEKINESIS])
.starterForms({ [SpeciesId.GENGAR]: 1 });
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.GENGAR]); await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.GENGAR]);
game.move.select(MoveId.BATON_PASS);
game.move.use(MoveId.BATON_PASS);
game.doSelectPartyPokemon(1); game.doSelectPartyPokemon(1);
await game.move.forceEnemyMove(MoveId.TELEKINESIS);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase"); await game.phaseInterceptor.to("MoveEndPhase");
expect(game.field.getPlayerPokemon().getTag(BattlerTagType.TELEKINESIS)).toBeUndefined();
expect(game.field.getPlayerPokemon()).toHaveBattlerTag(BattlerTagType.TELEKINESIS);
await game.toEndOfTurn();
expect(game.field.getPlayerPokemon()).not.toHaveBattlerTag(BattlerTagType.TELEKINESIS);
}); });
}); });

View File

@ -1,89 +0,0 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { BerryPhase } from "#phases/berry-phase";
import { MoveEffectPhase } from "#phases/move-effect-phase";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Thousand Arrows", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.enemySpecies(SpeciesId.TOGETIC)
.startingLevel(100)
.enemyLevel(100)
.moveset([MoveId.THOUSAND_ARROWS])
.enemyMoveset(MoveId.SPLASH);
});
it("move should hit and ground Flying-type targets", async () => {
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.THOUSAND_ARROWS);
await game.phaseInterceptor.to(MoveEffectPhase, false);
// Enemy should not be grounded before move effect is applied
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined();
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
});
it("move should hit and ground targets with Levitate", async () => {
game.override.enemySpecies(SpeciesId.SNORLAX).enemyAbility(AbilityId.LEVITATE);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.THOUSAND_ARROWS);
await game.phaseInterceptor.to(MoveEffectPhase, false);
// Enemy should not be grounded before move effect is applied
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined();
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
});
it("move should hit and ground targets under the effects of Magnet Rise", async () => {
game.override.enemySpecies(SpeciesId.SNORLAX);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemyPokemon = game.field.getEnemyPokemon();
enemyPokemon.addTag(BattlerTagType.FLOATING, undefined, MoveId.MAGNET_RISE);
game.move.select(MoveId.THOUSAND_ARROWS);
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getTag(BattlerTagType.FLOATING)).toBeUndefined();
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
});
});

View File

@ -39,15 +39,6 @@ describe("Move - Wish", () => {
.enemyLevel(100); .enemyLevel(100);
}); });
/**
* Expect that wish is active with the specified number of attacks.
* @param numAttacks - The number of wish instances that should be queued; default `1`
*/
function expectWishActive(numAttacks = 1) {
const wishes = game.scene.arena.positionalTagManager["tags"].filter(t => t.tagType === PositionalTagType.WISH);
expect(wishes).toHaveLength(numAttacks);
}
it("should heal the Pokemon in the current slot for 50% of the user's maximum HP", async () => { it("should heal the Pokemon in the current slot for 50% of the user's maximum HP", async () => {
await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]); await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]);
@ -58,19 +49,19 @@ describe("Move - Wish", () => {
game.move.use(MoveId.WISH); game.move.use(MoveId.WISH);
await game.toNextTurn(); await game.toNextTurn();
expectWishActive(); expect(game).toHavePositionalTag(PositionalTagType.WISH);
game.doSwitchPokemon(1); game.doSwitchPokemon(1);
await game.toEndOfTurn(); await game.toEndOfTurn();
expectWishActive(0); expect(game).toHavePositionalTag(PositionalTagType.WISH, 0);
expect(game.textInterceptor.logs).toContain( expect(game.textInterceptor.logs).toContain(
i18next.t("arenaTag:wishTagOnAdd", { i18next.t("arenaTag:wishTagOnAdd", {
pokemonNameWithAffix: getPokemonNameWithAffix(alomomola), pokemonNameWithAffix: getPokemonNameWithAffix(alomomola),
}), }),
); );
expect(alomomola.hp).toBe(1); expect(alomomola).toHaveHp(1);
expect(blissey.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1); expect(blissey).toHaveHp(toDmgValue(alomomola.getMaxHp() / 2) + 1);
}); });
it("should work if the user has full HP, but not if it already has an active Wish", async () => { it("should work if the user has full HP, but not if it already has an active Wish", async () => {
@ -82,13 +73,13 @@ describe("Move - Wish", () => {
game.move.use(MoveId.WISH); game.move.use(MoveId.WISH);
await game.toNextTurn(); await game.toNextTurn();
expectWishActive(); expect(game).toHavePositionalTag(PositionalTagType.WISH);
game.move.use(MoveId.WISH); game.move.use(MoveId.WISH);
await game.toEndOfTurn(); await game.toEndOfTurn();
expect(alomomola.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1); expect(alomomola.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1);
expect(alomomola.getLastXMoves()[0].result).toBe(MoveResult.FAIL); expect(alomomola).toHaveUsedMove({ result: MoveResult.FAIL });
}); });
it("should function independently of Future Sight", async () => { it("should function independently of Future Sight", async () => {
@ -103,7 +94,8 @@ describe("Move - Wish", () => {
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn(); await game.toNextTurn();
expectWishActive(1); expect(game).toHavePositionalTag(PositionalTagType.WISH);
expect(game).toHavePositionalTag(PositionalTagType.DELAYED_ATTACK);
}); });
it("should work in double battles and trigger in order of creation", async () => { it("should work in double battles and trigger in order of creation", async () => {
@ -127,7 +119,7 @@ describe("Move - Wish", () => {
await game.setTurnOrder(oldOrder.map(p => p.getBattlerIndex())); await game.setTurnOrder(oldOrder.map(p => p.getBattlerIndex()));
await game.toNextTurn(); await game.toNextTurn();
expectWishActive(4); expect(game).toHavePositionalTag(PositionalTagType.WISH, 4);
// Lower speed to change turn order // Lower speed to change turn order
alomomola.setStatStage(Stat.SPD, 6); alomomola.setStatStage(Stat.SPD, 6);
@ -141,7 +133,7 @@ describe("Move - Wish", () => {
await game.phaseInterceptor.to("PositionalTagPhase"); await game.phaseInterceptor.to("PositionalTagPhase");
// all wishes have activated and added healing phases // all wishes have activated and added healing phases
expectWishActive(0); expect(game).toHavePositionalTag(PositionalTagType.WISH, 0);
const healPhases = game.scene.phaseManager.phaseQueue.filter(p => p.is("PokemonHealPhase")); const healPhases = game.scene.phaseManager.phaseQueue.filter(p => p.is("PokemonHealPhase"));
expect(healPhases).toHaveLength(4); expect(healPhases).toHaveLength(4);
@ -165,14 +157,14 @@ describe("Move - Wish", () => {
game.move.use(MoveId.WISH, BattlerIndex.PLAYER_2); game.move.use(MoveId.WISH, BattlerIndex.PLAYER_2);
await game.toNextTurn(); await game.toNextTurn();
expectWishActive(); expect(game).toHavePositionalTag(PositionalTagType.WISH);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.move.use(MoveId.MEMENTO, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2); game.move.use(MoveId.MEMENTO, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2);
await game.toEndOfTurn(); await game.toEndOfTurn();
// Wish went away without doing anything // Wish went away without doing anything
expectWishActive(0); expect(game).toHavePositionalTag(PositionalTagType.WISH, 0);
expect(game.textInterceptor.logs).not.toContain( expect(game.textInterceptor.logs).not.toContain(
i18next.t("arenaTag:wishTagOnAdd", { i18next.t("arenaTag:wishTagOnAdd", {
pokemonNameWithAffix: getPokemonNameWithAffix(blissey), pokemonNameWithAffix: getPokemonNameWithAffix(blissey),

View File

@ -224,7 +224,7 @@ export class GameManager {
// This will consider all battle entry dialog as seens and skip them // This will consider all battle entry dialog as seens and skip them
vi.spyOn(this.scene.ui, "shouldSkipDialogue").mockReturnValue(true); vi.spyOn(this.scene.ui, "shouldSkipDialogue").mockReturnValue(true);
if (overrides.OPP_HELD_ITEMS_OVERRIDE.length === 0) { if (overrides.ENEMY_HELD_ITEMS_OVERRIDE.length === 0) {
this.removeEnemyHeldItems(); this.removeEnemyHeldItems();
} }

View File

@ -50,7 +50,7 @@ export class ChallengeModeHelper extends GameManagerHelper {
}); });
await this.game.phaseInterceptor.run(EncounterPhase); await this.game.phaseInterceptor.run(EncounterPhase);
if (overrides.OPP_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { if (overrides.ENEMY_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) {
this.game.removeEnemyHeldItems(); this.game.removeEnemyHeldItems();
} }
} }

View File

@ -53,7 +53,7 @@ export class ClassicModeHelper extends GameManagerHelper {
}); });
await this.game.phaseInterceptor.to(EncounterPhase); await this.game.phaseInterceptor.to(EncounterPhase);
if (overrides.OPP_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { if (overrides.ENEMY_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) {
this.game.removeEnemyHeldItems(); this.game.removeEnemyHeldItems();
} }
} }

View File

@ -37,7 +37,7 @@ export class DailyModeHelper extends GameManagerHelper {
await this.game.phaseInterceptor.to(EncounterPhase); await this.game.phaseInterceptor.to(EncounterPhase);
if (overrides.OPP_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) { if (overrides.ENEMY_HELD_ITEMS_OVERRIDE.length === 0 && this.game.override.removeEnemyStartingItems) {
this.game.removeEnemyHeldItems(); this.game.removeEnemyHeldItems();
} }
} }

View File

@ -228,8 +228,8 @@ export class MoveHelper extends GameManagerHelper {
console.warn("Player moveset override disabled due to use of `game.move.changeMoveset`!"); console.warn("Player moveset override disabled due to use of `game.move.changeMoveset`!");
} }
} else { } else {
if (coerceArray(Overrides.OPP_MOVESET_OVERRIDE).length > 0) { if (coerceArray(Overrides.ENEMY_MOVESET_OVERRIDE).length > 0) {
vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([]); vi.spyOn(Overrides, "ENEMY_MOVESET_OVERRIDE", "get").mockReturnValue([]);
console.warn("Enemy moveset override disabled due to use of `game.move.changeMoveset`!"); console.warn("Enemy moveset override disabled due to use of `game.move.changeMoveset`!");
} }
} }
@ -302,8 +302,8 @@ export class MoveHelper extends GameManagerHelper {
(this.game.scene.phaseManager.getCurrentPhase() as EnemyCommandPhase).getFieldIndex() (this.game.scene.phaseManager.getCurrentPhase() as EnemyCommandPhase).getFieldIndex()
]; ];
if ([Overrides.OPP_MOVESET_OVERRIDE].flat().length > 0) { if ([Overrides.ENEMY_MOVESET_OVERRIDE].flat().length > 0) {
vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([]); vi.spyOn(Overrides, "ENEMY_MOVESET_OVERRIDE", "get").mockReturnValue([]);
console.warn( console.warn(
"Warning: `forceEnemyMove` overwrites the Pokemon's moveset and disables the enemy moveset override!", "Warning: `forceEnemyMove` overwrites the Pokemon's moveset and disables the enemy moveset override!",
); );

View File

@ -1,7 +1,9 @@
/** biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */ /** biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */
import type { NewArenaEvent } from "#events/battle-scene"; import type { NewArenaEvent } from "#events/battle-scene";
/** biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */ /** biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */
import { TerrainType } from "#app/data/terrain";
import type { BattleStyle, RandomTrainerOverride } from "#app/overrides"; import type { BattleStyle, RandomTrainerOverride } from "#app/overrides";
import Overrides from "#app/overrides"; import Overrides from "#app/overrides";
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
@ -359,6 +361,19 @@ export class OverridesHelper extends GameManagerHelper {
return this; return this;
} }
/**
* Override the starting {@linkcode TerrainType} that will be set on entering a new biome.
* @param type - The {@linkcode TerrainType} to set.
* @returns `this`
* @remarks
* The newly added terrain will be refreshed upon reaching a new biome, and will be overridden as normal if a new terrain is set.
*/
public terrain(type: TerrainType): this {
vi.spyOn(Overrides, "STARTING_TERRAIN_OVERRIDE", "get").mockReturnValue(type);
this.log(`Starting terrain for next biome set to ${TerrainType[type]} (=${type})!`);
return this;
}
/** /**
* Override the seed * Override the seed
* @param seed - The seed to set * @param seed - The seed to set
@ -406,7 +421,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemySpecies(species: SpeciesId | number): this { public enemySpecies(species: SpeciesId | number): this {
vi.spyOn(Overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(species); vi.spyOn(Overrides, "ENEMY_SPECIES_OVERRIDE", "get").mockReturnValue(species);
this.log(`Enemy Pokemon species set to ${SpeciesId[species]} (=${species})!`); this.log(`Enemy Pokemon species set to ${SpeciesId[species]} (=${species})!`);
return this; return this;
} }
@ -416,7 +431,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enableEnemyFusion(): this { public enableEnemyFusion(): this {
vi.spyOn(Overrides, "OPP_FUSION_OVERRIDE", "get").mockReturnValue(true); vi.spyOn(Overrides, "ENEMY_FUSION_OVERRIDE", "get").mockReturnValue(true);
this.log("Enemy Pokemon is a random fusion!"); this.log("Enemy Pokemon is a random fusion!");
return this; return this;
} }
@ -427,7 +442,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyFusionSpecies(species: SpeciesId | number): this { public enemyFusionSpecies(species: SpeciesId | number): this {
vi.spyOn(Overrides, "OPP_FUSION_SPECIES_OVERRIDE", "get").mockReturnValue(species); vi.spyOn(Overrides, "ENEMY_FUSION_SPECIES_OVERRIDE", "get").mockReturnValue(species);
this.log(`Enemy Pokemon fusion species set to ${SpeciesId[species]} (=${species})!`); this.log(`Enemy Pokemon fusion species set to ${SpeciesId[species]} (=${species})!`);
return this; return this;
} }
@ -438,7 +453,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyAbility(ability: AbilityId): this { public enemyAbility(ability: AbilityId): this {
vi.spyOn(Overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(ability); vi.spyOn(Overrides, "ENEMY_ABILITY_OVERRIDE", "get").mockReturnValue(ability);
this.log(`Enemy Pokemon ability set to ${AbilityId[ability]} (=${ability})!`); this.log(`Enemy Pokemon ability set to ${AbilityId[ability]} (=${ability})!`);
return this; return this;
} }
@ -449,7 +464,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyPassiveAbility(passiveAbility: AbilityId): this { public enemyPassiveAbility(passiveAbility: AbilityId): this {
vi.spyOn(Overrides, "OPP_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(passiveAbility); vi.spyOn(Overrides, "ENEMY_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(passiveAbility);
this.log(`Enemy Pokemon PASSIVE ability set to ${AbilityId[passiveAbility]} (=${passiveAbility})!`); this.log(`Enemy Pokemon PASSIVE ability set to ${AbilityId[passiveAbility]} (=${passiveAbility})!`);
return this; return this;
} }
@ -460,7 +475,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyHasPassiveAbility(hasPassiveAbility: boolean | null): this { public enemyHasPassiveAbility(hasPassiveAbility: boolean | null): this {
vi.spyOn(Overrides, "OPP_HAS_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(hasPassiveAbility); vi.spyOn(Overrides, "ENEMY_HAS_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(hasPassiveAbility);
if (hasPassiveAbility === null) { if (hasPassiveAbility === null) {
this.log("Enemy Pokemon PASSIVE ability no longer force enabled or disabled!"); this.log("Enemy Pokemon PASSIVE ability no longer force enabled or disabled!");
} else { } else {
@ -475,7 +490,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyMoveset(moveset: MoveId | MoveId[]): this { public enemyMoveset(moveset: MoveId | MoveId[]): this {
vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(moveset); vi.spyOn(Overrides, "ENEMY_MOVESET_OVERRIDE", "get").mockReturnValue(moveset);
moveset = coerceArray(moveset); moveset = coerceArray(moveset);
const movesetStr = moveset.map(moveId => MoveId[moveId]).join(", "); const movesetStr = moveset.map(moveId => MoveId[moveId]).join(", ");
this.log(`Enemy Pokemon moveset set to ${movesetStr} (=[${moveset.join(", ")}])!`); this.log(`Enemy Pokemon moveset set to ${movesetStr} (=[${moveset.join(", ")}])!`);
@ -488,7 +503,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyLevel(level: number): this { public enemyLevel(level: number): this {
vi.spyOn(Overrides, "OPP_LEVEL_OVERRIDE", "get").mockReturnValue(level); vi.spyOn(Overrides, "ENEMY_LEVEL_OVERRIDE", "get").mockReturnValue(level);
this.log(`Enemy Pokemon level set to ${level}!`); this.log(`Enemy Pokemon level set to ${level}!`);
return this; return this;
} }
@ -499,7 +514,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyStatusEffect(statusEffect: StatusEffect): this { public enemyStatusEffect(statusEffect: StatusEffect): this {
vi.spyOn(Overrides, "OPP_STATUS_OVERRIDE", "get").mockReturnValue(statusEffect); vi.spyOn(Overrides, "ENEMY_STATUS_OVERRIDE", "get").mockReturnValue(statusEffect);
this.log(`Enemy Pokemon status-effect set to ${StatusEffect[statusEffect]} (=${statusEffect})!`); this.log(`Enemy Pokemon status-effect set to ${StatusEffect[statusEffect]} (=${statusEffect})!`);
return this; return this;
} }
@ -510,7 +525,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyHeldItems(items: ModifierOverride[]): this { public enemyHeldItems(items: ModifierOverride[]): this {
vi.spyOn(Overrides, "OPP_HELD_ITEMS_OVERRIDE", "get").mockReturnValue(items); vi.spyOn(Overrides, "ENEMY_HELD_ITEMS_OVERRIDE", "get").mockReturnValue(items);
this.log("Enemy Pokemon held items set to:", items); this.log("Enemy Pokemon held items set to:", items);
return this; return this;
} }
@ -571,7 +586,7 @@ export class OverridesHelper extends GameManagerHelper {
* @param variant - (Optional) The enemy's shiny {@linkcode Variant}. * @param variant - (Optional) The enemy's shiny {@linkcode Variant}.
*/ */
enemyShiny(shininess: boolean | null, variant?: Variant): this { enemyShiny(shininess: boolean | null, variant?: Variant): this {
vi.spyOn(Overrides, "OPP_SHINY_OVERRIDE", "get").mockReturnValue(shininess); vi.spyOn(Overrides, "ENEMY_SHINY_OVERRIDE", "get").mockReturnValue(shininess);
if (shininess === null) { if (shininess === null) {
this.log("Disabled enemy Pokemon shiny override!"); this.log("Disabled enemy Pokemon shiny override!");
} else { } else {
@ -579,7 +594,7 @@ export class OverridesHelper extends GameManagerHelper {
} }
if (variant !== undefined) { if (variant !== undefined) {
vi.spyOn(Overrides, "OPP_VARIANT_OVERRIDE", "get").mockReturnValue(variant); vi.spyOn(Overrides, "ENEMY_VARIANT_OVERRIDE", "get").mockReturnValue(variant);
this.log(`Set enemy shiny variant to be ${variant}!`); this.log(`Set enemy shiny variant to be ${variant}!`);
} }
return this; return this;
@ -594,7 +609,7 @@ export class OverridesHelper extends GameManagerHelper {
* @returns `this` * @returns `this`
*/ */
public enemyHealthSegments(healthSegments: number): this { public enemyHealthSegments(healthSegments: number): this {
vi.spyOn(Overrides, "OPP_HEALTH_SEGMENTS_OVERRIDE", "get").mockReturnValue(healthSegments); vi.spyOn(Overrides, "ENEMY_HEALTH_SEGMENTS_OVERRIDE", "get").mockReturnValue(healthSegments);
this.log("Enemy Pokemon health segments set to:", healthSegments); this.log("Enemy Pokemon health segments set to:", healthSegments);
return this; return this;
} }

View File

@ -1,4 +1,5 @@
import { getOnelineDiffStr } from "#test/test-utils/string-utils"; import { getOnelineDiffStr } from "#test/test-utils/string-utils";
import { receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/** /**
@ -14,22 +15,22 @@ export function toEqualArrayUnsorted(
): SyncExpectationResult { ): SyncExpectationResult {
if (!Array.isArray(received)) { if (!Array.isArray(received)) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected an array, but got ${this.utils.stringify(received)}!`, message: () => `Expected to receive an array, but got ${receivedStr(received)}!`,
}; };
} }
if (received.length !== expected.length) { if (received.length !== expected.length) {
return { return {
pass: false, pass: false,
message: () => `Expected to receive array of length ${received.length}, but got ${expected.length} instead!`, message: () => `Expected to receive an array of length ${received.length}, but got ${expected.length} instead!`,
actual: received,
expected, expected,
actual: received,
}; };
} }
const actualSorted = received.slice().sort(); const actualSorted = received.toSorted();
const expectedSorted = expected.slice().sort(); const expectedSorted = expected.toSorted();
const pass = this.equals(actualSorted, expectedSorted, [...this.customTesters, this.utils.iterableEquality]); const pass = this.equals(actualSorted, expectedSorted, [...this.customTesters, this.utils.iterableEquality]);
const actualStr = getOnelineDiffStr.call(this, actualSorted); const actualStr = getOnelineDiffStr.call(this, actualSorted);

View File

@ -21,8 +21,8 @@ export function toHaveAbilityApplied(
): SyncExpectationResult { ): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected to recieve a Pokemon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokemon, but got ${receivedStr(received)}!`,
}; };
} }

View File

@ -0,0 +1,77 @@
import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag";
import type { ArenaTagSide } from "#enums/arena-tag-side";
import type { ArenaTagType } from "#enums/arena-tag-type";
import type { OneOther } from "#test/@types/test-helpers";
// biome-ignore lint/correctness/noUnusedImports: TSDoc
import type { GameManager } from "#test/test-utils/game-manager";
import { getOnelineDiffStr } from "#test/test-utils/string-utils";
import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
// intersection required to preserve T for inferences
export type toHaveArenaTagOptions<T extends ArenaTagType> = OneOther<ArenaTagTypeMap[T], "tagType" | "side"> & {
tagType: T;
};
/**
* Matcher to check if the {@linkcode Arena} has a given {@linkcode ArenaTag} active.
* @param received - The object to check. Should be the current {@linkcode GameManager}.
* @param expectedTag - The `ArenaTagType` of the desired tag, or a partially-filled object
* containing the desired properties
* @param side - The {@linkcode ArenaTagSide | side of the field} the tag should affect, or
* {@linkcode ArenaTagSide.BOTH} to check both sides
* @returns The result of the matching
*/
export function toHaveArenaTag<T extends ArenaTagType>(
this: MatcherState,
received: unknown,
expectedTag: T | toHaveArenaTagOptions<T>,
side?: ArenaTagSide,
): SyncExpectationResult {
if (!isGameManagerInstance(received)) {
return {
pass: this.isNot,
message: () => `Expected to receive a GameManager, but got ${receivedStr(received)}!`,
};
}
if (!received.scene?.arena) {
return {
pass: this.isNot,
message: () => `Expected GameManager.${received.scene ? "scene.arena" : "scene"} to be defined!`,
};
}
// Coerce lone `tagType`s into objects
// Bangs are ok as we enforce safety via overloads
// @ts-expect-error - Typescript is being stupid as tag type and side will always exist
const etag: Partial<ArenaTag> & { tagType: T; side: ArenaTagSide } =
typeof expectedTag === "object" ? expectedTag : { tagType: expectedTag, side: side! };
// We need to get all tags for the case of checking properties of a tag present on both sides of the arena
const tags = received.scene.arena.findTagsOnSide(t => t.tagType === etag.tagType, etag.side);
if (tags.length === 0) {
return {
pass: false,
message: () => `Expected the Arena to have a tag of type ${etag.tagType}, but it didn't!`,
expected: etag.tagType,
actual: received.scene.arena.tags.map(t => t.tagType),
};
}
// Pass if any of the matching tags meet our criteria
const pass = tags.some(tag =>
this.equals(tag, expectedTag, [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]),
);
const expectedStr = getOnelineDiffStr.call(this, expectedTag);
return {
pass,
message: () =>
pass
? `Expected the Arena to NOT have a tag matching ${expectedStr}, but it did!`
: `Expected the Arena to have a tag matching ${expectedStr}, but it didn't!`,
expected: expectedTag,
actual: tags,
};
}

View File

@ -6,7 +6,7 @@ import { getStatName } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils"; import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export interface ToHaveEffectiveStatMatcherOptions { export interface toHaveEffectiveStatOptions {
/** /**
* The target {@linkcode Pokemon} * The target {@linkcode Pokemon}
* @see {@linkcode Pokemon.getEffectiveStat} * @see {@linkcode Pokemon.getEffectiveStat}
@ -30,7 +30,7 @@ export interface ToHaveEffectiveStatMatcherOptions {
* @param received - The object to check. Should be a {@linkcode Pokemon} * @param received - The object to check. Should be a {@linkcode Pokemon}
* @param stat - The {@linkcode EffectiveStat} to check * @param stat - The {@linkcode EffectiveStat} to check
* @param expectedValue - The expected value of the {@linkcode stat} * @param expectedValue - The expected value of the {@linkcode stat}
* @param options - The {@linkcode ToHaveEffectiveStatMatcherOptions} * @param options - The {@linkcode toHaveEffectiveStatOptions}
* @returns Whether the matcher passed * @returns Whether the matcher passed
*/ */
export function toHaveEffectiveStat( export function toHaveEffectiveStat(
@ -38,11 +38,11 @@ export function toHaveEffectiveStat(
received: unknown, received: unknown,
stat: EffectiveStat, stat: EffectiveStat,
expectedValue: number, expectedValue: number,
{ enemy, move, isCritical = false }: ToHaveEffectiveStatMatcherOptions = {}, { enemy, move, isCritical = false }: toHaveEffectiveStatOptions = {},
): SyncExpectationResult { ): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }

View File

@ -12,7 +12,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export function toHaveFainted(this: MatcherState, received: unknown): SyncExpectationResult { export function toHaveFainted(this: MatcherState, received: unknown): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }

View File

@ -12,7 +12,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export function toHaveFullHp(this: MatcherState, received: unknown): SyncExpectationResult { export function toHaveFullHp(this: MatcherState, received: unknown): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }

View File

@ -13,7 +13,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export function toHaveHp(this: MatcherState, received: unknown, expectedHp: number): SyncExpectationResult { export function toHaveHp(this: MatcherState, received: unknown, expectedHp: number): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }

View File

@ -0,0 +1,107 @@
// biome-ignore-start lint/correctness/noUnusedImports: TSDoc
import type { GameManager } from "#test/test-utils/game-manager";
// biome-ignore-end lint/correctness/noUnusedImports: TSDoc
import type { serializedPosTagMap } from "#data/positional-tags/load-positional-tag";
import type { PositionalTagType } from "#enums/positional-tag-type";
import type { OneOther } from "#test/@types/test-helpers";
import { getOnelineDiffStr } from "#test/test-utils/string-utils";
import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils";
import { toTitleCase } from "#utils/strings";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export type toHavePositionalTagOptions<P extends PositionalTagType> = OneOther<serializedPosTagMap[P], "tagType"> & {
tagType: P;
};
/**
* Matcher to check if the {@linkcode Arena} has a certain number of {@linkcode PositionalTag}s active.
* @param received - The object to check. Should be the current {@linkcode GameManager}
* @param expectedTag - The {@linkcode PositionalTagType} of the desired tag, or a partially-filled {@linkcode PositionalTag}
* containing the desired properties
* @param count - The number of tags that should be active; defaults to `1` and must be within the range `[0, 4]`
* @returns The result of the matching
*/
export function toHavePositionalTag<P extends PositionalTagType>(
this: MatcherState,
received: unknown,
expectedTag: P | toHavePositionalTagOptions<P>,
count = 1,
): SyncExpectationResult {
if (!isGameManagerInstance(received)) {
return {
pass: this.isNot,
message: () => `Expected to receive a GameManager, but got ${receivedStr(received)}!`,
};
}
if (!received.scene?.arena?.positionalTagManager) {
return {
pass: this.isNot,
message: () =>
`Expected GameManager.${received.scene?.arena ? "scene.arena.positionalTagManager" : received.scene ? "scene.arena" : "scene"} to be defined!`,
};
}
// TODO: Increase limit if triple battles are added
if (count < 0 || count > 4) {
return {
pass: this.isNot,
message: () => `Expected count to be between 0 and 4, but got ${count} instead!`,
};
}
const allTags = received.scene.arena.positionalTagManager.tags;
const tagType = typeof expectedTag === "string" ? expectedTag : expectedTag.tagType;
const matchingTags = allTags.filter(t => t.tagType === tagType);
// If checking exclusively tag type, check solely the number of matching tags on field
if (typeof expectedTag === "string") {
const pass = matchingTags.length === count;
const expectedStr = getPosTagStr(expectedTag);
return {
pass,
message: () =>
pass
? `Expected the Arena to NOT have ${count} ${expectedStr} active, but it did!`
: `Expected the Arena to have ${count} ${expectedStr} active, but got ${matchingTags.length} instead!`,
expected: expectedTag,
actual: allTags,
};
}
// Check for equality with the provided object
if (matchingTags.length === 0) {
return {
pass: false,
message: () => `Expected the Arena to have a tag of type ${expectedTag.tagType}, but it didn't!`,
expected: expectedTag.tagType,
actual: received.scene.arena.tags.map(t => t.tagType),
};
}
// Pass if any of the matching tags meet the criteria
const pass = matchingTags.some(tag =>
this.equals(tag, expectedTag, [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]),
);
const expectedStr = getOnelineDiffStr.call(this, expectedTag);
return {
pass,
message: () =>
pass
? `Expected the Arena to NOT have a tag matching ${expectedStr}, but it did!`
: `Expected the Arena to have a tag matching ${expectedStr}, but it didn't!`,
expected: expectedTag,
actual: matchingTags,
};
}
function getPosTagStr(pType: PositionalTagType, count = 1): string {
let ret = toTitleCase(pType) + "Tag";
if (count > 1) {
ret += "s";
}
return ret;
}

View File

@ -23,14 +23,14 @@ export function toHaveStatStage(
): SyncExpectationResult { ): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }
if (expectedStage < -6 || expectedStage > 6) { if (expectedStage < -6 || expectedStage > 6) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected ${expectedStage} to be within the range [-6, 6]!`, message: () => `Expected ${expectedStage} to be within the range [-6, 6]!`,
}; };
} }

View File

@ -28,7 +28,7 @@ export function toHaveStatusEffect(
): SyncExpectationResult { ): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }
@ -37,10 +37,8 @@ export function toHaveStatusEffect(
const actualEffect = received.status?.effect ?? StatusEffect.NONE; const actualEffect = received.status?.effect ?? StatusEffect.NONE;
// Check exclusively effect equality first, coercing non-matching status effects to numbers. // Check exclusively effect equality first, coercing non-matching status effects to numbers.
if (actualEffect !== (expectedStatus as Exclude<typeof expectedStatus, StatusEffect>)?.effect) { if (typeof expectedStatus === "object" && actualEffect !== expectedStatus.effect) {
// This is actually 100% safe as `expectedStatus?.effect` will evaluate to `undefined` if a StatusEffect was passed, expectedStatus = expectedStatus.effect;
// which will never match actualEffect by definition
expectedStatus = (expectedStatus as Exclude<typeof expectedStatus, StatusEffect>).effect;
} }
if (typeof expectedStatus === "number") { if (typeof expectedStatus === "number") {

View File

@ -24,7 +24,7 @@ export function toHaveTakenDamage(
): SyncExpectationResult { ): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }

View File

@ -20,15 +20,15 @@ export function toHaveTerrain(
): SyncExpectationResult { ): SyncExpectationResult {
if (!isGameManagerInstance(received)) { if (!isGameManagerInstance(received)) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected GameManager, but got ${receivedStr(received)}!`, message: () => `Expected to receive a GameManager, but got ${receivedStr(received)}!`,
}; };
} }
if (!received.scene?.arena) { if (!received.scene?.arena) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`, message: () => `Expected GameManager.${received.scene ? "scene.arena" : "scene"} to be defined!`,
}; };
} }
@ -41,8 +41,8 @@ export function toHaveTerrain(
pass, pass,
message: () => message: () =>
pass pass
? `Expected Arena to NOT have ${expectedStr} active, but it did!` ? `Expected the Arena to NOT have ${expectedStr} active, but it did!`
: `Expected Arena to have ${expectedStr} active, but got ${actualStr} instead!`, : `Expected the Arena to have ${expectedStr} active, but got ${actualStr} instead!`,
expected: expectedTerrainType, expected: expectedTerrainType,
actual, actual,
}; };

View File

@ -7,10 +7,16 @@ import { isPokemonInstance, receivedStr } from "../test-utils";
export interface toHaveTypesOptions { export interface toHaveTypesOptions {
/** /**
* Whether to enforce exact matches (`true`) or superset matches (`false`). * Value dictating the strength of the enforced typing match.
* @defaultValue `true` *
* Possible values (in ascending order of strength) are:
* - `"ordered"`: Enforce that the {@linkcode Pokemon}'s types are identical **and in the same order**
* - `"unordered"`: Enforce that the {@linkcode Pokemon}'s types are identical **without checking order**
* - `"superset"`: Enforce that the {@linkcode Pokemon}'s types are **a superset of** the expected types
* (all must be present, but extras can be there)
* @defaultValue `"unordered"`
*/ */
exact?: boolean; mode?: "ordered" | "unordered" | "superset";
/** /**
* Optional arguments to pass to {@linkcode Pokemon.getTypes}. * Optional arguments to pass to {@linkcode Pokemon.getTypes}.
*/ */
@ -18,35 +24,54 @@ export interface toHaveTypesOptions {
} }
/** /**
* Matcher that checks if an array contains exactly the given items, disregarding order. * Matcher that checks if a Pokemon's typing is as expected.
* @param received - The object to check. Should be an array of one or more {@linkcode PokemonType}s. * @param received - The object to check. Should be a {@linkcode Pokemon}
* @param options - The {@linkcode toHaveTypesOptions | options} for this matcher * @param expectedTypes - An array of one or more {@linkcode PokemonType}s to compare against.
* @param mode - The mode to perform the matching in.
* Possible values (in ascending order of strength) are:
* - `"ordered"`: Enforce that the {@linkcode Pokemon}'s types are identical **and in the same order**
* - `"unordered"`: Enforce that the {@linkcode Pokemon}'s types are identical **without checking order**
* - `"superset"`: Enforce that the {@linkcode Pokemon}'s types are **a superset of** the expected types
* (all must be present, but extras can be there)
*
* Default `unordered`
* @param args - Extra arguments passed to {@linkcode Pokemon.getTypes}
* @returns The result of the matching * @returns The result of the matching
*/ */
export function toHaveTypes( export function toHaveTypes(
this: MatcherState, this: MatcherState,
received: unknown, received: unknown,
expected: [PokemonType, ...PokemonType[]], expectedTypes: [PokemonType, ...PokemonType[]],
options: toHaveTypesOptions = {}, { mode = "unordered", args = [] }: toHaveTypesOptions = {},
): SyncExpectationResult { ): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected to recieve a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }
const actualTypes = received.getTypes(...(options.args ?? [])).sort(); // Return early if no types were passed in
const expectedTypes = expected.slice().sort(); if (expectedTypes.length === 0) {
return {
pass: this.isNot,
message: () => "Expected to receive a non-empty array of PokemonTypes!",
};
}
// Avoid sorting the types if strict ordering is desired
const actualSorted = mode === "ordered" ? received.getTypes(...args) : received.getTypes(...args).toSorted();
const expectedSorted = mode === "ordered" ? expectedTypes : expectedTypes.toSorted();
// Exact matches do not care about subset equality // Exact matches do not care about subset equality
const matchers = options.exact const matchers =
? [...this.customTesters, this.utils.iterableEquality] mode === "superset"
: [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]; ? [...this.customTesters, this.utils.iterableEquality]
const pass = this.equals(actualTypes, expectedTypes, matchers); : [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality];
const pass = this.equals(actualSorted, expectedSorted, matchers);
const actualStr = stringifyEnumArray(PokemonType, actualTypes); const actualStr = stringifyEnumArray(PokemonType, actualSorted);
const expectedStr = stringifyEnumArray(PokemonType, expectedTypes); const expectedStr = stringifyEnumArray(PokemonType, expectedSorted);
const pkmName = getPokemonNameWithAffix(received); const pkmName = getPokemonNameWithAffix(received);
return { return {
@ -55,7 +80,7 @@ export function toHaveTypes(
pass pass
? `Expected ${pkmName} to NOT have types ${expectedStr}, but it did!` ? `Expected ${pkmName} to NOT have types ${expectedStr}, but it did!`
: `Expected ${pkmName} to have types ${expectedStr}, but got ${actualStr} instead!`, : `Expected ${pkmName} to have types ${expectedStr}, but got ${actualStr} instead!`,
expected: expectedTypes, expected: expectedSorted,
actual: actualTypes, actual: actualSorted,
}; };
} }

View File

@ -13,7 +13,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/** /**
* Matcher to check the contents of a {@linkcode Pokemon}'s move history. * Matcher to check the contents of a {@linkcode Pokemon}'s move history.
* @param received - The actual value received. Should be a {@linkcode Pokemon} * @param received - The actual value received. Should be a {@linkcode Pokemon}
* @param expectedValue - The {@linkcode MoveId} the Pokemon is expected to have used, * @param expectedMove - The {@linkcode MoveId} the Pokemon is expected to have used,
* or a partially filled {@linkcode TurnMove} containing the desired properties to check * or a partially filled {@linkcode TurnMove} containing the desired properties to check
* @param index - The index of the move history entry to check, in order from most recent to least recent. * @param index - The index of the move history entry to check, in order from most recent to least recent.
* Default `0` (last used move) * Default `0` (last used move)
@ -22,12 +22,12 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export function toHaveUsedMove( export function toHaveUsedMove(
this: MatcherState, this: MatcherState,
received: unknown, received: unknown,
expectedResult: MoveId | AtLeastOne<TurnMove>, expectedMove: MoveId | AtLeastOne<TurnMove>,
index = 0, index = 0,
): SyncExpectationResult { ): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }
@ -37,34 +37,33 @@ export function toHaveUsedMove(
if (move === undefined) { if (move === undefined) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected ${pkmName} to have used ${index + 1} moves, but it didn't!`, message: () => `Expected ${pkmName} to have used ${index + 1} moves, but it didn't!`,
actual: received.getLastXMoves(-1), actual: received.getLastXMoves(-1),
}; };
} }
// Coerce to a `TurnMove` // Coerce to a `TurnMove`
if (typeof expectedResult === "number") { if (typeof expectedMove === "number") {
expectedResult = { move: expectedResult }; expectedMove = { move: expectedMove };
} }
const moveIndexStr = index === 0 ? "last move" : `${getOrdinal(index)} most recent move`; const moveIndexStr = index === 0 ? "last move" : `${getOrdinal(index)} most recent move`;
const pass = this.equals(move, expectedResult, [ const pass = this.equals(move, expectedMove, [
...this.customTesters, ...this.customTesters,
this.utils.subsetEquality, this.utils.subsetEquality,
this.utils.iterableEquality, this.utils.iterableEquality,
]); ]);
const expectedStr = getOnelineDiffStr.call(this, expectedResult); const expectedStr = getOnelineDiffStr.call(this, expectedMove);
return { return {
pass, pass,
message: () => message: () =>
pass pass
? `Expected ${pkmName}'s ${moveIndexStr} to NOT match ${expectedStr}, but it did!` ? `Expected ${pkmName}'s ${moveIndexStr} to NOT match ${expectedStr}, but it did!`
: // Replace newlines with spaces to preserve one-line ness : `Expected ${pkmName}'s ${moveIndexStr} to match ${expectedStr}, but it didn't!`,
`Expected ${pkmName}'s ${moveIndexStr} to match ${expectedStr}, but it didn't!`, expected: expectedMove,
expected: expectedResult,
actual: move, actual: move,
}; };
} }

View File

@ -13,7 +13,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/** /**
* Matcher to check the amount of PP consumed by a {@linkcode Pokemon}. * Matcher to check the amount of PP consumed by a {@linkcode Pokemon}.
* @param received - The actual value received. Should be a {@linkcode Pokemon} * @param received - The actual value received. Should be a {@linkcode Pokemon}
* @param expectedValue - The {@linkcode MoveId} that should have consumed PP * @param moveId - The {@linkcode MoveId} that should have consumed PP
* @param ppUsed - The numerical amount of PP that should have been consumed, * @param ppUsed - The numerical amount of PP that should have been consumed,
* or `all` to indicate the move should be _out_ of PP * or `all` to indicate the move should be _out_ of PP
* @returns Whether the matcher passed * @returns Whether the matcher passed
@ -23,35 +23,35 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export function toHaveUsedPP( export function toHaveUsedPP(
this: MatcherState, this: MatcherState,
received: unknown, received: unknown,
expectedMove: MoveId, moveId: MoveId,
ppUsed: number | "all", ppUsed: number | "all",
): SyncExpectationResult { ): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }
const override = received.isPlayer() ? Overrides.MOVESET_OVERRIDE : Overrides.OPP_MOVESET_OVERRIDE; const override = received.isPlayer() ? Overrides.MOVESET_OVERRIDE : Overrides.ENEMY_MOVESET_OVERRIDE;
if (coerceArray(override).length > 0) { if (coerceArray(override).length > 0) {
return { return {
pass: false, pass: this.isNot,
message: () => message: () =>
`Cannot test for PP consumption with ${received.isPlayer() ? "player" : "enemy"} moveset overrides active!`, `Cannot test for PP consumption with ${received.isPlayer() ? "player" : "enemy"} moveset overrides active!`,
}; };
} }
const pkmName = getPokemonNameWithAffix(received); const pkmName = getPokemonNameWithAffix(received);
const moveStr = getEnumStr(MoveId, expectedMove); const moveStr = getEnumStr(MoveId, moveId);
const movesetMoves = received.getMoveset().filter(pm => pm.moveId === expectedMove); const movesetMoves = received.getMoveset().filter(pm => pm.moveId === moveId);
if (movesetMoves.length !== 1) { if (movesetMoves.length !== 1) {
return { return {
pass: false, pass: this.isNot,
message: () => message: () =>
`Expected MoveId.${moveStr} to appear in ${pkmName}'s moveset exactly once, but got ${movesetMoves.length} times!`, `Expected MoveId.${moveStr} to appear in ${pkmName}'s moveset exactly once, but got ${movesetMoves.length} times!`,
expected: expectedMove, expected: moveId,
actual: received.getMoveset(), actual: received.getMoveset(),
}; };
} }

View File

@ -20,15 +20,15 @@ export function toHaveWeather(
): SyncExpectationResult { ): SyncExpectationResult {
if (!isGameManagerInstance(received)) { if (!isGameManagerInstance(received)) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected GameManager, but got ${receivedStr(received)}!`, message: () => `Expected to receive a GameManager, but got ${receivedStr(received)}!`,
}; };
} }
if (!received.scene?.arena) { if (!received.scene?.arena) {
return { return {
pass: false, pass: this.isNot,
message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`, message: () => `Expected GameManager.${received.scene ? "scene.arena" : "scene"} to be defined!`,
}; };
} }
@ -41,8 +41,8 @@ export function toHaveWeather(
pass, pass,
message: () => message: () =>
pass pass
? `Expected Arena to NOT have ${expectedStr} weather active, but it did!` ? `Expected the Arena to NOT have ${expectedStr} weather active, but it did!`
: `Expected Arena to have ${expectedStr} weather active, but got ${actualStr} instead!`, : `Expected the Arena to have ${expectedStr} weather active, but got ${actualStr} instead!`,
expected: expectedWeatherType, expected: expectedWeatherType,
actual, actual,
}; };

View File

@ -36,6 +36,7 @@ import { NewBiomeEncounterPhase } from "#phases/new-biome-encounter-phase";
import { NextEncounterPhase } from "#phases/next-encounter-phase"; import { NextEncounterPhase } from "#phases/next-encounter-phase";
import { PartyExpPhase } from "#phases/party-exp-phase"; import { PartyExpPhase } from "#phases/party-exp-phase";
import { PartyHealPhase } from "#phases/party-heal-phase"; import { PartyHealPhase } from "#phases/party-heal-phase";
import { PokemonHealPhase } from "#phases/pokemon-heal-phase";
import { PokemonTransformPhase } from "#phases/pokemon-transform-phase"; import { PokemonTransformPhase } from "#phases/pokemon-transform-phase";
import { PositionalTagPhase } from "#phases/positional-tag-phase"; import { PositionalTagPhase } from "#phases/positional-tag-phase";
import { PostGameOverPhase } from "#phases/post-game-over-phase"; import { PostGameOverPhase } from "#phases/post-game-over-phase";
@ -146,6 +147,7 @@ export class PhaseInterceptor {
[PositionalTagPhase, this.startPhase], [PositionalTagPhase, this.startPhase],
[PokemonTransformPhase, this.startPhase], [PokemonTransformPhase, this.startPhase],
[MysteryEncounterPhase, this.startPhase], [MysteryEncounterPhase, this.startPhase],
[PokemonHealPhase, this.startPhase],
[MysteryEncounterOptionSelectedPhase, this.startPhase], [MysteryEncounterOptionSelectedPhase, this.startPhase],
[MysteryEncounterBattlePhase, this.startPhase], [MysteryEncounterBattlePhase, this.startPhase],
[MysteryEncounterRewardsPhase, this.startPhase], [MysteryEncounterRewardsPhase, this.startPhase],

View File

@ -34,10 +34,10 @@ interface getEnumStrOptions {
* @returns The stringified representation of `val` as dictated by the options. * @returns The stringified representation of `val` as dictated by the options.
* @example * @example
* ```ts * ```ts
* enum fakeEnum { * enum testEnum {
* ONE: 1, * ONE = 1,
* TWO: 2, * TWO = 2,
* THREE: 3, * THREE = 3,
* } * }
* getEnumStr(fakeEnum, fakeEnum.ONE); // Output: "ONE (=1)" * getEnumStr(fakeEnum, fakeEnum.ONE); // Output: "ONE (=1)"
* getEnumStr(fakeEnum, fakeEnum.TWO, {casing: "Title", prefix: "fakeEnum.", suffix: "!!!"}); // Output: "fakeEnum.TWO!!! (=2)" * getEnumStr(fakeEnum, fakeEnum.TWO, {casing: "Title", prefix: "fakeEnum.", suffix: "!!!"}); // Output: "fakeEnum.TWO!!! (=2)"
@ -174,10 +174,14 @@ export function getStatName(s: Stat): string {
* Convert an object into a oneline diff to be shown in an error message. * Convert an object into a oneline diff to be shown in an error message.
* @param obj - The object to return the oneline diff of * @param obj - The object to return the oneline diff of
* @returns The updated diff * @returns The updated diff
* @example
* ```ts
* const diff = getOnelineDiffStr.call(this, obj)
* ```
*/ */
export function getOnelineDiffStr(this: MatcherState, obj: unknown): string { export function getOnelineDiffStr(this: MatcherState, obj: unknown): string {
return this.utils return this.utils
.stringify(obj, undefined, { maxLength: 35, indent: 0, printBasicPrototype: false }) .stringify(obj, undefined, { maxLength: 35, indent: 0, printBasicPrototype: false })
.replace(/\n/g, " ") // Replace newlines with spaces .replace(/\n/g, " ") // Replace newlines with spaces
.replace(/,(\s*)}$/g, "$1}"); .replace(/,(\s*)}$/g, "$1}"); // Trim trailing commas
} }