Compare commits

...

13 Commits

Author SHA1 Message Date
Bertie690
567030afed
Merge 7e402d02b0 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
7e402d02b0
Added missing brace 2025-08-10 14:20:20 -04:00
Bertie690
371e99a4a2
Fix flying-press.test.ts 2025-08-10 14:18:00 -04:00
Bertie690
13a4b99072 Added overrideGameWithChallenges 2025-08-09 13:44:28 -04:00
Bertie690
216018b409 Fixed bugs with freeze dry 2025-08-08 22:27:56 -04:00
Bertie690
7c60d0a5b1 Added extra Wonder Guard test 2025-08-08 22:15:06 -04:00
Bertie690
f5e0ddd7af Made getAttackTypeEffectiveness take an object for parameters; added FP tests 2025-08-08 22:08:15 -04:00
Bertie690
1f50ebdae0 Cleaned up flying press/etc moves; removed VariableMoveTypeMultiplierAttr 2025-08-08 19:19:00 -04:00
72 changed files with 1950 additions and 1179 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

@ -727,9 +727,7 @@ export class AttackTypeImmunityAbAttr extends TypeImmunityAbAttr {
override canApply(params: TypeMultiplierAbAttrParams): boolean { override canApply(params: TypeMultiplierAbAttrParams): boolean {
const { move } = params; const { move } = params;
return ( return (
move.category !== MoveCategory.STATUS && move.category !== MoveCategory.STATUS && !move.hasAttr("VariableMoveTypeChartAttr") && super.canApply(params)
!move.hasAttr("NeutralDamageAgainstFlyingTypeMultiplierAttr") &&
super.canApply(params)
); );
} }
} }
@ -4188,71 +4186,43 @@ function getWeatherCondition(...weatherTypes: WeatherType[]): AbAttrCondition {
if (globalScene.arena.weather?.isEffectSuppressed()) { if (globalScene.arena.weather?.isEffectSuppressed()) {
return false; return false;
} }
const weatherType = globalScene.arena.weather?.weatherType; return weatherTypes.includes(globalScene.arena.getWeatherType());
return !!weatherType && weatherTypes.indexOf(weatherType) > -1;
}; };
} }
function getAnticipationCondition(): AbAttrCondition { /**
return (pokemon: Pokemon) => { * Condition used by {@linkcode AbilityId.ANTICIPATION} to show a message if any opponent knows a
for (const opponent of pokemon.getOpponents()) { * "dangerous" move.
for (const move of opponent.moveset) { * @param pokemon - The {@linkcode Pokemon} with this ability
// ignore null/undefined moves * @returns Whether the message should be shown
if (!move) { */
continue; const anticipationCondition: AbAttrCondition = (pokemon: Pokemon) =>
} pokemon.getOpponents().some(opponent =>
// the move's base type (not accounting for variable type changes) is super effective opponent.moveset.some(movesetMove => {
if ( // ignore null/undefined moves or non-attacks
move.getMove().is("AttackMove") && const move = movesetMove?.getMove();
pokemon.getAttackTypeEffectiveness(move.getMove().type, opponent, true, undefined, move.getMove()) >= 2 if (!move?.is("AttackMove")) {
) { return false;
return true;
}
// move is a OHKO
if (move.getMove().hasAttr("OneHitKOAttr")) {
return true;
}
// edge case for hidden power, type is computed
if (move.getMove().id === MoveId.HIDDEN_POWER) {
const iv_val = Math.floor(
(((opponent.ivs[Stat.HP] & 1) +
(opponent.ivs[Stat.ATK] & 1) * 2 +
(opponent.ivs[Stat.DEF] & 1) * 4 +
(opponent.ivs[Stat.SPD] & 1) * 8 +
(opponent.ivs[Stat.SPATK] & 1) * 16 +
(opponent.ivs[Stat.SPDEF] & 1) * 32) *
15) /
63,
);
const type = [
PokemonType.FIGHTING,
PokemonType.FLYING,
PokemonType.POISON,
PokemonType.GROUND,
PokemonType.ROCK,
PokemonType.BUG,
PokemonType.GHOST,
PokemonType.STEEL,
PokemonType.FIRE,
PokemonType.WATER,
PokemonType.GRASS,
PokemonType.ELECTRIC,
PokemonType.PSYCHIC,
PokemonType.ICE,
PokemonType.DRAGON,
PokemonType.DARK,
][iv_val];
if (pokemon.getAttackTypeEffectiveness(type, opponent) >= 2) {
return true;
}
}
} }
}
return false; if (move.hasAttr("OneHitKOAttr")) {
}; return true;
} }
// Check whether the move's base type (not accounting for variable type changes) is super effective
const type = new NumberHolder(
pokemon.getAttackTypeEffectiveness(move.type, {
source: opponent,
ignoreStrongWinds: true,
move: move,
}),
);
// edge case for hidden power, type is computed
applyMoveAttrs("HiddenPowerTypeAttr", opponent, pokemon, move, type);
return type.value >= 2;
}),
);
/** /**
* Creates an ability condition that causes the ability to fail if that ability * Creates an ability condition that causes the ability to fail if that ability
@ -7083,7 +7053,7 @@ export function initAbilities() {
.attr(PostFaintContactDamageAbAttr, 4) .attr(PostFaintContactDamageAbAttr, 4)
.bypassFaint(), .bypassFaint(),
new Ability(AbilityId.ANTICIPATION, 4) new Ability(AbilityId.ANTICIPATION, 4)
.conditionalAttr(getAnticipationCondition(), PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonAnticipation", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })), .conditionalAttr(anticipationCondition, PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonAnticipation", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })),
new Ability(AbilityId.FOREWARN, 4) new Ability(AbilityId.FOREWARN, 4)
.attr(ForewarnAbAttr), .attr(ForewarnAbAttr),
new Ability(AbilityId.UNAWARE, 4) new Ability(AbilityId.UNAWARE, 4)

View File

@ -958,7 +958,7 @@ class StealthRockTag extends ArenaTrapTag {
} }
getDamageHpRatio(pokemon: Pokemon): number { getDamageHpRatio(pokemon: Pokemon): number {
const effectiveness = pokemon.getAttackTypeEffectiveness(PokemonType.ROCK, undefined, true); const effectiveness = pokemon.getAttackTypeEffectiveness(PokemonType.ROCK, { ignoreStrongWinds: true });
let damageHpRatio = 0; let damageHpRatio = 0;

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

@ -67,7 +67,7 @@ import { StatusEffect } from "#enums/status-effect";
import { SwitchType } from "#enums/switch-type"; import { SwitchType } from "#enums/switch-type";
import { WeatherType } from "#enums/weather-type"; import { WeatherType } from "#enums/weather-type";
import { MoveUsedEvent } from "#events/battle-scene"; import { MoveUsedEvent } from "#events/battle-scene";
import type { EnemyPokemon, Pokemon } from "#field/pokemon"; import { EnemyPokemon, Pokemon } from "#field/pokemon";
import { import {
AttackTypeBoosterModifier, AttackTypeBoosterModifier,
BerryModifier, BerryModifier,
@ -1005,7 +1005,7 @@ export class AttackMove extends Move {
const ret = super.getTargetBenefitScore(user, target, move); const ret = super.getTargetBenefitScore(user, target, move);
let attackScore = 0; let attackScore = 0;
const effectiveness = target.getAttackTypeEffectiveness(this.type, user, undefined, undefined, this); const effectiveness = target.getAttackTypeEffectiveness(this.type, {source: user, move: this});
attackScore = Math.pow(effectiveness - 1, 2) * (effectiveness < 1 ? -2 : 2); attackScore = Math.pow(effectiveness - 1, 2) * (effectiveness < 1 ? -2 : 2);
const [ thisStat, offStat ]: EffectiveStat[] = this.category === MoveCategory.PHYSICAL ? [ Stat.ATK, Stat.SPATK ] : [ Stat.SPATK, Stat.ATK ]; const [ thisStat, offStat ]: EffectiveStat[] = this.category === MoveCategory.PHYSICAL ? [ Stat.ATK, Stat.SPATK ] : [ Stat.SPATK, Stat.ATK ];
const statHolder = new NumberHolder(user.getEffectiveStat(thisStat, target)); const statHolder = new NumberHolder(user.getEffectiveStat(thisStat, target));
@ -1811,7 +1811,7 @@ export class SacrificialAttr extends MoveEffectAttr {
if (user.isBoss()) { if (user.isBoss()) {
return -20; return -20;
} }
return Math.ceil(((1 - user.getHpRatio()) * 10 - 10) * (target.getAttackTypeEffectiveness(move.type, user) - 0.5)); return Math.ceil(((1 - user.getHpRatio()) * 10 - 10) * (target.getAttackTypeEffectiveness(move.type, {source: user}) - 0.5));
} }
} }
@ -1849,7 +1849,7 @@ export class SacrificialAttrOnHit extends MoveEffectAttr {
if (user.isBoss()) { if (user.isBoss()) {
return -20; return -20;
} }
return Math.ceil(((1 - user.getHpRatio()) * 10 - 10) * (target.getAttackTypeEffectiveness(move.type, user) - 0.5)); return Math.ceil(((1 - user.getHpRatio()) * 10 - 10) * (target.getAttackTypeEffectiveness(move.type, {source: user}) - 0.5));
} }
} }
@ -1891,7 +1891,7 @@ export class HalfSacrificialAttr extends MoveEffectAttr {
if (user.isBoss()) { if (user.isBoss()) {
return -10; return -10;
} }
return Math.ceil(((1 - user.getHpRatio() / 2) * 10 - 10) * (target.getAttackTypeEffectiveness(move.type, user) - 0.5)); return Math.ceil(((1 - user.getHpRatio() / 2) * 10 - 10) * (target.getAttackTypeEffectiveness(move.type, {source: user}) - 0.5));
} }
} }
@ -5365,86 +5365,80 @@ export class CombinedPledgeTypeAttr extends VariableMoveTypeAttr {
} }
} }
export class VariableMoveTypeMultiplierAttr extends MoveAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
return false;
}
}
export class NeutralDamageAgainstFlyingTypeMultiplierAttr extends VariableMoveTypeMultiplierAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!target.getTag(BattlerTagType.IGNORE_FLYING)) {
const multiplier = args[0] as NumberHolder;
//When a flying type is hit, the first hit is always 1x multiplier.
if (target.isOfType(PokemonType.FLYING)) {
multiplier.value = 1;
}
return true;
}
return false;
}
}
export class IceNoEffectTypeAttr extends VariableMoveTypeMultiplierAttr {
/**
* Checks to see if the Target is Ice-Type or not. If so, the move will have no effect.
* @param user n/a
* @param target The {@linkcode Pokemon} targeted by the move
* @param move n/a
* @param args `[0]` a {@linkcode NumberHolder | NumberHolder} containing a type effectiveness multiplier
* @returns `true` if this Ice-type immunity applies; `false` otherwise
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const multiplier = args[0] as NumberHolder;
if (target.isOfType(PokemonType.ICE)) {
multiplier.value = 0;
return true;
}
return false;
}
}
export class FlyingTypeMultiplierAttr extends VariableMoveTypeMultiplierAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const multiplier = args[0] as NumberHolder;
multiplier.value *= target.getAttackTypeEffectiveness(PokemonType.FLYING, user);
return true;
}
}
/** /**
* Attribute for moves which have a custom type chart interaction. * Attribute for moves which have a custom type chart interaction.
*/ */
export class VariableMoveTypeChartAttr extends MoveAttr { export class VariableMoveTypeChartAttr extends MoveAttr {
/** /**
* @param user {@linkcode Pokemon} using the move * @param user - The {@linkcode Pokemon} using the move
* @param target {@linkcode Pokemon} target of the move * @param target - The {@linkcode Pokemon} targeted by the move
* @param move {@linkcode Move} with this attribute * @param move - The {@linkcode Move} with this attribute
* @param args [0] {@linkcode NumberHolder} holding the type effectiveness * @param args -
* @param args [1] A single defensive type of the target * `[0]`: A {@linkcode NumberHolder} holding the type effectiveness
* * `[1]`: The target's entire defensive type profile
* @returns true if application of the attribute succeeds * `[2]`: The current {@linkcode PokemonType} of the move
* @returns `true` if application of the attribute succeeds
*/ */
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { apply(user: Pokemon, target: Pokemon, move: Move, args: [multiplier: NumberHolder, types: PokemonType[], moveType: PokemonType]): boolean {
return false; return false;
} }
} }
/** /**
* This class forces Freeze-Dry to be super effective against Water Type. * Attribute to implement {@linkcode MoveId.FREEZE_DRY}'s guaranteed water type super effectiveness.
*/ */
export class FreezeDryAttr extends VariableMoveTypeChartAttr { export class FreezeDryAttr extends VariableMoveTypeChartAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { apply(user: Pokemon, target: Pokemon, move: Move, args: [multiplier: NumberHolder, types: PokemonType[], moveType: PokemonType]): boolean {
const multiplier = args[0] as NumberHolder; const [multiplier, types, moveType] = args;
const defType = args[1] as PokemonType; if (!types.includes(PokemonType.WATER)) {
if (defType === PokemonType.WATER) {
multiplier.value = 2;
return true;
} else {
return false; return false;
} }
// Replace whatever the prior "normal" water effectiveness was with a guaranteed 2x multi
const normalEff = getTypeDamageMultiplier(moveType, PokemonType.WATER)
multiplier.value = 2 * multiplier.value / normalEff;
return true;
}
}
/**
* Attribute used by {@linkcode MoveId.THOUSAND_ARROWS} to cause it to deal a fixed 1x damage
* against all ungrounded flying types.
*/
export class NeutralDamageAgainstFlyingTypeAttr extends VariableMoveTypeChartAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: [multiplier: NumberHolder, types: PokemonType[], moveType: PokemonType]): boolean {
const [multiplier, types] = args;
if (target.isGrounded() || !types.includes(PokemonType.FLYING)) {
return false;
}
multiplier.value = 1;
return true;
}
}
/**
* Attribute used by {@linkcode MoveId.FLYING_PRESS} to add the Flying Type to its type effectiveness.
*/
export class FlyingTypeMultiplierAttr extends VariableMoveTypeChartAttr {
apply(user: Pokemon, target: Pokemon, _move: Move, args: [multiplier: NumberHolder, types: PokemonType[], moveType: PokemonType]): boolean {
const multiplier = args[0];
multiplier.value *= target.getAttackTypeEffectiveness(PokemonType.FLYING, {source: user});
return true;
}
}
/**
* Attribute used by {@linkcode MoveId.SHEER_COLD} to implement its Gen VII+ ice ineffectiveness.
*/
export class IceNoEffectTypeAttr extends VariableMoveTypeChartAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: [multiplier: NumberHolder, types: PokemonType[], moveType: PokemonType]): boolean {
const [multiplier, types] = args;
if (types.includes(PokemonType.ICE)) {
multiplier.value = 0;
return true;
}
return false;
} }
} }
@ -8132,7 +8126,9 @@ export class UpperHandCondition extends MoveCondition {
} }
} }
export class HitsSameTypeAttr extends VariableMoveTypeMultiplierAttr { // TODO: Does this need to extend from this?
// The only reason it might is to show ineffectiveness text but w/e
export class HitsSameTypeAttr extends VariableMoveTypeChartAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const multiplier = args[0] as NumberHolder; const multiplier = args[0] as NumberHolder;
if (!user.getTypes(true).some(type => target.getTypes(true).includes(type))) { if (!user.getTypes(true).some(type => target.getTypes(true).includes(type))) {
@ -8401,8 +8397,7 @@ const MoveAttrs = Object.freeze({
TeraStarstormTypeAttr, TeraStarstormTypeAttr,
MatchUserTypeAttr, MatchUserTypeAttr,
CombinedPledgeTypeAttr, CombinedPledgeTypeAttr,
VariableMoveTypeMultiplierAttr, NeutralDamageAgainstFlyingTypeAttr,
NeutralDamageAgainstFlyingTypeMultiplierAttr,
IceNoEffectTypeAttr, IceNoEffectTypeAttr,
FlyingTypeMultiplierAttr, FlyingTypeMultiplierAttr,
VariableMoveTypeChartAttr, VariableMoveTypeChartAttr,
@ -10446,7 +10441,7 @@ export function initMoves() {
.attr(HitHealAttr, 0.75) .attr(HitHealAttr, 0.75)
.triageMove(), .triageMove(),
new AttackMove(MoveId.THOUSAND_ARROWS, PokemonType.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6) new AttackMove(MoveId.THOUSAND_ARROWS, PokemonType.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, -1, 0, 6)
.attr(NeutralDamageAgainstFlyingTypeMultiplierAttr) .attr(NeutralDamageAgainstFlyingTypeAttr)
.attr(FallDownAttr) .attr(FallDownAttr)
.attr(HitsTagAttr, BattlerTagType.FLYING) .attr(HitsTagAttr, BattlerTagType.FLYING)
.attr(HitsTagAttr, BattlerTagType.FLOATING) .attr(HitsTagAttr, BattlerTagType.FLOATING)
@ -11387,9 +11382,10 @@ export function initMoves() {
new AttackMove(MoveId.RUINATION, PokemonType.DARK, MoveCategory.SPECIAL, -1, 90, 10, -1, 0, 9) new AttackMove(MoveId.RUINATION, PokemonType.DARK, MoveCategory.SPECIAL, -1, 90, 10, -1, 0, 9)
.attr(TargetHalfHpDamageAttr), .attr(TargetHalfHpDamageAttr),
new AttackMove(MoveId.COLLISION_COURSE, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 100, 100, 5, -1, 0, 9) new AttackMove(MoveId.COLLISION_COURSE, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 100, 100, 5, -1, 0, 9)
.attr(MovePowerMultiplierAttr, (user, target, move) => target.getAttackTypeEffectiveness(move.type, user) >= 2 ? 5461 / 4096 : 1), // TODO: Do we want to change this to 4/3?
.attr(MovePowerMultiplierAttr, (user, target, move) => target.getAttackTypeEffectiveness(move.type, {source: user}) >= 2 ? 5461 / 4096 : 1),
new AttackMove(MoveId.ELECTRO_DRIFT, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 100, 100, 5, -1, 0, 9) new AttackMove(MoveId.ELECTRO_DRIFT, PokemonType.ELECTRIC, MoveCategory.SPECIAL, 100, 100, 5, -1, 0, 9)
.attr(MovePowerMultiplierAttr, (user, target, move) => target.getAttackTypeEffectiveness(move.type, user) >= 2 ? 5461 / 4096 : 1) .attr(MovePowerMultiplierAttr, (user, target, move) => target.getAttackTypeEffectiveness(move.type, {source: user}) >= 2 ? 5461 / 4096 : 1)
.makesContact(), .makesContact(),
new SelfStatusMove(MoveId.SHED_TAIL, PokemonType.NORMAL, -1, 10, -1, 0, 9) new SelfStatusMove(MoveId.SHED_TAIL, PokemonType.NORMAL, -1, 10, -1, 0, 9)
.attr(AddSubstituteAttr, 0.5, true) .attr(AddSubstituteAttr, 0.5, true)

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

@ -2,6 +2,13 @@ import { PokemonType } from "#enums/pokemon-type";
export type TypeDamageMultiplier = 0 | 0.125 | 0.25 | 0.5 | 1 | 2 | 4 | 8; export type TypeDamageMultiplier = 0 | 0.125 | 0.25 | 0.5 | 1 | 2 | 4 | 8;
/**
* Get the type effectiveness multiplier of one PokemonType against another.
* @param attackType - The {@linkcode PokemonType} of the attacker
* @param defType - The {@linkcode PokemonType} of the defender
* @returns The type damage multiplier between the two types;
* will be either `0`, `0.5`, `1` or `2`.
*/
export function getTypeDamageMultiplier(attackType: PokemonType, defType: PokemonType): TypeDamageMultiplier { export function getTypeDamageMultiplier(attackType: PokemonType, defType: PokemonType): TypeDamageMultiplier {
if (attackType === PokemonType.UNKNOWN || defType === PokemonType.UNKNOWN) { if (attackType === PokemonType.UNKNOWN || defType === PokemonType.UNKNOWN) {
return 1; return 1;

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";
@ -130,7 +130,8 @@ import {
TempStatStageBoosterModifier, TempStatStageBoosterModifier,
} from "#modifiers/modifier"; } from "#modifiers/modifier";
import { applyMoveAttrs } from "#moves/apply-attrs"; import { applyMoveAttrs } from "#moves/apply-attrs";
import type { Move } from "#moves/move"; // biome-ignore lint/correctness/noUnusedImports: TSDoc
import type { Move, VariableMoveTypeChartAttr } from "#moves/move";
import { getMoveTargets } from "#moves/move-utils"; import { getMoveTargets } from "#moves/move-utils";
import { PokemonMove } from "#moves/pokemon-move"; import { PokemonMove } from "#moves/pokemon-move";
import { loadMoveAnimations } from "#sprites/pokemon-asset-loader"; import { loadMoveAnimations } from "#sprites/pokemon-asset-loader";
@ -139,6 +140,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";
@ -205,6 +208,38 @@ type getBaseDamageParams = Omit<damageParams, "effectiveness">;
/** Type for the parameters of {@linkcode Pokemon#getAttackDamage | getAttackDamage} */ /** Type for the parameters of {@linkcode Pokemon#getAttackDamage | getAttackDamage} */
type getAttackDamageParams = Omit<damageParams, "moveCategory">; type getAttackDamageParams = Omit<damageParams, "moveCategory">;
/**
* Type for the parameters of {@linkcode Pokemon.getAttackTypeEffectiveness | getAttackTypeEffectiveness}
* and associated helper functions.
*/
type getAttackTypeEffectivenessParams = {
/**
* The {@linkcode Pokemon} using the move, used to check the user's Scrappy and Mind's Eye abilities
* and the effects of Foresight/Odor Sleuth.
*/
source?: Pokemon;
/**
* If `true`, ignores the effect of strong winds (used by anticipation, forewarn, stealth rocks)
* @defaultValue `false`
*/
ignoreStrongWinds?: boolean;
/**
* If `true`, will prevent changes to game state during calculations.
* @defaultValue `false`
*/
simulated?: boolean;
/**
* The {@linkcode Move} whose type effectiveness is being checked.
* Used for applying {@linkcode VariableMoveTypeChartAttr}
*/
move?: Move;
/**
* Whether to consider this Pokemon's {@linkcode IllusionData | illusion} when determining types.
* @defaultValue `false`
*/
useIllusion?: boolean;
};
export abstract class Pokemon extends Phaser.GameObjects.Container { export abstract class Pokemon extends Phaser.GameObjects.Container {
/** /**
* This pokemon's {@link https://bulbapedia.bulbagarden.net/wiki/Personality_value | Personality value/PID}, * This pokemon's {@link https://bulbapedia.bulbagarden.net/wiki/Personality_value | Personality value/PID},
@ -1825,7 +1860,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 +2065,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 +2095,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 +2163,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;
@ -2397,11 +2432,10 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
const typeMultiplier = new NumberHolder( const typeMultiplier = new NumberHolder(
move.category !== MoveCategory.STATUS || move.hasAttr("RespectAttackTypeImmunityAttr") move.category !== MoveCategory.STATUS || move.hasAttr("RespectAttackTypeImmunityAttr")
? this.getAttackTypeEffectiveness(moveType, source, false, simulated, move, useIllusion) ? this.getAttackTypeEffectiveness(moveType, { source, simulated, move, useIllusion })
: 1, : 1,
); );
applyMoveAttrs("VariableMoveTypeMultiplierAttr", source, this, move, typeMultiplier);
if (this.getTypes(true, true).find(t => move.isTypeImmune(source, this, t))) { if (this.getTypes(true, true).find(t => move.isTypeImmune(source, this, t))) {
typeMultiplier.value = 0; typeMultiplier.value = 0;
} }
@ -2461,26 +2495,31 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
} }
/** /**
* Calculates the move's type effectiveness multiplier based on the target's type/s. * Calculate the type effectiveness multiplier of a Move used **against** this Pokemon.
* @param moveType {@linkcode PokemonType} the type of the move being used * @param moveType - The {@linkcode PokemonType} of the move being used
* @param source {@linkcode Pokemon} the Pokemon using the move * @param source - The {@linkcode Pokemon} using the move, used to check the user's Scrappy and Mind's Eye abilities
* @param ignoreStrongWinds whether or not this ignores strong winds (anticipation, forewarn, stealth rocks) * and the effects of Foresight/Odor Sleuth
* @param simulated tag to only apply the strong winds effect message when the move is used * @param ignoreStrongWinds - If `true`, ignores the effect of strong winds (used by anticipation, forewarn, stealth rocks);
* @param move (optional) the move whose type effectiveness is to be checked. Used for applying {@linkcode VariableMoveTypeChartAttr} * default `false`
* @param useIllusion - Whether we want the attack type effectiveness on the illusion or not * @param simulated - If `true`, will prevent changes to game state during calculations; default `false`
* @returns a multiplier for the type effectiveness * @param move - The {@linkcode Move} whose type effectiveness is being checked. Used for applying {@linkcode VariableMoveTypeChartAttr}
* @param useIllusion - Whether to consider this Pokemon's {@linkcode IllusionData | illusion} when determining types; default `false`
* @returns The computed type effectiveness multiplier.
*/ */
getAttackTypeEffectiveness( getAttackTypeEffectiveness(
moveType: PokemonType, moveType: PokemonType,
source?: Pokemon, {
ignoreStrongWinds = false, source,
simulated = true, ignoreStrongWinds = false,
move?: Move, simulated = true,
useIllusion = false, move,
useIllusion = false,
}: getAttackTypeEffectivenessParams = {},
): TypeDamageMultiplier { ): TypeDamageMultiplier {
if (moveType === PokemonType.STELLAR) { if (moveType === PokemonType.STELLAR) {
return this.isTerastallized ? 2 : 1; return this.isTerastallized ? 2 : 1;
} }
const types = this.getTypes(true, true, undefined, useIllusion); const types = this.getTypes(true, true, undefined, useIllusion);
const arena = globalScene.arena; const arena = globalScene.arena;
@ -2493,57 +2532,79 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
} }
} }
let multiplier = types const multi = new NumberHolder(1);
.map(defenderType => { for (const defenderType of types) {
const multiplier = new NumberHolder(getTypeDamageMultiplier(moveType, defenderType)); const typeMulti = new NumberHolder(getTypeDamageMultiplier(moveType, defenderType));
applyChallenges(ChallengeType.TYPE_EFFECTIVENESS, multiplier); applyChallenges(ChallengeType.TYPE_EFFECTIVENESS, typeMulti);
if (move) { // If the target is immune to the type in question, check for any effects that would ignore said effect
applyMoveAttrs("VariableMoveTypeChartAttr", null, this, move, multiplier, defenderType); // TODO: Review if the `isActive` check is needed anymore
} if (
if (source) { source?.isActive(true) &&
const ignoreImmunity = new BooleanHolder(false); typeMulti.value === 0 &&
if (source.isActive(true) && source.hasAbilityWithAttr("IgnoreTypeImmunityAbAttr")) { this.checkIgnoreTypeImmunity({ source, simulated, moveType, defenderType })
applyAbAttrs("IgnoreTypeImmunityAbAttr", { ) {
pokemon: source, typeMulti.value = 1;
cancelled: ignoreImmunity, }
simulated, multi.value *= typeMulti.value;
moveType, }
defenderType,
});
}
if (ignoreImmunity.value) {
if (multiplier.value === 0) {
return 1;
}
}
const exposedTags = this.findTags(tag => tag instanceof ExposedTag) as ExposedTag[]; // Apply any typing changes from Freeze-Dry, etc.
if (exposedTags.some(t => t.ignoreImmunity(defenderType, moveType))) { if (move) {
if (multiplier.value === 0) { applyMoveAttrs("VariableMoveTypeChartAttr", null, this, move, multi, types, moveType);
return 1; }
}
}
}
return multiplier.value;
})
.reduce((acc, cur) => acc * cur, 1) as TypeDamageMultiplier;
// Handle strong winds lowering effectiveness of types super effective against pure flying
const typeMultiplierAgainstFlying = new NumberHolder(getTypeDamageMultiplier(moveType, PokemonType.FLYING)); const typeMultiplierAgainstFlying = new NumberHolder(getTypeDamageMultiplier(moveType, PokemonType.FLYING));
applyChallenges(ChallengeType.TYPE_EFFECTIVENESS, typeMultiplierAgainstFlying); applyChallenges(ChallengeType.TYPE_EFFECTIVENESS, typeMultiplierAgainstFlying);
// Handle strong winds lowering effectiveness of types super effective against pure flying
if ( if (
!ignoreStrongWinds && !ignoreStrongWinds &&
arena.weather?.weatherType === WeatherType.STRONG_WINDS && arena.getWeatherType() === WeatherType.STRONG_WINDS &&
!arena.weather.isEffectSuppressed() && !arena.weather?.isEffectSuppressed() &&
this.isOfType(PokemonType.FLYING) && types.includes(PokemonType.FLYING) &&
typeMultiplierAgainstFlying.value === 2 typeMultiplierAgainstFlying.value === 2
) { ) {
multiplier /= 2; multi.value /= 2;
if (!simulated) { if (!simulated) {
globalScene.phaseManager.queueMessage(i18next.t("weather:strongWindsEffectMessage")); globalScene.phaseManager.queueMessage(i18next.t("weather:strongWindsEffectMessage"));
} }
} }
return multiplier as TypeDamageMultiplier; return multi.value as TypeDamageMultiplier;
}
/**
* Sub-method of {@linkcode getAttackTypeEffectiveness} that handles nullifying type immunities.
* @param source - The {@linkcode Pokemon} from whom the attack is sourced
* @param simulated - If `true`, will prevent displaying messages upon activation
* @param moveType - The {@linkcode PokemonType} whose offensive typing is being checked
* @param defenderType - The defender's {@linkcode PokemonType} being checked
* @returns Whether the type immunity was bypassed
*/
private checkIgnoreTypeImmunity({
source,
simulated,
moveType,
defenderType,
}: {
source: Pokemon;
simulated: boolean;
moveType: PokemonType;
defenderType: PokemonType;
}): boolean {
const exposedTags = this.findTags(tag => tag instanceof ExposedTag) as ExposedTag[];
const hasExposed = exposedTags.some(t => t.ignoreImmunity(defenderType, moveType));
if (hasExposed) {
return true;
}
const ignoreImmunity = new BooleanHolder(false);
applyAbAttrs("IgnoreTypeImmunityAbAttr", {
pokemon: source,
cancelled: ignoreImmunity,
simulated,
moveType,
defenderType,
});
return ignoreImmunity.value;
} }
/** /**
@ -2563,10 +2624,15 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* Based on how effectively this Pokemon defends against the opponent's types. * Based on how effectively this Pokemon defends against the opponent's types.
* This score cannot be higher than 4. * This score cannot be higher than 4.
*/ */
let defScore = 1 / Math.max(this.getAttackTypeEffectiveness(enemyTypes[0], opponent), 0.25); let defScore = 1 / Math.max(this.getAttackTypeEffectiveness(enemyTypes[0], { source: opponent }), 0.25);
if (enemyTypes.length > 1) { if (enemyTypes.length > 1) {
defScore *= defScore *=
1 / Math.max(this.getAttackTypeEffectiveness(enemyTypes[1], opponent, false, false, undefined, true), 0.25); // TODO: Shouldn't this pass `simulated=true` here?
1 /
Math.max(
this.getAttackTypeEffectiveness(enemyTypes[1], { source: opponent, simulated: false, useIllusion: true }),
0.25,
);
} }
const moveset = this.moveset; const moveset = this.moveset;
@ -2580,7 +2646,11 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
continue; continue;
} }
const moveType = resolvedMove.type; const moveType = resolvedMove.type;
let thisScore = opponent.getAttackTypeEffectiveness(moveType, this, false, true, undefined, true); let thisScore = opponent.getAttackTypeEffectiveness(moveType, {
source: this,
simulated: true,
useIllusion: true,
});
// Add STAB multiplier for attack type effectiveness. // Add STAB multiplier for attack type effectiveness.
// For now, simply don't apply STAB to moves that may change type // For now, simply don't apply STAB to moves that may change type
@ -3001,8 +3071,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 =
@ -5822,45 +5892,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 +6325,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 +6350,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

@ -62,15 +62,24 @@ export class GameMode implements GameModeConfig {
/** /**
* Enables challenges if they are disabled and sets the specified challenge's value * Enables challenges if they are disabled and sets the specified challenge's value
* @param challenge The challenge to set * @param challenge - The challenge to set
* @param value The value to give the challenge. Impact depends on the specific challenge * @param value - The value to give the challenge. Impact depends on the specific challenge
* @param severity - If provided, will override the given severity amount. Unused if `challenge` does not use severity
* @todo Add severity support to daily mode challenge setting
*/ */
setChallengeValue(challenge: Challenges, value: number) { setChallengeValue(challenge: Challenges, value: number, severity?: number) {
if (!this.isChallenge) { if (!this.isChallenge) {
this.isChallenge = true; this.isChallenge = true;
this.challenges = allChallenges.map(c => copyChallenge(c)); this.challenges = allChallenges.map(c => copyChallenge(c));
} }
this.challenges.filter((chal: Challenge) => chal.id === challenge).map((chal: Challenge) => (chal.value = value)); this.challenges
.filter((chal: Challenge) => chal.id === challenge)
.forEach(chal => {
chal.value = value;
if (chal.hasSeverity()) {
chal.severity = severity ?? chal.severity;
}
});
} }
/** /**

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

@ -179,25 +179,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 +206,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 +214,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 +276,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) {

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

@ -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

@ -88,6 +88,7 @@ describe("Abilities - Illusion", () => {
expect(game.field.getPlayerPokemon().summonData.illusion).toBeFalsy(); expect(game.field.getPlayerPokemon().summonData.illusion).toBeFalsy();
}); });
// TODO: This doesn't actually check that the ai calls the function this way... useless test
it("causes enemy AI to consider the illusion's type instead of the actual type when considering move effectiveness", async () => { it("causes enemy AI to consider the illusion's type instead of the actual type when considering move effectiveness", async () => {
game.override.enemyMoveset([MoveId.FLAMETHROWER, MoveId.PSYCHIC, MoveId.TACKLE]); game.override.enemyMoveset([MoveId.FLAMETHROWER, MoveId.PSYCHIC, MoveId.TACKLE]);
await game.classicMode.startBattle([SpeciesId.ZOROARK, SpeciesId.FEEBAS]); await game.classicMode.startBattle([SpeciesId.ZOROARK, SpeciesId.FEEBAS]);
@ -97,22 +98,16 @@ describe("Abilities - Illusion", () => {
const flameThrower = enemy.getMoveset()[0]!.getMove(); const flameThrower = enemy.getMoveset()[0]!.getMove();
const psychic = enemy.getMoveset()[1]!.getMove(); const psychic = enemy.getMoveset()[1]!.getMove();
const flameThrowerEffectiveness = zoroark.getAttackTypeEffectiveness( const flameThrowerEffectiveness = zoroark.getAttackTypeEffectiveness(flameThrower.type, {
flameThrower.type, source: enemy,
enemy, move: flameThrower,
undefined, useIllusion: true,
undefined, });
flameThrower, const psychicEffectiveness = zoroark.getAttackTypeEffectiveness(psychic.type, {
true, source: enemy,
); move: psychic,
const psychicEffectiveness = zoroark.getAttackTypeEffectiveness( useIllusion: true,
psychic.type, });
enemy,
undefined,
undefined,
psychic,
true,
);
expect(psychicEffectiveness).above(flameThrowerEffectiveness); expect(psychicEffectiveness).above(flameThrowerEffectiveness);
}); });

View File

@ -1,6 +1,7 @@
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 { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { PokemonType } from "#enums/pokemon-type";
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";
import Phaser from "phaser"; import Phaser from "phaser";
@ -113,4 +114,18 @@ describe("Abilities - Tera Shell", () => {
} }
expect(spy).toHaveReturnedTimes(2); expect(spy).toHaveReturnedTimes(2);
}); });
it("should overwrite Freeze-Dry", async () => {
await game.classicMode.startBattle([SpeciesId.TERAPAGOS]);
const terapagos = game.field.getPlayerPokemon();
terapagos.summonData.types = [PokemonType.WATER];
const spy = vi.spyOn(terapagos, "getMoveEffectiveness");
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.FREEZE_DRY);
await game.toEndOfTurn();
expect(spy).toHaveLastReturnedWith(0.5);
});
}); });

View File

@ -42,7 +42,7 @@ describe("Weather - Strong Winds", () => {
game.move.select(MoveId.THUNDERBOLT); game.move.select(MoveId.THUNDERBOLT);
await game.phaseInterceptor.to(TurnStartPhase); await game.phaseInterceptor.to(TurnStartPhase);
expect(enemy.getAttackTypeEffectiveness(allMoves[MoveId.THUNDERBOLT].type, pikachu)).toBe(0.5); expect(enemy.getAttackTypeEffectiveness(allMoves[MoveId.THUNDERBOLT].type, { source: pikachu })).toBe(0.5);
}); });
it("electric type move is neutral for flying type pokemon", async () => { it("electric type move is neutral for flying type pokemon", async () => {
@ -53,7 +53,7 @@ describe("Weather - Strong Winds", () => {
game.move.select(MoveId.THUNDERBOLT); game.move.select(MoveId.THUNDERBOLT);
await game.phaseInterceptor.to(TurnStartPhase); await game.phaseInterceptor.to(TurnStartPhase);
expect(enemy.getAttackTypeEffectiveness(allMoves[MoveId.THUNDERBOLT].type, pikachu)).toBe(1); expect(enemy.getAttackTypeEffectiveness(allMoves[MoveId.THUNDERBOLT].type, { source: pikachu })).toBe(1);
}); });
it("ice type move is neutral for flying type pokemon", async () => { it("ice type move is neutral for flying type pokemon", async () => {
@ -64,7 +64,7 @@ describe("Weather - Strong Winds", () => {
game.move.select(MoveId.ICE_BEAM); game.move.select(MoveId.ICE_BEAM);
await game.phaseInterceptor.to(TurnStartPhase); await game.phaseInterceptor.to(TurnStartPhase);
expect(enemy.getAttackTypeEffectiveness(allMoves[MoveId.ICE_BEAM].type, pikachu)).toBe(1); expect(enemy.getAttackTypeEffectiveness(allMoves[MoveId.ICE_BEAM].type, { source: pikachu })).toBe(1);
}); });
it("rock type move is neutral for flying type pokemon", async () => { it("rock type move is neutral for flying type pokemon", async () => {
@ -75,7 +75,7 @@ describe("Weather - Strong Winds", () => {
game.move.select(MoveId.ROCK_SLIDE); game.move.select(MoveId.ROCK_SLIDE);
await game.phaseInterceptor.to(TurnStartPhase); await game.phaseInterceptor.to(TurnStartPhase);
expect(enemy.getAttackTypeEffectiveness(allMoves[MoveId.ROCK_SLIDE].type, pikachu)).toBe(1); expect(enemy.getAttackTypeEffectiveness(allMoves[MoveId.ROCK_SLIDE].type, { source: pikachu })).toBe(1);
}); });
it("weather goes away when last trainer pokemon dies to indirect damage", async () => { it("weather goes away when last trainer pokemon dies to indirect damage", async () => {

View File

@ -106,21 +106,6 @@ describe("Inverse Battle", () => {
expect(currentHp).toBeGreaterThan((maxHp * 31) / 32 - 1); expect(currentHp).toBeGreaterThan((maxHp * 31) / 32 - 1);
}); });
it("Freeze Dry is 2x effective against Water Type like other Ice type Move - Freeze Dry against Squirtle", async () => {
game.override.moveset([MoveId.FREEZE_DRY]).enemySpecies(SpeciesId.SQUIRTLE);
await game.challengeMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveLastReturnedWith(2);
});
it("Water Absorb should heal against water moves - Water Absorb against Water gun", async () => { it("Water Absorb should heal against water moves - Water Absorb against Water gun", async () => {
game.override.moveset([MoveId.WATER_GUN]).enemyAbility(AbilityId.WATER_ABSORB); game.override.moveset([MoveId.WATER_GUN]).enemyAbility(AbilityId.WATER_ABSORB);
@ -164,6 +149,7 @@ describe("Inverse Battle", () => {
expect(enemy.status?.effect).not.toBe(StatusEffect.PARALYSIS); expect(enemy.status?.effect).not.toBe(StatusEffect.PARALYSIS);
}); });
// TODO: These should belong to their respective moves' test files, not the inverse battle mechanic itself
it("Ground type is not immune to Thunder Wave - Thunder Wave against Sandshrew", async () => { it("Ground type is not immune to Thunder Wave - Thunder Wave against Sandshrew", async () => {
game.override.moveset([MoveId.THUNDER_WAVE]).enemySpecies(SpeciesId.SANDSHREW); game.override.moveset([MoveId.THUNDER_WAVE]).enemySpecies(SpeciesId.SANDSHREW);
@ -202,21 +188,6 @@ describe("Inverse Battle", () => {
expect(player.getTypes()[0]).toBe(PokemonType.DRAGON); expect(player.getTypes()[0]).toBe(PokemonType.DRAGON);
}); });
it("Flying Press should be 0.25x effective against Grass + Dark Type - Flying Press against Meowscarada", async () => {
game.override.moveset([MoveId.FLYING_PRESS]).enemySpecies(SpeciesId.MEOWSCARADA);
await game.challengeMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FLYING_PRESS);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveLastReturnedWith(0.25);
});
it("Scrappy ability has no effect - Tackle against Ghost Type still 2x effective with Scrappy", async () => { it("Scrappy ability has no effect - Tackle against Ghost Type still 2x effective with Scrappy", async () => {
game.override.moveset([MoveId.TACKLE]).ability(AbilityId.SCRAPPY).enemySpecies(SpeciesId.GASTLY); game.override.moveset([MoveId.TACKLE]).ability(AbilityId.SCRAPPY).enemySpecies(SpeciesId.GASTLY);

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,131 @@
import { allAbilities, allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { BattlerTagType } from "#enums/battler-tag-type";
import { Challenges } from "#enums/challenges";
import { MoveId } from "#enums/move-id";
import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import type { EnemyPokemon, PlayerPokemon } from "#field/pokemon";
import { GameManager } from "#test/test-utils/game-manager";
import { getEnumValues } from "#utils/enums";
import { toTitleCase } from "#utils/strings";
import Phaser from "phaser";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
describe.sequential("Move - Flying Press", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
let hawlucha: PlayerPokemon;
let enemy: EnemyPokemon;
beforeAll(async () => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
game = new GameManager(phaserGame);
game.override
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
await game.classicMode.startBattle([SpeciesId.HAWLUCHA]);
hawlucha = game.field.getPlayerPokemon();
enemy = game.field.getEnemyPokemon();
});
afterAll(() => {
game.phaseInterceptor.restoreOg();
});
// Reset temp data after each test
afterEach(() => {
hawlucha.resetSummonData();
enemy.resetSummonData();
});
const pokemonTypes = getEnumValues(PokemonType);
function checkEffForAllTypes(primaryType: PokemonType) {
for (const type of pokemonTypes) {
enemy.summonData.types = [type];
const primaryEff = enemy.getAttackTypeEffectiveness(primaryType, { source: hawlucha });
const flyingEff = enemy.getAttackTypeEffectiveness(PokemonType.FLYING, { source: hawlucha });
const flyingPressEff = enemy.getAttackTypeEffectiveness(hawlucha.getMoveType(allMoves[MoveId.FLYING_PRESS]), {
source: hawlucha,
move: allMoves[MoveId.FLYING_PRESS],
});
expect
.soft(
flyingPressEff,
`Flying Press effectiveness against ${toTitleCase(PokemonType[type])} was incorrect!` +
`\nExpected: ${flyingPressEff},` +
`\nActual: ${primaryEff * flyingEff} (=${primaryEff} * ${flyingEff})`,
)
.toBe(primaryEff * flyingEff);
}
}
describe("Normal -", () => {
it("should deal damage as a Fighting/Flying type move by default", async () => {
checkEffForAllTypes(PokemonType.FIGHTING);
});
it("should deal damage as an Electric/Flying type move when Electrify is active", async () => {
hawlucha.addTag(BattlerTagType.ELECTRIFIED);
checkEffForAllTypes(PokemonType.ELECTRIC);
});
it("should deal damage as a Normal/Flying type move when Normalize is active", async () => {
hawlucha.setTempAbility(allAbilities[AbilityId.NORMALIZE]);
checkEffForAllTypes(PokemonType.NORMAL);
});
it("should deal 8x damage against a Normal/Ice type with Grass added", () => {
enemy.summonData.types = [PokemonType.NORMAL, PokemonType.ICE];
enemy.summonData.addedType = PokemonType.GRASS;
const moveType = hawlucha.getMoveType(allMoves[MoveId.FLYING_PRESS]);
const flyingPressEff = enemy.getAttackTypeEffectiveness(moveType, {
source: hawlucha,
move: allMoves[MoveId.FLYING_PRESS],
});
expect(flyingPressEff).toBe(8);
});
});
describe("Inverse Battle -", () => {
beforeAll(() => {
game.challengeMode.overrideGameWithChallenges(Challenges.INVERSE_BATTLE, 1, 1);
});
it("should deal damage as a Fighting/Flying type move by default", async () => {
checkEffForAllTypes(PokemonType.FIGHTING);
});
it("should deal damage as an Electric/Flying type move when Electrify is active", async () => {
hawlucha.addTag(BattlerTagType.ELECTRIFIED);
checkEffForAllTypes(PokemonType.ELECTRIC);
});
it("should deal damage as a Normal/Flying type move when Normalize is active", async () => {
hawlucha.setTempAbility(allAbilities[AbilityId.NORMALIZE]);
checkEffForAllTypes(PokemonType.NORMAL);
});
it("should deal 0.125x damage against a Normal/Ice type with Grass added", () => {
enemy.summonData.types = [PokemonType.NORMAL, PokemonType.ICE];
enemy.summonData.addedType = PokemonType.GRASS;
const moveType = hawlucha.getMoveType(allMoves[MoveId.FLYING_PRESS]);
const flyingPressEff = enemy.getAttackTypeEffectiveness(moveType, {
source: hawlucha,
move: allMoves[MoveId.FLYING_PRESS],
});
expect(flyingPressEff).toBe(0.125);
});
});
});

View File

@ -1,330 +1,140 @@
import { allMoves } from "#data/data-lists";
import type { TypeDamageMultiplier } from "#data/type";
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index"; import { BattlerTagType } from "#enums/battler-tag-type";
import { Challenges } from "#enums/challenges"; import { Challenges } from "#enums/challenges";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { PokemonType } from "#enums/pokemon-type"; import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import type { EnemyPokemon, PlayerPokemon } from "#field/pokemon";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
import { stringifyEnumArray } from "#test/test-utils/string-utils";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
describe("Moves - Freeze-Dry", () => { type typesArray = [PokemonType] | [PokemonType, PokemonType] | [PokemonType, PokemonType, PokemonType];
describe.sequential("Move - Freeze-Dry", () => {
let phaserGame: Phaser.Game; let phaserGame: Phaser.Game;
let game: GameManager; let game: GameManager;
beforeAll(() => { let feebas: PlayerPokemon;
let enemy: EnemyPokemon;
beforeAll(async () => {
phaserGame = new Phaser.Game({ phaserGame = new Phaser.Game({
type: Phaser.HEADLESS, type: Phaser.HEADLESS,
}); });
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame); game = new GameManager(phaserGame);
game.override game.override
.battleStyle("single") .battleStyle("single")
.enemySpecies(SpeciesId.MAGIKARP) .enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH) .enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH) .enemyMoveset(MoveId.SPLASH)
.starterSpecies(SpeciesId.FEEBAS) .ability(AbilityId.BALL_FETCH);
.ability(AbilityId.BALL_FETCH)
.moveset([MoveId.FREEZE_DRY, MoveId.FORESTS_CURSE, MoveId.SOAK]); await game.classicMode.startBattle([SpeciesId.FEEBAS]);
feebas = game.field.getPlayerPokemon();
enemy = game.field.getEnemyPokemon();
}); });
it("should deal 2x damage to pure water types", async () => { // Reset temp data after each test
await game.classicMode.startBattle(); afterEach(() => {
feebas.resetSummonData();
const enemy = game.field.getEnemyPokemon(); enemy.resetSummonData();
vi.spyOn(enemy, "getMoveEffectiveness"); enemy.isTerastallized = false;
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(2);
}); });
it("should deal 4x damage to water/flying types", async () => { afterAll(() => {
game.override.enemySpecies(SpeciesId.WINGULL); game.phaseInterceptor.restoreOg();
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(4);
});
it("should deal 1x damage to water/fire types", async () => {
game.override.enemySpecies(SpeciesId.VOLCANION);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(1);
}); });
/** /**
* Freeze drys forced super effectiveness should overwrite wonder guard * Check that Freeze-Dry is the given effectiveness against the given type.
* @param types - The base {@linkcode PokemonType}s to set; will populate `addedType` if above 3
* @param multi - The expected {@linkcode TypeDamageMultiplier}
*/ */
it("should deal 2x dmg against soaked wonder guard target", async () => { function expectEffectiveness(types: typesArray, multi: TypeDamageMultiplier): void {
game.override enemy.summonData.types = types.slice(0, 2);
.enemySpecies(SpeciesId.SHEDINJA) if (types[2] !== undefined) {
.enemyMoveset(MoveId.SPLASH) enemy.summonData.addedType = types[2];
.starterSpecies(SpeciesId.MAGIKARP) }
.moveset([MoveId.SOAK, MoveId.FREEZE_DRY]);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon(); const moveType = feebas.getMoveType(allMoves[MoveId.FREEZE_DRY]);
vi.spyOn(enemy, "getMoveEffectiveness"); const eff = enemy.getAttackTypeEffectiveness(moveType, { source: feebas, move: allMoves[MoveId.FREEZE_DRY] });
expect(
eff,
`Freeze-dry effectiveness against ${stringifyEnumArray(PokemonType, types)} was ${eff} instead of ${multi}!`,
).toBe(multi);
}
game.move.select(MoveId.SOAK); describe("Normal -", () => {
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); it.each<{ name: string; types: typesArray; eff: TypeDamageMultiplier }>([
await game.toNextTurn(); { name: "Pure Water", types: [PokemonType.WATER], eff: 2 },
{ name: "Water/Ground", types: [PokemonType.WATER, PokemonType.GROUND], eff: 4 },
{ name: "Water/Flying/Grass", types: [PokemonType.WATER, PokemonType.FLYING, PokemonType.GRASS], eff: 8 },
{ name: "Water/Fire", types: [PokemonType.WATER, PokemonType.FIRE], eff: 1 },
])("should be $effx effective against a $name-type opponent", ({ types, eff }) => {
expectEffectiveness(types, eff);
});
game.move.select(MoveId.FREEZE_DRY); it("should deal 2x dmg against soaked wonder guard target", async () => {
await game.phaseInterceptor.to("MoveEffectPhase"); game.field.mockAbility(enemy, AbilityId.WONDER_GUARD);
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(2); expectEffectiveness([PokemonType.WATER], 2);
expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); });
it("should consider the target's Tera Type", async () => {
// Steel type terastallized into Water; 2x
enemy.teraType = PokemonType.WATER;
enemy.isTerastallized = true;
expectEffectiveness([PokemonType.STEEL], 2);
// Water type terastallized into steel; 0.5x
enemy.teraType = PokemonType.STEEL;
expectEffectiveness([PokemonType.WATER], 2);
});
it.each<{ name: string; types: typesArray; eff: TypeDamageMultiplier }>([
{ name: "Pure Water", types: [PokemonType.WATER], eff: 2 },
{ name: "Water/Ghost", types: [PokemonType.WATER, PokemonType.GHOST], eff: 0 },
])("should be $effx effective against a $name-type opponent with Normalize", ({ types, eff }) => {
game.field.mockAbility(feebas, AbilityId.NORMALIZE);
expectEffectiveness(types, eff);
});
it("should not stack with Electrify", async () => {
feebas.addTag(BattlerTagType.ELECTRIFIED);
expect(feebas.getMoveType(allMoves[MoveId.FREEZE_DRY])).toBe(PokemonType.ELECTRIC);
expectEffectiveness([PokemonType.WATER], 2);
});
}); });
it("should deal 8x damage to water/ground/grass type under Forest's Curse", async () => { describe("Inverse Battle -", () => {
game.override.enemySpecies(SpeciesId.QUAGSIRE); beforeAll(() => {
await game.classicMode.startBattle(); game.challengeMode.overrideGameWithChallenges(Challenges.INVERSE_BATTLE, 1, 1);
});
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness"); it("should deal 2x damage to Water type", async () => {
expectEffectiveness([PokemonType.WATER], 2);
game.move.select(MoveId.FORESTS_CURSE); });
await game.toNextTurn();
it("should deal 2x damage to Water type under Normalize", async () => {
game.move.select(MoveId.FREEZE_DRY); game.field.mockAbility(feebas, AbilityId.NORMALIZE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); expectEffectiveness([PokemonType.WATER], 2);
await game.phaseInterceptor.to("MoveEffectPhase"); });
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(8); it("should still deal 2x damage to Water type under Electrify", async () => {
}); feebas.addTag(BattlerTagType.ELECTRIFIED);
expectEffectiveness([PokemonType.WATER], 2);
it("should deal 2x damage to steel type terastallized into water", async () => { });
game.override.enemySpecies(SpeciesId.SKARMORY);
await game.classicMode.startBattle(); it("should deal 1x damage to Water/Flying type under Electrify", async () => {
feebas.addTag(BattlerTagType.ELECTRIFIED);
const enemy = game.field.getEnemyPokemon(); expectEffectiveness([PokemonType.WATER, PokemonType.FLYING], 1);
enemy.teraType = PokemonType.WATER; });
enemy.isTerastallized = true;
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(2);
});
it("should deal 0.5x damage to water type terastallized into fire", async () => {
game.override.enemySpecies(SpeciesId.PELIPPER);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
enemy.teraType = PokemonType.FIRE;
enemy.isTerastallized = true;
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(0.5);
});
it("should deal 0.5x damage to water type Terapagos with Tera Shell", async () => {
game.override.enemySpecies(SpeciesId.TERAPAGOS).enemyAbility(AbilityId.TERA_SHELL);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.SOAK);
await game.toNextTurn();
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(0.5);
});
it("should deal 2x damage to water type under Normalize", async () => {
game.override.ability(AbilityId.NORMALIZE);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(2);
});
it("should deal 0.25x damage to rock/steel type under Normalize", async () => {
game.override.ability(AbilityId.NORMALIZE).enemySpecies(SpeciesId.SHIELDON);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(0.25);
});
it("should deal 0x damage to water/ghost type under Normalize", async () => {
game.override.ability(AbilityId.NORMALIZE).enemySpecies(SpeciesId.JELLICENT);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(0);
});
it("should deal 2x damage to water type under Electrify", async () => {
game.override.enemyMoveset([MoveId.ELECTRIFY]);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(2);
});
it("should deal 4x damage to water/flying type under Electrify", async () => {
game.override.enemyMoveset([MoveId.ELECTRIFY]).enemySpecies(SpeciesId.GYARADOS);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(4);
});
it("should deal 0x damage to water/ground type under Electrify", async () => {
game.override.enemyMoveset([MoveId.ELECTRIFY]).enemySpecies(SpeciesId.BARBOACH);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(0);
});
it("should deal 0.25x damage to Grass/Dragon type under Electrify", async () => {
game.override.enemyMoveset([MoveId.ELECTRIFY]).enemySpecies(SpeciesId.FLAPPLE);
await game.classicMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(0.25);
});
it("should deal 2x damage to Water type during inverse battle", async () => {
game.override.moveset([MoveId.FREEZE_DRY]).enemySpecies(SpeciesId.MAGIKARP);
game.challengeMode.addChallenge(Challenges.INVERSE_BATTLE, 1, 1);
await game.challengeMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveLastReturnedWith(2);
});
it("should deal 2x damage to Water type during inverse battle under Normalize", async () => {
game.override.moveset([MoveId.FREEZE_DRY]).ability(AbilityId.NORMALIZE).enemySpecies(SpeciesId.MAGIKARP);
game.challengeMode.addChallenge(Challenges.INVERSE_BATTLE, 1, 1);
await game.challengeMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveLastReturnedWith(2);
});
it("should deal 2x damage to Water type during inverse battle under Electrify", async () => {
game.override.moveset([MoveId.FREEZE_DRY]).enemySpecies(SpeciesId.MAGIKARP).enemyMoveset([MoveId.ELECTRIFY]);
game.challengeMode.addChallenge(Challenges.INVERSE_BATTLE, 1, 1);
await game.challengeMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(enemy.getMoveEffectiveness).toHaveLastReturnedWith(2);
});
it("should deal 1x damage to water/flying type during inverse battle under Electrify", async () => {
game.override.enemyMoveset([MoveId.ELECTRIFY]).enemySpecies(SpeciesId.GYARADOS);
game.challengeMode.addChallenge(Challenges.INVERSE_BATTLE, 1, 1);
await game.challengeMode.startBattle();
const enemy = game.field.getEnemyPokemon();
vi.spyOn(enemy, "getMoveEffectiveness");
game.move.select(MoveId.FREEZE_DRY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemy.getMoveEffectiveness).toHaveReturnedWith(1);
}); });
}); });

