Compare commits

...

16 Commits

Author SHA1 Message Date
Bertie690
e81c49c969
Merge 6866248b41 into 3b36ab17e4 2025-08-05 00:57:37 -05:00
Jimmybald1
3b36ab17e4
[Bug] Protect now tracks success chance properly (#5869)
* Protect rng now resets on new waves and fixed to look at all turns in the same wave.

* Added per-wave move history object to fix issues

@Jimmybald1 I added a commented out `console.log` in the protect code (L5797) for you to use for testing

* Added many tests

* Wave move history has to be looped in reverse

* Update src/data/moves/move.ts

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

* Update src/data/moves/move.ts

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

* comments

* Fixed forceEnemyMove references after merge

* Removed console log

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

* Fixed test message

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

* Apply Biome

* Fix merge issues

* Fix Crafty Shield test

* Remove protect chance reset on wave change

* Fix merge issue

---------

Co-authored-by: Jimmybald1 <147992650+IBBCalc@users.noreply.github.com>
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: Amani H. <109637146+xsn34kzx@users.noreply.github.com>
Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>
2025-08-05 07:35:14 +02:00
Bertie690
6866248b41
Update test/test-utils/matchers/to-have-positional-tag.ts
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
2025-08-05 00:05:04 -04:00
Bertie690
9298ff8282
Update test/test-utils/matchers/to-have-positional-tag.ts
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
2025-08-05 00:04:55 -04:00
Bertie690
7e7ca6b3fa
Update test/test-utils/matchers/to-have-positional-tag.ts
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
2025-08-05 00:04:39 -04:00
Bertie690
cd890025d1
Update test/@types/vitest.d.ts
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
2025-08-05 00:04:31 -04:00
Bertie690
71801fe298
Update test/test-utils/matchers/to-have-types.ts
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
2025-08-05 00:04:20 -04:00
Bertie690
e968063eaa Added toHavePositionalTag matcher 2025-08-03 17:21:24 -04:00
Bertie690
b98ff5ae90 Moar fixups to strings 2025-08-03 17:12:59 -04:00
Bertie690
c89accc673 Shuffled a few funcs around 2025-08-03 16:26:39 -04:00
Bertie690
df8d1dc8c7 More semantic changes 2025-08-03 16:15:32 -04:00
Bertie690
9455030fbe More improvements and minor fixes 2025-08-03 15:48:08 -04:00
Bertie690
dbea701d6d
Update test/test-utils/matchers/to-have-arena-tag.ts
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
2025-08-03 13:27:26 -04:00
Bertie690
641f5f5b97 Removed accidental test file addition 2025-08-03 11:16:01 -04:00
Bertie690
f5154179b3 Fixed imports and stufff 2025-08-03 11:12:19 -04:00
Bertie690
49825a6729 [Test] Added toHaveArenaTagMatcher + fixed prior matchers 2025-08-02 23:52:07 -04:00
31 changed files with 795 additions and 374 deletions

View File

@ -5912,20 +5912,21 @@ export class ProtectAttr extends AddBattlerTagAttr {
getCondition(): MoveConditionFunc {
return ((user, target, move): boolean => {
let timesUsed = 0;
const moveHistory = user.getLastXMoves();
let turnMove: TurnMove | undefined;
while (moveHistory.length) {
turnMove = moveHistory.shift();
if (!allMoves[turnMove?.move ?? MoveId.NONE].hasAttr("ProtectAttr") || turnMove?.result !== MoveResult.SUCCESS) {
for (const turnMove of user.getLastXMoves(-1).slice()) {
if (
// Quick & Wide guard increment the Protect counter without using it for fail chance
!(allMoves[turnMove.move].hasAttr("ProtectAttr") ||
[MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) ||
turnMove.result !== MoveResult.SUCCESS
) {
break;
}
timesUsed++;
timesUsed++
}
if (timesUsed) {
return !user.randBattleSeedInt(Math.pow(3, timesUsed));
}
return true;
return timesUsed === 0 || user.randBattleSeedInt(Math.pow(3, timesUsed)) === 0;
});
}
}

View File

@ -253,7 +253,6 @@ export class PokemonTempSummonData {
* Only currently used for positioning the battle cursor.
*/
turnCount = 1;
/**
* The number of turns this pokemon has spent in the active position since the start of the wave
* without switching out.

View File

@ -5094,6 +5094,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
*/
resetWaveData(): void {
this.waveData = new PokemonWaveData();
this.tempSummonData.waveTurnCount = 1;
}
resetTera(): void {

View File

@ -58,12 +58,6 @@ export class BattleEndPhase extends BattlePhase {
globalScene.phaseManager.unshiftNew("GameOverPhase", true);
}
for (const pokemon of globalScene.getField()) {
if (pokemon) {
pokemon.tempSummonData.waveTurnCount = 1;
}
}
for (const pokemon of globalScene.getPokemonAllowedInBattle()) {
applyAbAttrs("PostBattleAbAttr", { pokemon, victory: this.isVictory });
}

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 { ArenaTag } from "#data/arena-tag";
import type { AbilityId } from "#enums/ability-id";
import type { ArenaTagType } from "#enums/arena-tag-type";
import type { BattlerTagType } from "#enums/battler-tag-type";
import type { MoveId } from "#enums/move-id";
import type { PokemonType } from "#enums/pokemon-type";
import type { BattleStat, EffectiveStat, Stat } from "#enums/stat";
import type { StatusEffect } from "#enums/status-effect";
import type { WeatherType } from "#enums/weather-type";
import type { Arena } from "#field/arena";
import type { Pokemon } from "#field/pokemon";
import type { ToHaveEffectiveStatMatcherOptions } from "#test/test-utils/matchers/to-have-effective-stat";
import type { toHaveEffectiveStatOptions } from "#test/test-utils/matchers/to-have-effective-stat";
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 { TurnMove } from "#types/turn-move";
import type { AtLeastOne } from "#types/type-helpers";
import type { toDmgValue } from "utils/common";
import type { expect } from "vitest";
import type { PositionalTag } from "#data/positional-tags/positional-tag";
import { PositionalTagType } from "#enums/positional-tag-type";
import type Overrides from "#app/overrides";
import type { ArenaTagSide } from "#enums/arena-tag-side";
import type { PokemonMove } from "#moves/pokemon-move";
import { toHaveArenaTagOptions } from "#test/test-utils/matchers/to-have-arena-tag";
import { toHavePositionalTagOptions } from "#test/test-utils/matchers/to-have-positional-tag";
declare module "vitest" {
interface Assertion {
interface Assertion<T> {
/**
* 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
* @see {@linkcode expect.arrayContaining}
*/
toEqualArrayUnsorted<E>(expected: E[]): void;
toEqualArrayUnsorted(expected: T[]): void;
/**
* 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;
// #region Arena Matchers
/**
* Check whether the current {@linkcode WeatherType} is as expected.
@ -80,9 +53,63 @@ declare module "vitest" {
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 of the field} the tag should affect, or
* {@linkcode ArenaTagSide.BOTH} to check both sides;
* default `ArenaTagSide.BOTH`
*/
toHaveArenaTag(expectedType: ArenaTagType, side?: ArenaTagSide): void;
/**
* Check whether the current {@linkcode Arena} contains the given {@linkcode PositionalTag}.
* @param expectedTag - A partially-filled {@linkcode 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 expectedTags - The expected {@linkcode PokemonType}s to check against; must have length `>0`
* @param options - The {@linkcode toHaveTypesOptions | options} passed to the matcher
*/
toHaveTypes(expectedTags: 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` (last used move)
* @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}.
@ -106,7 +133,7 @@ declare module "vitest" {
/**
* Check whether a {@linkcode Pokemon} has applied a specific {@linkcode AbilityId}.
* @param expectedAbilityId - The expected {@linkcode AbilityId}
* @param expectedAbilityId - The expected {@linkcode AbilityId} to check for
*/
toHaveAbilityApplied(expectedAbilityId: AbilityId): void;
@ -116,24 +143,36 @@ declare module "vitest" {
*/
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 {@linkcode expectedDamageTaken} with {@linkcode toDmgValue}; default `true`
*/
toHaveTakenDamage(expectedDamageTaken: number, roundDown?: boolean): void;
/**
* Check whether a {@linkcode Pokemon} is currently fainted (as determined by {@linkcode Pokemon.isFainted}).
* @remarks
* When checking whether an enemy wild Pokemon is fainted, one must reference it in a variable _before_ the fainting effect occurs
* as otherwise the Pokemon will be GC'ed and rendered `undefined`.
* 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 removed from the field and garbage collected.
*/
toHaveFainted(): void;
/**
* Check whether a {@linkcode Pokemon} is at full HP.
*/
toHaveFullHp(): void;
/**
* Check whether a {@linkcode Pokemon} has consumed the given amount of PP for one of its moves.
* @param expectedValue - The {@linkcode MoveId} of the {@linkcode PokemonMove} that should have consumed PP
* @param moveId - The {@linkcode MoveId} of the {@linkcode PokemonMove} that should have consumed PP
* @param ppUsed - The numerical amount of PP that should have been consumed,
* or `all` to indicate the move should be _out_ of PP
* @remarks
* If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.OPP_MOVESET_OVERRIDE},
* does not contain {@linkcode expectedMove}
* or contains the desired move more than once, this will fail the test.
* If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.OPP_MOVESET_OVERRIDE}
* or does not contain exactly 1 copy of {@linkcode moveId}, this will fail the test.
*/
toHaveUsedPP(expectedMove: MoveId, ppUsed: number | "all"): void;
toHaveUsedPP(moveId: MoveId, ppUsed: number | "all"): void;
// #region Pokemon Matchers
}
}

View File

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

View File

@ -5,7 +5,7 @@ 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, test } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Baneful Bunker", () => {
let phaserGame: Phaser.Game;
@ -26,55 +26,51 @@ describe("Moves - Baneful Bunker", () => {
game.override
.battleStyle("single")
.moveset(MoveId.SLASH)
.enemySpecies(SpeciesId.SNORLAX)
.moveset([MoveId.SLASH, MoveId.FLASH_CANNON])
.enemySpecies(SpeciesId.TOXAPEX)
.enemyAbility(AbilityId.INSOMNIA)
.enemyMoveset(MoveId.BANEFUL_BUNKER)
.startingLevel(100)
.enemyLevel(100);
});
test("should protect the user and poison attackers that make contact", async () => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const leadPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
function expectProtected() {
expect(game.scene.getEnemyPokemon()?.hp).toBe(game.scene.getEnemyPokemon()?.getMaxHp());
expect(game.scene.getPlayerPokemon()?.status?.effect).toBe(StatusEffect.POISON);
}
it("should protect the user and poison attackers that make contact", async () => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
game.move.select(MoveId.SLASH);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase", false);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
expect(leadPokemon.status?.effect === StatusEffect.POISON).toBeTruthy();
expectProtected();
});
test("should protect the user and poison attackers that make contact, regardless of accuracy checks", async () => {
it("should ignore accuracy checks", async () => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const leadPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.SLASH);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("MoveEffectPhase");
await game.phaseInterceptor.to("MoveEndPhase"); // baneful bunker
await game.move.forceMiss();
await game.phaseInterceptor.to("BerryPhase", false);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
expect(leadPokemon.status?.effect === StatusEffect.POISON).toBeTruthy();
expectProtected();
});
test("should not poison attackers that don't make contact", async () => {
game.override.moveset(MoveId.FLASH_CANNON);
it("should block non-contact moves without poisoning attackers", async () => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const leadPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
const charizard = game.field.getPlayerPokemon();
const toxapex = game.field.getEnemyPokemon();
game.move.select(MoveId.FLASH_CANNON);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("MoveEffectPhase");
await game.move.forceMiss();
await game.phaseInterceptor.to("BerryPhase", false);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
expect(leadPokemon.status?.effect === StatusEffect.POISON).toBeFalsy();
expect(toxapex.hp).toBe(toxapex.getMaxHp());
expect(charizard.status?.effect).toBeUndefined();
});
});

View File

@ -1,12 +1,14 @@
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
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 { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { BerryPhase } from "#phases/berry-phase";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Crafty Shield", () => {
let phaserGame: Phaser.Game;
@ -27,68 +29,100 @@ describe("Moves - Crafty Shield", () => {
game.override
.battleStyle("double")
.moveset([MoveId.CRAFTY_SHIELD, MoveId.SPLASH, MoveId.SWORDS_DANCE])
.enemySpecies(SpeciesId.SNORLAX)
.enemyMoveset([MoveId.GROWL])
.enemySpecies(SpeciesId.DUSKNOIR)
.enemyMoveset(MoveId.GROWL)
.enemyAbility(AbilityId.INSOMNIA)
.startingLevel(100)
.enemyLevel(100);
});
test("should protect the user and allies from status moves", async () => {
it("should protect the user and allies from status moves", async () => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD, SpeciesId.BLASTOISE]);
const leadPokemon = game.scene.getPlayerField();
const [charizard, blastoise] = game.scene.getPlayerField();
game.move.use(MoveId.CRAFTY_SHIELD, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.GROWL);
await game.move.forceEnemyMove(MoveId.GROWL);
game.move.select(MoveId.CRAFTY_SHIELD);
game.move.select(MoveId.SPLASH, 1);
await game.phaseInterceptor.to("TurnEndPhase");
await game.phaseInterceptor.to(BerryPhase, false);
leadPokemon.forEach(p => expect(p.getStatStage(Stat.ATK)).toBe(0));
expect(charizard.getStatStage(Stat.ATK)).toBe(0);
expect(blastoise.getStatStage(Stat.ATK)).toBe(0);
});
test("should not protect the user and allies from attack moves", async () => {
game.override.enemyMoveset([MoveId.TACKLE]);
it("should not protect the user and allies from attack moves", async () => {
game.override.enemyMoveset(MoveId.TACKLE);
await game.classicMode.startBattle([SpeciesId.CHARIZARD, SpeciesId.BLASTOISE]);
const leadPokemon = game.scene.getPlayerField();
const [charizard, blastoise] = game.scene.getPlayerField();
game.move.select(MoveId.CRAFTY_SHIELD);
game.move.select(MoveId.SPLASH, 1);
game.move.use(MoveId.CRAFTY_SHIELD, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.TACKLE, BattlerIndex.PLAYER);
await game.move.forceEnemyMove(MoveId.TACKLE, BattlerIndex.PLAYER_2);
await game.phaseInterceptor.to("TurnEndPhase");
await game.phaseInterceptor.to(BerryPhase, false);
expect(leadPokemon.some(p => p.hp < p.getMaxHp())).toBeTruthy();
expect(charizard.isFullHp()).toBe(false);
expect(blastoise.isFullHp()).toBe(false);
});
test("should protect the user and allies from moves that ignore other protection", async () => {
game.override.enemySpecies(SpeciesId.DUSCLOPS).enemyMoveset([MoveId.CURSE]);
it("should not block entry hazards and field-targeted moves", async () => {
game.override.enemyMoveset([MoveId.PERISH_SONG, MoveId.TOXIC_SPIKES]);
await game.classicMode.startBattle([SpeciesId.CHARIZARD, SpeciesId.BLASTOISE]);
const leadPokemon = game.scene.getPlayerField();
const [charizard, blastoise] = game.scene.getPlayerField();
game.move.select(MoveId.CRAFTY_SHIELD);
game.move.select(MoveId.SPLASH, 1);
game.move.use(MoveId.CRAFTY_SHIELD, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.PERISH_SONG);
await game.move.forceEnemyMove(MoveId.TOXIC_SPIKES);
await game.phaseInterceptor.to("TurnEndPhase");
await game.phaseInterceptor.to(BerryPhase, false);
leadPokemon.forEach(p => expect(p.getTag(BattlerTagType.CURSED)).toBeUndefined());
expect(game.scene.arena.getTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.PLAYER)).toBeDefined();
expect(charizard.getTag(BattlerTagType.PERISH_SONG)).toBeDefined();
expect(blastoise.getTag(BattlerTagType.PERISH_SONG)).toBeDefined();
});
test("should not block allies' self-targeted moves", async () => {
it("should protect the user and allies from moves that ignore other protection", async () => {
game.override.moveset(MoveId.CURSE);
await game.classicMode.startBattle([SpeciesId.CHARIZARD, SpeciesId.BLASTOISE]);
const leadPokemon = game.scene.getPlayerField();
const [charizard, blastoise] = game.scene.getPlayerField();
game.move.select(MoveId.CRAFTY_SHIELD);
game.move.select(MoveId.SWORDS_DANCE, 1);
game.move.use(MoveId.CRAFTY_SHIELD, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.CURSE, BattlerIndex.PLAYER);
await game.move.forceEnemyMove(MoveId.CURSE, BattlerIndex.PLAYER_2);
await game.phaseInterceptor.to(BerryPhase, false);
await game.toEndOfTurn();
expect(leadPokemon[0].getStatStage(Stat.ATK)).toBe(0);
expect(leadPokemon[1].getStatStage(Stat.ATK)).toBe(2);
expect(charizard.getTag(BattlerTagType.CURSED)).toBeUndefined();
expect(blastoise.getTag(BattlerTagType.CURSED)).toBeUndefined();
const [dusknoir1, dusknoir2] = game.scene.getEnemyField();
expect(dusknoir1).toHaveFullHp();
expect(dusknoir2).toHaveFullHp();
});
it("should not block allies' self or ally-targeted moves", async () => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD, SpeciesId.BLASTOISE]);
const [charizard, blastoise] = game.scene.getPlayerField();
game.move.use(MoveId.CRAFTY_SHIELD, BattlerIndex.PLAYER);
game.move.use(MoveId.SWORDS_DANCE, BattlerIndex.PLAYER_2);
await game.phaseInterceptor.to("TurnEndPhase");
expect(charizard.getStatStage(Stat.ATK)).toBe(0);
expect(blastoise.getStatStage(Stat.ATK)).toBe(2);
game.move.use(MoveId.HOWL, BattlerIndex.PLAYER);
game.move.use(MoveId.CRAFTY_SHIELD, BattlerIndex.PLAYER_2);
await game.phaseInterceptor.to("TurnEndPhase");
expect(charizard.getStatStage(Stat.ATK)).toBe(1);
expect(blastoise.getStatStage(Stat.ATK)).toBe(3);
});
});

View File

@ -1,9 +1,10 @@
import { AbilityId } from "#enums/ability-id";
import { HitResult } from "#enums/hit-result";
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 } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Endure", () => {
let phaserGame: Phaser.Game;
@ -22,7 +23,7 @@ describe("Moves - Endure", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([MoveId.THUNDER, MoveId.BULLET_SEED, MoveId.TOXIC, MoveId.SHEER_COLD])
.moveset([MoveId.THUNDER, MoveId.BULLET_SEED, MoveId.SHEER_COLD])
.ability(AbilityId.SKILL_LINK)
.startingLevel(100)
.battleStyle("single")
@ -32,7 +33,7 @@ describe("Moves - Endure", () => {
.enemyMoveset(MoveId.ENDURE);
});
it("should let the pokemon survive with 1 HP", async () => {
it("should let the pokemon survive with 1 HP from attacks", async () => {
await game.classicMode.startBattle([SpeciesId.ARCEUS]);
game.move.select(MoveId.THUNDER);
@ -41,7 +42,7 @@ describe("Moves - Endure", () => {
expect(game.field.getEnemyPokemon().hp).toBe(1);
});
it("should let the pokemon survive with 1 HP when hit with a multihit move", async () => {
it("should let the pokemon survive with 1 HP from multi-strike moves", async () => {
await game.classicMode.startBattle([SpeciesId.ARCEUS]);
game.move.select(MoveId.BULLET_SEED);
@ -57,30 +58,27 @@ describe("Moves - Endure", () => {
game.move.select(MoveId.SHEER_COLD);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.isFainted()).toBeFalsy();
expect(enemy.hp).toBe(1);
});
// comprehensive indirect damage test copied from Reviver Seed test
it.each([
{ moveType: "Damaging Move Chip Damage", move: MoveId.SALT_CURE },
{ moveType: "Chip Damage", move: MoveId.LEECH_SEED },
{ moveType: "Trapping Chip Damage", move: MoveId.WHIRLPOOL },
{ moveType: "Status Effect Damage", move: MoveId.TOXIC },
{ moveType: "Damaging Move Chip", move: MoveId.SALT_CURE },
{ moveType: "Status Move Chip", move: MoveId.LEECH_SEED },
{ moveType: "Partial Trapping move", move: MoveId.WHIRLPOOL },
{ moveType: "Status Effect", move: MoveId.TOXIC },
{ moveType: "Weather", move: MoveId.SANDSTORM },
])("should not prevent fainting from $moveType", async ({ move }) => {
game.override
.enemyLevel(1)
.startingLevel(100)
.enemySpecies(SpeciesId.MAGIKARP)
.moveset(move)
.enemyMoveset(MoveId.ENDURE);
])("should not prevent fainting from $moveType Damage", async ({ move }) => {
game.override.moveset(move).enemyLevel(100);
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]);
const enemy = game.field.getEnemyPokemon();
enemy.damageAndUpdate(enemy.hp - 1);
enemy.hp = 2;
// force attack to do 1 dmg (for salt cure)
vi.spyOn(enemy, "getAttackDamage").mockReturnValue({ cancelled: false, result: HitResult.EFFECTIVE, damage: 1 });
game.move.select(move);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.isFainted()).toBeTruthy();
expect(enemy.isFainted()).toBe(true);
});
});

View File

@ -1,15 +1,14 @@
import { ArenaTrapTag } from "#data/arena-tag";
import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { MoveUseMode } from "#enums/move-use-mode";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Protect", () => {
let phaserGame: Phaser.Game;
@ -27,90 +26,210 @@ describe("Moves - Protect", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.moveset([MoveId.PROTECT])
.moveset([MoveId.PROTECT, MoveId.SPIKY_SHIELD, MoveId.ENDURE, MoveId.SPLASH])
.enemySpecies(SpeciesId.SNORLAX)
.enemyAbility(AbilityId.INSOMNIA)
.enemyMoveset([MoveId.TACKLE])
.enemyMoveset(MoveId.LUMINA_CRASH)
.startingLevel(100)
.enemyLevel(100);
});
test("should protect the user from attacks", async () => {
it("should protect the user from attacks and their secondary effects", async () => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const leadPokemon = game.field.getPlayerPokemon();
const charizard = game.field.getPlayerPokemon();
game.move.select(MoveId.PROTECT);
await game.phaseInterceptor.to("BerryPhase", false);
expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp());
expect(charizard.hp).toBe(charizard.getMaxHp());
expect(charizard.getStatStage(Stat.SPDEF)).toBe(0);
expect(charizard);
});
test("should prevent secondary effects from the opponent's attack", async () => {
game.override.enemyMoveset([MoveId.CEASELESS_EDGE]);
vi.spyOn(allMoves[MoveId.CEASELESS_EDGE], "accuracy", "get").mockReturnValue(100);
it.each<{ numTurns: number; chance: number }>([
{ numTurns: 1, chance: 3 },
{ numTurns: 2, chance: 9 },
{ numTurns: 3, chance: 27 },
{ numTurns: 4, chance: 81 },
])("should have a 1/$chance success rate after $numTurns successful uses", async ({ numTurns, chance }) => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const leadPokemon = game.field.getPlayerPokemon();
const charizard = game.scene.getPlayerPokemon()!;
// mock RNG roll to suceed unless exactly the desired chance is hit
vi.spyOn(charizard, "randBattleSeedInt").mockImplementation(range => (range !== chance ? 0 : 1));
const conditionSpy = vi.spyOn(allMoves[MoveId.PROTECT]["conditions"][0], "apply");
// click protect many times
for (let x = 0; x < numTurns; x++) {
game.move.select(MoveId.PROTECT);
await game.toNextTurn();
expect(charizard.hp).toBe(charizard.getMaxHp());
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(conditionSpy).toHaveLastReturnedWith(true);
}
game.move.select(MoveId.PROTECT);
await game.toNextTurn();
await game.phaseInterceptor.to("BerryPhase", false);
expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp());
expect(game.scene.arena.getTagOnSide(ArenaTrapTag, ArenaTagSide.ENEMY)).toBeUndefined();
expect(charizard.hp).toBeLessThan(charizard.getMaxHp());
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(conditionSpy).toHaveLastReturnedWith(false);
});
test("should protect the user from status moves", async () => {
game.override.enemyMoveset([MoveId.CHARM]);
it("should share fail chance with all move variants", async () => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const leadPokemon = game.field.getPlayerPokemon();
const charizard = game.field.getPlayerPokemon();
charizard.summonData.moveHistory = [
{ move: MoveId.ENDURE, result: MoveResult.SUCCESS, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL },
{
move: MoveId.SPIKY_SHIELD,
result: MoveResult.SUCCESS,
targets: [BattlerIndex.PLAYER],
useMode: MoveUseMode.NORMAL,
},
];
// force protect to fail on anything >=2 uses (1/9 chance)
vi.spyOn(charizard, "randBattleSeedInt").mockImplementation(range => (range >= 9 ? 1 : 0));
game.move.select(MoveId.PROTECT);
await game.toNextTurn();
await game.phaseInterceptor.to("BerryPhase", false);
expect(leadPokemon.getStatStage(Stat.ATK)).toBe(0);
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
test("should stop subsequent hits of a multi-hit move", async () => {
it("should reset fail chance on move failure", async () => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const charizard = game.scene.getPlayerPokemon()!;
// force protect to always fail if RNG roll attempt is made
vi.spyOn(charizard, "randBattleSeedInt").mockReturnValue(1);
game.move.select(MoveId.PROTECT);
await game.toNextTurn();
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
game.move.select(MoveId.SPIKY_SHIELD);
await game.toNextTurn();
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
game.move.select(MoveId.SPIKY_SHIELD);
await game.toNextTurn();
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
});
it("should reset fail chance on using another move", async () => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const charizard = game.scene.getPlayerPokemon()!;
// force protect to always fail if RNG roll attempt is made
vi.spyOn(charizard, "randBattleSeedInt").mockReturnValue(1);
game.move.select(MoveId.PROTECT);
await game.toNextTurn();
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
game.move.select(MoveId.SPLASH);
await game.toNextTurn();
game.move.select(MoveId.PROTECT);
await game.toNextTurn();
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
});
it("should reset fail chance on starting a new wave", async () => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const charizard = game.field.getPlayerPokemon();
// force protect to always fail if RNG roll attempt is made
vi.spyOn(charizard, "randBattleSeedInt").mockReturnValue(1);
game.move.select(MoveId.PROTECT);
// Wait until move end phase to kill opponent to ensure protect doesn't fail due to going last
await game.phaseInterceptor.to("MoveEndPhase");
await game.doKillOpponents();
await game.toNextWave();
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
game.move.select(MoveId.SPIKY_SHIELD);
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
});
it("should not be blocked by Psychic Terrain", async () => {
game.override.ability(AbilityId.PSYCHIC_SURGE);
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const charizard = game.scene.getPlayerPokemon()!;
game.move.select(MoveId.PROTECT);
await game.toNextTurn();
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
});
it("should stop subsequent hits of multi-hit moves", async () => {
game.override.enemyMoveset([MoveId.TACHYON_CUTTER]);
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const leadPokemon = game.field.getPlayerPokemon();
const charizard = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.PROTECT);
await game.phaseInterceptor.to("BerryPhase", false);
expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp());
expect(charizard.hp).toBe(charizard.getMaxHp());
expect(enemyPokemon.turnData.hitCount).toBe(1);
});
test("should fail if the user is the last to move in the turn", async () => {
game.override.enemyMoveset([MoveId.PROTECT]);
it("should fail if the user moves last in the turn", async () => {
game.override.enemyMoveset(MoveId.PROTECT);
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const leadPokemon = game.field.getPlayerPokemon();
const charizard = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.PROTECT);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase", false);
expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(leadPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should not block Protection-bypassing moves or Future Sight", async () => {
game.override.enemyMoveset([MoveId.FUTURE_SIGHT, MoveId.MIGHTY_CLEAVE, MoveId.SPORE]);
await game.classicMode.startBattle([SpeciesId.AGGRON]);
const aggron = game.scene.getPlayerPokemon()!;
vi.spyOn(aggron, "randBattleSeedInt").mockReturnValue(0);
// Turn 1: setup future sight
game.move.select(MoveId.PROTECT);
await game.move.forceEnemyMove(MoveId.FUTURE_SIGHT);
await game.toNextTurn();
// Turn 2: mighty cleave
game.move.select(MoveId.PROTECT);
await game.move.forceEnemyMove(MoveId.MIGHTY_CLEAVE);
await game.toNextTurn();
expect(aggron.hp).toBeLessThan(aggron.getMaxHp());
aggron.hp = aggron.getMaxHp();
// turn 3: Future Sight hits
game.move.select(MoveId.PROTECT);
await game.move.forceEnemyMove(MoveId.SPORE);
await game.toNextTurn();
expect(aggron.hp).toBeLessThan(aggron.getMaxHp());
expect(aggron.status?.effect).toBeUndefined(); // check that protect actually worked
});
// TODO: Add test
it.todo("should not reset counter when throwing balls");
});

View File

@ -3,10 +3,9 @@ import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Quick Guard", () => {
let phaserGame: Phaser.Game;
@ -27,74 +26,72 @@ describe("Moves - Quick Guard", () => {
game.override
.battleStyle("double")
.moveset([MoveId.QUICK_GUARD, MoveId.SPLASH, MoveId.FOLLOW_ME])
.moveset([MoveId.QUICK_GUARD, MoveId.SPLASH, MoveId.SPIKY_SHIELD])
.enemySpecies(SpeciesId.SNORLAX)
.enemyMoveset([MoveId.QUICK_ATTACK])
.enemyAbility(AbilityId.INSOMNIA)
.enemyMoveset(MoveId.QUICK_ATTACK)
.enemyAbility(AbilityId.BALL_FETCH)
.startingLevel(100)
.enemyLevel(100);
});
test("should protect the user and allies from priority moves", async () => {
it("should protect the user and allies from priority moves", async () => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD, SpeciesId.BLASTOISE]);
const playerPokemon = game.scene.getPlayerField();
game.move.select(MoveId.QUICK_GUARD);
game.move.select(MoveId.SPLASH, 1);
const [charizard, blastoise] = game.scene.getPlayerField();
game.move.select(MoveId.QUICK_GUARD, BattlerIndex.PLAYER);
game.move.select(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.QUICK_ATTACK, BattlerIndex.PLAYER);
await game.move.forceEnemyMove(MoveId.QUICK_ATTACK, BattlerIndex.PLAYER_2);
await game.phaseInterceptor.to("BerryPhase", false);
playerPokemon.forEach(p => expect(p.hp).toBe(p.getMaxHp()));
expect(charizard.hp).toBe(charizard.getMaxHp());
expect(blastoise.hp).toBe(blastoise.getMaxHp());
});
test("should protect the user and allies from Prankster-boosted moves", async () => {
game.override.enemyAbility(AbilityId.PRANKSTER).enemyMoveset([MoveId.GROWL]);
it.each<{ name: string; move: MoveId; ability: AbilityId }>([
{ name: "Prankster", move: MoveId.SPORE, ability: AbilityId.PRANKSTER },
{ name: "Gale Wings", move: MoveId.BRAVE_BIRD, ability: AbilityId.GALE_WINGS },
])("should protect the user and allies from $name-boosted moves", async ({ move, ability }) => {
game.override.enemyMoveset(move).enemyAbility(ability);
await game.classicMode.startBattle([SpeciesId.CHARIZARD, SpeciesId.BLASTOISE]);
const playerPokemon = game.scene.getPlayerField();
game.move.select(MoveId.QUICK_GUARD);
game.move.select(MoveId.SPLASH, 1);
const [charizard, blastoise] = game.scene.getPlayerField();
game.move.select(MoveId.QUICK_GUARD, BattlerIndex.PLAYER);
game.move.select(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(move, BattlerIndex.PLAYER);
await game.move.forceEnemyMove(move, BattlerIndex.PLAYER_2);
await game.phaseInterceptor.to("BerryPhase", false);
playerPokemon.forEach(p => expect(p.getStatStage(Stat.ATK)).toBe(0));
expect(charizard.hp).toBe(charizard.getMaxHp());
expect(blastoise.hp).toBe(blastoise.getMaxHp());
expect(charizard.status?.effect).toBeUndefined();
expect(blastoise.status?.effect).toBeUndefined();
});
test("should stop subsequent hits of a multi-hit priority move", async () => {
game.override.enemyMoveset([MoveId.WATER_SHURIKEN]);
await game.classicMode.startBattle([SpeciesId.CHARIZARD, SpeciesId.BLASTOISE]);
const playerPokemon = game.scene.getPlayerField();
const enemyPokemon = game.scene.getEnemyField();
game.move.select(MoveId.QUICK_GUARD);
game.move.select(MoveId.FOLLOW_ME, 1);
await game.phaseInterceptor.to("BerryPhase", false);
playerPokemon.forEach(p => expect(p.hp).toBe(p.getMaxHp()));
enemyPokemon.forEach(p => expect(p.turnData.hitCount).toBe(1));
});
test("should fail if the user is the last to move in the turn", async () => {
game.override.battleStyle("single").enemyMoveset([MoveId.QUICK_GUARD]);
it("should increment (but not respect) other protection moves' fail counters", async () => {
game.override.battleStyle("single");
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
const charizard = game.scene.getPlayerPokemon()!;
// force protect to fail on anything >0 uses
vi.spyOn(charizard, "randBattleSeedInt").mockReturnValue(1);
game.move.select(MoveId.QUICK_GUARD);
await game.toNextTurn();
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
await game.phaseInterceptor.to("BerryPhase", false);
game.move.select(MoveId.QUICK_GUARD);
await game.toNextTurn();
expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(playerPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
// ignored fail chance
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
game.move.select(MoveId.SPIKY_SHIELD);
await game.toNextTurn();
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
});

View File

@ -1,11 +1,12 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { BerryPhase } from "#phases/berry-phase";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Wide Guard", () => {
let phaserGame: Phaser.Game;
@ -26,71 +27,84 @@ describe("Moves - Wide Guard", () => {
game.override
.battleStyle("double")
.moveset([MoveId.WIDE_GUARD, MoveId.SPLASH, MoveId.SURF])
.moveset([MoveId.WIDE_GUARD, MoveId.SPLASH, MoveId.SURF, MoveId.SPIKY_SHIELD])
.enemySpecies(SpeciesId.SNORLAX)
.enemyMoveset(MoveId.SWIFT)
.enemyMoveset([MoveId.SWIFT, MoveId.GROWL, MoveId.TACKLE])
.enemyAbility(AbilityId.INSOMNIA)
.startingLevel(100)
.enemyLevel(100);
});
test("should protect the user and allies from multi-target attack moves", async () => {
it("should protect the user and allies from multi-target attack and status moves", async () => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD, SpeciesId.BLASTOISE]);
const [charizard, blastoise] = game.scene.getPlayerField();
const leadPokemon = game.scene.getPlayerField();
game.move.select(MoveId.WIDE_GUARD, BattlerIndex.PLAYER);
game.move.select(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.SWIFT);
await game.move.forceEnemyMove(MoveId.GROWL);
await game.phaseInterceptor.to("TurnEndPhase");
game.move.select(MoveId.WIDE_GUARD);
game.move.select(MoveId.SPLASH, 1);
await game.phaseInterceptor.to(BerryPhase, false);
leadPokemon.forEach(p => expect(p.hp).toBe(p.getMaxHp()));
expect(charizard.hp).toBe(charizard.getMaxHp());
expect(blastoise.hp).toBe(blastoise.getMaxHp());
expect(charizard.getStatStage(Stat.ATK)).toBe(0);
expect(blastoise.getStatStage(Stat.ATK)).toBe(0);
});
test("should protect the user and allies from multi-target status moves", async () => {
game.override.enemyMoveset([MoveId.GROWL]);
it("should not protect the user and allies from single-target moves", async () => {
await game.classicMode.startBattle([SpeciesId.CHARIZARD, SpeciesId.BLASTOISE]);
const leadPokemon = game.scene.getPlayerField();
const [charizard, blastoise] = game.scene.getPlayerField();
game.move.select(MoveId.WIDE_GUARD, BattlerIndex.PLAYER);
game.move.select(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.TACKLE, BattlerIndex.PLAYER);
await game.move.forceEnemyMove(MoveId.TACKLE, BattlerIndex.PLAYER_2);
await game.phaseInterceptor.to("TurnEndPhase");
game.move.select(MoveId.WIDE_GUARD);
game.move.select(MoveId.SPLASH, 1);
await game.phaseInterceptor.to(BerryPhase, false);
leadPokemon.forEach(p => expect(p.getStatStage(Stat.ATK)).toBe(0));
expect(charizard.hp).toBeLessThan(charizard.getMaxHp());
expect(blastoise.hp).toBeLessThan(blastoise.getMaxHp());
});
test("should not protect the user and allies from single-target moves", async () => {
game.override.enemyMoveset([MoveId.TACKLE]);
it("should protect the user from its ally's multi-target move", async () => {
game.override.enemyMoveset(MoveId.SPLASH);
await game.classicMode.startBattle([SpeciesId.CHARIZARD, SpeciesId.BLASTOISE]);
const leadPokemon = game.scene.getPlayerField();
const charizard = game.scene.getPlayerPokemon()!;
const [snorlax1, snorlax2] = game.scene.getEnemyField();
game.move.select(MoveId.WIDE_GUARD);
game.move.select(MoveId.SPLASH, 1);
game.move.select(MoveId.WIDE_GUARD, BattlerIndex.PLAYER);
game.move.select(MoveId.SURF, BattlerIndex.PLAYER_2);
await game.phaseInterceptor.to("TurnEndPhase");
await game.phaseInterceptor.to(BerryPhase, false);
expect(leadPokemon.some(p => p.hp < p.getMaxHp())).toBeTruthy();
expect(charizard.hp).toBe(charizard.getMaxHp());
expect(snorlax1.hp).toBeLessThan(snorlax1.getMaxHp());
expect(snorlax2.hp).toBeLessThan(snorlax2.getMaxHp());
});
test("should protect the user from its ally's multi-target move", async () => {
game.override.enemyMoveset([MoveId.SPLASH]);
it("should increment (but not respect) other protection moves' fail counters", async () => {
game.override.battleStyle("single");
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
await game.classicMode.startBattle([SpeciesId.CHARIZARD, SpeciesId.BLASTOISE]);
const leadPokemon = game.scene.getPlayerField();
const enemyPokemon = game.scene.getEnemyField();
const charizard = game.scene.getPlayerPokemon()!;
// force protect to fail on anything other than a guaranteed success
vi.spyOn(charizard, "randBattleSeedInt").mockReturnValue(1);
game.move.select(MoveId.WIDE_GUARD);
game.move.select(MoveId.SURF, 1);
await game.toNextTurn();
await game.phaseInterceptor.to(BerryPhase, false);
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(leadPokemon[0].hp).toBe(leadPokemon[0].getMaxHp());
enemyPokemon.forEach(p => expect(p.hp).toBeLessThan(p.getMaxHp()));
// ignored fail chance
game.move.select(MoveId.WIDE_GUARD);
await game.toNextTurn();
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
game.move.select(MoveId.SPIKY_SHIELD);
await game.toNextTurn();
// ignored fail chance
expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
});

View File

@ -39,15 +39,6 @@ describe("Move - Wish", () => {
.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 () => {
await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]);
@ -58,19 +49,19 @@ describe("Move - Wish", () => {
game.move.use(MoveId.WISH);
await game.toNextTurn();
expectWishActive();
expect(game).toHavePositionalTag(PositionalTagType.WISH);
game.doSwitchPokemon(1);
await game.toEndOfTurn();
expectWishActive(0);
expect(game).toHavePositionalTag(PositionalTagType.WISH, 0);
expect(game.textInterceptor.logs).toContain(
i18next.t("arenaTag:wishTagOnAdd", {
pokemonNameWithAffix: getPokemonNameWithAffix(alomomola),
}),
);
expect(alomomola.hp).toBe(1);
expect(blissey.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1);
expect(alomomola).toHaveHp(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 () => {
@ -82,13 +73,13 @@ describe("Move - Wish", () => {
game.move.use(MoveId.WISH);
await game.toNextTurn();
expectWishActive();
expect(game).toHavePositionalTag(PositionalTagType.WISH);
game.move.use(MoveId.WISH);
await game.toEndOfTurn();
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 () => {
@ -103,7 +94,8 @@ describe("Move - Wish", () => {
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
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 () => {
@ -127,7 +119,7 @@ describe("Move - Wish", () => {
await game.setTurnOrder(oldOrder.map(p => p.getBattlerIndex()));
await game.toNextTurn();
expectWishActive(4);
expect(game).toHavePositionalTag(PositionalTagType.WISH, 4);
// Lower speed to change turn order
alomomola.setStatStage(Stat.SPD, 6);
@ -141,7 +133,7 @@ describe("Move - Wish", () => {
await game.phaseInterceptor.to("PositionalTagPhase");
// 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"));
expect(healPhases).toHaveLength(4);
@ -165,14 +157,14 @@ describe("Move - Wish", () => {
game.move.use(MoveId.WISH, BattlerIndex.PLAYER_2);
await game.toNextTurn();
expectWishActive();
expect(game).toHavePositionalTag(PositionalTagType.WISH);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.move.use(MoveId.MEMENTO, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2);
await game.toEndOfTurn();
// Wish went away without doing anything
expectWishActive(0);
expect(game).toHavePositionalTag(PositionalTagType.WISH, 0);
expect(game.textInterceptor.logs).not.toContain(
i18next.t("arenaTag:wishTagOnAdd", {
pokemonNameWithAffix: getPokemonNameWithAffix(blissey),

View File

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

View File

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

View File

@ -0,0 +1,79 @@
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 { NonFunctionPropertiesRecursive } from "#types/type-helpers";
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 {@linkcode 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,
// simplified types used for brevity; full overloads are in `vitest.d.ts`
expectedTag: T | (Partial<NonFunctionPropertiesRecursive<ArenaTag>> & { tagType: T; side: ArenaTagSide }),
side?: ArenaTagSide,
): SyncExpectationResult {
if (!isGameManagerInstance(received)) {
return {
pass: this.isNot,
message: () => `Expected to recieve 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!`,
};
}
if (typeof expectedTag === "string") {
// Coerce lone `tagType`s into objects
// Bangs are ok as we enforce safety via overloads
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 === expectedTag.tagType, expectedTag.side);
if (tags.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 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 type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export interface ToHaveEffectiveStatMatcherOptions {
export interface toHaveEffectiveStatOptions {
/**
* The target {@linkcode Pokemon}
* @see {@linkcode Pokemon.getEffectiveStat}
@ -30,7 +30,7 @@ export interface ToHaveEffectiveStatMatcherOptions {
* @param received - The object to check. Should be a {@linkcode Pokemon}
* @param stat - The {@linkcode EffectiveStat} to check
* @param expectedValue - The expected value of the {@linkcode stat}
* @param options - The {@linkcode ToHaveEffectiveStatMatcherOptions}
* @param options - The {@linkcode toHaveEffectiveStatOptions}
* @returns Whether the matcher passed
*/
export function toHaveEffectiveStat(
@ -38,11 +38,11 @@ export function toHaveEffectiveStat(
received: unknown,
stat: EffectiveStat,
expectedValue: number,
{ enemy, move, isCritical = false }: ToHaveEffectiveStatMatcherOptions = {},
{ enemy, move, isCritical = false }: toHaveEffectiveStatOptions = {},
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
pass: this.isNot,
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 {
if (!isPokemonInstance(received)) {
return {
pass: false,
pass: this.isNot,
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 {
if (!isPokemonInstance(received)) {
return {
pass: false,
pass: this.isNot,
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 {
if (!isPokemonInstance(received)) {
return {
pass: false,
pass: this.isNot,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}

View File

@ -0,0 +1,108 @@
// 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 { SerializedPositionalTag, 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,
// simplified types used for brevity; full overloads are in `vitest.d.ts`
expectedTag: P | (Partial<SerializedPositionalTag> & { tagType: P }),
count = 1,
): SyncExpectationResult {
if (!isGameManagerInstance(received)) {
return {
pass: this.isNot,
message: () => `Expected to recieve 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 our 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 {
if (!isPokemonInstance(received)) {
return {
pass: false,
pass: this.isNot,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
if (expectedStage < -6 || expectedStage > 6) {
return {
pass: false,
pass: this.isNot,
message: () => `Expected ${expectedStage} to be within the range [-6, 6]!`,
};
}

View File

@ -28,7 +28,7 @@ export function toHaveStatusEffect(
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
pass: this.isNot,
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;
// Check exclusively effect equality first, coercing non-matching status effects to numbers.
if (actualEffect !== (expectedStatus as Exclude<typeof expectedStatus, StatusEffect>)?.effect) {
// This is actually 100% safe as `expectedStatus?.effect` will evaluate to `undefined` if a StatusEffect was passed,
// which will never match actualEffect by definition
expectedStatus = (expectedStatus as Exclude<typeof expectedStatus, StatusEffect>).effect;
if (typeof expectedStatus === "object" && actualEffect !== expectedStatus.effect) {
expectedStatus = expectedStatus.effect;
}
if (typeof expectedStatus === "number") {

View File

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

View File

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

View File

@ -7,10 +7,16 @@ import { isPokemonInstance, receivedStr } from "../test-utils";
export interface toHaveTypesOptions {
/**
* Whether to enforce exact matches (`true`) or superset matches (`false`).
* @defaultValue `true`
* Value dictating the strength of the enforced typing match.
*
* 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}.
*/
@ -18,35 +24,46 @@ export interface toHaveTypesOptions {
}
/**
* Matcher that checks if an array contains exactly the given items, disregarding order.
* @param received - The object to check. Should be an array of one or more {@linkcode PokemonType}s.
* @param options - The {@linkcode toHaveTypesOptions | options} for this matcher
* Matcher that checks if a {@linkcode Pokemon}'s typing is as expected.
* @param received - The object to check. Should be a {@linkcode Pokemon}
* @param expectedTypes - An array of one or more {@linkcode PokemonType}s to compare against.
* @param mode - The mode to perform the matching;
* @returns The result of the matching
*/
export function toHaveTypes(
this: MatcherState,
received: unknown,
expected: [PokemonType, ...PokemonType[]],
options: toHaveTypesOptions = {},
expectedTypes: [PokemonType, ...PokemonType[]],
{ mode = "unordered", args = [] }: toHaveTypesOptions = {},
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
pass: this.isNot,
message: () => `Expected to recieve a Pokémon, but got ${receivedStr(received)}!`,
};
}
const actualTypes = received.getTypes(...(options.args ?? [])).sort();
const expectedTypes = expected.slice().sort();
// Return early if no types were passed in
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
const matchers = options.exact
? [...this.customTesters, this.utils.iterableEquality]
: [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality];
const pass = this.equals(actualTypes, expectedTypes, matchers);
const matchers =
mode === "superset"
? [...this.customTesters, this.utils.iterableEquality]
: [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality];
const pass = this.equals(actualSorted, expectedSorted, matchers);
const actualStr = stringifyEnumArray(PokemonType, actualTypes);
const expectedStr = stringifyEnumArray(PokemonType, expectedTypes);
const actualStr = stringifyEnumArray(PokemonType, actualSorted);
const expectedStr = stringifyEnumArray(PokemonType, expectedSorted);
const pkmName = getPokemonNameWithAffix(received);
return {
@ -55,7 +72,7 @@ export function toHaveTypes(
pass
? `Expected ${pkmName} to NOT have types ${expectedStr}, but it did!`
: `Expected ${pkmName} to have types ${expectedStr}, but got ${actualStr} instead!`,
expected: expectedTypes,
actual: actualTypes,
expected: expectedSorted,
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.
* @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
* @param index - The index of the move history entry to check, in order from most recent to least recent.
* Default `0` (last used move)
@ -22,12 +22,12 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export function toHaveUsedMove(
this: MatcherState,
received: unknown,
expectedResult: MoveId | AtLeastOne<TurnMove>,
expectedMove: MoveId | AtLeastOne<TurnMove>,
index = 0,
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
pass: this.isNot,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
@ -37,34 +37,33 @@ export function toHaveUsedMove(
if (move === undefined) {
return {
pass: false,
pass: this.isNot,
message: () => `Expected ${pkmName} to have used ${index + 1} moves, but it didn't!`,
actual: received.getLastXMoves(-1),
};
}
// Coerce to a `TurnMove`
if (typeof expectedResult === "number") {
expectedResult = { move: expectedResult };
if (typeof expectedMove === "number") {
expectedMove = { move: expectedMove };
}
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.utils.subsetEquality,
this.utils.iterableEquality,
]);
const expectedStr = getOnelineDiffStr.call(this, expectedResult);
const expectedStr = getOnelineDiffStr.call(this, expectedMove);
return {
pass,
message: () =>
pass
? `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: expectedResult,
: `Expected ${pkmName}'s ${moveIndexStr} to match ${expectedStr}, but it didn't!`,
expected: expectedMove,
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}.
* @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,
* or `all` to indicate the move should be _out_ of PP
* @returns Whether the matcher passed
@ -23,12 +23,12 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export function toHaveUsedPP(
this: MatcherState,
received: unknown,
expectedMove: MoveId,
moveId: MoveId,
ppUsed: number | "all",
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
pass: this.isNot,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
@ -36,22 +36,22 @@ export function toHaveUsedPP(
const override = received.isPlayer() ? Overrides.MOVESET_OVERRIDE : Overrides.OPP_MOVESET_OVERRIDE;
if (coerceArray(override).length > 0) {
return {
pass: false,
pass: this.isNot,
message: () =>
`Cannot test for PP consumption with ${received.isPlayer() ? "player" : "enemy"} moveset overrides active!`,
};
}
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) {
return {
pass: false,
pass: this.isNot,
message: () =>
`Expected MoveId.${moveStr} to appear in ${pkmName}'s moveset exactly once, but got ${movesetMoves.length} times!`,
expected: expectedMove,
expected: moveId,
actual: received.getMoveset(),
};
}

View File

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

View File

@ -34,10 +34,10 @@ interface getEnumStrOptions {
* @returns The stringified representation of `val` as dictated by the options.
* @example
* ```ts
* enum fakeEnum {
* ONE: 1,
* TWO: 2,
* THREE: 3,
* enum testEnum {
* ONE = 1,
* TWO = 2,
* THREE = 3,
* }
* getEnumStr(fakeEnum, fakeEnum.ONE); // Output: "ONE (=1)"
* 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.
* @param obj - The object to return the oneline diff of
* @returns The updated diff
* @example
* ```ts
* const diff = getOnelineDiffStr.call(this, obj)
* ```
*/
export function getOnelineDiffStr(this: MatcherState, obj: unknown): string {
return this.utils
.stringify(obj, undefined, { maxLength: 35, indent: 0, printBasicPrototype: false })
.replace(/\n/g, " ") // Replace newlines with spaces
.replace(/,(\s*)}$/g, "$1}");
.replace(/,(\s*)}$/g, "$1}"); // Trim trailing commas
}