diff --git a/.gitignore b/.gitignore index c22d0b2ce4c..9d96ed04a15 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ coverage /dependency-graph.svg /.vs + + +# Script outputs +./*.csv \ No newline at end of file diff --git a/public/locales b/public/locales index 0e5c6096ba2..6b3f37cb351 160000 --- a/public/locales +++ b/public/locales @@ -1 +1 @@ -Subproject commit 0e5c6096ba26f6b87aed1aab3fe9b0b23f6cbb7b +Subproject commit 6b3f37cb351552721232f4dabefa17bddb5b9004 diff --git a/scripts/find_sprite_variant_mismatches.py b/scripts/find_sprite_variant_mismatches.py new file mode 100644 index 00000000000..483695fdb66 --- /dev/null +++ b/scripts/find_sprite_variant_mismatches.py @@ -0,0 +1,98 @@ +""" +Validates the contents of the variant's masterlist file and identifies +any mismatched entries for the sprite of the same key between front, back, exp, exp back, and female. + +This will create a csv file that contains all of the entries with mismatches. + +An empty entry means that there was not a mismatch for that version of the sprite (meaning it matches front). +""" + +import sys + +if sys.version_info < (3, 7): + msg = "This script requires Python 3.7+" + raise RuntimeError(msg) + +import json +import os +import csv +from dataclasses import dataclass, field +from typing import Literal as L + +MASTERLIST_PATH = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "public", "images", "pokemon", "variant", "_masterlist.json" +) +DEFAULT_OUTPUT_PATH = "sprite-mismatches.csv" + + +@dataclass(order=True) +class Sprite: + key: str = field(compare=False) + front: list[int] = field(default_factory=list, compare=False) + back: list[int] = field(default_factory=list, compare=False) + female: list[int] = field(default_factory=list, compare=False) + exp: list[int] = field(default_factory=list, compare=False) + expback: list[int] = field(default_factory=list, compare=False) + sortedKey: tuple[int] | tuple[int, str] = field(init=False, repr=False, compare=True) + + def as_row(self) -> tuple[str, list[int] | L[""], list[int] | L[""], list[int] | L[""], list[int] | L[""], list[int] | L[""]]: + """return sprite information as a tuple for csv writing""" + return (self.key, self.front or "", self.back or "", self.exp or "", self.expback or "", self.female or "") + + def is_mismatch(self) -> bool: + """return True if the female, back, or exp sprites do not match the front""" + for val in [self.back, self.exp, self.expback, self.female]: + if val != [] and val != self.front: + return True + return False + + def __post_init__(self): + split = self.key.split("-", maxsplit=1) + self.sortedKey = (int(split[0]), split[1]) if len(split) == 2 else (int(split[0]),) + + +def make_mismatch_sprite_list(path): + with open(path, "r") as f: + masterlist: dict = json.load(f) + + # Go through the keys in "front" and "back" and make sure they match the masterlist + back_data: dict[str, list[int]] = masterlist.pop("back", {}) + exp_data: dict[str, list[int]] = masterlist.pop("exp", {}) + exp_back_data: dict[str, list[int]] = exp_data.get("back", []) + female_data: dict[str, list[int]] = masterlist.pop("female", {}) + + sprites: list[Sprite] = [] + + for key, item in masterlist.items(): + sprite = Sprite( + key, front=item, back=back_data.get(key, []), exp=exp_data.get(key, []), expback=exp_back_data.get(key, []), female=female_data.get(key, []) + ) + if sprite.is_mismatch(): + sprites.append(sprite) + + return sprites + + +def write_mismatch_csv(filename: str, mismatches: list[Sprite]): + with open(filename, "w", newline="") as csvfile: + writer = csv.writer(csvfile) + writer.writerow(["key", "front", "back", "exp", "expback", "female"]) + for sprite in sorted(mismatches): + writer.writerow(sprite.as_row()) + + +if __name__ == "__main__": + import argparse + + p = argparse.ArgumentParser("find_sprite_variant_mismatches", description=__doc__) + + p.add_argument( + "-o", + "--output", + default=DEFAULT_OUTPUT_PATH, + help=f"The path to a file to save the output file. If not specified, will write to {DEFAULT_OUTPUT_PATH}.", + ) + p.add_argument("--masterlist", default=MASTERLIST_PATH, help=f"The path to the masterlist file to validate. Defaults to {MASTERLIST_PATH}.") + args = p.parse_args() + mismatches = make_mismatch_sprite_list(args.masterlist) + write_mismatch_csv(args.output, mismatches) diff --git a/src/data/weather.ts b/src/data/weather.ts index fb9528f5664..362663eb8c1 100644 --- a/src/data/weather.ts +++ b/src/data/weather.ts @@ -205,6 +205,28 @@ export function getWeatherClearMessage(weatherType: WeatherType): string | null return null; } +export function getLegendaryWeatherContinuesMessage(weatherType: WeatherType): string | null { + switch (weatherType) { + case WeatherType.HARSH_SUN: + return i18next.t("weather:harshSunContinueMessage"); + case WeatherType.HEAVY_RAIN: + return i18next.t("weather:heavyRainContinueMessage"); + case WeatherType.STRONG_WINDS: + return i18next.t("weather:strongWindsContinueMessage"); + } + return null; +} + +export function getWeatherBlockMessage(weatherType: WeatherType): string { + switch (weatherType) { + case WeatherType.HARSH_SUN: + return i18next.t("weather:harshSunEffectMessage"); + case WeatherType.HEAVY_RAIN: + return i18next.t("weather:heavyRainEffectMessage"); + } + return i18next.t("weather:defaultEffectMessage"); +} + export function getTerrainStartMessage(terrainType: TerrainType): string | null { switch (terrainType) { case TerrainType.MISTY: diff --git a/src/field/arena.ts b/src/field/arena.ts index 6cda257738e..f1043c852ef 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -5,7 +5,7 @@ import type { Constructor } from "#app/utils"; import * as Utils from "#app/utils"; import type PokemonSpecies from "#app/data/pokemon-species"; import { getPokemonSpecies } from "#app/data/pokemon-species"; -import { getTerrainClearMessage, getTerrainStartMessage, getWeatherClearMessage, getWeatherStartMessage, Weather } from "#app/data/weather"; +import { getTerrainClearMessage, getTerrainStartMessage, getWeatherClearMessage, getWeatherStartMessage, getLegendaryWeatherContinuesMessage, Weather } from "#app/data/weather"; import { CommonAnim } from "#app/data/battle-anims"; import type { PokemonType } from "#enums/type"; import type Move from "#app/data/moves/move"; @@ -274,6 +274,12 @@ export class Arena { const oldWeatherType = this.weather?.weatherType || WeatherType.NONE; + if (this.weather?.isImmutable() && ![ WeatherType.HARSH_SUN, WeatherType.HEAVY_RAIN, WeatherType.STRONG_WINDS, WeatherType.NONE ].includes(weather)) { + globalScene.unshiftPhase(new CommonAnimPhase(undefined, undefined, CommonAnim.SUNNY + (oldWeatherType - 1), true)); + globalScene.queueMessage(getLegendaryWeatherContinuesMessage(oldWeatherType)!); + return false; + } + this.weather = weather ? new Weather(weather, hasPokemonSource ? 5 : 0) : null; this.eventTarget.dispatchEvent(new WeatherChangedEvent(oldWeatherType, this.weather?.weatherType!, this.weather?.turnsLeft!)); // TODO: is this bang correct? @@ -358,6 +364,10 @@ export class Arena { return !!this.terrain && this.terrain.isMoveTerrainCancelled(user, targets, move); } + public getWeatherType(): WeatherType { + return this.weather?.weatherType ?? WeatherType.NONE; + } + public getTerrainType(): TerrainType { return this.terrain?.terrainType ?? TerrainType.NONE; } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 7c0c67fc767..3c207f426b3 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2886,7 +2886,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { /** Reduces damage if this Pokemon has a relevant screen (e.g. Light Screen for special attacks) */ const screenMultiplier = new Utils.NumberHolder(1); - globalScene.arena.applyTagsForSide(WeakenMoveScreenTag, defendingSide, simulated, source, moveCategory, screenMultiplier); + + // Critical hits should bypass screens + if (!isCritical) { + globalScene.arena.applyTagsForSide(WeakenMoveScreenTag, defendingSide, simulated, source, moveCategory, screenMultiplier); + } /** * For each {@linkcode HitsTagAttr} the move has, doubles the damage of the move if: diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index c17252eac39..24bf8721428 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -30,7 +30,7 @@ import { MoveFlags } from "#enums/MoveFlags"; import { SpeciesFormChangePreMoveTrigger } from "#app/data/pokemon-forms"; import { getStatusEffectActivationText, getStatusEffectHealText } from "#app/data/status-effect"; import { PokemonType } from "#enums/type"; -import { getTerrainBlockMessage } from "#app/data/weather"; +import { getTerrainBlockMessage, getWeatherBlockMessage } from "#app/data/weather"; import { MoveUsedEvent } from "#app/events/battle-scene"; import type { PokemonMove } from "#app/field/pokemon"; import type Pokemon from "#app/field/pokemon"; @@ -342,9 +342,10 @@ export class MovePhase extends BattlePhase { * TODO: is this sustainable? */ let failedDueToTerrain: boolean = false; + let failedDueToWeather: boolean = false; if (success) { const passesConditions = move.applyConditions(this.pokemon, targets[0], move); - const failedDueToWeather: boolean = globalScene.arena.isMoveWeatherCancelled(this.pokemon, move); + failedDueToWeather = globalScene.arena.isMoveWeatherCancelled(this.pokemon, move); failedDueToTerrain = globalScene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move); success = passesConditions && !failedDueToWeather && !failedDueToTerrain; } @@ -381,6 +382,8 @@ export class MovePhase extends BattlePhase { failedText = failureMessage; } else if (failedDueToTerrain) { failedText = getTerrainBlockMessage(targets[0], globalScene.arena.getTerrainType()); + } else if (failedDueToWeather) { + failedText = getWeatherBlockMessage(globalScene.arena.getWeatherType()); } this.showFailedText(failedText); diff --git a/test/moves/aurora_veil.test.ts b/test/moves/aurora_veil.test.ts index 6c45d2b4b36..eb77cdc3a44 100644 --- a/test/moves/aurora_veil.test.ts +++ b/test/moves/aurora_veil.test.ts @@ -1,7 +1,7 @@ import type BattleScene from "#app/battle-scene"; import { ArenaTagSide } from "#app/data/arena-tag"; import type Move from "#app/data/moves/move"; -import { allMoves } from "#app/data/moves/move"; +import { allMoves, CritOnlyAttr } from "#app/data/moves/move"; import { ArenaTagType } from "#app/enums/arena-tag-type"; import type Pokemon from "#app/field/pokemon"; import { TurnEndPhase } from "#app/phases/turn-end-phase"; @@ -12,7 +12,7 @@ import { Species } from "#enums/species"; import { WeatherType } from "#enums/weather-type"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; let globalScene: BattleScene; @@ -47,7 +47,7 @@ describe("Moves - Aurora Veil", () => { it("reduces damage of physical attacks by half in a single battle", async () => { const moveToUse = Moves.TACKLE; - await game.startBattle([ Species.SHUCKLE ]); + await game.classicMode.startBattle([ Species.SHUCKLE ]); game.move.select(moveToUse); @@ -61,7 +61,7 @@ describe("Moves - Aurora Veil", () => { game.override.battleType("double"); const moveToUse = Moves.ROCK_SLIDE; - await game.startBattle([ Species.SHUCKLE, Species.SHUCKLE ]); + await game.classicMode.startBattle([ Species.SHUCKLE, Species.SHUCKLE ]); game.move.select(moveToUse); game.move.select(moveToUse, 1); @@ -74,7 +74,7 @@ describe("Moves - Aurora Veil", () => { it("reduces damage of special attacks by half in a single battle", async () => { const moveToUse = Moves.ABSORB; - await game.startBattle([ Species.SHUCKLE ]); + await game.classicMode.startBattle([ Species.SHUCKLE ]); game.move.select(moveToUse); @@ -89,7 +89,7 @@ describe("Moves - Aurora Veil", () => { game.override.battleType("double"); const moveToUse = Moves.DAZZLING_GLEAM; - await game.startBattle([ Species.SHUCKLE, Species.SHUCKLE ]); + await game.classicMode.startBattle([ Species.SHUCKLE, Species.SHUCKLE ]); game.move.select(moveToUse); game.move.select(moveToUse, 1); @@ -99,6 +99,31 @@ describe("Moves - Aurora Veil", () => { expect(mockedDmg).toBe(allMoves[moveToUse].power * doubleBattleMultiplier); }); + + it("does not affect physical critical hits", async () => { + game.override.moveset([ Moves.WICKED_BLOW ]); + const moveToUse = Moves.WICKED_BLOW; + await game.classicMode.startBattle([ Species.SHUCKLE ]); + + game.move.select(moveToUse); + await game.phaseInterceptor.to(TurnEndPhase); + + const mockedDmg = getMockedMoveDamage(game.scene.getEnemyPokemon()!, game.scene.getPlayerPokemon()!, allMoves[moveToUse]); + expect(mockedDmg).toBe(allMoves[moveToUse].power); + }); + + it("does not affect critical hits", async () => { + game.override.moveset([ Moves.FROST_BREATH ]); + const moveToUse = Moves.FROST_BREATH; + vi.spyOn(allMoves[Moves.FROST_BREATH], "accuracy", "get").mockReturnValue(100); + await game.classicMode.startBattle([ Species.SHUCKLE ]); + + game.move.select(moveToUse); + await game.phaseInterceptor.to(TurnEndPhase); + + const mockedDmg = getMockedMoveDamage(game.scene.getEnemyPokemon()!, game.scene.getPlayerPokemon()!, allMoves[moveToUse]); + expect(mockedDmg).toBe(allMoves[moveToUse].power); + }); }); /** @@ -115,7 +140,9 @@ const getMockedMoveDamage = (defender: Pokemon, attacker: Pokemon, move: Move) = const side = defender.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; if (globalScene.arena.getTagOnSide(ArenaTagType.AURORA_VEIL, side)) { - globalScene.arena.applyTagsForSide(ArenaTagType.AURORA_VEIL, side, false, attacker, move.category, multiplierHolder); + if (move.getAttrs(CritOnlyAttr).length === 0) { + globalScene.arena.applyTagsForSide(ArenaTagType.AURORA_VEIL, side, false, attacker, move.category, multiplierHolder); + } } return move.power * multiplierHolder.value; diff --git a/test/moves/light_screen.test.ts b/test/moves/light_screen.test.ts index f4ee7d0bae6..2a4a1568136 100644 --- a/test/moves/light_screen.test.ts +++ b/test/moves/light_screen.test.ts @@ -1,7 +1,7 @@ import type BattleScene from "#app/battle-scene"; import { ArenaTagSide } from "#app/data/arena-tag"; import type Move from "#app/data/moves/move"; -import { allMoves } from "#app/data/moves/move"; +import { allMoves, CritOnlyAttr } from "#app/data/moves/move"; import { Abilities } from "#app/enums/abilities"; import { ArenaTagType } from "#app/enums/arena-tag-type"; import type Pokemon from "#app/field/pokemon"; @@ -11,7 +11,7 @@ import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; let globalScene: BattleScene; @@ -45,7 +45,7 @@ describe("Moves - Light Screen", () => { it("reduces damage of special attacks by half in a single battle", async () => { const moveToUse = Moves.ABSORB; - await game.startBattle([ Species.SHUCKLE ]); + await game.classicMode.startBattle([ Species.SHUCKLE ]); game.move.select(moveToUse); @@ -60,7 +60,7 @@ describe("Moves - Light Screen", () => { game.override.battleType("double"); const moveToUse = Moves.DAZZLING_GLEAM; - await game.startBattle([ Species.SHUCKLE, Species.SHUCKLE ]); + await game.classicMode.startBattle([ Species.SHUCKLE, Species.SHUCKLE ]); game.move.select(moveToUse); game.move.select(moveToUse, 1); @@ -73,7 +73,7 @@ describe("Moves - Light Screen", () => { it("does not affect physical attacks", async () => { const moveToUse = Moves.TACKLE; - await game.startBattle([ Species.SHUCKLE ]); + await game.classicMode.startBattle([ Species.SHUCKLE ]); game.move.select(moveToUse); @@ -82,6 +82,19 @@ describe("Moves - Light Screen", () => { expect(mockedDmg).toBe(allMoves[moveToUse].power); }); + + it("does not affect critical hits", async () => { + game.override.moveset([ Moves.FROST_BREATH ]); + const moveToUse = Moves.FROST_BREATH; + vi.spyOn(allMoves[Moves.FROST_BREATH], "accuracy", "get").mockReturnValue(100); + await game.classicMode.startBattle([ Species.SHUCKLE ]); + + game.move.select(moveToUse); + await game.phaseInterceptor.to(TurnEndPhase); + + const mockedDmg = getMockedMoveDamage(game.scene.getEnemyPokemon()!, game.scene.getPlayerPokemon()!, allMoves[moveToUse]); + expect(mockedDmg).toBe(allMoves[moveToUse].power); + }); }); /** @@ -98,7 +111,9 @@ const getMockedMoveDamage = (defender: Pokemon, attacker: Pokemon, move: Move) = const side = defender.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; if (globalScene.arena.getTagOnSide(ArenaTagType.LIGHT_SCREEN, side)) { - globalScene.arena.applyTagsForSide(ArenaTagType.LIGHT_SCREEN, side, false, attacker, move.category, multiplierHolder); + if (move.getAttrs(CritOnlyAttr).length === 0) { + globalScene.arena.applyTagsForSide(ArenaTagType.LIGHT_SCREEN, side, false, attacker, move.category, multiplierHolder); + } } return move.power * multiplierHolder.value; diff --git a/test/moves/reflect.test.ts b/test/moves/reflect.test.ts index 032756194ff..4f818afb071 100644 --- a/test/moves/reflect.test.ts +++ b/test/moves/reflect.test.ts @@ -1,7 +1,7 @@ import type BattleScene from "#app/battle-scene"; import { ArenaTagSide } from "#app/data/arena-tag"; import type Move from "#app/data/moves/move"; -import { allMoves } from "#app/data/moves/move"; +import { allMoves, CritOnlyAttr } from "#app/data/moves/move"; import { Abilities } from "#app/enums/abilities"; import { ArenaTagType } from "#app/enums/arena-tag-type"; import type Pokemon from "#app/field/pokemon"; @@ -45,7 +45,7 @@ describe("Moves - Reflect", () => { it("reduces damage of physical attacks by half in a single battle", async () => { const moveToUse = Moves.TACKLE; - await game.startBattle([ Species.SHUCKLE ]); + await game.classicMode.startBattle([ Species.SHUCKLE ]); game.move.select(moveToUse); @@ -59,7 +59,7 @@ describe("Moves - Reflect", () => { game.override.battleType("double"); const moveToUse = Moves.ROCK_SLIDE; - await game.startBattle([ Species.SHUCKLE, Species.SHUCKLE ]); + await game.classicMode.startBattle([ Species.SHUCKLE, Species.SHUCKLE ]); game.move.select(moveToUse); game.move.select(moveToUse, 1); @@ -72,7 +72,7 @@ describe("Moves - Reflect", () => { it("does not affect special attacks", async () => { const moveToUse = Moves.ABSORB; - await game.startBattle([ Species.SHUCKLE ]); + await game.classicMode.startBattle([ Species.SHUCKLE ]); game.move.select(moveToUse); @@ -82,6 +82,18 @@ describe("Moves - Reflect", () => { expect(mockedDmg).toBe(allMoves[moveToUse].power); }); + + it("does not affect critical hits", async () => { + game.override.moveset([ Moves.WICKED_BLOW ]); + const moveToUse = Moves.WICKED_BLOW; + await game.classicMode.startBattle([ Species.SHUCKLE ]); + + game.move.select(moveToUse); + await game.phaseInterceptor.to(TurnEndPhase); + + const mockedDmg = getMockedMoveDamage(game.scene.getEnemyPokemon()!, game.scene.getPlayerPokemon()!, allMoves[moveToUse]); + expect(mockedDmg).toBe(allMoves[moveToUse].power); + }); }); /** @@ -98,7 +110,9 @@ const getMockedMoveDamage = (defender: Pokemon, attacker: Pokemon, move: Move) = const side = defender.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; if (globalScene.arena.getTagOnSide(ArenaTagType.REFLECT, side)) { - globalScene.arena.applyTagsForSide(ArenaTagType.REFLECT, side, false, attacker, move.category, multiplierHolder); + if (move.getAttrs(CritOnlyAttr).length === 0) { + globalScene.arena.applyTagsForSide(ArenaTagType.REFLECT, side, false, attacker, move.category, multiplierHolder); + } } return move.power * multiplierHolder.value;