Merge branch 'beta' into reduce-gameData

This commit is contained in:
Sirz Benjie 2025-09-10 14:04:41 -05:00 committed by GitHub
commit 6d0a888b0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 415 additions and 123 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

View File

@ -7003,6 +7003,7 @@ export function initAbilities() {
.attr(StatMultiplierAbAttr, Stat.SPATK, 1.5)
.condition(getWeatherCondition(WeatherType.SUNNY, WeatherType.HARSH_SUN)),
new Ability(AbilityId.QUICK_FEET, 4)
// TODO: This should ignore the speed drop, not manually undo it
.conditionalAttr(pokemon => pokemon.status ? pokemon.status.effect === StatusEffect.PARALYSIS : false, StatMultiplierAbAttr, Stat.SPD, 2)
.conditionalAttr(pokemon => !!pokemon.status || pokemon.hasAbility(AbilityId.COMATOSE), StatMultiplierAbAttr, Stat.SPD, 1.5),
new Ability(AbilityId.NORMALIZE, 4)

View File

@ -2159,7 +2159,7 @@ export class HighestStatBoostTag extends AbilityBattlerTag {
null,
false,
null,
true,
false,
);
}

View File

@ -4067,7 +4067,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
*/
getCriticalHitResult(source: Pokemon, move: Move): boolean {
if (move.hasAttr("FixedDamageAttr")) {
// fixed damage moves (Dragon Rage, etc.) will nevet crit
// fixed damage moves (Dragon Rage, etc.) will never crit
return false;
}
@ -5662,7 +5662,6 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
/**
* Generates a random number using the current battle's seed, or the global seed if `globalScene.currentBattle` is falsy
* <!-- @import "../battle".Battle -->
* This calls either {@linkcode BattleScene.randBattleSeedInt}({@linkcode range}, {@linkcode min}) in `src/battle-scene.ts`
* which calls {@linkcode Battle.randSeedInt}({@linkcode range}, {@linkcode min}) in `src/battle.ts`
* which calls {@linkcode randSeedInt randSeedInt}({@linkcode range}, {@linkcode min}) in `src/utils.ts`,

View File

@ -154,6 +154,7 @@ export class LoadingScene extends SceneBase {
this.loadImage("select_gen_cursor", "ui");
this.loadImage("select_gen_cursor_highlight", "ui");
this.loadImage("language_icon", "ui");
this.loadImage("saving_icon", "ui");
this.loadImage("discord", "ui");
this.loadImage("google", "ui");

View File

@ -0,0 +1,101 @@
import { globalScene } from "#app/global-scene";
import type { SettingsDisplayUiHandler } from "#ui/settings-display-ui-handler";
import i18next from "i18next";
const cancelHandler = () => {
globalScene.ui.revertMode();
const handler = globalScene.ui.getHandler();
// Reset the cursor to the current language, if in the settings menu
if (handler && typeof (handler as SettingsDisplayUiHandler).setOptionCursor === "function") {
(handler as SettingsDisplayUiHandler).setOptionCursor(-1, 0, true);
}
};
const changeLocaleHandler = (locale: string): boolean => {
try {
i18next.changeLanguage(locale);
localStorage.setItem("prLang", locale);
cancelHandler();
// Reload the whole game to apply the new locale since also some constants are translated
window.location.reload();
return true;
} catch (error) {
console.error("Error changing locale:", error);
return false;
}
};
export const languageOptions = [
{
label: "English",
handler: () => changeLocaleHandler("en"),
},
{
label: "Español (ES)",
handler: () => changeLocaleHandler("es-ES"),
},
{
label: "Español (LATAM)",
handler: () => changeLocaleHandler("es-MX"),
},
{
label: "Français",
handler: () => changeLocaleHandler("fr"),
},
{
label: "Deutsch",
handler: () => changeLocaleHandler("de"),
},
{
label: "Italiano",
handler: () => changeLocaleHandler("it"),
},
{
label: "Português (BR)",
handler: () => changeLocaleHandler("pt-BR"),
},
{
label: "한국어",
handler: () => changeLocaleHandler("ko"),
},
{
label: "日本語",
handler: () => changeLocaleHandler("ja"),
},
{
label: "简体中文",
handler: () => changeLocaleHandler("zh-CN"),
},
{
label: "繁體中文",
handler: () => changeLocaleHandler("zh-TW"),
},
{
label: "Català (Needs Help)",
handler: () => changeLocaleHandler("ca"),
},
{
label: "Türkçe (Needs Help)",
handler: () => changeLocaleHandler("tr"),
},
{
label: "Русский (Needs Help)",
handler: () => changeLocaleHandler("ru"),
},
{
label: "Dansk (Needs Help)",
handler: () => changeLocaleHandler("da"),
},
{
label: "Română (Needs Help)",
handler: () => changeLocaleHandler("ro"),
},
{
label: "Tagalog (Needs Help)",
handler: () => changeLocaleHandler("tl"),
},
{
label: i18next.t("settings:back"),
handler: () => cancelHandler(),
},
];

View File

@ -6,10 +6,10 @@ import { PlayerGender } from "#enums/player-gender";
import { ShopCursorTarget } from "#enums/shop-cursor-target";
import { UiMode } from "#enums/ui-mode";
import { CandyUpgradeNotificationChangedEvent } from "#events/battle-scene";
import type { SettingsUiHandler } from "#ui/settings-ui-handler";
import { updateWindowType } from "#ui/ui-theme";
import { isLocal } from "#utils/common";
import i18next from "i18next";
import { languageOptions } from "./settings-language";
const VOLUME_OPTIONS: SettingOption[] = [
{
@ -911,98 +911,8 @@ export function setSetting(setting: string, value: number): boolean {
break;
case SettingKeys.Language:
if (value && globalScene.ui) {
const cancelHandler = () => {
globalScene.ui.revertMode();
(globalScene.ui.getHandler() as SettingsUiHandler).setOptionCursor(-1, 0, true);
};
const changeLocaleHandler = (locale: string): boolean => {
try {
i18next.changeLanguage(locale);
localStorage.setItem("prLang", locale);
cancelHandler();
// Reload the whole game to apply the new locale since also some constants are translated
window.location.reload();
return true;
} catch (error) {
console.error("Error changing locale:", error);
return false;
}
};
globalScene.ui.setOverlayMode(UiMode.OPTION_SELECT, {
options: [
{
label: "English",
handler: () => changeLocaleHandler("en"),
},
{
label: "Español (ES)",
handler: () => changeLocaleHandler("es-ES"),
},
{
label: "Español (LATAM)",
handler: () => changeLocaleHandler("es-MX"),
},
{
label: "Français",
handler: () => changeLocaleHandler("fr"),
},
{
label: "Deutsch",
handler: () => changeLocaleHandler("de"),
},
{
label: "Italiano",
handler: () => changeLocaleHandler("it"),
},
{
label: "Português (BR)",
handler: () => changeLocaleHandler("pt-BR"),
},
{
label: "한국어",
handler: () => changeLocaleHandler("ko"),
},
{
label: "日本語",
handler: () => changeLocaleHandler("ja"),
},
{
label: "简体中文",
handler: () => changeLocaleHandler("zh-CN"),
},
{
label: "繁體中文",
handler: () => changeLocaleHandler("zh-TW"),
},
{
label: "Català (Needs Help)",
handler: () => changeLocaleHandler("ca"),
},
{
label: "Türkçe (Needs Help)",
handler: () => changeLocaleHandler("tr"),
},
{
label: "Русский (Needs Help)",
handler: () => changeLocaleHandler("ru"),
},
{
label: "Dansk (Needs Help)",
handler: () => changeLocaleHandler("da"),
},
{
label: "Română (Needs Help)",
handler: () => changeLocaleHandler("ro"),
},
{
label: "Tagalog (Needs Help)",
handler: () => changeLocaleHandler("tl"),
},
{
label: i18next.t("settings:back"),
handler: () => cancelHandler(),
},
],
options: languageOptions,
maxOptions: 7,
});
return false;

View File

@ -5,9 +5,9 @@ import type Phaser from "phaser";
const repeatInputDelayMillis = 250;
export class TouchControl {
events: Phaser.Events.EventEmitter;
readonly events: Phaser.Events.EventEmitter;
private buttonLock: string[] = [];
private inputInterval: NodeJS.Timeout[] = [];
private readonly inputInterval: NodeJS.Timeout[] = [];
/** Whether touch controls are disabled */
private disabled = false;
/** Whether the last touch event has finished before disabling */
@ -61,12 +61,46 @@ export class TouchControl {
* event, removes the keydown state, and removes the 'active' class from the node and the last touched element.
*/
bindKey(node: HTMLElement, key: string) {
node.addEventListener("touchstart", (event: TouchEvent) => {
// Handle touch events for touch devices
this.touchButtonDown(node, key);
event.preventDefault();
// prevent pointer event from also firing (undefined just sets presence of custom attribute)
if (event.currentTarget instanceof HTMLElement) {
event.currentTarget.dataset.skipPointerEvent = undefined;
}
});
node.addEventListener("pointerdown", event => {
const currentTarget = event.currentTarget;
if (currentTarget instanceof HTMLElement && "skipPointerDown" in currentTarget.dataset) {
return;
}
event.preventDefault();
this.touchButtonDown(node, key);
});
node.addEventListener("touchcancel", (event: TouchEvent) => {
if (event.currentTarget instanceof HTMLElement && "skipPointerDown" in event.currentTarget.dataset) {
delete event.currentTarget.dataset.skipPointerEvent;
}
});
node.addEventListener("touchend", (event: TouchEvent) => {
event.preventDefault();
this.touchButtonUp(node, key, event.target?.["id"]);
if (event.currentTarget instanceof HTMLElement && "skipPointerDown" in event.currentTarget.dataset) {
// allow pointer event to once again fire
delete event.currentTarget.dataset.skipPointerEvent;
event.currentTarget.dataset.skipPointerUp = undefined;
}
});
node.addEventListener("pointerup", event => {
if (event.currentTarget instanceof HTMLElement && "skipPointerUp" in event.currentTarget.dataset) {
delete event.currentTarget.dataset.skipPointerUp;
return;
}
event.preventDefault();
this.touchButtonUp(node, key, event.target?.["id"]);
});

View File

@ -2,6 +2,7 @@ import { pokerogueApi } from "#api/pokerogue-api";
import { globalScene } from "#app/global-scene";
import { TextStyle } from "#enums/text-style";
import { UiMode } from "#enums/ui-mode";
import { languageOptions } from "#system/settings-language";
import type { OptionSelectItem } from "#ui/handlers/abstract-option-select-ui-handler";
import type { InputFieldConfig } from "#ui/handlers/form-modal-ui-handler";
import { FormModalUiHandler } from "#ui/handlers/form-modal-ui-handler";
@ -31,6 +32,7 @@ export class LoginFormUiHandler extends FormModalUiHandler {
private discordImage: Phaser.GameObjects.Image;
private usernameInfoImage: Phaser.GameObjects.Image;
private saveDownloadImage: Phaser.GameObjects.Image;
private changeLanguageImage: Phaser.GameObjects.Image;
private externalPartyContainer: Phaser.GameObjects.Container;
private infoContainer: Phaser.GameObjects.Container;
private externalPartyBg: Phaser.GameObjects.NineSlice;
@ -82,8 +84,14 @@ export class LoginFormUiHandler extends FormModalUiHandler {
scale: 0.75,
});
this.changeLanguageImage = this.buildInteractableImage("language_icon", "change-language-icon", {
x: 40,
scale: 0.5,
});
this.infoContainer.add(this.usernameInfoImage);
this.infoContainer.add(this.saveDownloadImage);
this.infoContainer.add(this.changeLanguageImage);
this.getUi().add(this.infoContainer);
this.infoContainer.setVisible(false);
this.infoContainer.disableInteractive();
@ -163,13 +171,18 @@ export class LoginFormUiHandler extends FormModalUiHandler {
const [usernameInput, passwordInput] = this.inputs;
pokerogueApi.account.login({ username: usernameInput.text, password: passwordInput.text }).then(error => {
if (!error && originalLoginAction) {
originalLoginAction();
} else {
onFail(error);
}
});
pokerogueApi.account
.login({
username: usernameInput.text,
password: passwordInput.text,
})
.then(error => {
if (!error && originalLoginAction) {
originalLoginAction();
} else {
onFail(error);
}
});
}
};
@ -185,9 +198,13 @@ export class LoginFormUiHandler extends FormModalUiHandler {
this.infoContainer.setVisible(false);
this.setMouseCursorStyle("default"); //reset cursor
[this.discordImage, this.googleImage, this.usernameInfoImage, this.saveDownloadImage].forEach(img =>
img.off("pointerdown"),
);
[
this.discordImage,
this.googleImage,
this.usernameInfoImage,
this.saveDownloadImage,
this.changeLanguageImage,
].forEach(img => img.off("pointerdown"));
}
private processExternalProvider(config: ModalConfig): void {
@ -206,6 +223,7 @@ export class LoginFormUiHandler extends FormModalUiHandler {
this.getUi().moveTo(this.infoContainer, this.getUi().length - 1);
this.usernameInfoImage.setPositionRelative(this.infoContainer, 0, 0);
this.saveDownloadImage.setPositionRelative(this.infoContainer, 20, 0);
this.changeLanguageImage.setPositionRelative(this.infoContainer, 40, 0);
this.discordImage.on("pointerdown", () => {
const redirectUri = encodeURIComponent(`${import.meta.env.VITE_SERVER_URL}/auth/discord/callback`);
@ -288,6 +306,14 @@ export class LoginFormUiHandler extends FormModalUiHandler {
}
});
this.changeLanguageImage.on("pointerdown", () => {
globalScene.ui.setOverlayMode(UiMode.OPTION_SELECT, {
options: languageOptions,
maxOptions: 7,
delay: 1000,
});
});
this.externalPartyContainer.setAlpha(0);
globalScene.tweens.add({
targets: this.externalPartyContainer,

View File

@ -27,6 +27,8 @@ import type { toHaveBattlerTagOptions } from "#test/test-utils/matchers/to-have-
declare module "vitest" {
interface Assertion<T> {
// #region Generic Matchers
/**
* Check whether an array contains EXACTLY the given items (in any order).
*
@ -38,11 +40,20 @@ declare module "vitest" {
*/
toEqualArrayUnsorted(expected: T[]): void;
// #endregion Generic Matchers
// #region GameManager Matchers
/**
* Check if the {@linkcode GameManager} has shown the given message at least once in the current battle.
* @param expectedMessage - The expected message
*/
toHaveShownMessage(expectedMessage: string): void;
/**
* Check if the currently-running {@linkcode Phase} is of the given type.
* @param expectedPhase - The expected {@linkcode PhaseString}
*/
toBeAtPhase(expectedPhase: PhaseString): void;
// #endregion GameManager Matchers
// #region Arena Matchers

View File

@ -57,7 +57,7 @@ describe("Abilities - Arena Trap", () => {
await game.phaseInterceptor.to("CommandPhase");
expect(game.textInterceptor.logs).toContain(
expect(game).toHaveShownMessage(
i18next.t("abilityTriggers:arenaTrap", {
pokemonNameWithAffix: getPokemonNameWithAffix(enemy),
abilityName: allAbilities[AbilityId.ARENA_TRAP].name,

View File

@ -99,7 +99,7 @@ describe("Abilities - Cud Chew", () => {
expect(abDisplaySpy.mock.calls[1][2]).toBe(false);
// should display messgae
expect(game.textInterceptor.logs).toContain(
expect(game).toHaveShownMessage(
i18next.t("battle:hpIsFull", {
pokemonName: getPokemonNameWithAffix(farigiraf),
}),

View File

@ -54,7 +54,7 @@ describe("Ability - Truant", () => {
expect(player.getLastXMoves(1)[0]).toEqual(expect.objectContaining({ move: MoveId.NONE, result: MoveResult.FAIL }));
expect(enemy.hp).toBe(enemy.getMaxHp());
expect(game.textInterceptor.logs).toContain(
expect(game).toHaveShownMessage(
i18next.t("battlerTags:truantLapse", {
pokemonNameWithAffix: getPokemonNameWithAffix(player),
}),

View File

@ -8,6 +8,7 @@ import { toHaveFainted } from "#test/test-utils/matchers/to-have-fainted";
import { toHaveFullHp } from "#test/test-utils/matchers/to-have-full-hp";
import { toHaveHp } from "#test/test-utils/matchers/to-have-hp";
import { toHavePositionalTag } from "#test/test-utils/matchers/to-have-positional-tag";
import { toHaveShownMessage } from "#test/test-utils/matchers/to-have-shown-message";
import { toHaveStatStage } from "#test/test-utils/matchers/to-have-stat-stage";
import { toHaveStatusEffect } from "#test/test-utils/matchers/to-have-status-effect";
import { toHaveTakenDamage } from "#test/test-utils/matchers/to-have-taken-damage";
@ -25,6 +26,7 @@ import { expect } from "vitest";
expect.extend({
toEqualArrayUnsorted,
toHaveShownMessage,
toBeAtPhase,
toHaveWeather,
toHaveTerrain,

View File

@ -47,7 +47,7 @@ describe("Moves - Chilly Reception", () => {
expect(game.field.getPlayerPokemon()).toBe(meowth);
expect(slowking.isOnField()).toBe(false);
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
expect(game.textInterceptor.logs).toContain(
expect(game).toHaveShownMessage(
i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(slowking) }),
);
});
@ -110,7 +110,7 @@ describe("Moves - Chilly Reception", () => {
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
expect(game.field.getPlayerPokemon()).toBe(slowking);
expect(slowking.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(game.textInterceptor.logs).toContain(
expect(game).toHaveShownMessage(
i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(slowking) }),
);
});
@ -129,7 +129,7 @@ describe("Moves - Chilly Reception", () => {
expect(game.field.getPlayerPokemon()).toBe(meowth);
expect(slowking.isOnField()).toBe(false);
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
expect(game.textInterceptor.logs).not.toContain(
expect(game).not.toHaveShownMessage(
i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(slowking) }),
);
});

View File

@ -99,7 +99,7 @@ describe("Moves - Delayed Attacks", () => {
expectFutureSightActive(0);
const enemy = game.field.getEnemyPokemon();
expect(enemy).not.toHaveFullHp();
expect(game.textInterceptor.logs).toContain(
expect(game).toHaveShownMessage(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(enemy),
moveName: allMoves[move].name,
@ -227,7 +227,7 @@ describe("Moves - Delayed Attacks", () => {
expect(karp).toHaveFullHp();
expect(feebas).toHaveFullHp();
expect(game.textInterceptor.logs).not.toContain(
expect(game).not.toHaveShownMessage(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(karp),
moveName: allMoves[MoveId.FUTURE_SIGHT].name,
@ -256,7 +256,7 @@ describe("Moves - Delayed Attacks", () => {
await passTurns(2);
expect(enemy1).not.toHaveFullHp();
expect(game.textInterceptor.logs).toContain(
expect(game).toHaveShownMessage(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(enemy1),
moveName: allMoves[MoveId.FUTURE_SIGHT].name,
@ -284,7 +284,7 @@ describe("Moves - Delayed Attacks", () => {
expectFutureSightActive(0);
expect(enemy1).toHaveFullHp();
expect(game.textInterceptor.logs).not.toContain(
expect(game).not.toHaveShownMessage(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(enemy1),
moveName: allMoves[MoveId.FUTURE_SIGHT].name,
@ -321,7 +321,7 @@ describe("Moves - Delayed Attacks", () => {
expect(enemy1).toHaveFullHp();
expect(enemy2).not.toHaveFullHp();
expect(game.textInterceptor.logs).toContain(
expect(game).toHaveShownMessage(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(enemy2),
moveName: allMoves[MoveId.FUTURE_SIGHT].name,
@ -354,7 +354,7 @@ describe("Moves - Delayed Attacks", () => {
// Player Normalize was not applied due to being off field
const enemy = game.field.getEnemyPokemon();
expect(enemy).not.toHaveFullHp();
expect(game.textInterceptor.logs).toContain(
expect(game).toHaveShownMessage(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(enemy),
moveName: allMoves[MoveId.DOOM_DESIRE].name,

View File

@ -0,0 +1,104 @@
import { getPokemonNameWithAffix } from "#app/messages";
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Move - Laser Focus", () => {
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
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH)
.startingLevel(100)
.enemyLevel(100);
});
it("should make the user's next attack a guaranteed critical hit", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
game.move.use(MoveId.LASER_FOCUS);
await game.toNextTurn();
const feebas = game.field.getPlayerPokemon();
expect(feebas).toHaveBattlerTag(BattlerTagType.ALWAYS_CRIT);
expect(game).toHaveShownMessage(
i18next.t("battlerTags:laserFocusOnAdd", {
pokemonNameWithAffix: getPokemonNameWithAffix(feebas),
}),
);
const enemy = game.field.getEnemyPokemon();
const critSpy = vi.spyOn(enemy, "getCriticalHitResult");
game.move.use(MoveId.TACKLE);
await game.toEndOfTurn();
expect(critSpy).toHaveLastReturnedWith(true);
});
it("should disappear at the end of the next turn", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const feebas = game.field.getPlayerPokemon();
game.move.use(MoveId.LASER_FOCUS);
await game.toNextTurn();
expect(feebas).toHaveBattlerTag(BattlerTagType.ALWAYS_CRIT);
game.move.use(MoveId.SPLASH);
await game.toNextTurn();
expect(feebas).not.toHaveBattlerTag(BattlerTagType.ALWAYS_CRIT);
const enemy = game.field.getEnemyPokemon();
const critSpy = vi.spyOn(enemy, "getCriticalHitResult");
game.move.use(MoveId.TACKLE);
await game.toEndOfTurn();
expect(critSpy).toHaveLastReturnedWith(false);
});
it("should boost all attacks until the end of the next turn", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
game.move.use(MoveId.LASER_FOCUS);
await game.toNextTurn();
const enemy = game.field.getEnemyPokemon();
const critSpy = vi.spyOn(enemy, "getCriticalHitResult");
game.move.use(MoveId.TACKLE);
await game.move.forceEnemyMove(MoveId.INSTRUCT);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
expect(critSpy).toHaveReturnedTimes(2);
expect(critSpy).toHaveNthReturnedWith(1, true);
expect(critSpy).toHaveNthReturnedWith(2, true);
});
});

View File

@ -0,0 +1,52 @@
import { loggedInUser } from "#app/account";
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe.each<{ name: string; move: MoveId; message: () => string }>([
{ name: "Splash", move: MoveId.SPLASH, message: () => i18next.t("moveTriggers:splash") },
{
name: "Celebrate",
move: MoveId.CELEBRATE,
message: () => i18next.t("moveTriggers:celebrate", { playerName: loggedInUser?.username }),
},
])("Move - $name", ({ move, message }) => {
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
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.TACKLE)
.startingLevel(100)
.enemyLevel(100);
});
it("should show a message on use", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
game.move.use(move);
await game.toEndOfTurn();
expect(game).toHaveShownMessage(message());
});
});

View File

@ -55,7 +55,7 @@ describe("Move - Wish", () => {
await game.toEndOfTurn();
expect(game).toHavePositionalTag(PositionalTagType.WISH, 0);
expect(game.textInterceptor.logs).toContain(
expect(game).toHaveShownMessage(
i18next.t("arenaTag:wishTagOnAdd", {
pokemonNameWithAffix: getPokemonNameWithAffix(alomomola),
}),
@ -165,7 +165,7 @@ describe("Move - Wish", () => {
// Wish went away without doing anything
expect(game).toHavePositionalTag(PositionalTagType.WISH, 0);
expect(game.textInterceptor.logs).not.toContain(
expect(game).not.toHaveShownMessage(
i18next.t("arenaTag:wishTagOnAdd", {
pokemonNameWithAffix: getPokemonNameWithAffix(blissey),
}),

View File

@ -342,7 +342,11 @@ export class OverridesHelper extends GameManagerHelper {
/**
* Force random critical hit rolls to always or never suceed.
* @param crits - `true` to guarantee crits on eligible moves, `false` to force rolls to fail, `null` to disable override
* @remarks This does not bypass effects that guarantee or block critical hits; it merely mocks the chance-based rolls.
* @remarks
* This does not change any effects that guarantee or block critical hits;
* it merely mocks any chance-based rolls not already at 100%. \
* For instance, a Pokemon at +3 crit stages will still critically hit with the override set to `false`,
* whereas one at +2 crit stages (a 50% chance) will not.
* @returns `this`
*/
public criticalHits(crits: boolean | null): this {

View File

@ -0,0 +1,43 @@
// biome-ignore lint/correctness/noUnusedImports: TSDoc
import type { GameManager } from "#test/test-utils/game-manager";
import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils";
import { truncateString } from "#utils/common";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher to check if the {@linkcode GameManager} has shown the given message at least once.
* @param received - The object to check. Should be the current {@linkcode GameManager}.
* @param expectedMessage - The expected message
* @returns The result of the matching
*/
export function toHaveShownMessage(
this: MatcherState,
received: unknown,
expectedMessage: string,
): SyncExpectationResult {
if (!isGameManagerInstance(received)) {
return {
pass: this.isNot,
message: () => `Expected to receive a GameManager, but got ${receivedStr(received)}!`,
};
}
if (!received.textInterceptor) {
return {
pass: this.isNot,
message: () => "Expected GameManager.TextInterceptor to be defined!",
};
}
// Pass if any of the matching tags meet our criteria
const pass = received.textInterceptor.logs.includes(expectedMessage);
return {
pass,
message: () =>
pass
? `Expected the GameManager to NOT have shown the message ${truncateString(expectedMessage, 30)}, but it did!`
: `Expected the GameManager to have shown the message ${truncateString(expectedMessage, 30)}, but it didn't!`,
expected: expectedMessage,
actual: received.textInterceptor.logs,
};
}

View File

@ -13,6 +13,10 @@
{
"tagName": "@module",
"syntaxKind": "modifier"
},
{
"tagName": "@interface",
"syntaxKind": "modifier"
}
]
}