View File

@ -32,16 +32,25 @@ describe("Moves - Synchronoise", () => {
.enemyMoveset(MoveId.SPLASH); .enemyMoveset(MoveId.SPLASH);
}); });
it("should consider the user's tera type if it is terastallized", async () => { // TODO: Write test
it.todo("should affect all opponents that share a type with the user");
it("should consider the user's Tera Type if it is Terastallized", async () => {
await game.classicMode.startBattle([SpeciesId.BIDOOF]); await game.classicMode.startBattle([SpeciesId.BIDOOF]);
const playerPokemon = game.field.getPlayerPokemon(); const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon(); const enemyPokemon = game.field.getEnemyPokemon();
// force the player to be terastallized
playerPokemon.teraType = PokemonType.WATER; playerPokemon.teraType = PokemonType.WATER;
playerPokemon.isTerastallized = true; game.move.selectWithTera(MoveId.SYNCHRONOISE);
game.move.select(MoveId.SYNCHRONOISE); await game.toEndOfTurn();
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); expect(enemyPokemon).not.toHaveFullHp();
}); });
// TODO: Write test
it.todo("should fail if no opponents share a type with the user");
// TODO: Write test
it.todo("should fail if the user is typeless");
}); });

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

@ -12,6 +12,8 @@ import { generateStarter } from "#test/test-utils/game-manager-utils";
import { GameManagerHelper } from "#test/test-utils/helpers/game-manager-helper"; import { GameManagerHelper } from "#test/test-utils/helpers/game-manager-helper";
import { copyChallenge } from "data/challenge"; import { copyChallenge } from "data/challenge";
type challengeStub = { id: Challenges; value: number; severity: number };
/** /**
* Helper to handle Challenge mode specifics * Helper to handle Challenge mode specifics
*/ */
@ -33,8 +35,9 @@ export class ChallengeModeHelper extends GameManagerHelper {
* Runs the Challenge game to the summon phase. * Runs the Challenge game to the summon phase.
* @param gameMode - Optional game mode to set. * @param gameMode - Optional game mode to set.
* @returns A promise that resolves when the summon phase is reached. * @returns A promise that resolves when the summon phase is reached.
* @todo this duplicates nearly all its code with the classic mode variant...
*/ */
async runToSummon(species?: SpeciesId[]) { private async runToSummon(species?: SpeciesId[]) {
await this.game.runToTitle(); await this.game.runToTitle();
if (this.game.override.disableShinies) { if (this.game.override.disableShinies) {
@ -50,7 +53,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();
} }
} }
@ -88,4 +91,26 @@ export class ChallengeModeHelper extends GameManagerHelper {
await this.game.phaseInterceptor.to(CommandPhase); await this.game.phaseInterceptor.to(CommandPhase);
console.log("==================[New Turn]=================="); console.log("==================[New Turn]==================");
} }
/**
* Override an already-started game with the given challenges.
* @param id - The challenge id
* @param value - The challenge value
* @param severity - The challenge severity
* @todo Make severity optional for challenges that do not require it
*/
public overrideGameWithChallenges(id: Challenges, value: number, severity: number): void;
/**
* Override an already-started game with the given challenges.
* @param challenges - One or more challenges to set.
*/
public overrideGameWithChallenges(challenges: challengeStub[]): void;
public overrideGameWithChallenges(challenges: challengeStub[] | Challenges, value?: number, severity?: number): void {
if (typeof challenges !== "object") {
challenges = [{ id: challenges, value: value!, severity: severity! }];
}
for (const challenge of challenges) {
this.game.scene.gameMode.setChallengeValue(challenge.id, challenge.value, challenge.severity);
}
}
} }

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

@ -18,7 +18,7 @@ export class DailyModeHelper extends GameManagerHelper {
* @returns A promise that resolves when the summon phase is reached. * @returns A promise that resolves when the summon phase is reached.
* @remarks Please do not use for starting normal battles - use {@linkcode startBattle} instead * @remarks Please do not use for starting normal battles - use {@linkcode startBattle} instead
*/ */
async runToSummon(): Promise<void> { private async runToSummon(): Promise<void> {
await this.game.runToTitle(); await this.game.runToTitle();
if (this.game.override.disableShinies) { if (this.game.override.disableShinies) {
@ -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

@ -406,7 +406,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 +416,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 +427,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 +438,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 +449,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 +460,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 +475,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 +488,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 +499,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 +510,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 +571,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 +579,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 +594,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

@ -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
} }