mirror of
https://github.com/pagefaultgames/pokerogue.git
synced 2025-08-26 01:09:29 +02:00
Compare commits
10 Commits
2be428c123
...
10353b326d
Author | SHA1 | Date | |
---|---|---|---|
|
10353b326d | ||
|
23271901cf | ||
|
1517e0512e | ||
|
ccbb35d178 | ||
|
0da37a0f0c | ||
|
39f959d98a | ||
|
8b6ca73324 | ||
|
214e9de1de | ||
|
687a28e85f | ||
|
ba48f16500 |
@ -90,9 +90,13 @@ If this feature requires new text, the text should be integrated into the code w
|
|||||||
- For any feature pulled from the mainline Pokémon games (e.g. a Move or Ability implementation), it's best practice to include a source link for any added text.
|
- For any feature pulled from the mainline Pokémon games (e.g. a Move or Ability implementation), it's best practice to include a source link for any added text.
|
||||||
[Poké Corpus](https://abcboy101.github.io/poke-corpus/) is a great resource for finding text from the mainline games; otherwise, a video/picture showing the text being displayed should suffice.
|
[Poké Corpus](https://abcboy101.github.io/poke-corpus/) is a great resource for finding text from the mainline games; otherwise, a video/picture showing the text being displayed should suffice.
|
||||||
- You should also [notify the current Head of Translation](#notifying-translation) to ensure a fast response.
|
- You should also [notify the current Head of Translation](#notifying-translation) to ensure a fast response.
|
||||||
3. At this point, you may begin [testing locales integration in your main PR](#documenting-locales-changes).
|
3. Your locales should use the following format:
|
||||||
4. The Translation Team will approve the locale PR (after corrections, if necessary), then merge it into `pokerogue-locales`.
|
- File names should be in `kebab-case`. Example: `trainer-names.json`
|
||||||
5. The Dev Team will approve your main PR for your feature, then merge it into PokéRogue's beta environment.
|
- Key names should be in `camelCase`. Example: `aceTrainer`
|
||||||
|
- If you make use of i18next's inbuilt [context support](https://www.i18next.com/translation-function/context), you need to use `snake_case` for the context key. Example: `aceTrainer_male`
|
||||||
|
4. At this point, you may begin [testing locales integration in your main PR](#documenting-locales-changes).
|
||||||
|
5. The Translation Team will approve the locale PR (after corrections, if necessary), then merge it into `pokerogue-locales`.
|
||||||
|
6. The Dev Team will approve your main PR for your feature, then merge it into PokéRogue's beta environment.
|
||||||
|
|
||||||
[^2]: For those wondering, the reason for choosing English specifically is due to it being the master language set in Pontoon (the program used by the Translation Team to perform locale updates).
|
[^2]: For those wondering, the reason for choosing English specifically is due to it being the master language set in Pontoon (the program used by the Translation Team to perform locale updates).
|
||||||
If a key is present in any language _except_ the master language, it won't appear anywhere else in the translation tool, rendering missing English keys quite a hassle.
|
If a key is present in any language _except_ the master language, it won't appear anywhere else in the translation tool, rendering missing English keys quite a hassle.
|
||||||
|
@ -1,13 +1,24 @@
|
|||||||
|
import type { Pokemon } from "#field/pokemon";
|
||||||
import type {
|
import type {
|
||||||
AttackMove,
|
AttackMove,
|
||||||
ChargingAttackMove,
|
ChargingAttackMove,
|
||||||
ChargingSelfStatusMove,
|
ChargingSelfStatusMove,
|
||||||
|
Move,
|
||||||
MoveAttr,
|
MoveAttr,
|
||||||
MoveAttrConstructorMap,
|
MoveAttrConstructorMap,
|
||||||
SelfStatusMove,
|
SelfStatusMove,
|
||||||
StatusMove,
|
StatusMove,
|
||||||
} from "#moves/move";
|
} from "#moves/move";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A generic function producing a message during a Move's execution.
|
||||||
|
* @param user - The {@linkcode Pokemon} using the move
|
||||||
|
* @param target - The {@linkcode Pokemon} targeted by the move
|
||||||
|
* @param move - The {@linkcode Move} being used
|
||||||
|
* @returns a string
|
||||||
|
*/
|
||||||
|
export type MoveMessageFunc = (user: Pokemon, target: Pokemon, move: Move) => string;
|
||||||
|
|
||||||
export type MoveAttrFilter = (attr: MoveAttr) => boolean;
|
export type MoveAttrFilter = (attr: MoveAttr) => boolean;
|
||||||
|
|
||||||
export type * from "#moves/move";
|
export type * from "#moves/move";
|
||||||
|
@ -1670,6 +1670,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr {
|
|||||||
constructor(
|
constructor(
|
||||||
private newType: PokemonType,
|
private newType: PokemonType,
|
||||||
private powerMultiplier: number,
|
private powerMultiplier: number,
|
||||||
|
// TODO: all moves with this attr solely check the move being used...
|
||||||
private condition?: PokemonAttackCondition,
|
private condition?: PokemonAttackCondition,
|
||||||
) {
|
) {
|
||||||
super(false);
|
super(false);
|
||||||
|
@ -28,7 +28,7 @@ import type {
|
|||||||
SerializableArenaTagType,
|
SerializableArenaTagType,
|
||||||
} from "#types/arena-tags";
|
} from "#types/arena-tags";
|
||||||
import type { Mutable } from "#types/type-helpers";
|
import type { Mutable } from "#types/type-helpers";
|
||||||
import { BooleanHolder, NumberHolder, toDmgValue } from "#utils/common";
|
import { BooleanHolder, type NumberHolder, toDmgValue } from "#utils/common";
|
||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -725,42 +725,79 @@ export class IonDelugeTag extends ArenaTag {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract class to implement arena traps.
|
* Abstract class to implement [arena traps (AKA entry hazards)](https://bulbapedia.bulbagarden.net/wiki/List_of_moves_that_cause_entry_hazards).
|
||||||
|
* These persistent tags remain on-field across turns and apply effects to any {@linkcode Pokemon} switching in. \
|
||||||
|
* Uniquely, adding a tag multiple times will stack multiple "layers" of the effect, increasing its severity.
|
||||||
*/
|
*/
|
||||||
export abstract class ArenaTrapTag extends SerializableArenaTag {
|
export abstract class ArenaTrapTag extends SerializableArenaTag {
|
||||||
abstract readonly tagType: ArenaTrapTagType;
|
abstract readonly tagType: ArenaTrapTagType;
|
||||||
public layers: number;
|
|
||||||
public maxLayers: number;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new instance of the ArenaTrapTag class.
|
* The current number of layers this tag has.
|
||||||
*
|
* Starts at 1 and increases each time the trap is laid.
|
||||||
* @param tagType - The type of the arena tag.
|
|
||||||
* @param sourceMove - The move that created the tag.
|
|
||||||
* @param sourceId - The ID of the source of the tag.
|
|
||||||
* @param side - The side (player or enemy) the tag affects.
|
|
||||||
* @param maxLayers - The maximum amount of layers this tag can have.
|
|
||||||
*/
|
*/
|
||||||
constructor(sourceMove: MoveId, sourceId: number | undefined, side: ArenaTagSide, maxLayers: number) {
|
public layers = 1;
|
||||||
super(0, sourceMove, sourceId, side);
|
/** The maximum number of layers this tag can have. */
|
||||||
|
public abstract get maxLayers(): number;
|
||||||
this.layers = 1;
|
/** Whether this tag should only affect grounded targets; default `true` */
|
||||||
this.maxLayers = maxLayers;
|
protected get groundedOnly(): boolean {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
onOverlap(arena: Arena, _source: Pokemon | null): void {
|
constructor(sourceMove: MoveId, sourceId: number | undefined, side: ArenaTagSide) {
|
||||||
if (this.layers < this.maxLayers) {
|
super(0, sourceMove, sourceId, side);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add a `canAdd` field to arena tags to remove need for callers to check layer counts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display text when this tag is added to the field.
|
||||||
|
* @param _arena - The {@linkcode Arena} at the time of adding this tag
|
||||||
|
* @param quiet - Whether to suppress messages during tag creation; default `false`
|
||||||
|
*/
|
||||||
|
override onAdd(_arena: Arena, quiet = false): void {
|
||||||
|
// We assume `quiet=true` means "just add the bloody tag no questions asked"
|
||||||
|
if (quiet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = this.getSourcePokemon();
|
||||||
|
if (!source) {
|
||||||
|
console.warn(
|
||||||
|
"Failed to get source Pokemon for AernaTrapTag on add message!" +
|
||||||
|
`\nTag type: ${this.tagType}` +
|
||||||
|
`\nPID: ${this.sourceId}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalScene.phaseManager.queueMessage(this.getAddMessage(source));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the text to be displayed upon adding a new layer to this trap.
|
||||||
|
* @param source - The {@linkcode Pokemon} having created this tag
|
||||||
|
* @returns The localized message to be displayed on screen.
|
||||||
|
*/
|
||||||
|
protected abstract getAddMessage(source: Pokemon): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new layer to this tag upon overlap, triggering the tag's normal {@linkcode onAdd} effects upon doing so.
|
||||||
|
* @param arena - The {@linkcode arena} at the time of adding the tag
|
||||||
|
*/
|
||||||
|
override onOverlap(arena: Arena): void {
|
||||||
|
if (this.layers >= this.maxLayers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.layers++;
|
this.layers++;
|
||||||
|
|
||||||
this.onAdd(arena);
|
this.onAdd(arena);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activates the hazard effect onto a Pokemon when it enters the field
|
* Activate the hazard effect onto a Pokemon when it enters the field.
|
||||||
* @param _arena the {@linkcode Arena} containing this tag
|
* @param _arena - The {@linkcode Arena} at the time of tag activation
|
||||||
* @param simulated if `true`, only checks if the hazard would activate.
|
* @param simulated - Whether to suppress activation effects during execution
|
||||||
* @param pokemon the {@linkcode Pokemon} triggering this hazard
|
* @param pokemon - The {@linkcode Pokemon} triggering this hazard
|
||||||
* @returns `true` if this hazard affects the given Pokemon; `false` otherwise.
|
* @returns `true` if this hazard affects the given Pokemon; `false` otherwise.
|
||||||
*/
|
*/
|
||||||
override apply(_arena: Arena, simulated: boolean, pokemon: Pokemon): boolean {
|
override apply(_arena: Arena, simulated: boolean, pokemon: Pokemon): boolean {
|
||||||
@ -768,12 +805,21 @@ export abstract class ArenaTrapTag extends SerializableArenaTag {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.groundedOnly && !pokemon.isGrounded()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return this.activateTrap(pokemon, simulated);
|
return this.activateTrap(pokemon, simulated);
|
||||||
}
|
}
|
||||||
|
|
||||||
activateTrap(_pokemon: Pokemon, _simulated: boolean): boolean {
|
/**
|
||||||
return false;
|
* Activate this trap's effects when a Pokemon switches into it.
|
||||||
}
|
* @param _pokemon - The {@linkcode Pokemon}
|
||||||
|
* @param _simulated - Whether the activation is simulated
|
||||||
|
* @returns Whether the trap activation succeeded
|
||||||
|
* @todo Do we need the return value? nothing uses it
|
||||||
|
*/
|
||||||
|
protected abstract activateTrap(_pokemon: Pokemon, _simulated: boolean): boolean;
|
||||||
|
|
||||||
getMatchupScoreMultiplier(pokemon: Pokemon): number {
|
getMatchupScoreMultiplier(pokemon: Pokemon): number {
|
||||||
return pokemon.isGrounded()
|
return pokemon.isGrounded()
|
||||||
@ -781,122 +827,174 @@ export abstract class ArenaTrapTag extends SerializableArenaTag {
|
|||||||
: Phaser.Math.Linear(0, 1 / Math.pow(2, this.layers), Math.min(pokemon.getHpRatio(), 0.5) * 2);
|
: Phaser.Math.Linear(0, 1 / Math.pow(2, this.layers), Math.min(pokemon.getHpRatio(), 0.5) * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
public loadTag<T extends this>(source: BaseArenaTag & Pick<T, "tagType" | "layers" | "maxLayers">): void {
|
public loadTag<T extends this>(source: BaseArenaTag & Pick<T, "tagType" | "layers">): void {
|
||||||
super.loadTag(source);
|
super.loadTag(source);
|
||||||
this.layers = source.layers;
|
this.layers = source.layers;
|
||||||
this.maxLayers = source.maxLayers;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract class to implement damaging entry hazards.
|
||||||
|
* Currently used for {@linkcode SpikesTag} and {@linkcode StealthRockTag}.
|
||||||
|
*/
|
||||||
|
abstract class DamagingTrapTag extends ArenaTrapTag {
|
||||||
|
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
|
||||||
|
// Check for magic guard immunity
|
||||||
|
const cancelled = new BooleanHolder(false);
|
||||||
|
applyAbAttrs("BlockNonDirectDamageAbAttr", { pokemon, cancelled });
|
||||||
|
if (cancelled.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (simulated) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Damage the target and trigger a message
|
||||||
|
const damageHpRatio = this.getDamageHpRatio(pokemon);
|
||||||
|
const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio);
|
||||||
|
|
||||||
|
globalScene.phaseManager.queueMessage(this.getTriggerMessage(pokemon));
|
||||||
|
pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT });
|
||||||
|
pokemon.turnData.damageTaken += damage;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the text to be displayed when this tag deals damage.
|
||||||
|
* @param _pokemon - The {@linkcode Pokemon} switching in
|
||||||
|
* @returns The localized trigger message to be displayed on-screen.
|
||||||
|
*/
|
||||||
|
protected abstract getTriggerMessage(_pokemon: Pokemon): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the amount of damage this tag should deal to the given Pokemon, relative to its maximum HP.
|
||||||
|
* @param _pokemon - The {@linkcode Pokemon} switching in
|
||||||
|
* @returns The percentage of max HP to deal upon activation.
|
||||||
|
*/
|
||||||
|
protected abstract getDamageHpRatio(_pokemon: Pokemon): number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Spikes_(move) Spikes}.
|
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Spikes_(move) Spikes}.
|
||||||
* Applies up to 3 layers of Spikes, dealing 1/8th, 1/6th, or 1/4th of the the Pokémon's HP
|
* Applies up to 3 layers of Spikes, dealing 1/8th, 1/6th, or 1/4th of the the Pokémon's HP
|
||||||
* in damage for 1, 2, or 3 layers of Spikes respectively if they are summoned into this trap.
|
* in damage for 1, 2, or 3 layers of Spikes respectively if they are summoned into this trap.
|
||||||
*/
|
*/
|
||||||
class SpikesTag extends ArenaTrapTag {
|
class SpikesTag extends DamagingTrapTag {
|
||||||
public readonly tagType = ArenaTagType.SPIKES;
|
public readonly tagType = ArenaTagType.SPIKES;
|
||||||
|
override get maxLayers() {
|
||||||
|
return 3 as const;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(sourceId: number | undefined, side: ArenaTagSide) {
|
constructor(sourceId: number | undefined, side: ArenaTagSide) {
|
||||||
super(MoveId.SPIKES, sourceId, side, 3);
|
super(MoveId.SPIKES, sourceId, side);
|
||||||
}
|
}
|
||||||
|
|
||||||
onAdd(arena: Arena, quiet = false): void {
|
protected override getAddMessage(source: Pokemon): string {
|
||||||
super.onAdd(arena);
|
return i18next.t("arenaTag:spikesOnAdd", {
|
||||||
|
|
||||||
// We assume `quiet=true` means "just add the bloody tag no questions asked"
|
|
||||||
if (quiet) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = this.getSourcePokemon();
|
|
||||||
if (!source) {
|
|
||||||
console.warn(`Failed to get source Pokemon for SpikesTag on add message; id: ${this.sourceId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
globalScene.phaseManager.queueMessage(
|
|
||||||
i18next.t("arenaTag:spikesOnAdd", {
|
|
||||||
moveName: this.getMoveName(),
|
moveName: this.getMoveName(),
|
||||||
opponentDesc: source.getOpponentDescriptor(),
|
opponentDesc: source.getOpponentDescriptor(),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
|
protected override getTriggerMessage(pokemon: Pokemon): string {
|
||||||
if (!pokemon.isGrounded()) {
|
return i18next.t("arenaTag:spikesActivateTrap", {
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelled = new BooleanHolder(false);
|
|
||||||
applyAbAttrs("BlockNonDirectDamageAbAttr", { pokemon, cancelled });
|
|
||||||
if (simulated || cancelled.value) {
|
|
||||||
return !cancelled.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const damageHpRatio = 1 / (10 - 2 * this.layers);
|
|
||||||
const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio);
|
|
||||||
|
|
||||||
globalScene.phaseManager.queueMessage(
|
|
||||||
i18next.t("arenaTag:spikesActivateTrap", {
|
|
||||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||||
}),
|
});
|
||||||
);
|
}
|
||||||
pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT });
|
|
||||||
pokemon.turnData.damageTaken += damage;
|
protected override getDamageHpRatio(_pokemon: Pokemon): number {
|
||||||
return true;
|
// 1/8 for 1 layer, 1/6 for 2, 1/4 for 3
|
||||||
|
return 1 / (10 - 2 * this.layers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Toxic_Spikes_(move) Toxic Spikes}.
|
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Stealth_Rock_(move) | Stealth Rock}.
|
||||||
* Applies up to 2 layers of Toxic Spikes, poisoning or badly poisoning any Pokémon who is
|
* Applies up to 1 layer of Stealth Rocks, dealing percentage-based damage to any Pokémon
|
||||||
* summoned into this trap if 1 or 2 layers of Toxic Spikes respectively are up. Poison-type
|
* who is summoned into the trap based on the Rock type's type effectiveness.
|
||||||
* Pokémon summoned into this trap remove it entirely.
|
|
||||||
*/
|
*/
|
||||||
class ToxicSpikesTag extends ArenaTrapTag {
|
class StealthRockTag extends DamagingTrapTag {
|
||||||
#neutralized: boolean;
|
public readonly tagType = ArenaTagType.STEALTH_ROCK;
|
||||||
public readonly tagType = ArenaTagType.TOXIC_SPIKES;
|
public override get maxLayers() {
|
||||||
|
return 1 as const;
|
||||||
|
}
|
||||||
|
protected override get groundedOnly() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(sourceId: number | undefined, side: ArenaTagSide) {
|
constructor(sourceId: number | undefined, side: ArenaTagSide) {
|
||||||
super(MoveId.TOXIC_SPIKES, sourceId, side, 2);
|
super(MoveId.STEALTH_ROCK, sourceId, side);
|
||||||
this.#neutralized = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onAdd(arena: Arena, quiet = false): void {
|
protected override getAddMessage(source: Pokemon): string {
|
||||||
super.onAdd(arena);
|
return i18next.t("arenaTag:stealthRockOnAdd", {
|
||||||
|
opponentDesc: source.getOpponentDescriptor(),
|
||||||
if (quiet) {
|
});
|
||||||
// We assume `quiet=true` means "just add the bloody tag no questions asked"
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = this.getSourcePokemon();
|
protected override getTriggerMessage(pokemon: Pokemon): string {
|
||||||
if (!source) {
|
return i18next.t("arenaTag:stealthRockActivateTrap", {
|
||||||
console.warn(`Failed to get source Pokemon for ToxicSpikesTag on add message; id: ${this.sourceId}`);
|
pokemonName: getPokemonNameWithAffix(pokemon),
|
||||||
return;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
globalScene.phaseManager.queueMessage(
|
protected override getDamageHpRatio(pokemon: Pokemon): number {
|
||||||
i18next.t("arenaTag:toxicSpikesOnAdd", {
|
const effectiveness = pokemon.getAttackTypeEffectiveness(PokemonType.ROCK, undefined, true);
|
||||||
|
return 0.125 * effectiveness;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMatchupScoreMultiplier(pokemon: Pokemon): number {
|
||||||
|
const damageHpRatio = this.getDamageHpRatio(pokemon);
|
||||||
|
return Phaser.Math.Linear(super.getMatchupScoreMultiplier(pokemon), 1, 1 - Math.pow(damageHpRatio, damageHpRatio));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Toxic_Spikes_(move) | Toxic Spikes}.
|
||||||
|
* Applies up to 2 layers of Toxic Spikes, poisoning or badly poisoning any Pokémon switched in
|
||||||
|
* based on the current layer count. \
|
||||||
|
* Poison-type Pokémon will remove it entirely upon switch-in.
|
||||||
|
*/
|
||||||
|
class ToxicSpikesTag extends ArenaTrapTag {
|
||||||
|
/**
|
||||||
|
* Whether the tag is currently in the process of being neutralized by a Poison-type.
|
||||||
|
* @defaultValue `false`
|
||||||
|
*/
|
||||||
|
#neutralized = false;
|
||||||
|
public readonly tagType = ArenaTagType.TOXIC_SPIKES;
|
||||||
|
override get maxLayers() {
|
||||||
|
return 2 as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(sourceId: number | undefined, side: ArenaTagSide) {
|
||||||
|
super(MoveId.TOXIC_SPIKES, sourceId, side);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getAddMessage(source: Pokemon): string {
|
||||||
|
return i18next.t("arenaTag:toxicSpikesOnAdd", {
|
||||||
moveName: this.getMoveName(),
|
moveName: this.getMoveName(),
|
||||||
opponentDesc: source.getOpponentDescriptor(),
|
opponentDesc: source.getOpponentDescriptor(),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onRemove(arena: Arena): void {
|
// Override remove function to only display text when not neutralized
|
||||||
|
override onRemove(arena: Arena): void {
|
||||||
if (!this.#neutralized) {
|
if (!this.#neutralized) {
|
||||||
super.onRemove(arena);
|
super.onRemove(arena);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
|
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
|
||||||
if (pokemon.isGrounded()) {
|
|
||||||
if (simulated) {
|
if (simulated) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pokemon.isOfType(PokemonType.POISON)) {
|
if (pokemon.isOfType(PokemonType.POISON)) {
|
||||||
|
// Neutralize the tag and remove it from the field.
|
||||||
|
// Message cannot be moved to `onRemove` as that requires a reference to the neutralizing pokemon
|
||||||
this.#neutralized = true;
|
this.#neutralized = true;
|
||||||
if (globalScene.arena.removeTag(this.tagType)) {
|
globalScene.arena.removeTagOnSide(this.tagType, this.side);
|
||||||
globalScene.phaseManager.queueMessage(
|
globalScene.phaseManager.queueMessage(
|
||||||
i18next.t("arenaTag:toxicSpikesActivateTrapPoison", {
|
i18next.t("arenaTag:toxicSpikesActivateTrapPoison", {
|
||||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||||
@ -905,17 +1003,10 @@ class ToxicSpikesTag extends ArenaTrapTag {
|
|||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else if (!pokemon.status) {
|
|
||||||
const toxic = this.layers > 1;
|
|
||||||
if (
|
|
||||||
pokemon.trySetStatus(!toxic ? StatusEffect.POISON : StatusEffect.TOXIC, true, null, 0, this.getMoveName())
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
// Attempt to poison the target, suppressing any status effect messages
|
||||||
|
const effect = this.layers === 1 ? StatusEffect.POISON : StatusEffect.TOXIC;
|
||||||
|
return pokemon.trySetStatus(effect, true, null, 0, this.getMoveName(), false, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
getMatchupScoreMultiplier(pokemon: Pokemon): number {
|
getMatchupScoreMultiplier(pokemon: Pokemon): number {
|
||||||
@ -930,131 +1021,29 @@ class ToxicSpikesTag extends ArenaTrapTag {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Stealth_Rock_(move) Stealth Rock}.
|
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Sticky_Web_(move) | Sticky Web}.
|
||||||
* Applies up to 1 layer of Stealth Rocks, dealing percentage-based damage to any Pokémon
|
* Applies a single-layer trap that lowers the Speed of all grounded Pokémon switching in.
|
||||||
* who is summoned into the trap, based on the Rock type's type effectiveness.
|
|
||||||
*/
|
|
||||||
class StealthRockTag extends ArenaTrapTag {
|
|
||||||
public readonly tagType = ArenaTagType.STEALTH_ROCK;
|
|
||||||
constructor(sourceId: number | undefined, side: ArenaTagSide) {
|
|
||||||
super(MoveId.STEALTH_ROCK, sourceId, side, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
onAdd(arena: Arena, quiet = false): void {
|
|
||||||
super.onAdd(arena);
|
|
||||||
|
|
||||||
if (quiet) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = this.getSourcePokemon();
|
|
||||||
if (!quiet && source) {
|
|
||||||
globalScene.phaseManager.queueMessage(
|
|
||||||
i18next.t("arenaTag:stealthRockOnAdd", {
|
|
||||||
opponentDesc: source.getOpponentDescriptor(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getDamageHpRatio(pokemon: Pokemon): number {
|
|
||||||
const effectiveness = pokemon.getAttackTypeEffectiveness(PokemonType.ROCK, undefined, true);
|
|
||||||
|
|
||||||
let damageHpRatio = 0;
|
|
||||||
|
|
||||||
switch (effectiveness) {
|
|
||||||
case 0:
|
|
||||||
damageHpRatio = 0;
|
|
||||||
break;
|
|
||||||
case 0.25:
|
|
||||||
damageHpRatio = 0.03125;
|
|
||||||
break;
|
|
||||||
case 0.5:
|
|
||||||
damageHpRatio = 0.0625;
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
damageHpRatio = 0.125;
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
damageHpRatio = 0.25;
|
|
||||||
break;
|
|
||||||
case 4:
|
|
||||||
damageHpRatio = 0.5;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return damageHpRatio;
|
|
||||||
}
|
|
||||||
|
|
||||||
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
|
|
||||||
const cancelled = new BooleanHolder(false);
|
|
||||||
applyAbAttrs("BlockNonDirectDamageAbAttr", { pokemon, cancelled });
|
|
||||||
if (cancelled.value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const damageHpRatio = this.getDamageHpRatio(pokemon);
|
|
||||||
if (!damageHpRatio) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (simulated) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const damage = toDmgValue(pokemon.getMaxHp() * damageHpRatio);
|
|
||||||
globalScene.phaseManager.queueMessage(
|
|
||||||
i18next.t("arenaTag:stealthRockActivateTrap", {
|
|
||||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
pokemon.damageAndUpdate(damage, { result: HitResult.INDIRECT });
|
|
||||||
pokemon.turnData.damageTaken += damage;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
getMatchupScoreMultiplier(pokemon: Pokemon): number {
|
|
||||||
const damageHpRatio = this.getDamageHpRatio(pokemon);
|
|
||||||
return Phaser.Math.Linear(super.getMatchupScoreMultiplier(pokemon), 1, 1 - Math.pow(damageHpRatio, damageHpRatio));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Sticky_Web_(move) Sticky Web}.
|
|
||||||
* Applies up to 1 layer of Sticky Web, which lowers the Speed by one stage
|
|
||||||
* to any Pokémon who is summoned into this trap.
|
|
||||||
*/
|
*/
|
||||||
class StickyWebTag extends ArenaTrapTag {
|
class StickyWebTag extends ArenaTrapTag {
|
||||||
public readonly tagType = ArenaTagType.STICKY_WEB;
|
public readonly tagType = ArenaTagType.STICKY_WEB;
|
||||||
|
public override get maxLayers() {
|
||||||
|
return 1 as const;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(sourceId: number | undefined, side: ArenaTagSide) {
|
constructor(sourceId: number | undefined, side: ArenaTagSide) {
|
||||||
super(MoveId.STICKY_WEB, sourceId, side, 1);
|
super(MoveId.STICKY_WEB, sourceId, side);
|
||||||
}
|
}
|
||||||
|
|
||||||
onAdd(arena: Arena, quiet = false): void {
|
protected override getAddMessage(source: Pokemon): string {
|
||||||
super.onAdd(arena);
|
return i18next.t("arenaTag:stickyWebOnAdd", {
|
||||||
|
|
||||||
// We assume `quiet=true` means "just add the bloody tag no questions asked"
|
|
||||||
if (quiet) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = this.getSourcePokemon();
|
|
||||||
if (!source) {
|
|
||||||
console.warn(`Failed to get source Pokemon for SpikesTag on add message; id: ${this.sourceId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
globalScene.phaseManager.queueMessage(
|
|
||||||
i18next.t("arenaTag:stickyWebOnAdd", {
|
|
||||||
moveName: this.getMoveName(),
|
moveName: this.getMoveName(),
|
||||||
opponentDesc: source.getOpponentDescriptor(),
|
opponentDesc: source.getOpponentDescriptor(),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
|
override activateTrap(pokemon: Pokemon, simulated: boolean): boolean {
|
||||||
if (pokemon.isGrounded()) {
|
|
||||||
const cancelled = new BooleanHolder(false);
|
const cancelled = new BooleanHolder(false);
|
||||||
|
// TODO: Does this need to pass `simulated` as a parameter?
|
||||||
applyAbAttrs("ProtectStatAbAttr", {
|
applyAbAttrs("ProtectStatAbAttr", {
|
||||||
pokemon,
|
pokemon,
|
||||||
cancelled,
|
cancelled,
|
||||||
@ -1062,23 +1051,26 @@ class StickyWebTag extends ArenaTrapTag {
|
|||||||
stages: -1,
|
stages: -1,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (simulated) {
|
if (cancelled.value) {
|
||||||
return !cancelled.value;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (simulated) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cancelled.value) {
|
|
||||||
globalScene.phaseManager.queueMessage(
|
globalScene.phaseManager.queueMessage(
|
||||||
i18next.t("arenaTag:stickyWebActivateTrap", {
|
i18next.t("arenaTag:stickyWebActivateTrap", {
|
||||||
pokemonName: pokemon.getNameToRender(),
|
pokemonName: pokemon.getNameToRender(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const stages = new NumberHolder(-1);
|
|
||||||
globalScene.phaseManager.unshiftNew(
|
globalScene.phaseManager.unshiftNew(
|
||||||
"StatStageChangePhase",
|
"StatStageChangePhase",
|
||||||
pokemon.getBattlerIndex(),
|
pokemon.getBattlerIndex(),
|
||||||
false,
|
false,
|
||||||
[Stat.SPD],
|
[Stat.SPD],
|
||||||
stages.value,
|
-1,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
@ -1090,7 +1082,73 @@ class StickyWebTag extends ArenaTrapTag {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
/**
|
||||||
|
* This arena tag facilitates the application of the move Imprison
|
||||||
|
* Imprison remains in effect as long as the source Pokemon is active and present on the field.
|
||||||
|
* Imprison will apply to any opposing Pokemon that switch onto the field as well.
|
||||||
|
*/
|
||||||
|
class ImprisonTag extends ArenaTrapTag {
|
||||||
|
public readonly tagType = ArenaTagType.IMPRISON;
|
||||||
|
public override get maxLayers() {
|
||||||
|
return 1 as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(sourceId: number | undefined, side: ArenaTagSide) {
|
||||||
|
super(MoveId.IMPRISON, sourceId, side);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the effects of Imprison to all opposing on-field Pokemon.
|
||||||
|
*/
|
||||||
|
override onAdd(_arena: Arena, quiet = false) {
|
||||||
|
super.onAdd(_arena, quiet);
|
||||||
|
|
||||||
|
const party = this.getAffectedPokemon();
|
||||||
|
party.forEach(p => {
|
||||||
|
if (p.isAllowedInBattle()) {
|
||||||
|
p.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getAddMessage(source: Pokemon): string {
|
||||||
|
return i18next.t("battlerTags:imprisonOnAdd", {
|
||||||
|
pokemonNameWithAffix: getPokemonNameWithAffix(source),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the source Pokemon is still active on the field
|
||||||
|
* @param _arena
|
||||||
|
* @returns `true` if the source of the tag is still active on the field | `false` if not
|
||||||
|
*/
|
||||||
|
override lapse(): boolean {
|
||||||
|
const source = this.getSourcePokemon();
|
||||||
|
return !!source?.isActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This applies the effects of Imprison to any opposing Pokemon that switch into the field while the source Pokemon is still active
|
||||||
|
* @param {Pokemon} pokemon the Pokemon Imprison is applied to
|
||||||
|
* @returns `true`
|
||||||
|
*/
|
||||||
|
override activateTrap(pokemon: Pokemon): boolean {
|
||||||
|
const source = this.getSourcePokemon();
|
||||||
|
if (source?.isActive(true) && pokemon.isAllowedInBattle()) {
|
||||||
|
pokemon.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the arena tag is removed, it also attempts to remove any related Battler Tags if they haven't already been removed from the affected Pokemon
|
||||||
|
* @param arena
|
||||||
|
*/
|
||||||
|
override onRemove(): void {
|
||||||
|
const party = this.getAffectedPokemon();
|
||||||
|
party.forEach(p => {
|
||||||
|
p.removeTag(BattlerTagType.IMPRISON);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1287,75 +1345,6 @@ class NoneTag extends ArenaTag {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* This arena tag facilitates the application of the move Imprison
|
|
||||||
* Imprison remains in effect as long as the source Pokemon is active and present on the field.
|
|
||||||
* Imprison will apply to any opposing Pokemon that switch onto the field as well.
|
|
||||||
*/
|
|
||||||
class ImprisonTag extends ArenaTrapTag {
|
|
||||||
public readonly tagType = ArenaTagType.IMPRISON;
|
|
||||||
constructor(sourceId: number | undefined, side: ArenaTagSide) {
|
|
||||||
super(MoveId.IMPRISON, sourceId, side, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply the effects of Imprison to all opposing on-field Pokemon.
|
|
||||||
*/
|
|
||||||
override onAdd() {
|
|
||||||
const source = this.getSourcePokemon();
|
|
||||||
if (!source) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const party = this.getAffectedPokemon();
|
|
||||||
party.forEach(p => {
|
|
||||||
if (p.isAllowedInBattle()) {
|
|
||||||
p.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
globalScene.phaseManager.queueMessage(
|
|
||||||
i18next.t("battlerTags:imprisonOnAdd", {
|
|
||||||
pokemonNameWithAffix: getPokemonNameWithAffix(source),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the source Pokemon is still active on the field
|
|
||||||
* @param _arena
|
|
||||||
* @returns `true` if the source of the tag is still active on the field | `false` if not
|
|
||||||
*/
|
|
||||||
override lapse(): boolean {
|
|
||||||
const source = this.getSourcePokemon();
|
|
||||||
return !!source?.isActive(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This applies the effects of Imprison to any opposing Pokemon that switch into the field while the source Pokemon is still active
|
|
||||||
* @param {Pokemon} pokemon the Pokemon Imprison is applied to
|
|
||||||
* @returns `true`
|
|
||||||
*/
|
|
||||||
override activateTrap(pokemon: Pokemon): boolean {
|
|
||||||
const source = this.getSourcePokemon();
|
|
||||||
if (source?.isActive(true) && pokemon.isAllowedInBattle()) {
|
|
||||||
pokemon.addTag(BattlerTagType.IMPRISON, 1, MoveId.IMPRISON, this.sourceId);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When the arena tag is removed, it also attempts to remove any related Battler Tags if they haven't already been removed from the affected Pokemon
|
|
||||||
* @param arena
|
|
||||||
*/
|
|
||||||
override onRemove(): void {
|
|
||||||
const party = this.getAffectedPokemon();
|
|
||||||
party.forEach(p => {
|
|
||||||
p.removeTag(BattlerTagType.IMPRISON);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arena Tag implementing the "sea of fire" effect from the combination
|
* Arena Tag implementing the "sea of fire" effect from the combination
|
||||||
* of {@link https://bulbapedia.bulbagarden.net/wiki/Fire_Pledge_(move) | Fire Pledge}
|
* of {@link https://bulbapedia.bulbagarden.net/wiki/Fire_Pledge_(move) | Fire Pledge}
|
||||||
|
@ -86,7 +86,7 @@ import { PokemonHealPhase } from "#phases/pokemon-heal-phase";
|
|||||||
import { SwitchSummonPhase } from "#phases/switch-summon-phase";
|
import { SwitchSummonPhase } from "#phases/switch-summon-phase";
|
||||||
import type { AttackMoveResult } from "#types/attack-move-result";
|
import type { AttackMoveResult } from "#types/attack-move-result";
|
||||||
import type { Localizable } from "#types/locales";
|
import type { Localizable } from "#types/locales";
|
||||||
import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString } from "#types/move-types";
|
import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString, MoveMessageFunc } from "#types/move-types";
|
||||||
import type { TurnMove } from "#types/turn-move";
|
import type { TurnMove } from "#types/turn-move";
|
||||||
import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common";
|
import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common";
|
||||||
import { getEnumValues } from "#utils/enums";
|
import { getEnumValues } from "#utils/enums";
|
||||||
@ -1357,20 +1357,20 @@ export class MoveHeaderAttr extends MoveAttr {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Header attribute to queue a message at the beginning of a turn.
|
* Header attribute to queue a message at the beginning of a turn.
|
||||||
* @see {@link MoveHeaderAttr}
|
|
||||||
*/
|
*/
|
||||||
export class MessageHeaderAttr extends MoveHeaderAttr {
|
export class MessageHeaderAttr extends MoveHeaderAttr {
|
||||||
private message: string | ((user: Pokemon, move: Move) => string);
|
/** The message to display, or a function producing one. */
|
||||||
|
private message: string | MoveMessageFunc;
|
||||||
|
|
||||||
constructor(message: string | ((user: Pokemon, move: Move) => string)) {
|
constructor(message: string | MoveMessageFunc) {
|
||||||
super();
|
super();
|
||||||
this.message = message;
|
this.message = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
|
apply(user: Pokemon, target: Pokemon, move: Move): boolean {
|
||||||
const message = typeof this.message === "string"
|
const message = typeof this.message === "string"
|
||||||
? this.message
|
? this.message
|
||||||
: this.message(user, move);
|
: this.message(user, target, move);
|
||||||
|
|
||||||
if (message) {
|
if (message) {
|
||||||
globalScene.phaseManager.queueMessage(message);
|
globalScene.phaseManager.queueMessage(message);
|
||||||
@ -1418,21 +1418,21 @@ export class BeakBlastHeaderAttr extends AddBattlerTagHeaderAttr {
|
|||||||
*/
|
*/
|
||||||
export class PreMoveMessageAttr extends MoveAttr {
|
export class PreMoveMessageAttr extends MoveAttr {
|
||||||
/** The message to display or a function returning one */
|
/** The message to display or a function returning one */
|
||||||
private message: string | ((user: Pokemon, target: Pokemon, move: Move) => string | undefined);
|
private message: string | MoveMessageFunc;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new {@linkcode PreMoveMessageAttr} to display a message before move execution.
|
* Create a new {@linkcode PreMoveMessageAttr} to display a message before move execution.
|
||||||
* @param message - The message to display before move use, either as a string or a function producing one.
|
* @param message - The message to display before move use, either` a literal string or a function producing one.
|
||||||
* @remarks
|
* @remarks
|
||||||
* If {@linkcode message} evaluates to an empty string (`''`), no message will be displayed
|
* If {@linkcode message} evaluates to an empty string (`""`), no message will be displayed
|
||||||
* (though the move will still succeed).
|
* (though the move will still succeed).
|
||||||
*/
|
*/
|
||||||
constructor(message: string | ((user: Pokemon, target: Pokemon, move: Move) => string)) {
|
constructor(message: string | MoveMessageFunc) {
|
||||||
super();
|
super();
|
||||||
this.message = message;
|
this.message = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean {
|
apply(user: Pokemon, target: Pokemon, move: Move): boolean {
|
||||||
const message = typeof this.message === "function"
|
const message = typeof this.message === "function"
|
||||||
? this.message(user, target, move)
|
? this.message(user, target, move)
|
||||||
: this.message;
|
: this.message;
|
||||||
@ -1453,18 +1453,17 @@ export class PreMoveMessageAttr extends MoveAttr {
|
|||||||
* @extends MoveAttr
|
* @extends MoveAttr
|
||||||
*/
|
*/
|
||||||
export class PreUseInterruptAttr extends MoveAttr {
|
export class PreUseInterruptAttr extends MoveAttr {
|
||||||
protected message?: string | ((user: Pokemon, target: Pokemon, move: Move) => string);
|
protected message: string | MoveMessageFunc;
|
||||||
protected overridesFailedMessage: boolean;
|
|
||||||
protected conditionFunc: MoveConditionFunc;
|
protected conditionFunc: MoveConditionFunc;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new MoveInterruptedMessageAttr.
|
* Create a new MoveInterruptedMessageAttr.
|
||||||
* @param message The message to display when the move is interrupted, or a function that formats the message based on the user, target, and move.
|
* @param message The message to display when the move is interrupted, or a function that formats the message based on the user, target, and move.
|
||||||
*/
|
*/
|
||||||
constructor(message?: string | ((user: Pokemon, target: Pokemon, move: Move) => string), conditionFunc?: MoveConditionFunc) {
|
constructor(message: string | MoveMessageFunc, conditionFunc: MoveConditionFunc) {
|
||||||
super();
|
super();
|
||||||
this.message = message;
|
this.message = message;
|
||||||
this.conditionFunc = conditionFunc ?? (() => true);
|
this.conditionFunc = conditionFunc;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1485,11 +1484,9 @@ export class PreUseInterruptAttr extends MoveAttr {
|
|||||||
*/
|
*/
|
||||||
override getFailedText(user: Pokemon, target: Pokemon, move: Move): string | undefined {
|
override getFailedText(user: Pokemon, target: Pokemon, move: Move): string | undefined {
|
||||||
if (this.message && this.conditionFunc(user, target, move)) {
|
if (this.message && this.conditionFunc(user, target, move)) {
|
||||||
const message =
|
return typeof this.message === "string"
|
||||||
typeof this.message === "string"
|
? this.message
|
||||||
? (this.message as string)
|
|
||||||
: this.message(user, target, move);
|
: this.message(user, target, move);
|
||||||
return message;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1694,18 +1691,31 @@ export class SurviveDamageAttr extends ModifiedDamageAttr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SplashAttr extends MoveEffectAttr {
|
/**
|
||||||
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
|
* Move attribute to display arbitrary text during a move's execution.
|
||||||
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:splash"));
|
*/
|
||||||
return true;
|
export class MessageAttr extends MoveEffectAttr {
|
||||||
}
|
/** The message to display, either as a string or a function returning one. */
|
||||||
|
private message: string | MoveMessageFunc;
|
||||||
|
|
||||||
|
constructor(message: string | MoveMessageFunc, options?: MoveEffectAttrOptions) {
|
||||||
|
// TODO: Do we need to respect `selfTarget` if we're just displaying text?
|
||||||
|
super(false, options)
|
||||||
|
this.message = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CelebrateAttr extends MoveEffectAttr {
|
override apply(user: Pokemon, target: Pokemon, move: Move): boolean {
|
||||||
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
|
const message = typeof this.message === "function"
|
||||||
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:celebrate", { playerName: loggedInUser?.username }));
|
? this.message(user, target, move)
|
||||||
|
: this.message;
|
||||||
|
|
||||||
|
// TODO: Consider changing if/when MoveAttr `apply` return values become significant
|
||||||
|
if (message) {
|
||||||
|
globalScene.phaseManager.queueMessage(message, 500);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RecoilAttr extends MoveEffectAttr {
|
export class RecoilAttr extends MoveEffectAttr {
|
||||||
@ -5931,38 +5941,6 @@ export class ProtectAttr extends AddBattlerTagAttr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IgnoreAccuracyAttr extends AddBattlerTagAttr {
|
|
||||||
constructor() {
|
|
||||||
super(BattlerTagType.IGNORE_ACCURACY, true, false, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
|
|
||||||
if (!super.apply(user, target, move, args)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:tookAimAtTarget", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target) }));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FaintCountdownAttr extends AddBattlerTagAttr {
|
|
||||||
constructor() {
|
|
||||||
super(BattlerTagType.PERISH_SONG, false, true, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
|
|
||||||
if (!super.apply(user, target, move, args)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:faintCountdown", { pokemonName: getPokemonNameWithAffix(target), turnCount: this.turnCountMin - 1 }));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attribute to remove all Substitutes from the field.
|
* Attribute to remove all Substitutes from the field.
|
||||||
* @extends MoveEffectAttr
|
* @extends MoveEffectAttr
|
||||||
@ -6603,8 +6581,10 @@ export class ChillyReceptionAttr extends ForceSwitchOutAttr {
|
|||||||
return (user, target, move) => globalScene.arena.weather?.weatherType !== WeatherType.SNOW || super.getSwitchOutCondition()(user, target, move);
|
return (user, target, move) => globalScene.arena.weather?.weatherType !== WeatherType.SNOW || super.getSwitchOutCondition()(user, target, move);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RemoveTypeAttr extends MoveEffectAttr {
|
export class RemoveTypeAttr extends MoveEffectAttr {
|
||||||
|
|
||||||
|
// TODO: Remove the message callback
|
||||||
private removedType: PokemonType;
|
private removedType: PokemonType;
|
||||||
private messageCallback: ((user: Pokemon) => void) | undefined;
|
private messageCallback: ((user: Pokemon) => void) | undefined;
|
||||||
|
|
||||||
@ -8299,8 +8279,6 @@ const MoveAttrs = Object.freeze({
|
|||||||
RandomLevelDamageAttr,
|
RandomLevelDamageAttr,
|
||||||
ModifiedDamageAttr,
|
ModifiedDamageAttr,
|
||||||
SurviveDamageAttr,
|
SurviveDamageAttr,
|
||||||
SplashAttr,
|
|
||||||
CelebrateAttr,
|
|
||||||
RecoilAttr,
|
RecoilAttr,
|
||||||
SacrificialAttr,
|
SacrificialAttr,
|
||||||
SacrificialAttrOnHit,
|
SacrificialAttrOnHit,
|
||||||
@ -8443,8 +8421,7 @@ const MoveAttrs = Object.freeze({
|
|||||||
RechargeAttr,
|
RechargeAttr,
|
||||||
TrapAttr,
|
TrapAttr,
|
||||||
ProtectAttr,
|
ProtectAttr,
|
||||||
IgnoreAccuracyAttr,
|
MessageAttr,
|
||||||
FaintCountdownAttr,
|
|
||||||
RemoveAllSubstitutesAttr,
|
RemoveAllSubstitutesAttr,
|
||||||
HitsTagAttr,
|
HitsTagAttr,
|
||||||
HitsTagForDoubleDamageAttr,
|
HitsTagForDoubleDamageAttr,
|
||||||
@ -8938,7 +8915,7 @@ export function initMoves() {
|
|||||||
new AttackMove(MoveId.PSYWAVE, PokemonType.PSYCHIC, MoveCategory.SPECIAL, -1, 100, 15, -1, 0, 1)
|
new AttackMove(MoveId.PSYWAVE, PokemonType.PSYCHIC, MoveCategory.SPECIAL, -1, 100, 15, -1, 0, 1)
|
||||||
.attr(RandomLevelDamageAttr),
|
.attr(RandomLevelDamageAttr),
|
||||||
new SelfStatusMove(MoveId.SPLASH, PokemonType.NORMAL, -1, 40, -1, 0, 1)
|
new SelfStatusMove(MoveId.SPLASH, PokemonType.NORMAL, -1, 40, -1, 0, 1)
|
||||||
.attr(SplashAttr)
|
.attr(MessageAttr, i18next.t("moveTriggers:splash"))
|
||||||
.condition(failOnGravityCondition),
|
.condition(failOnGravityCondition),
|
||||||
new SelfStatusMove(MoveId.ACID_ARMOR, PokemonType.POISON, -1, 20, -1, 0, 1)
|
new SelfStatusMove(MoveId.ACID_ARMOR, PokemonType.POISON, -1, 20, -1, 0, 1)
|
||||||
.attr(StatStageChangeAttr, [ Stat.DEF ], 2, true),
|
.attr(StatStageChangeAttr, [ Stat.DEF ], 2, true),
|
||||||
@ -9000,7 +8977,10 @@ export function initMoves() {
|
|||||||
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1)
|
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1)
|
||||||
.reflectable(),
|
.reflectable(),
|
||||||
new StatusMove(MoveId.MIND_READER, PokemonType.NORMAL, -1, 5, -1, 0, 2)
|
new StatusMove(MoveId.MIND_READER, PokemonType.NORMAL, -1, 5, -1, 0, 2)
|
||||||
.attr(IgnoreAccuracyAttr),
|
.attr(AddBattlerTagAttr, BattlerTagType.IGNORE_ACCURACY, true, false, 2)
|
||||||
|
.attr(MessageAttr, (user, target) =>
|
||||||
|
i18next.t("moveTriggers:tookAimAtTarget", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target) })
|
||||||
|
),
|
||||||
new StatusMove(MoveId.NIGHTMARE, PokemonType.GHOST, 100, 15, -1, 0, 2)
|
new StatusMove(MoveId.NIGHTMARE, PokemonType.GHOST, 100, 15, -1, 0, 2)
|
||||||
.attr(AddBattlerTagAttr, BattlerTagType.NIGHTMARE)
|
.attr(AddBattlerTagAttr, BattlerTagType.NIGHTMARE)
|
||||||
.condition(targetSleptOrComatoseCondition),
|
.condition(targetSleptOrComatoseCondition),
|
||||||
@ -9088,7 +9068,9 @@ export function initMoves() {
|
|||||||
return lastTurnMove.length === 0 || lastTurnMove[0].move !== move.id || lastTurnMove[0].result !== MoveResult.SUCCESS;
|
return lastTurnMove.length === 0 || lastTurnMove[0].move !== move.id || lastTurnMove[0].result !== MoveResult.SUCCESS;
|
||||||
}),
|
}),
|
||||||
new StatusMove(MoveId.PERISH_SONG, PokemonType.NORMAL, -1, 5, -1, 0, 2)
|
new StatusMove(MoveId.PERISH_SONG, PokemonType.NORMAL, -1, 5, -1, 0, 2)
|
||||||
.attr(FaintCountdownAttr)
|
.attr(AddBattlerTagAttr, BattlerTagType.PERISH_SONG, false, true, 4)
|
||||||
|
.attr(MessageAttr, (_user, target) =>
|
||||||
|
i18next.t("moveTriggers:faintCountdown", { pokemonName: getPokemonNameWithAffix(target), turnCount: 3 }))
|
||||||
.ignoresProtect()
|
.ignoresProtect()
|
||||||
.soundBased()
|
.soundBased()
|
||||||
.condition(failOnBossCondition)
|
.condition(failOnBossCondition)
|
||||||
@ -9104,7 +9086,10 @@ export function initMoves() {
|
|||||||
.attr(MultiHitAttr)
|
.attr(MultiHitAttr)
|
||||||
.makesContact(false),
|
.makesContact(false),
|
||||||
new StatusMove(MoveId.LOCK_ON, PokemonType.NORMAL, -1, 5, -1, 0, 2)
|
new StatusMove(MoveId.LOCK_ON, PokemonType.NORMAL, -1, 5, -1, 0, 2)
|
||||||
.attr(IgnoreAccuracyAttr),
|
.attr(AddBattlerTagAttr, BattlerTagType.IGNORE_ACCURACY, true, false, 2)
|
||||||
|
.attr(MessageAttr, (user, target) =>
|
||||||
|
i18next.t("moveTriggers:tookAimAtTarget", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target) })
|
||||||
|
),
|
||||||
new AttackMove(MoveId.OUTRAGE, PokemonType.DRAGON, MoveCategory.PHYSICAL, 120, 100, 10, -1, 0, 2)
|
new AttackMove(MoveId.OUTRAGE, PokemonType.DRAGON, MoveCategory.PHYSICAL, 120, 100, 10, -1, 0, 2)
|
||||||
.attr(FrenzyAttr)
|
.attr(FrenzyAttr)
|
||||||
.attr(MissEffectAttr, frenzyMissFunc)
|
.attr(MissEffectAttr, frenzyMissFunc)
|
||||||
@ -9331,8 +9316,8 @@ export function initMoves() {
|
|||||||
&& (user.status.effect === StatusEffect.BURN || user.status.effect === StatusEffect.POISON || user.status.effect === StatusEffect.TOXIC || user.status.effect === StatusEffect.PARALYSIS) ? 2 : 1)
|
&& (user.status.effect === StatusEffect.BURN || user.status.effect === StatusEffect.POISON || user.status.effect === StatusEffect.TOXIC || user.status.effect === StatusEffect.PARALYSIS) ? 2 : 1)
|
||||||
.attr(BypassBurnDamageReductionAttr),
|
.attr(BypassBurnDamageReductionAttr),
|
||||||
new AttackMove(MoveId.FOCUS_PUNCH, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 150, 100, 20, -1, -3, 3)
|
new AttackMove(MoveId.FOCUS_PUNCH, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 150, 100, 20, -1, -3, 3)
|
||||||
.attr(MessageHeaderAttr, (user, move) => i18next.t("moveTriggers:isTighteningFocus", { pokemonName: getPokemonNameWithAffix(user) }))
|
.attr(MessageHeaderAttr, (user) => i18next.t("moveTriggers:isTighteningFocus", { pokemonName: getPokemonNameWithAffix(user) }))
|
||||||
.attr(PreUseInterruptAttr, (user, target, move) => i18next.t("moveTriggers:lostFocus", { pokemonName: getPokemonNameWithAffix(user) }), user => !!user.turnData.attacksReceived.find(r => r.damage))
|
.attr(PreUseInterruptAttr, (user) => i18next.t("moveTriggers:lostFocus", { pokemonName: getPokemonNameWithAffix(user) }), user => user.turnData.attacksReceived.some(r => r.damage > 0))
|
||||||
.punchingMove(),
|
.punchingMove(),
|
||||||
new AttackMove(MoveId.SMELLING_SALTS, PokemonType.NORMAL, MoveCategory.PHYSICAL, 70, 100, 10, -1, 0, 3)
|
new AttackMove(MoveId.SMELLING_SALTS, PokemonType.NORMAL, MoveCategory.PHYSICAL, 70, 100, 10, -1, 0, 3)
|
||||||
.attr(MovePowerMultiplierAttr, (user, target, move) => target.status?.effect === StatusEffect.PARALYSIS ? 2 : 1)
|
.attr(MovePowerMultiplierAttr, (user, target, move) => target.status?.effect === StatusEffect.PARALYSIS ? 2 : 1)
|
||||||
@ -10433,7 +10418,8 @@ export function initMoves() {
|
|||||||
new AttackMove(MoveId.DAZZLING_GLEAM, PokemonType.FAIRY, MoveCategory.SPECIAL, 80, 100, 10, -1, 0, 6)
|
new AttackMove(MoveId.DAZZLING_GLEAM, PokemonType.FAIRY, MoveCategory.SPECIAL, 80, 100, 10, -1, 0, 6)
|
||||||
.target(MoveTarget.ALL_NEAR_ENEMIES),
|
.target(MoveTarget.ALL_NEAR_ENEMIES),
|
||||||
new SelfStatusMove(MoveId.CELEBRATE, PokemonType.NORMAL, -1, 40, -1, 0, 6)
|
new SelfStatusMove(MoveId.CELEBRATE, PokemonType.NORMAL, -1, 40, -1, 0, 6)
|
||||||
.attr(CelebrateAttr),
|
// NB: This needs a lambda function as the user will not be logged in by the time the moves are initialized
|
||||||
|
.attr(MessageAttr, () => i18next.t("moveTriggers:celebrate", { playerName: loggedInUser?.username })),
|
||||||
new StatusMove(MoveId.HOLD_HANDS, PokemonType.NORMAL, -1, 40, -1, 0, 6)
|
new StatusMove(MoveId.HOLD_HANDS, PokemonType.NORMAL, -1, 40, -1, 0, 6)
|
||||||
.ignoresSubstitute()
|
.ignoresSubstitute()
|
||||||
.target(MoveTarget.NEAR_ALLY),
|
.target(MoveTarget.NEAR_ALLY),
|
||||||
@ -10608,7 +10594,12 @@ export function initMoves() {
|
|||||||
.attr(StatStageChangeAttr, [ Stat.SPD ], -1)
|
.attr(StatStageChangeAttr, [ Stat.SPD ], -1)
|
||||||
.reflectable(),
|
.reflectable(),
|
||||||
new SelfStatusMove(MoveId.LASER_FOCUS, PokemonType.NORMAL, -1, 30, -1, 0, 7)
|
new SelfStatusMove(MoveId.LASER_FOCUS, PokemonType.NORMAL, -1, 30, -1, 0, 7)
|
||||||
.attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_CRIT, true, false),
|
.attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_CRIT, true, false)
|
||||||
|
.attr(MessageAttr, (user) =>
|
||||||
|
i18next.t("battlerTags:laserFocusOnAdd", {
|
||||||
|
pokemonNameWithAffix: getPokemonNameWithAffix(user),
|
||||||
|
}),
|
||||||
|
),
|
||||||
new StatusMove(MoveId.GEAR_UP, PokemonType.STEEL, -1, 20, -1, 0, 7)
|
new StatusMove(MoveId.GEAR_UP, PokemonType.STEEL, -1, 20, -1, 0, 7)
|
||||||
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, false, { condition: (user, target, move) => !![ AbilityId.PLUS, AbilityId.MINUS ].find(a => target.hasAbility(a, false)) })
|
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, false, { condition: (user, target, move) => !![ AbilityId.PLUS, AbilityId.MINUS ].find(a => target.hasAbility(a, false)) })
|
||||||
.ignoresSubstitute()
|
.ignoresSubstitute()
|
||||||
|
@ -38,6 +38,7 @@ export enum UiMode {
|
|||||||
UNAVAILABLE,
|
UNAVAILABLE,
|
||||||
CHALLENGE_SELECT,
|
CHALLENGE_SELECT,
|
||||||
RENAME_POKEMON,
|
RENAME_POKEMON,
|
||||||
|
RENAME_RUN,
|
||||||
RUN_HISTORY,
|
RUN_HISTORY,
|
||||||
RUN_INFO,
|
RUN_INFO,
|
||||||
TEST_DIALOGUE,
|
TEST_DIALOGUE,
|
||||||
|
@ -127,6 +127,7 @@ export interface SessionSaveData {
|
|||||||
battleType: BattleType;
|
battleType: BattleType;
|
||||||
trainer: TrainerData;
|
trainer: TrainerData;
|
||||||
gameVersion: string;
|
gameVersion: string;
|
||||||
|
runNameText: 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,
|
||||||
@ -979,6 +980,54 @@ export class GameData {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async renameSession(slotId: number, newName: string): Promise<boolean> {
|
||||||
|
return new Promise(async resolve => {
|
||||||
|
if (slotId < 0) {
|
||||||
|
return resolve(false);
|
||||||
|
}
|
||||||
|
const sessionData: SessionSaveData | null = await this.getSession(slotId);
|
||||||
|
|
||||||
|
if (!sessionData) {
|
||||||
|
return resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newName === "") {
|
||||||
|
return resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionData.runNameText = newName;
|
||||||
|
const updatedDataStr = JSON.stringify(sessionData);
|
||||||
|
const encrypted = encrypt(updatedDataStr, bypassLogin);
|
||||||
|
const secretId = this.secretId;
|
||||||
|
const trainerId = this.trainerId;
|
||||||
|
|
||||||
|
if (bypassLogin) {
|
||||||
|
localStorage.setItem(
|
||||||
|
`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`,
|
||||||
|
encrypt(updatedDataStr, bypassLogin),
|
||||||
|
);
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pokerogueApi.savedata.session
|
||||||
|
.update({ slot: slotId, trainerId, secretId, clientSessionId }, encrypted)
|
||||||
|
.then(error => {
|
||||||
|
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> {
|
||||||
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: TODO: fix this
|
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: TODO: fix this
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
|
54
src/ui/rename-run-ui-handler.ts
Normal file
54
src/ui/rename-run-ui-handler.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import i18next from "i18next";
|
||||||
|
import type { InputFieldConfig } from "./form-modal-ui-handler";
|
||||||
|
import { FormModalUiHandler } from "./form-modal-ui-handler";
|
||||||
|
import type { ModalConfig } from "./modal-ui-handler";
|
||||||
|
|
||||||
|
export class RenameRunFormUiHandler extends FormModalUiHandler {
|
||||||
|
getModalTitle(_config?: ModalConfig): string {
|
||||||
|
return i18next.t("menu:renamerun");
|
||||||
|
}
|
||||||
|
|
||||||
|
getWidth(_config?: ModalConfig): number {
|
||||||
|
return 160;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMargin(_config?: ModalConfig): [number, number, number, number] {
|
||||||
|
return [0, 0, 48, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
getButtonLabels(_config?: ModalConfig): string[] {
|
||||||
|
return [i18next.t("menu:rename"), i18next.t("menu:cancel")];
|
||||||
|
}
|
||||||
|
|
||||||
|
getReadableErrorMessage(error: string): string {
|
||||||
|
const colonIndex = error?.indexOf(":");
|
||||||
|
if (colonIndex > 0) {
|
||||||
|
error = error.slice(0, colonIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.getReadableErrorMessage(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
override getInputFieldConfigs(): InputFieldConfig[] {
|
||||||
|
return [{ label: i18next.t("menu:runName") }];
|
||||||
|
}
|
||||||
|
|
||||||
|
show(args: any[]): boolean {
|
||||||
|
if (!super.show(args)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.inputs?.length) {
|
||||||
|
this.inputs.forEach(input => {
|
||||||
|
input.text = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const config = args[0] as ModalConfig;
|
||||||
|
this.submitAction = _ => {
|
||||||
|
this.sanitizeInputs();
|
||||||
|
const sanitizedName = btoa(encodeURIComponent(this.inputs[0].text));
|
||||||
|
config.buttonActions[0](sanitizedName);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
@ -207,6 +207,10 @@ 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);
|
||||||
|
runName.setOrigin(0, 0);
|
||||||
|
runName.setPositionRelative(headerBg, 60, 4);
|
||||||
|
this.runContainer.add(runName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { GameMode } from "#app/game-mode";
|
import { GameMode } from "#app/game-mode";
|
||||||
import { globalScene } from "#app/global-scene";
|
import { globalScene } from "#app/global-scene";
|
||||||
import { Button } from "#enums/buttons";
|
import { Button } from "#enums/buttons";
|
||||||
|
import { GameModes } from "#enums/game-modes";
|
||||||
import { TextStyle } from "#enums/text-style";
|
import { TextStyle } from "#enums/text-style";
|
||||||
import { UiMode } from "#enums/ui-mode";
|
import { UiMode } from "#enums/ui-mode";
|
||||||
// biome-ignore lint/performance/noNamespaceImport: See `src/system/game-data.ts`
|
// biome-ignore lint/performance/noNamespaceImport: See `src/system/game-data.ts`
|
||||||
import * as Modifier from "#modifiers/modifier";
|
import * as Modifier from "#modifiers/modifier";
|
||||||
import type { SessionSaveData } from "#system/game-data";
|
import type { SessionSaveData } from "#system/game-data";
|
||||||
import type { PokemonData } from "#system/pokemon-data";
|
import type { PokemonData } from "#system/pokemon-data";
|
||||||
|
import type { OptionSelectConfig } from "#ui/abstract-option-select-ui-handler";
|
||||||
import { MessageUiHandler } from "#ui/message-ui-handler";
|
import { MessageUiHandler } from "#ui/message-ui-handler";
|
||||||
import { RunDisplayMode } from "#ui/run-info-ui-handler";
|
import { RunDisplayMode } from "#ui/run-info-ui-handler";
|
||||||
import { addTextObject } from "#ui/text";
|
import { addTextObject } from "#ui/text";
|
||||||
@ -15,7 +17,7 @@ import { fixedInt, formatLargeNumber, getPlayTimeString, isNullOrUndefined } fro
|
|||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
|
|
||||||
const SESSION_SLOTS_COUNT = 5;
|
const SESSION_SLOTS_COUNT = 5;
|
||||||
const SLOTS_ON_SCREEN = 3;
|
const SLOTS_ON_SCREEN = 2;
|
||||||
|
|
||||||
export enum SaveSlotUiMode {
|
export enum SaveSlotUiMode {
|
||||||
LOAD,
|
LOAD,
|
||||||
@ -33,6 +35,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
|
|||||||
|
|
||||||
private uiMode: SaveSlotUiMode;
|
private uiMode: SaveSlotUiMode;
|
||||||
private saveSlotSelectCallback: SaveSlotSelectCallback | null;
|
private saveSlotSelectCallback: SaveSlotSelectCallback | null;
|
||||||
|
protected manageDataConfig: OptionSelectConfig;
|
||||||
|
|
||||||
private scrollCursor = 0;
|
private scrollCursor = 0;
|
||||||
|
|
||||||
@ -101,6 +104,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
|
|||||||
|
|
||||||
processInput(button: Button): boolean {
|
processInput(button: Button): boolean {
|
||||||
const ui = this.getUi();
|
const ui = this.getUi();
|
||||||
|
const manageDataOptions: any[] = [];
|
||||||
|
|
||||||
let success = false;
|
let success = false;
|
||||||
let error = false;
|
let error = false;
|
||||||
@ -109,14 +113,115 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
|
|||||||
const originalCallback = this.saveSlotSelectCallback;
|
const originalCallback = this.saveSlotSelectCallback;
|
||||||
if (button === Button.ACTION) {
|
if (button === Button.ACTION) {
|
||||||
const cursor = this.cursor + this.scrollCursor;
|
const cursor = this.cursor + this.scrollCursor;
|
||||||
if (this.uiMode === SaveSlotUiMode.LOAD && !this.sessionSlots[cursor].hasData) {
|
const sessionSlot = this.sessionSlots[cursor];
|
||||||
|
if (this.uiMode === SaveSlotUiMode.LOAD && !sessionSlot.hasData) {
|
||||||
error = true;
|
error = true;
|
||||||
} else {
|
} else {
|
||||||
switch (this.uiMode) {
|
switch (this.uiMode) {
|
||||||
case SaveSlotUiMode.LOAD:
|
case SaveSlotUiMode.LOAD:
|
||||||
this.saveSlotSelectCallback = null;
|
if (!sessionSlot.malformed) {
|
||||||
|
manageDataOptions.push({
|
||||||
|
label: i18next.t("menu:loadGame"),
|
||||||
|
handler: () => {
|
||||||
|
globalScene.ui.revertMode();
|
||||||
originalCallback?.(cursor);
|
originalCallback?.(cursor);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
keepOpen: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
manageDataOptions.push({
|
||||||
|
label: i18next.t("saveSlotSelectUiHandler:renameRun"),
|
||||||
|
handler: () => {
|
||||||
|
globalScene.ui.revertMode();
|
||||||
|
ui.setOverlayMode(
|
||||||
|
UiMode.RENAME_RUN,
|
||||||
|
{
|
||||||
|
buttonActions: [
|
||||||
|
(sanitizedName: string) => {
|
||||||
|
const name = decodeURIComponent(atob(sanitizedName));
|
||||||
|
globalScene.gameData.renameSession(cursor, name).then(response => {
|
||||||
|
if (response[0] === false) {
|
||||||
|
globalScene.reset(true);
|
||||||
|
} else {
|
||||||
|
this.clearSessionSlots();
|
||||||
|
this.cursorObj = null;
|
||||||
|
this.populateSessionSlots();
|
||||||
|
this.setScrollCursor(0);
|
||||||
|
this.setCursor(0);
|
||||||
|
ui.revertMode();
|
||||||
|
ui.showText("", 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
ui.revertMode();
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.manageDataConfig = {
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 48,
|
||||||
|
options: manageDataOptions,
|
||||||
|
maxOptions: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
manageDataOptions.push({
|
||||||
|
label: i18next.t("saveSlotSelectUiHandler:deleteRun"),
|
||||||
|
handler: () => {
|
||||||
|
globalScene.ui.revertMode();
|
||||||
|
ui.showText(i18next.t("saveSlotSelectUiHandler:deleteData"), null, () => {
|
||||||
|
ui.setOverlayMode(
|
||||||
|
UiMode.CONFIRM,
|
||||||
|
() => {
|
||||||
|
globalScene.gameData.tryClearSession(cursor).then(response => {
|
||||||
|
if (response[0] === false) {
|
||||||
|
globalScene.reset(true);
|
||||||
|
} else {
|
||||||
|
this.clearSessionSlots();
|
||||||
|
this.cursorObj = null;
|
||||||
|
this.populateSessionSlots();
|
||||||
|
this.setScrollCursor(0);
|
||||||
|
this.setCursor(0);
|
||||||
|
ui.revertMode();
|
||||||
|
ui.showText("", 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
ui.revertMode();
|
||||||
|
ui.showText("", 0);
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
19,
|
||||||
|
import.meta.env.DEV ? 300 : 2000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
keepOpen: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
manageDataOptions.push({
|
||||||
|
label: i18next.t("menuUiHandler:cancel"),
|
||||||
|
handler: () => {
|
||||||
|
globalScene.ui.revertMode();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
keepOpen: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.setOverlayMode(UiMode.MENU_OPTION_SELECT, this.manageDataConfig);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SaveSlotUiMode.SAVE: {
|
case SaveSlotUiMode.SAVE: {
|
||||||
const saveAndCallback = () => {
|
const saveAndCallback = () => {
|
||||||
const originalCallback = this.saveSlotSelectCallback;
|
const originalCallback = this.saveSlotSelectCallback;
|
||||||
@ -161,6 +266,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.saveSlotSelectCallback = null;
|
this.saveSlotSelectCallback = null;
|
||||||
|
ui.showText("", 0);
|
||||||
originalCallback?.(-1);
|
originalCallback?.(-1);
|
||||||
success = true;
|
success = true;
|
||||||
}
|
}
|
||||||
@ -267,33 +373,34 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
|
|||||||
this.cursorObj = globalScene.add.container(0, 0);
|
this.cursorObj = globalScene.add.container(0, 0);
|
||||||
const cursorBox = globalScene.add.nineslice(
|
const cursorBox = globalScene.add.nineslice(
|
||||||
0,
|
0,
|
||||||
0,
|
15,
|
||||||
"select_cursor_highlight_thick",
|
"select_cursor_highlight_thick",
|
||||||
undefined,
|
undefined,
|
||||||
296,
|
294,
|
||||||
44,
|
this.sessionSlots[prevSlotIndex ?? 0]?.saveData?.runNameText ? 50 : 60,
|
||||||
6,
|
6,
|
||||||
6,
|
6,
|
||||||
6,
|
6,
|
||||||
6,
|
6,
|
||||||
);
|
);
|
||||||
const rightArrow = globalScene.add.image(0, 0, "cursor");
|
const rightArrow = globalScene.add.image(0, 0, "cursor");
|
||||||
rightArrow.setPosition(160, 0);
|
rightArrow.setPosition(160, 15);
|
||||||
rightArrow.setName("rightArrow");
|
rightArrow.setName("rightArrow");
|
||||||
this.cursorObj.add([cursorBox, rightArrow]);
|
this.cursorObj.add([cursorBox, rightArrow]);
|
||||||
this.sessionSlotsContainer.add(this.cursorObj);
|
this.sessionSlotsContainer.add(this.cursorObj);
|
||||||
}
|
}
|
||||||
const cursorPosition = cursor + this.scrollCursor;
|
const cursorPosition = cursor + this.scrollCursor;
|
||||||
const cursorIncrement = cursorPosition * 56;
|
const cursorIncrement = cursorPosition * 76;
|
||||||
if (this.sessionSlots[cursorPosition] && this.cursorObj) {
|
if (this.sessionSlots[cursorPosition] && this.cursorObj) {
|
||||||
const hasData = this.sessionSlots[cursorPosition].hasData;
|
const session = this.sessionSlots[cursorPosition];
|
||||||
|
const hasData = session.hasData && !session.malformed;
|
||||||
// If the session slot lacks session data, it does not move from its default, central position.
|
// If the session slot lacks session data, it does not move from its default, central position.
|
||||||
// Only session slots with session data will move leftwards and have a visible arrow.
|
// Only session slots with session data will move leftwards and have a visible arrow.
|
||||||
if (!hasData) {
|
if (!hasData) {
|
||||||
this.cursorObj.setPosition(151, 26 + cursorIncrement);
|
this.cursorObj.setPosition(151, 20 + cursorIncrement);
|
||||||
this.sessionSlots[cursorPosition].setPosition(0, cursorIncrement);
|
this.sessionSlots[cursorPosition].setPosition(0, cursorIncrement);
|
||||||
} else {
|
} else {
|
||||||
this.cursorObj.setPosition(145, 26 + cursorIncrement);
|
this.cursorObj.setPosition(145, 20 + cursorIncrement);
|
||||||
this.sessionSlots[cursorPosition].setPosition(-6, cursorIncrement);
|
this.sessionSlots[cursorPosition].setPosition(-6, cursorIncrement);
|
||||||
}
|
}
|
||||||
this.setArrowVisibility(hasData);
|
this.setArrowVisibility(hasData);
|
||||||
@ -311,7 +418,8 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
|
|||||||
revertSessionSlot(slotIndex: number): void {
|
revertSessionSlot(slotIndex: number): void {
|
||||||
const sessionSlot = this.sessionSlots[slotIndex];
|
const sessionSlot = this.sessionSlots[slotIndex];
|
||||||
if (sessionSlot) {
|
if (sessionSlot) {
|
||||||
sessionSlot.setPosition(0, slotIndex * 56);
|
const valueHeight = 76;
|
||||||
|
sessionSlot.setPosition(0, slotIndex * valueHeight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -340,7 +448,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
|
|||||||
this.setCursor(this.cursor, prevSlotIndex);
|
this.setCursor(this.cursor, prevSlotIndex);
|
||||||
globalScene.tweens.add({
|
globalScene.tweens.add({
|
||||||
targets: this.sessionSlotsContainer,
|
targets: this.sessionSlotsContainer,
|
||||||
y: this.sessionSlotsContainerInitialY - 56 * scrollCursor,
|
y: this.sessionSlotsContainerInitialY - 76 * scrollCursor,
|
||||||
duration: fixedInt(325),
|
duration: fixedInt(325),
|
||||||
ease: "Sine.easeInOut",
|
ease: "Sine.easeInOut",
|
||||||
});
|
});
|
||||||
@ -374,12 +482,14 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
|
|||||||
class SessionSlot extends Phaser.GameObjects.Container {
|
class SessionSlot extends Phaser.GameObjects.Container {
|
||||||
public slotId: number;
|
public slotId: number;
|
||||||
public hasData: boolean;
|
public hasData: boolean;
|
||||||
|
/** Indicates the save slot ran into an error while being loaded */
|
||||||
|
public malformed: boolean;
|
||||||
|
private slotWindow: Phaser.GameObjects.NineSlice;
|
||||||
private loadingLabel: Phaser.GameObjects.Text;
|
private loadingLabel: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
public saveData: SessionSaveData;
|
public saveData: SessionSaveData;
|
||||||
|
|
||||||
constructor(slotId: number) {
|
constructor(slotId: number) {
|
||||||
super(globalScene, 0, slotId * 56);
|
super(globalScene, 0, slotId * 76);
|
||||||
|
|
||||||
this.slotId = slotId;
|
this.slotId = slotId;
|
||||||
|
|
||||||
@ -387,32 +497,89 @@ class SessionSlot extends Phaser.GameObjects.Container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
const slotWindow = addWindow(0, 0, 304, 52);
|
this.slotWindow = addWindow(0, 0, 304, 70);
|
||||||
this.add(slotWindow);
|
this.add(this.slotWindow);
|
||||||
|
|
||||||
this.loadingLabel = addTextObject(152, 26, i18next.t("saveSlotSelectUiHandler:loading"), TextStyle.WINDOW);
|
this.loadingLabel = addTextObject(152, 33, i18next.t("saveSlotSelectUiHandler:loading"), TextStyle.WINDOW);
|
||||||
this.loadingLabel.setOrigin(0.5, 0.5);
|
this.loadingLabel.setOrigin(0.5, 0.5);
|
||||||
this.add(this.loadingLabel);
|
this.add(this.loadingLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a name for sessions that don't have a name yet.
|
||||||
|
* @param data - The {@linkcode SessionSaveData} being checked
|
||||||
|
* @returns The default name for the given data.
|
||||||
|
*/
|
||||||
|
decideFallback(data: SessionSaveData): string {
|
||||||
|
let fallbackName = `${GameMode.getModeName(data.gameMode)}`;
|
||||||
|
switch (data.gameMode) {
|
||||||
|
case GameModes.CLASSIC:
|
||||||
|
fallbackName += ` (${globalScene.gameData.gameStats.classicSessionsPlayed + 1})`;
|
||||||
|
break;
|
||||||
|
case GameModes.ENDLESS:
|
||||||
|
case GameModes.SPLICED_ENDLESS:
|
||||||
|
fallbackName += ` (${globalScene.gameData.gameStats.endlessSessionsPlayed + 1})`;
|
||||||
|
break;
|
||||||
|
case GameModes.DAILY: {
|
||||||
|
const runDay = new Date(data.timestamp).toLocaleDateString();
|
||||||
|
fallbackName += ` (${runDay})`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GameModes.CHALLENGE: {
|
||||||
|
const activeChallenges = data.challenges.filter(c => c.value !== 0);
|
||||||
|
if (activeChallenges.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
fallbackName = "";
|
||||||
|
for (const challenge of activeChallenges.slice(0, 3)) {
|
||||||
|
if (fallbackName !== "") {
|
||||||
|
fallbackName += ", ";
|
||||||
|
}
|
||||||
|
fallbackName += challenge.toChallenge().getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeChallenges.length > 3) {
|
||||||
|
fallbackName += ", ...";
|
||||||
|
} else if (fallbackName === "") {
|
||||||
|
// Something went wrong when retrieving the names of the active challenges,
|
||||||
|
// so fall back to just naming the run "Challenge"
|
||||||
|
fallbackName = `${GameMode.getModeName(data.gameMode)}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackName;
|
||||||
|
}
|
||||||
|
|
||||||
async setupWithData(data: SessionSaveData) {
|
async setupWithData(data: SessionSaveData) {
|
||||||
|
const hasName = data?.runNameText;
|
||||||
this.remove(this.loadingLabel, true);
|
this.remove(this.loadingLabel, true);
|
||||||
|
if (hasName) {
|
||||||
|
const nameLabel = addTextObject(8, 5, data.runNameText, TextStyle.WINDOW);
|
||||||
|
this.add(nameLabel);
|
||||||
|
} else {
|
||||||
|
const fallbackName = this.decideFallback(data);
|
||||||
|
await globalScene.gameData.renameSession(this.slotId, fallbackName);
|
||||||
|
const nameLabel = addTextObject(8, 5, fallbackName, TextStyle.WINDOW);
|
||||||
|
this.add(nameLabel);
|
||||||
|
}
|
||||||
|
|
||||||
const gameModeLabel = addTextObject(
|
const gameModeLabel = addTextObject(
|
||||||
8,
|
8,
|
||||||
5,
|
19,
|
||||||
`${GameMode.getModeName(data.gameMode) || i18next.t("gameMode:unknown")} - ${i18next.t("saveSlotSelectUiHandler:wave")} ${data.waveIndex}`,
|
`${GameMode.getModeName(data.gameMode) || i18next.t("gameMode:unknown")} - ${i18next.t("saveSlotSelectUiHandler:wave")} ${data.waveIndex}`,
|
||||||
TextStyle.WINDOW,
|
TextStyle.WINDOW,
|
||||||
);
|
);
|
||||||
this.add(gameModeLabel);
|
this.add(gameModeLabel);
|
||||||
|
|
||||||
const timestampLabel = addTextObject(8, 19, new Date(data.timestamp).toLocaleString(), TextStyle.WINDOW);
|
const timestampLabel = addTextObject(8, 33, new Date(data.timestamp).toLocaleString(), TextStyle.WINDOW);
|
||||||
this.add(timestampLabel);
|
this.add(timestampLabel);
|
||||||
|
|
||||||
const playTimeLabel = addTextObject(8, 33, getPlayTimeString(data.playTime), TextStyle.WINDOW);
|
const playTimeLabel = addTextObject(8, 47, getPlayTimeString(data.playTime), TextStyle.WINDOW);
|
||||||
this.add(playTimeLabel);
|
this.add(playTimeLabel);
|
||||||
|
|
||||||
const pokemonIconsContainer = globalScene.add.container(144, 4);
|
const pokemonIconsContainer = globalScene.add.container(144, 16);
|
||||||
data.party.forEach((p: PokemonData, i: number) => {
|
data.party.forEach((p: PokemonData, i: number) => {
|
||||||
const iconContainer = globalScene.add.container(26 * i, 0);
|
const iconContainer = globalScene.add.container(26 * i, 0);
|
||||||
iconContainer.setScale(0.75);
|
iconContainer.setScale(0.75);
|
||||||
@ -441,7 +608,7 @@ class SessionSlot extends Phaser.GameObjects.Container {
|
|||||||
|
|
||||||
this.add(pokemonIconsContainer);
|
this.add(pokemonIconsContainer);
|
||||||
|
|
||||||
const modifierIconsContainer = globalScene.add.container(148, 30);
|
const modifierIconsContainer = globalScene.add.container(148, 38);
|
||||||
modifierIconsContainer.setScale(0.5);
|
modifierIconsContainer.setScale(0.5);
|
||||||
let visibleModifierIndex = 0;
|
let visibleModifierIndex = 0;
|
||||||
for (const m of data.modifiers) {
|
for (const m of data.modifiers) {
|
||||||
@ -464,20 +631,30 @@ class SessionSlot extends Phaser.GameObjects.Container {
|
|||||||
|
|
||||||
load(): Promise<boolean> {
|
load(): Promise<boolean> {
|
||||||
return new Promise<boolean>(resolve => {
|
return new Promise<boolean>(resolve => {
|
||||||
globalScene.gameData.getSession(this.slotId).then(async sessionData => {
|
globalScene.gameData
|
||||||
|
.getSession(this.slotId)
|
||||||
|
.then(async sessionData => {
|
||||||
// Ignore the results if the view was exited
|
// Ignore the results if the view was exited
|
||||||
if (!this.active) {
|
if (!this.active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.hasData = !!sessionData;
|
||||||
if (!sessionData) {
|
if (!sessionData) {
|
||||||
this.hasData = false;
|
|
||||||
this.loadingLabel.setText(i18next.t("saveSlotSelectUiHandler:empty"));
|
this.loadingLabel.setText(i18next.t("saveSlotSelectUiHandler:empty"));
|
||||||
resolve(false);
|
resolve(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.hasData = true;
|
|
||||||
this.saveData = sessionData;
|
this.saveData = sessionData;
|
||||||
await this.setupWithData(sessionData);
|
resolve(true);
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
if (!this.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn(`Failed to load session slot #${this.slotId}:`, e);
|
||||||
|
this.loadingLabel.setText(i18next.t("menu:failedToLoadSession"));
|
||||||
|
this.hasData = true;
|
||||||
|
this.malformed = true;
|
||||||
resolve(true);
|
resolve(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -60,6 +60,7 @@ import { addWindow } from "#ui/ui-theme";
|
|||||||
import { UnavailableModalUiHandler } from "#ui/unavailable-modal-ui-handler";
|
import { UnavailableModalUiHandler } from "#ui/unavailable-modal-ui-handler";
|
||||||
import { executeIf } from "#utils/common";
|
import { executeIf } from "#utils/common";
|
||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
|
import { RenameRunFormUiHandler } from "./rename-run-ui-handler";
|
||||||
|
|
||||||
const transitionModes = [
|
const transitionModes = [
|
||||||
UiMode.SAVE_SLOT,
|
UiMode.SAVE_SLOT,
|
||||||
@ -98,6 +99,7 @@ const noTransitionModes = [
|
|||||||
UiMode.SESSION_RELOAD,
|
UiMode.SESSION_RELOAD,
|
||||||
UiMode.UNAVAILABLE,
|
UiMode.UNAVAILABLE,
|
||||||
UiMode.RENAME_POKEMON,
|
UiMode.RENAME_POKEMON,
|
||||||
|
UiMode.RENAME_RUN,
|
||||||
UiMode.TEST_DIALOGUE,
|
UiMode.TEST_DIALOGUE,
|
||||||
UiMode.AUTO_COMPLETE,
|
UiMode.AUTO_COMPLETE,
|
||||||
UiMode.ADMIN,
|
UiMode.ADMIN,
|
||||||
@ -168,6 +170,7 @@ export class UI extends Phaser.GameObjects.Container {
|
|||||||
new UnavailableModalUiHandler(),
|
new UnavailableModalUiHandler(),
|
||||||
new GameChallengesUiHandler(),
|
new GameChallengesUiHandler(),
|
||||||
new RenameFormUiHandler(),
|
new RenameFormUiHandler(),
|
||||||
|
new RenameRunFormUiHandler(),
|
||||||
new RunHistoryUiHandler(),
|
new RunHistoryUiHandler(),
|
||||||
new RunInfoUiHandler(),
|
new RunInfoUiHandler(),
|
||||||
new TestDialogueUiHandler(UiMode.TEST_DIALOGUE),
|
new TestDialogueUiHandler(UiMode.TEST_DIALOGUE),
|
||||||
|
27
test/@types/test-helpers.ts
Normal file
27
test/@types/test-helpers.ts
Normal 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];
|
||||||
|
};
|
26
test/@types/vitest.d.ts
vendored
26
test/@types/vitest.d.ts
vendored
@ -1,20 +1,27 @@
|
|||||||
import type { TerrainType } from "#app/data/terrain";
|
import type { TerrainType } from "#app/data/terrain";
|
||||||
|
import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag";
|
||||||
import type { AbilityId } from "#enums/ability-id";
|
import type { AbilityId } from "#enums/ability-id";
|
||||||
|
import type { ArenaTagType } from "#enums/arena-tag-type";
|
||||||
import type { BattlerTagType } from "#enums/battler-tag-type";
|
import type { 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 { 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 { ToHaveEffectiveStatMatcherOptions } from "#test/test-utils/matchers/to-have-effective-stat";
|
||||||
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 "vitest";
|
||||||
import type Overrides from "#app/overrides";
|
import type Overrides from "#app/overrides";
|
||||||
|
import type { ArenaTagSide } from "#enums/arena-tag-side";
|
||||||
import type { PokemonMove } from "#moves/pokemon-move";
|
import type { PokemonMove } from "#moves/pokemon-move";
|
||||||
|
import type { OneOther } from "#test/@types/test-helpers";
|
||||||
|
|
||||||
declare module "vitest" {
|
declare module "vitest" {
|
||||||
interface Assertion {
|
interface Assertion {
|
||||||
@ -35,6 +42,7 @@ declare module "vitest" {
|
|||||||
* @param expected - The expected types (in any order)
|
* @param expected - The expected types (in any order)
|
||||||
* @param options - The options passed to the matcher
|
* @param options - The options passed to the matcher
|
||||||
*/
|
*/
|
||||||
|
toHaveTypes(expected: PokemonType[], options?: toHaveTypesOptions): void;
|
||||||
toHaveTypes(expected: [PokemonType, ...PokemonType[]], options?: toHaveTypesOptions): void;
|
toHaveTypes(expected: [PokemonType, ...PokemonType[]], options?: toHaveTypesOptions): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -79,6 +87,24 @@ declare module "vitest" {
|
|||||||
*/
|
*/
|
||||||
toHaveTerrain(expectedTerrainType: TerrainType): void;
|
toHaveTerrain(expectedTerrainType: TerrainType): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}.
|
||||||
|
*
|
||||||
|
* @param expectedType - A partially-filled {@linkcode ArenaTag} containing the desired properties
|
||||||
|
*/
|
||||||
|
toHaveArenaTag<T extends ArenaTagType>(
|
||||||
|
expectedType: OneOther<ArenaTagTypeMap[T], "tagType" | "side"> & { tagType: T }, // intersection required bc this doesn't preserve T
|
||||||
|
): void;
|
||||||
|
/**
|
||||||
|
* Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}.
|
||||||
|
*
|
||||||
|
* @param expectedType - The {@linkcode ArenaTagType} of the desired tag
|
||||||
|
* @param side - The {@linkcode ArenaTagSide | side of the field} the tag should affect, or
|
||||||
|
* {@linkcode ArenaTagSide.BOTH} to check both sides;
|
||||||
|
* default `ArenaTagSide.BOTH`
|
||||||
|
*/
|
||||||
|
toHaveArenaTag(expectedType: ArenaTagType, side?: ArenaTagSide): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether a {@linkcode Pokemon} is at full HP.
|
* Check whether a {@linkcode Pokemon} is at full HP.
|
||||||
*/
|
*/
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
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";
|
||||||
@ -28,6 +29,7 @@ expect.extend({
|
|||||||
toHaveTakenDamage,
|
toHaveTakenDamage,
|
||||||
toHaveWeather,
|
toHaveWeather,
|
||||||
toHaveTerrain,
|
toHaveTerrain,
|
||||||
|
toHaveArenaTag,
|
||||||
toHaveFullHp,
|
toHaveFullHp,
|
||||||
toHaveStatusEffect,
|
toHaveStatusEffect,
|
||||||
toHaveStatStage,
|
toHaveStatStage,
|
||||||
|
235
test/moves/entry-hazards.test.ts
Normal file
235
test/moves/entry-hazards.test.ts
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
import { getPokemonNameWithAffix } from "#app/messages";
|
||||||
|
import { ArenaTrapTag } from "#data/arena-tag";
|
||||||
|
import { allMoves } from "#data/data-lists";
|
||||||
|
import type { TypeDamageMultiplier } from "#data/type";
|
||||||
|
import { AbilityId } from "#enums/ability-id";
|
||||||
|
import { ArenaTagSide } from "#enums/arena-tag-side";
|
||||||
|
import { ArenaTagType } from "#enums/arena-tag-type";
|
||||||
|
import { BattleType } from "#enums/battle-type";
|
||||||
|
import { BattlerIndex } from "#enums/battler-index";
|
||||||
|
import { MoveId } from "#enums/move-id";
|
||||||
|
import { MoveResult } from "#enums/move-result";
|
||||||
|
import { PokemonType } from "#enums/pokemon-type";
|
||||||
|
import { SpeciesId } from "#enums/species-id";
|
||||||
|
import { Stat } from "#enums/stat";
|
||||||
|
import { StatusEffect } from "#enums/status-effect";
|
||||||
|
import { GameManager } from "#test/test-utils/game-manager";
|
||||||
|
import type { ArenaTrapTagType } from "#types/arena-tags";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import Phaser from "phaser";
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("Moves - Entry Hazards", () => {
|
||||||
|
let phaserGame: Phaser.Game;
|
||||||
|
let game: GameManager;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
phaserGame = new Phaser.Game({
|
||||||
|
type: Phaser.HEADLESS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
game.phaseInterceptor.restoreOg();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
game = new GameManager(phaserGame);
|
||||||
|
game.override
|
||||||
|
.battleStyle("single")
|
||||||
|
.enemySpecies(SpeciesId.BLISSEY)
|
||||||
|
.startingLevel(100)
|
||||||
|
.enemyLevel(100)
|
||||||
|
.enemyAbility(AbilityId.BALL_FETCH)
|
||||||
|
.ability(AbilityId.BALL_FETCH)
|
||||||
|
.enemyMoveset(MoveId.SPLASH)
|
||||||
|
.battleType(BattleType.TRAINER);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each<{ name: string; move: MoveId; tagType: ArenaTrapTagType }>([
|
||||||
|
{ name: "Spikes", move: MoveId.SPIKES, tagType: ArenaTagType.SPIKES },
|
||||||
|
{
|
||||||
|
name: "Toxic Spikes",
|
||||||
|
move: MoveId.TOXIC_SPIKES,
|
||||||
|
tagType: ArenaTagType.TOXIC_SPIKES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Stealth Rock",
|
||||||
|
move: MoveId.STEALTH_ROCK,
|
||||||
|
tagType: ArenaTagType.STEALTH_ROCK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sticky Web",
|
||||||
|
move: MoveId.STICKY_WEB,
|
||||||
|
tagType: ArenaTagType.STICKY_WEB,
|
||||||
|
},
|
||||||
|
])("General checks - $name", ({ move, tagType }) => {
|
||||||
|
it("should add a persistent tag to the opposing side of the field", async () => {
|
||||||
|
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]);
|
||||||
|
|
||||||
|
expect(game).not.toHaveArenaTag(tagType);
|
||||||
|
|
||||||
|
game.move.use(move);
|
||||||
|
await game.toNextTurn();
|
||||||
|
|
||||||
|
// Tag should've been added to the opposing side of the field
|
||||||
|
expect(game).not.toHaveArenaTag(tagType, ArenaTagSide.PLAYER);
|
||||||
|
expect(game).toHaveArenaTag(tagType, ArenaTagSide.ENEMY);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: re-enable after re-fixing hazards moves
|
||||||
|
it.todo("should work when all targets fainted", async () => {
|
||||||
|
game.override.enemySpecies(SpeciesId.DIGLETT).battleStyle("double").startingLevel(1000);
|
||||||
|
await game.classicMode.startBattle([SpeciesId.RAYQUAZA, SpeciesId.SHUCKLE]);
|
||||||
|
|
||||||
|
const [enemy1, enemy2] = game.scene.getEnemyField();
|
||||||
|
|
||||||
|
game.move.use(MoveId.HYPER_VOICE, BattlerIndex.PLAYER);
|
||||||
|
game.move.use(MoveId.SPIKES, BattlerIndex.PLAYER_2);
|
||||||
|
await game.toEndOfTurn();
|
||||||
|
|
||||||
|
expect(enemy1.isFainted()).toBe(true);
|
||||||
|
expect(enemy2.isFainted()).toBe(true);
|
||||||
|
expect(game.scene.arena.getTagOnSide(ArenaTrapTag, ArenaTagSide.ENEMY)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxLayers = tagType === ArenaTagType.SPIKES ? 3 : tagType === ArenaTagType.TOXIC_SPIKES ? 2 : 1;
|
||||||
|
const msgText =
|
||||||
|
maxLayers === 1
|
||||||
|
? "should fail if added while already present"
|
||||||
|
: `can be added up to ${maxLayers} times in a row before failing`;
|
||||||
|
|
||||||
|
it(msgText, async () => {
|
||||||
|
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
|
||||||
|
|
||||||
|
const feebas = game.field.getPlayerPokemon();
|
||||||
|
|
||||||
|
// set up hazards until at max layers
|
||||||
|
for (let i = 0; i < maxLayers; i++) {
|
||||||
|
game.move.use(move);
|
||||||
|
await game.toNextTurn();
|
||||||
|
|
||||||
|
expect(feebas).toHaveUsedMove({ move, result: MoveResult.SUCCESS });
|
||||||
|
expect(game).toHaveArenaTag({ tagType, side: ArenaTagSide.ENEMY, layers: i + 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
game.move.use(move);
|
||||||
|
await game.toNextTurn();
|
||||||
|
|
||||||
|
expect(feebas).toHaveUsedMove({ move, result: MoveResult.FAIL });
|
||||||
|
expect(game).toHaveArenaTag({ tagType, side: ArenaTagSide.ENEMY, layers: maxLayers });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Spikes", () => {
|
||||||
|
it.each<{ layers: number; damage: number }>([
|
||||||
|
{ layers: 1, damage: 12.5 },
|
||||||
|
{ layers: 2, damage: 100 / 6 },
|
||||||
|
{ layers: 3, damage: 25 },
|
||||||
|
])("should play message and deal $damage% of the target's max HP at $layers", async ({ layers, damage }) => {
|
||||||
|
for (let i = 0; i < layers; i++) {
|
||||||
|
game.scene.arena.addTag(ArenaTagType.SPIKES, 0, undefined, 0, ArenaTagSide.ENEMY);
|
||||||
|
}
|
||||||
|
|
||||||
|
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]);
|
||||||
|
|
||||||
|
const enemy = game.field.getEnemyPokemon();
|
||||||
|
expect(enemy).toHaveTakenDamage((enemy.getMaxHp() * damage) / 100);
|
||||||
|
expect(game.textInterceptor.logs).toContain(
|
||||||
|
i18next.t("arenaTag:spikesActivateTrap", {
|
||||||
|
pokemonNameWithAffix: getPokemonNameWithAffix(enemy),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Toxic Spikes", () => {
|
||||||
|
it.each<{ name: string; layers: number; status: StatusEffect }>([
|
||||||
|
{ name: "Poison", layers: 1, status: StatusEffect.POISON },
|
||||||
|
{ name: "Toxic", layers: 2, status: StatusEffect.TOXIC },
|
||||||
|
])("should apply $name at $layers without displaying neutralization msg", async ({ layers, status }) => {
|
||||||
|
for (let i = 0; i < layers; i++) {
|
||||||
|
game.scene.arena.addTag(ArenaTagType.TOXIC_SPIKES, 0, undefined, 0, ArenaTagSide.ENEMY);
|
||||||
|
}
|
||||||
|
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]);
|
||||||
|
|
||||||
|
const enemy = game.field.getEnemyPokemon();
|
||||||
|
expect(enemy).toHaveStatusEffect(status);
|
||||||
|
// shoudl
|
||||||
|
expect(game.textInterceptor.logs).not.toContain(
|
||||||
|
i18next.t("arenaTag:toxicSpikesActivateTrapPoison", {
|
||||||
|
pokemonNameWithAffix: getPokemonNameWithAffix(enemy),
|
||||||
|
moveName: allMoves[MoveId.TOXIC_SPIKES].name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be removed without triggering upon a grounded Poison-type switching in", async () => {
|
||||||
|
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.EKANS]);
|
||||||
|
|
||||||
|
game.scene.arena.addTag(ArenaTagType.TOXIC_SPIKES, 0, undefined, 0, ArenaTagSide.ENEMY);
|
||||||
|
|
||||||
|
game.doSwitchPokemon(1);
|
||||||
|
await game.toNextTurn();
|
||||||
|
|
||||||
|
const ekans = game.field.getPlayerPokemon();
|
||||||
|
expect(game).not.toHaveArenaTag(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.PLAYER);
|
||||||
|
expect(game.textInterceptor.logs).not.toContain(
|
||||||
|
i18next.t("arenaTag:toxicSpikesActivateTrapPoison", {
|
||||||
|
pokemonNameWithAffix: getPokemonNameWithAffix(ekans),
|
||||||
|
moveName: allMoves[MoveId.TOXIC_SPIKES].name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(ekans).not.toHaveStatusEffect(StatusEffect.POISON);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Stealth Rock", () => {
|
||||||
|
it.each<{ multi: TypeDamageMultiplier; species: SpeciesId }>([
|
||||||
|
{ multi: 0.25, species: SpeciesId.LUCARIO },
|
||||||
|
{ multi: 0.5, species: SpeciesId.DURALUDON },
|
||||||
|
{ multi: 1, species: SpeciesId.LICKILICKY },
|
||||||
|
{ multi: 2, species: SpeciesId.DARMANITAN },
|
||||||
|
// TODO: Figure out why quad weak pokemon are taking 33% damage from rocks instead of 50%
|
||||||
|
// { multi: 4, species: SpeciesId.ARTICUNO },
|
||||||
|
])("should deal damage based on the target's weakness to Rock - $multi", async ({ multi, species }) => {
|
||||||
|
game.override.enemySpecies(species);
|
||||||
|
game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 0, undefined, 0, ArenaTagSide.ENEMY);
|
||||||
|
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.EKANS]);
|
||||||
|
|
||||||
|
const enemy = game.field.getEnemyPokemon();
|
||||||
|
expect(enemy.getAttackTypeEffectiveness(PokemonType.ROCK, undefined, true)).toBe(multi);
|
||||||
|
expect(enemy).toHaveTakenDamage(enemy.getMaxHp() * 0.125 * multi);
|
||||||
|
expect(game.textInterceptor.logs).toContain(
|
||||||
|
i18next.t("arenaTag:stealthRockActivateTrap", {
|
||||||
|
pokemonName: getPokemonNameWithAffix(enemy),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore strong winds for type effectiveness", async () => {
|
||||||
|
game.override.enemyAbility(AbilityId.DELTA_STREAM).enemySpecies(SpeciesId.RAYQUAZA);
|
||||||
|
game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 0, undefined, 0, ArenaTagSide.ENEMY);
|
||||||
|
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.EKANS]);
|
||||||
|
|
||||||
|
const rayquaza = game.field.getEnemyPokemon();
|
||||||
|
// took 25% damage despite strong winds halving effectiveness
|
||||||
|
expect(rayquaza).toHaveTakenDamage(rayquaza.getMaxHp() * 0.25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Sticky Web", () => {
|
||||||
|
it("should lower the target's speed by 1 stage on entry", async () => {
|
||||||
|
game.scene.arena.addTag(ArenaTagType.STICKY_WEB, 0, undefined, 0, ArenaTagSide.ENEMY);
|
||||||
|
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.EKANS]);
|
||||||
|
|
||||||
|
const enemy = game.field.getEnemyPokemon();
|
||||||
|
expect(enemy).toHaveStatStage(Stat.SPD, -1);
|
||||||
|
expect(game.textInterceptor.logs).toContain(
|
||||||
|
i18next.t("arenaTag:stickyWebActivateTrap", {
|
||||||
|
pokemonName: enemy.getNameToRender(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,99 +0,0 @@
|
|||||||
import { ArenaTrapTag } from "#data/arena-tag";
|
|
||||||
import { AbilityId } from "#enums/ability-id";
|
|
||||||
import { ArenaTagSide } from "#enums/arena-tag-side";
|
|
||||||
import { BattlerIndex } from "#enums/battler-index";
|
|
||||||
import { MoveId } from "#enums/move-id";
|
|
||||||
import { SpeciesId } from "#enums/species-id";
|
|
||||||
import { GameManager } from "#test/test-utils/game-manager";
|
|
||||||
import Phaser from "phaser";
|
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
describe("Moves - Spikes", () => {
|
|
||||||
let phaserGame: Phaser.Game;
|
|
||||||
let game: GameManager;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
phaserGame = new Phaser.Game({
|
|
||||||
type: Phaser.HEADLESS,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
game.phaseInterceptor.restoreOg();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
game = new GameManager(phaserGame);
|
|
||||||
game.override
|
|
||||||
.battleStyle("single")
|
|
||||||
.enemySpecies(SpeciesId.MAGIKARP)
|
|
||||||
.enemyAbility(AbilityId.BALL_FETCH)
|
|
||||||
.ability(AbilityId.BALL_FETCH)
|
|
||||||
.enemyMoveset(MoveId.SPLASH)
|
|
||||||
.moveset([MoveId.SPIKES, MoveId.SPLASH, MoveId.ROAR]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not damage the team that set them", async () => {
|
|
||||||
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]);
|
|
||||||
|
|
||||||
game.move.select(MoveId.SPIKES);
|
|
||||||
await game.toNextTurn();
|
|
||||||
|
|
||||||
game.move.select(MoveId.SPLASH);
|
|
||||||
await game.toNextTurn();
|
|
||||||
|
|
||||||
game.doSwitchPokemon(1);
|
|
||||||
await game.toNextTurn();
|
|
||||||
|
|
||||||
game.doSwitchPokemon(1);
|
|
||||||
await game.toNextTurn();
|
|
||||||
|
|
||||||
const player = game.scene.getPlayerParty()[0];
|
|
||||||
expect(player.hp).toBe(player.getMaxHp());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should damage opposing pokemon that are forced to switch in", async () => {
|
|
||||||
game.override.startingWave(5);
|
|
||||||
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]);
|
|
||||||
|
|
||||||
game.move.select(MoveId.SPIKES);
|
|
||||||
await game.toNextTurn();
|
|
||||||
|
|
||||||
game.move.select(MoveId.ROAR);
|
|
||||||
await game.toNextTurn();
|
|
||||||
|
|
||||||
const enemy = game.scene.getEnemyParty()[0];
|
|
||||||
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should damage opposing pokemon that choose to switch in", async () => {
|
|
||||||
game.override.startingWave(5);
|
|
||||||
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]);
|
|
||||||
|
|
||||||
game.move.select(MoveId.SPIKES);
|
|
||||||
await game.toNextTurn();
|
|
||||||
|
|
||||||
game.move.select(MoveId.SPLASH);
|
|
||||||
game.forceEnemyToSwitch();
|
|
||||||
await game.toNextTurn();
|
|
||||||
|
|
||||||
const enemy = game.scene.getEnemyParty()[0];
|
|
||||||
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: re-enable after re-fixing hazards moves
|
|
||||||
it.todo("should work when all targets fainted", async () => {
|
|
||||||
game.override.enemySpecies(SpeciesId.DIGLETT).battleStyle("double").startingLevel(1000);
|
|
||||||
await game.classicMode.startBattle([SpeciesId.RAYQUAZA, SpeciesId.SHUCKLE]);
|
|
||||||
|
|
||||||
const [enemy1, enemy2] = game.scene.getEnemyField();
|
|
||||||
|
|
||||||
game.move.use(MoveId.HYPER_VOICE, BattlerIndex.PLAYER);
|
|
||||||
game.move.use(MoveId.SPIKES, BattlerIndex.PLAYER_2);
|
|
||||||
await game.toEndOfTurn();
|
|
||||||
|
|
||||||
expect(enemy1.isFainted()).toBe(true);
|
|
||||||
expect(enemy2.isFainted()).toBe(true);
|
|
||||||
expect(game.scene.arena.getTagOnSide(ArenaTrapTag, ArenaTagSide.ENEMY)).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,143 +0,0 @@
|
|||||||
import type { ArenaTrapTag } from "#data/arena-tag";
|
|
||||||
import { AbilityId } from "#enums/ability-id";
|
|
||||||
import { ArenaTagSide } from "#enums/arena-tag-side";
|
|
||||||
import { ArenaTagType } from "#enums/arena-tag-type";
|
|
||||||
import { MoveId } from "#enums/move-id";
|
|
||||||
import { SpeciesId } from "#enums/species-id";
|
|
||||||
import { StatusEffect } from "#enums/status-effect";
|
|
||||||
import type { SessionSaveData } from "#system/game-data";
|
|
||||||
import { GameData } from "#system/game-data";
|
|
||||||
import { GameManager } from "#test/test-utils/game-manager";
|
|
||||||
import { decrypt, encrypt } from "#utils/data";
|
|
||||||
import Phaser from "phaser";
|
|
||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
describe("Moves - Toxic Spikes", () => {
|
|
||||||
let phaserGame: Phaser.Game;
|
|
||||||
let game: GameManager;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
phaserGame = new Phaser.Game({
|
|
||||||
type: Phaser.HEADLESS,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
game.phaseInterceptor.restoreOg();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
game = new GameManager(phaserGame);
|
|
||||||
game.override
|
|
||||||
.battleStyle("single")
|
|
||||||
.startingWave(5)
|
|
||||||
.enemySpecies(SpeciesId.RATTATA)
|
|
||||||
.enemyAbility(AbilityId.BALL_FETCH)
|
|
||||||
.ability(AbilityId.BALL_FETCH)
|
|
||||||
.enemyMoveset(MoveId.SPLASH)
|
|
||||||
.moveset([MoveId.TOXIC_SPIKES, MoveId.SPLASH, MoveId.ROAR, MoveId.COURT_CHANGE]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not affect the opponent if they do not switch", async () => {
|
|
||||||
await game.classicMode.runToSummon([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]);
|
|
||||||
|
|
||||||
const enemy = game.scene.getEnemyField()[0];
|
|
||||||
|
|
||||||
game.move.select(MoveId.TOXIC_SPIKES);
|
|
||||||
await game.phaseInterceptor.to("TurnEndPhase");
|
|
||||||
game.move.select(MoveId.SPLASH);
|
|
||||||
await game.phaseInterceptor.to("TurnEndPhase");
|
|
||||||
game.doSwitchPokemon(1);
|
|
||||||
await game.phaseInterceptor.to("TurnEndPhase");
|
|
||||||
|
|
||||||
expect(enemy.hp).toBe(enemy.getMaxHp());
|
|
||||||
expect(enemy.status?.effect).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should poison the opponent if they switch into 1 layer", async () => {
|
|
||||||
await game.classicMode.runToSummon([SpeciesId.MIGHTYENA]);
|
|
||||||
|
|
||||||
game.move.select(MoveId.TOXIC_SPIKES);
|
|
||||||
await game.phaseInterceptor.to("TurnEndPhase");
|
|
||||||
game.move.select(MoveId.ROAR);
|
|
||||||
await game.phaseInterceptor.to("TurnEndPhase");
|
|
||||||
|
|
||||||
const enemy = game.scene.getEnemyField()[0];
|
|
||||||
|
|
||||||
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
|
|
||||||
expect(enemy.status?.effect).toBe(StatusEffect.POISON);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should badly poison the opponent if they switch into 2 layers", async () => {
|
|
||||||
await game.classicMode.runToSummon([SpeciesId.MIGHTYENA]);
|
|
||||||
|
|
||||||
game.move.select(MoveId.TOXIC_SPIKES);
|
|
||||||
await game.phaseInterceptor.to("TurnEndPhase");
|
|
||||||
game.move.select(MoveId.TOXIC_SPIKES);
|
|
||||||
await game.phaseInterceptor.to("TurnEndPhase");
|
|
||||||
game.move.select(MoveId.ROAR);
|
|
||||||
await game.phaseInterceptor.to("TurnEndPhase");
|
|
||||||
|
|
||||||
const enemy = game.scene.getEnemyField()[0];
|
|
||||||
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
|
|
||||||
expect(enemy.status?.effect).toBe(StatusEffect.TOXIC);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be removed if a grounded poison pokemon switches in", async () => {
|
|
||||||
await game.classicMode.runToSummon([SpeciesId.MUK, SpeciesId.PIDGEY]);
|
|
||||||
|
|
||||||
const muk = game.field.getPlayerPokemon();
|
|
||||||
|
|
||||||
game.move.select(MoveId.TOXIC_SPIKES);
|
|
||||||
await game.toNextTurn();
|
|
||||||
// also make sure the toxic spikes are removed even if the pokemon
|
|
||||||
// that set them up is the one switching in (https://github.com/pagefaultgames/pokerogue/issues/935)
|
|
||||||
game.move.select(MoveId.COURT_CHANGE);
|
|
||||||
await game.toNextTurn();
|
|
||||||
game.doSwitchPokemon(1);
|
|
||||||
await game.toNextTurn();
|
|
||||||
game.doSwitchPokemon(1);
|
|
||||||
await game.toNextTurn();
|
|
||||||
game.move.select(MoveId.SPLASH);
|
|
||||||
await game.toNextTurn();
|
|
||||||
|
|
||||||
expect(muk.isFullHp()).toBe(true);
|
|
||||||
expect(muk.status?.effect).toBeUndefined();
|
|
||||||
expect(game.scene.arena.tags.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shouldn't create multiple layers per use in doubles", async () => {
|
|
||||||
await game.classicMode.runToSummon([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]);
|
|
||||||
|
|
||||||
game.move.select(MoveId.TOXIC_SPIKES);
|
|
||||||
await game.phaseInterceptor.to("TurnEndPhase");
|
|
||||||
|
|
||||||
const arenaTags = game.scene.arena.getTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.ENEMY) as ArenaTrapTag;
|
|
||||||
expect(arenaTags.tagType).toBe(ArenaTagType.TOXIC_SPIKES);
|
|
||||||
expect(arenaTags.layers).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should persist through reload", async () => {
|
|
||||||
game.override.startingWave(1);
|
|
||||||
const gameData = new GameData();
|
|
||||||
|
|
||||||
await game.classicMode.runToSummon([SpeciesId.MIGHTYENA]);
|
|
||||||
|
|
||||||
game.move.select(MoveId.TOXIC_SPIKES);
|
|
||||||
await game.phaseInterceptor.to("TurnEndPhase");
|
|
||||||
game.move.select(MoveId.SPLASH);
|
|
||||||
await game.doKillOpponents();
|
|
||||||
await game.phaseInterceptor.to("BattleEndPhase");
|
|
||||||
await game.toNextWave();
|
|
||||||
|
|
||||||
const sessionData: SessionSaveData = gameData.getSessionSaveData();
|
|
||||||
localStorage.setItem("sessionTestData", encrypt(JSON.stringify(sessionData), true));
|
|
||||||
const recoveredData: SessionSaveData = gameData.parseSessionData(
|
|
||||||
decrypt(localStorage.getItem("sessionTestData")!, true),
|
|
||||||
);
|
|
||||||
await gameData.loadSession(0, recoveredData);
|
|
||||||
|
|
||||||
expect(sessionData.arena.tags).toEqual(recoveredData.arena.tags);
|
|
||||||
localStorage.removeItem("sessionTestData");
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,4 +1,3 @@
|
|||||||
import { globalScene } from "#app/global-scene";
|
|
||||||
import { Status } from "#data/status-effect";
|
import { Status } from "#data/status-effect";
|
||||||
import { AbilityId } from "#enums/ability-id";
|
import { AbilityId } from "#enums/ability-id";
|
||||||
import { BattleType } from "#enums/battle-type";
|
import { BattleType } from "#enums/battle-type";
|
||||||
@ -179,18 +178,13 @@ describe("Moves - Whirlwind", () => {
|
|||||||
const eligibleEnemy = enemyParty.filter(p => p.hp > 0 && p.isAllowedInBattle());
|
const eligibleEnemy = enemyParty.filter(p => p.hp > 0 && p.isAllowedInBattle());
|
||||||
expect(eligibleEnemy.length).toBe(1);
|
expect(eligibleEnemy.length).toBe(1);
|
||||||
|
|
||||||
// Spy on the queueMessage function
|
|
||||||
const queueSpy = vi.spyOn(globalScene.phaseManager, "queueMessage");
|
|
||||||
|
|
||||||
// Player uses Whirlwind; opponent uses Splash
|
// Player uses Whirlwind; opponent uses Splash
|
||||||
game.move.select(MoveId.WHIRLWIND);
|
game.move.select(MoveId.WHIRLWIND);
|
||||||
await game.move.selectEnemyMove(MoveId.SPLASH);
|
await game.move.selectEnemyMove(MoveId.SPLASH);
|
||||||
await game.toNextTurn();
|
await game.toNextTurn();
|
||||||
|
|
||||||
// Verify that the failure message is displayed for Whirlwind
|
const player = game.field.getPlayerPokemon();
|
||||||
expect(queueSpy).toHaveBeenCalledWith(expect.stringContaining("But it failed"));
|
expect(player).toHaveUsedMove({ move: MoveId.WHIRLWIND, result: MoveResult.FAIL });
|
||||||
// Verify the opponent's Splash message
|
|
||||||
expect(queueSpy).toHaveBeenCalledWith(expect.stringContaining("But nothing happened!"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not pull in the other trainer's pokemon in a partner trainer battle", async () => {
|
it("should not pull in the other trainer's pokemon in a partner trainer battle", async () => {
|
||||||
|
82
test/system/rename-run.test.ts
Normal file
82
test/system/rename-run.test.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import * as account from "#app/account";
|
||||||
|
import * as bypassLoginModule from "#app/global-vars/bypass-login";
|
||||||
|
import { pokerogueApi } from "#app/plugins/api/pokerogue-api";
|
||||||
|
import type { SessionSaveData } from "#app/system/game-data";
|
||||||
|
import { AbilityId } from "#enums/ability-id";
|
||||||
|
import { MoveId } from "#enums/move-id";
|
||||||
|
import { GameManager } from "#test/test-utils/game-manager";
|
||||||
|
import Phaser from "phaser";
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
describe("System - Rename Run", () => {
|
||||||
|
let phaserGame: Phaser.Game;
|
||||||
|
let game: GameManager;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
phaserGame = new Phaser.Game({
|
||||||
|
type: Phaser.HEADLESS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
game = new GameManager(phaserGame);
|
||||||
|
game.override
|
||||||
|
.moveset([MoveId.SPLASH])
|
||||||
|
.battleStyle("single")
|
||||||
|
.enemyAbility(AbilityId.BALL_FETCH)
|
||||||
|
.enemyMoveset(MoveId.SPLASH);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
game.phaseInterceptor.restoreOg();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renameSession", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(false);
|
||||||
|
vi.spyOn(account, "updateUserInfo").mockImplementation(async () => [true, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false if slotId < 0", async () => {
|
||||||
|
const result = await game.scene.gameData.renameSession(-1, "Named Run");
|
||||||
|
|
||||||
|
expect(result).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false if getSession returns null", async () => {
|
||||||
|
vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue(null as unknown as SessionSaveData);
|
||||||
|
|
||||||
|
const result = await game.scene.gameData.renameSession(-1, "Named Run");
|
||||||
|
|
||||||
|
expect(result).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true if bypassLogin is true", async () => {
|
||||||
|
vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(true);
|
||||||
|
vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData);
|
||||||
|
|
||||||
|
const result = await game.scene.gameData.renameSession(0, "Named Run");
|
||||||
|
|
||||||
|
expect(result).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false if api returns error", async () => {
|
||||||
|
vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData);
|
||||||
|
vi.spyOn(pokerogueApi.savedata.session, "update").mockResolvedValue("Unknown Error!");
|
||||||
|
|
||||||
|
const result = await game.scene.gameData.renameSession(0, "Named Run");
|
||||||
|
|
||||||
|
expect(result).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true if api is succesfull", async () => {
|
||||||
|
vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData);
|
||||||
|
vi.spyOn(pokerogueApi.savedata.session, "update").mockResolvedValue("");
|
||||||
|
|
||||||
|
const result = await game.scene.gameData.renameSession(0, "Named Run");
|
||||||
|
|
||||||
|
expect(result).toEqual(true);
|
||||||
|
expect(account.updateUserInfo).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
80
test/test-utils/matchers/to-have-arena-tag.ts
Normal file
80
test/test-utils/matchers/to-have-arena-tag.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag";
|
||||||
|
import type { ArenaTagSide } from "#enums/arena-tag-side";
|
||||||
|
import { 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 { getEnumStr, getOnelineDiffStr, stringifyEnumArray } from "#test/test-utils/string-utils";
|
||||||
|
import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils";
|
||||||
|
import type { NonFunctionPropertiesRecursive } from "#types/type-helpers";
|
||||||
|
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
|
||||||
|
|
||||||
|
export type toHaveArenaTagOptions<T extends ArenaTagType> = OneOther<ArenaTagTypeMap[T], "tagType">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 expectedType - The {@linkcode ArenaTagType} of the desired tag, or a partially-filled object
|
||||||
|
* containing the desired properties
|
||||||
|
* @param side - The {@linkcode ArenaTagSide | side of the field} the tag should affect, or
|
||||||
|
* {@linkcode ArenaTagSide.BOTH} to check both sides
|
||||||
|
* @returns The result of the matching
|
||||||
|
*/
|
||||||
|
export function toHaveArenaTag<T extends ArenaTagType>(
|
||||||
|
this: MatcherState,
|
||||||
|
received: unknown,
|
||||||
|
// simplified types used for brevity; full overloads are in `vitest.d.ts`
|
||||||
|
expectedType: T | (Partial<NonFunctionPropertiesRecursive<ArenaTag>> & { tagType: T; side: ArenaTagSide }),
|
||||||
|
side?: ArenaTagSide,
|
||||||
|
): SyncExpectationResult {
|
||||||
|
if (!isGameManagerInstance(received)) {
|
||||||
|
return {
|
||||||
|
pass: this.isNot,
|
||||||
|
message: () => `Expected to recieve a GameManager, but got ${receivedStr(received)}!`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!received.scene?.arena) {
|
||||||
|
return {
|
||||||
|
pass: false,
|
||||||
|
message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof expectedType === "string") {
|
||||||
|
// Coerce lone `tagType`s into objects
|
||||||
|
// Bangs are ok as we enforce safety via overloads
|
||||||
|
expectedType = { tagType: expectedType, 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 === expectedType.tagType, expectedType.side);
|
||||||
|
if (!tags.length) {
|
||||||
|
const expectedStr = getEnumStr(ArenaTagType, expectedType.tagType);
|
||||||
|
return {
|
||||||
|
pass: false,
|
||||||
|
message: () => `Expected the arena to have a tag matching ${expectedStr}, but it didn't!`,
|
||||||
|
expected: getEnumStr(ArenaTagType, expectedType.tagType),
|
||||||
|
actual: stringifyEnumArray(
|
||||||
|
ArenaTagType,
|
||||||
|
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, expectedType, [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const expectedStr = getOnelineDiffStr.call(this, expectedType);
|
||||||
|
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: expectedType,
|
||||||
|
actual: tags,
|
||||||
|
};
|
||||||
|
}
|
@ -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") {
|
||||||
|
Loading…
Reference in New Issue
Block a user