diff --git a/src/battle-scene.ts b/src/battle-scene.ts index ecaffc5ed07..5c8099ac706 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -7,7 +7,6 @@ import type PokemonSpecies from "#app/data/pokemon-species"; import { allSpecies, getPokemonSpecies } from "#app/data/pokemon-species"; import { fixedInt, - deepMergeObjects, getIvsFromId, randSeedInt, getEnumValues, @@ -19,6 +18,7 @@ import { BooleanHolder, type Constructor, } from "#app/utils/common"; +import { deepMergeSpriteData } from "#app/utils/data"; import type { Modifier, ModifierPredicate, TurnHeldItemTransferModifier } from "./modifier/modifier"; import { ConsumableModifier, @@ -788,7 +788,7 @@ export default class BattleScene extends SceneBase { return; } const expVariantData = await this.cachedFetch("./images/pokemon/variant/_exp_masterlist.json").then(r => r.json()); - deepMergeObjects(variantData, expVariantData); + deepMergeSpriteData(variantData, expVariantData); } cachedFetch(url: string, init?: RequestInit): Promise { @@ -836,6 +836,7 @@ export default class BattleScene extends SceneBase { return this.getPlayerField().find(p => p.isActive() && (includeSwitching || p.switchOutStatus === false)); } + // TODO: Add `undefined` to return type /** * Returns an array of PlayerPokemon of length 1 or 2 depending on if in a double battle or not. * Does not actually check if the pokemon are on the field or not. @@ -851,9 +852,9 @@ export default class BattleScene extends SceneBase { } /** - * @returns The first {@linkcode EnemyPokemon} that is {@linkcode getEnemyField on the field} - * and {@linkcode EnemyPokemon.isActive is active} - * (aka {@linkcode EnemyPokemon.isAllowedInBattle is allowed in battle}), + * @returns The first {@linkcode EnemyPokemon} that is {@linkcode getEnemyField | on the field} + * and {@linkcode EnemyPokemon.isActive | is active} + * (aka {@linkcode EnemyPokemon.isAllowedInBattle | is allowed in battle}), * or `undefined` if there are no valid pokemon * @param includeSwitching Whether a pokemon that is currently switching out is valid, default `true` */ @@ -1298,14 +1299,13 @@ export default class BattleScene extends SceneBase { return Math.max(doubleChance.value, 1); } - // TODO: ...this never actually returns `null`, right? newBattle( waveIndex?: number, battleType?: BattleType, trainerData?: TrainerData, double?: boolean, mysteryEncounterType?: MysteryEncounterType, - ): Battle | null { + ): Battle { const _startingWave = Overrides.STARTING_WAVE_OVERRIDE || startingWave; const newWaveIndex = waveIndex || (this.currentBattle?.waveIndex || _startingWave - 1) + 1; let newDouble: boolean | undefined; @@ -1492,7 +1492,7 @@ export default class BattleScene extends SceneBase { }); for (const pokemon of this.getPlayerParty()) { - pokemon.resetBattleData(); + pokemon.resetBattleAndWaveData(); pokemon.resetTera(); applyPostBattleInitAbAttrs(PostBattleInitAbAttr, pokemon); if ( @@ -3261,6 +3261,7 @@ export default class BattleScene extends SceneBase { [this.modifierBar, this.enemyModifierBar].map(m => m.setVisible(visible)); } + // TODO: Document this - IDK what it does and it gets called a lot updateModifiers(player = true, instant?: boolean): void { const modifiers = player ? this.modifiers : (this.enemyModifiers as PersistentModifier[]); for (let m = 0; m < modifiers.length; m++) { @@ -3313,8 +3314,8 @@ export default class BattleScene extends SceneBase { * gets removed. This function does NOT apply in-battle effects, such as Unburden. * If in-battle effects are needed, use {@linkcode Pokemon.loseHeldItem} instead. * @param modifier The item to be removed. - * @param enemy If `true`, remove an item owned by the enemy. If `false`, remove an item owned by the player. Default is `false`. - * @returns `true` if the item exists and was successfully removed, `false` otherwise. + * @param enemy `true` to remove an item owned by the enemy rather than the player; default `false`. + * @returns `true` if the item exists and was successfully removed, `false` otherwise */ removeModifier(modifier: PersistentModifier, enemy = false): boolean { const modifiers = !enemy ? this.modifiers : this.enemyModifiers; diff --git a/src/data/custom-pokemon-data.ts b/src/data/custom-pokemon-data.ts index f89d6885c8d..15a83a0d718 100644 --- a/src/data/custom-pokemon-data.ts +++ b/src/data/custom-pokemon-data.ts @@ -8,15 +8,19 @@ import type { Nature } from "#enums/nature"; * Includes abilities, nature, changed types, etc. */ export class CustomPokemonData { - public spriteScale = -1; - public ability: Abilities | -1 = -1; - public passive: Abilities | -1 = -1; - public nature: Nature | -1 = -1; - public types: PokemonType[] = []; + public spriteScale: number; + public ability: Abilities | -1; + public passive: Abilities | -1; + public nature: Nature | -1; + public types: PokemonType[]; constructor(data?: CustomPokemonData | Partial) { if (!isNullOrUndefined(data)) { - Object.assign(this, data); + this.spriteScale = data.spriteScale ?? 1; + this.ability = data.ability ?? -1; + this.passive = data.passive || data.spriteScale; + this.spriteScale = this.spriteScale || data.spriteScale; + this.types = data.types || this.types; } } } diff --git a/src/data/pokemon-species.ts b/src/data/pokemon-species.ts index 95ff28e61e0..6212e7f32ef 100644 --- a/src/data/pokemon-species.ts +++ b/src/data/pokemon-species.ts @@ -33,6 +33,7 @@ import { SpeciesFormKey } from "#enums/species-form-key"; import { starterPassiveAbilities } from "#app/data/balance/passives"; import { loadPokemonVariantAssets } from "#app/sprites/pokemon-sprite"; import { hasExpSprite } from "#app/sprites/sprite-utils"; +import { Gender } from "./gender"; export enum Region { NORMAL, @@ -846,6 +847,23 @@ export default class PokemonSpecies extends PokemonSpeciesForm implements Locali return this.name; } + /** + * Pick and return a random {@linkcode Gender} for a {@linkcode Pokemon}. + * @param id The personality value of the pokemon being generated. + * @returns THe selected gender for this Pokemon, rolled based on its PID. + */ + generateGender(id: number): Gender { + if (isNullOrUndefined(this.malePercent)) { + return Gender.GENDERLESS; + } + + const genderChance = (id % 256) * 0.390625; + if (genderChance < this.malePercent) { + return Gender.MALE; + } + return Gender.FEMALE; + } + /** * Find the name of species with proper attachments for regionals and separate starter forms (Floette, Ursaluna) * @returns a string with the region name or other form name attached diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 1fe452bb082..0900dd992d5 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -334,7 +334,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { /* Pokemon data types, in vague order of precedence */ /** Data that resets on switch (stat stages, battler tags, etc.) */ - public summonData: PokemonSummonData; + public summonData: PokemonSummonData = new PokemonSummonData; /** Wave data correponding to moves/ability information revealed */ public waveData: PokemonWaveData = new PokemonWaveData; /** Data that resets only on battle end (hit count, harvest berries, etc.) */ @@ -354,8 +354,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { private shinySparkle: Phaser.GameObjects.Sprite; - // TODO: Rework this constructor - it's _far_ too complicated and could be modernized - // in a similar manner to the PokemonData constructor + // TODO: Rework this eventually constructor( x: number, y: number, diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index ae1b50a2e2b..d4fa7c78e6e 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -237,6 +237,10 @@ export abstract class PersistentModifier extends Modifier { abstract getMaxStackCount(forThreshold?: boolean): number; + getCountUnderMax(): number { + return this.getMaxStackCount() - this.getStackCount(); + } + isIconVisible(): boolean { return true; } @@ -658,7 +662,9 @@ export class TerastallizeAccessModifier extends PersistentModifier { } export abstract class PokemonHeldItemModifier extends PersistentModifier { + /** The ID of the {@linkcode Pokemon} that this item belongs to. */ public pokemonId: number; + /** Whether this item can be transfered to or stolen by another Pokemon. */ public isTransferable = true; constructor(type: ModifierType, pokemonId: number, stackCount?: number) { diff --git a/src/phases/encounter-phase.ts b/src/phases/encounter-phase.ts index fae061c9d04..de4b1461324 100644 --- a/src/phases/encounter-phase.ts +++ b/src/phases/encounter-phase.ts @@ -188,7 +188,7 @@ export class EncounterPhase extends BattlePhase { ]; const moveset: string[] = []; for (const move of enemyPokemon.getMoveset()) { - moveset.push(move!.getName()); // TODO: remove `!` after moveset-null removal PR + moveset.push(move.getName()); } console.log( @@ -550,7 +550,7 @@ export class EncounterPhase extends BattlePhase { if (enemyPokemon.isShiny(true)) { globalScene.unshiftPhase(new ShinySparklePhase(BattlerIndex.ENEMY + e)); } - /** This sets Eternatus' held item to be untransferrable, preventing it from being stolen */ + /** This sets Eternatus' held item to be untransferrable, preventing it from being stolen */ if ( enemyPokemon.species.speciesId === Species.ETERNATUS && (globalScene.gameMode.isBattleClassicFinalBoss(globalScene.currentBattle.waveIndex) || diff --git a/src/system/pokemon-data.ts b/src/system/pokemon-data.ts index 672a0301d5b..e4d0daad5a9 100644 --- a/src/system/pokemon-data.ts +++ b/src/system/pokemon-data.ts @@ -5,15 +5,19 @@ import { Nature } from "#enums/nature"; import type { PokeballType } from "#enums/pokeball"; import { getPokemonSpecies, getPokemonSpeciesForm } from "../data/pokemon-species"; import { Status } from "../data/status-effect"; -import Pokemon, { EnemyPokemon, PokemonMove, PokemonSummonData, type PokemonBattleData } from "../field/pokemon"; +import Pokemon, { + EnemyPokemon, + type PokemonMove, + type PokemonSummonData, + type PokemonBattleData, +} from "../field/pokemon"; import { TrainerSlot } from "#enums/trainer-slot"; import type { Variant } from "#app/sprites/variant"; import type { Biome } from "#enums/biome"; -import { Moves } from "#enums/moves"; +import type { Moves } from "#enums/moves"; import type { Species } from "#enums/species"; import { CustomPokemonData } from "#app/data/custom-pokemon-data"; import type { PokemonType } from "#enums/pokemon-type"; -import { loadBattlerTag } from "#app/data/battler-tags"; export default class PokemonData { public id: number; @@ -80,9 +84,8 @@ export default class PokemonData { * Construct a new {@linkcode PokemonData} instance out of a {@linkcode Pokemon} * or JSON representation thereof. * @param source The {@linkcode Pokemon} to convert into data (or a JSON object representing one) - * @param forHistory */ - constructor(source: Pokemon | any, forHistory = false) { + constructor(source: Pokemon | any) { const sourcePokemon = source instanceof Pokemon ? source : undefined; this.id = source.id; this.player = sourcePokemon ? sourcePokemon.isPlayer() : source.player; @@ -129,55 +132,28 @@ export default class PokemonData { this.customPokemonData = new CustomPokemonData(source.customPokemonData); - // Deprecated, but needed for session data migration - // TODO: Do we really need this?? - this.natureOverride = source.natureOverride; - this.mysteryEncounterPokemonData = source.mysteryEncounterPokemonData - ? new CustomPokemonData(source.mysteryEncounterPokemonData) - : null; - this.fusionMysteryEncounterPokemonData = source.fusionMysteryEncounterPokemonData - ? new CustomPokemonData(source.fusionMysteryEncounterPokemonData) - : null; + this.moveset = sourcePokemon?.moveset ?? source.moveset; - this.moveset = - sourcePokemon?.moveset ?? - (source.moveset || [new PokemonMove(Moves.TACKLE), new PokemonMove(Moves.GROWL)]) - .filter((m: any) => !!m) - .map((m: any) => new PokemonMove(m.moveId, m.ppUsed, m.ppUp, m.virtual, m.maxPpOverride)); + this.levelExp = source.levelExp; + this.hp = source.hp; - if (!forHistory) { - this.levelExp = source.levelExp; - this.hp = source.hp; + this.pauseEvolutions = !!source.pauseEvolutions; + this.evoCounter = source.evoCounter ?? 0; - this.pauseEvolutions = !!source.pauseEvolutions; - this.evoCounter = source.evoCounter ?? 0; + this.boss = (source instanceof EnemyPokemon && !!source.bossSegments) || (!this.player && !!source.boss); + this.bossSegments = source.bossSegments; + this.status = + sourcePokemon?.status ?? + (source.status + ? new Status(source.status.effect, source.status.toxicTurnCount, source.status.sleepTurnsRemaining) + : null); - this.boss = (source instanceof EnemyPokemon && !!source.bossSegments) || (!this.player && !!source.boss); - this.bossSegments = source.bossSegments; - this.status = - sourcePokemon?.status ?? - (source.status - ? new Status(source.status.effect, source.status.toxicTurnCount, source.status.sleepTurnsRemaining) - : null); + this.summonData = source.summonData; - // enemy pokemon don't use instantized summon data - if (this.player) { - this.summonData = sourcePokemon?.summonData ?? source.summonData; - } else { - console.log("this.player false!"); - this.summonData = new PokemonSummonData(); - } - - if (!sourcePokemon) { - this.summonData.moveset = source.summonData.moveset?.map(m => PokemonMove.loadMove(m)); - this.summonData.tags = source.tags.map((t: any) => loadBattlerTag(t)); - } - - this.summonDataSpeciesFormIndex = sourcePokemon - ? this.getSummonDataSpeciesFormIndex() - : source.summonDataSpeciesFormIndex; - this.battleData = sourcePokemon?.battleData ?? source.battleData; - } + this.summonDataSpeciesFormIndex = sourcePokemon + ? this.getSummonDataSpeciesFormIndex() + : source.summonDataSpeciesFormIndex; + this.battleData = sourcePokemon?.battleData ?? source.battleData; } toPokemon(battleType?: BattleType, partyMemberIndex = 0, double = false): Pokemon { diff --git a/src/system/version_migration/version_converter.ts b/src/system/version_migration/version_converter.ts index 1fdb9e93f88..798115e0395 100644 --- a/src/system/version_migration/version_converter.ts +++ b/src/system/version_migration/version_converter.ts @@ -59,6 +59,10 @@ import * as v1_7_0 from "./versions/v1_7_0"; // biome-ignore lint/style/noNamespaceImport: Convenience import * as v1_8_3 from "./versions/v1_8_3"; +// --- v1.9.0 PATCHES --- // +// biome-ignore lint/style/noNamespaceImport: Convenience +import * as v1_9_0 from "./versions/v1_9_0"; + /** Current game version */ const LATEST_VERSION = version; @@ -80,6 +84,7 @@ systemMigrators.push(...v1_8_3.systemMigrators); const sessionMigrators: SessionSaveMigrator[] = []; sessionMigrators.push(...v1_0_4.sessionMigrators); sessionMigrators.push(...v1_7_0.sessionMigrators); +sessionMigrators.push(...v1_9_0.sessionMigrators); /** All settings migrators */ const settingsMigrators: SettingsSaveMigrator[] = []; diff --git a/src/system/version_migration/versions/v1_9_0.ts b/src/system/version_migration/versions/v1_9_0.ts new file mode 100644 index 00000000000..fd9c6e3ee49 --- /dev/null +++ b/src/system/version_migration/versions/v1_9_0.ts @@ -0,0 +1,36 @@ +import type { SessionSaveMigrator } from "#app/@types/SessionSaveMigrator"; +import { loadBattlerTag } from "#app/data/battler-tags"; +import { PokemonMove } from "#app/field/pokemon"; +import type { SessionSaveData } from "#app/system/game-data"; +import PokemonData from "#app/system/pokemon-data"; +import { Moves } from "#enums/moves"; +import { PokeballType } from "#enums/pokeball"; + +/** + * Migrate all lingering rage fist data inside `CustomPokemonData`, + * as well as enforcing default values across the board. + * @param data - {@linkcode SystemSaveData} + */ +const migratePartyData: SessionSaveMigrator = { + version: "1.9.0", + migrate: (data: SessionSaveData): void => { + data.party = data.party.map(pkmnData => { + // this stuff is copied straight from the constructor fwiw + pkmnData.moveset = pkmnData.moveset.filter(m => !!m) ?? [ + new PokemonMove(Moves.TACKLE), + new PokemonMove(Moves.GROWL), + ]; + pkmnData.pokeball ??= PokeballType.POKEBALL; + pkmnData.summonData.tags = pkmnData.summonData.tags.map((t: any) => loadBattlerTag(t)); + if ( + "hitsRecCount" in pkmnData.customPokemonData && + typeof pkmnData.customPokemonData["hitsRecCount"] === "number" + ) { + pkmnData.battleData.hitCount = pkmnData.customPokemonData?.["hitsRecCount"]; + } + return new PokemonData(pkmnData); + }); + }, +}; + +export const sessionMigrators: Readonly = [migratePartyData] as const; diff --git a/src/ui/registration-form-ui-handler.ts b/src/ui/registration-form-ui-handler.ts index 3d4613c21d6..a60a53a8e7a 100644 --- a/src/ui/registration-form-ui-handler.ts +++ b/src/ui/registration-form-ui-handler.ts @@ -102,9 +102,9 @@ export default class RegistrationFormUiHandler extends FormModalUiHandler { // Prevent overlapping overrides on action modification this.submitAction = originalRegistrationAction; this.sanitizeInputs(); - globalScene.ui.setMode(UiMode.LOADING, { buttonActions: [] }); + globalScene.ui.setMode(UiMode.LOADING, { buttonActions: [] }); const onFail = error => { - globalScene.ui.setMode(UiMode.REGISTRATION_FORM, Object.assign(config, { errorMessage: error?.trim() })); + globalScene.ui.setMode(UiMode.REGISTRATION_FORM, Object.assign(config, { errorMessage: error?.trim() })); globalScene.ui.playError(); const errorMessageFontSize = languageSettings[i18next.resolvedLanguage!]?.errorMessageFontSize; if (errorMessageFontSize) { diff --git a/src/utils/common.ts b/src/utils/common.ts index 4acfabce080..1501e206d2d 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -469,7 +469,6 @@ export function truncateString(str: string, maxLength = 10) { /** * Perform a deep copy of an object. - * * @param values - The object to be deep copied. * @returns A new object that is a deep copy of the input. */ @@ -480,22 +479,20 @@ export function deepCopy(values: object): object { /** * Convert a space-separated string into a capitalized and underscored string. - * * @param input - The string to be converted. * @returns The converted string with words capitalized and separated by underscores. */ -export function reverseValueToKeySetting(input) { +export function reverseValueToKeySetting(input: string) { // Split the input string into an array of words const words = input.split(" "); // Capitalize the first letter of each word and convert the rest to lowercase - const capitalizedWords = words.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()); + const capitalizedWords = words.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()); // Join the capitalized words with underscores and return the result return capitalizedWords.join("_"); } /** * Capitalize a string. - * * @param str - The string to be capitalized. * @param sep - The separator between the words of the string. * @param lowerFirstChar - Whether the first character of the string should be lowercase or not. @@ -515,8 +512,8 @@ export function capitalizeString(str: string, sep: string, lowerFirstChar = true return null; } -export function isNullOrUndefined(object: any): object is undefined | null { - return null === object || undefined === object; +export function isNullOrUndefined(object: any): object is null | undefined { + return object === null || object === undefined; } /** @@ -579,25 +576,3 @@ export function animationFileName(move: Moves): string { export function camelCaseToKebabCase(str: string): string { return str.replace(/[A-Z]+(?![a-z])|[A-Z]/g, (s, o) => (o ? "-" : "") + s.toLowerCase()); } - -/** - * Merges the two objects, such that for each property in `b` that matches a property in `a`, - * the value in `a` is replaced by the value in `b`. This is done recursively if the property is a non-array object - * - * If the property does not exist in `a` or its `typeof` evaluates differently, the property is skipped. - * If the value of the property is an array, the array is replaced. If it is any other object, the object is merged recursively. - */ -// biome-ignore lint/complexity/noBannedTypes: This function is designed to merge json objects -export function deepMergeObjects(a: Object, b: Object) { - for (const key in b) { - // !(key in a) is redundant here, yet makes it clear that we're explicitly interested in properties that exist in `a` - if (!(key in a) || typeof a[key] !== typeof b[key]) { - continue; - } - if (typeof b[key] === "object" && !Array.isArray(b[key])) { - deepMergeObjects(a[key], b[key]); - } else { - a[key] = b[key]; - } - } -} diff --git a/src/utils/data.ts b/src/utils/data.ts new file mode 100644 index 00000000000..026d8c475a7 --- /dev/null +++ b/src/utils/data.ts @@ -0,0 +1,33 @@ +/** + * Deeply merge two JSON objects' common properties together. + * This copies all values from `source` that match properties inside `dest`, + * checking recursively for non-null nested objects. + + * If a property in `src` does not exist in `dest` or its `typeof` evaluates differently, it is skipped. + * If it is a non-array object, its properties are recursed into and checked in turn. + * All other values are copied verbatim. + * @param dest The object to merge values into + * @param source The object to source merged values from + * @remarks Do not use for regular objects; this is specifically made for JSON copying. + * @see deepMergeObjects + */ +export function deepMergeSpriteData(dest: object, source: object) { + // Grab all the keys present in both with similar types + const matchingKeys = Object.keys(source).filter(key => { + const destVal = dest[key]; + const sourceVal = source[key]; + + return ( + // Somewhat redundant, but makes it clear that we're explicitly interested in properties that exist in both + key in source && Array.isArray(sourceVal) === Array.isArray(destVal) && typeof sourceVal === typeof destVal + ); + }); + + for (const key of matchingKeys) { + if (typeof source[key] === "object" && source[key] !== null && !Array.isArray(source[key])) { + deepMergeSpriteData(dest[key], source[key]); + } else { + dest[key] = source[key]; + } + } +} diff --git a/test/abilities/cud_chew.test.ts b/test/abilities/cud_chew.test.ts index 6b3d55b7943..683238d99ee 100644 --- a/test/abilities/cud_chew.test.ts +++ b/test/abilities/cud_chew.test.ts @@ -31,7 +31,7 @@ describe("Abilities - Cud Chew", () => { .moveset([Moves.BUG_BITE, Moves.SPLASH, Moves.HYPER_VOICE, Moves.STUFF_CHEEKS]) .startingHeldItems([{ name: "BERRY", type: BerryType.SITRUS, count: 1 }]) .ability(Abilities.CUD_CHEW) - .battleType("single") + .battleStyle("single") .disableCrits() .enemySpecies(Species.MAGIKARP) .enemyAbility(Abilities.BALL_FETCH) diff --git a/test/abilities/harvest.test.ts b/test/abilities/harvest.test.ts index 5d2a23cbdba..472273d3d9c 100644 --- a/test/abilities/harvest.test.ts +++ b/test/abilities/harvest.test.ts @@ -1,7 +1,7 @@ import type Pokemon from "#app/field/pokemon"; import { BerryModifier, PreserveBerryModifier } from "#app/modifier/modifier"; import type { ModifierOverride } from "#app/modifier/modifier-type"; -import type { BooleanHolder } from "#app/utils"; +import type { BooleanHolder } from "#app/utils/common"; import { Abilities } from "#enums/abilities"; import { BerryType } from "#enums/berry-type"; import { Moves } from "#enums/moves"; @@ -45,7 +45,7 @@ describe("Abilities - Harvest", () => { .moveset([Moves.SPLASH, Moves.NATURAL_GIFT, Moves.FALSE_SWIPE, Moves.GASTRO_ACID]) .ability(Abilities.HARVEST) .startingLevel(100) - .battleType("single") + .battleStyle("single") .disableCrits() .statusActivation(false) // Since we're using nuzzle to proc both enigma and sitrus berries .weather(WeatherType.SUNNY) // guaranteed recovery diff --git a/test/field/pokemon.test.ts b/test/field/pokemon.test.ts index 85128a31f7f..60ccb460a3e 100644 --- a/test/field/pokemon.test.ts +++ b/test/field/pokemon.test.ts @@ -6,6 +6,9 @@ import type BattleScene from "#app/battle-scene"; import { Moves } from "#app/enums/moves"; import { PokemonType } from "#enums/pokemon-type"; import { CustomPokemonData } from "#app/data/custom-pokemon-data"; +import PokemonData from "#app/system/pokemon-data"; +import { PlayerPokemon } from "#app/field/pokemon"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; describe("Spec - Pokemon", () => { let phaserGame: Phaser.Game; @@ -209,4 +212,28 @@ describe("Spec - Pokemon", () => { expect(types[1]).toBe(PokemonType.DARK); }); }); + + it("should be more or less equivalent when converting to and from PokemonData", async () => { + await game.classicMode.startBattle([Species.ALAKAZAM]); + const alakazam = game.scene.getPlayerPokemon()!; + expect(alakazam).toBeDefined(); + + alakazam.hp = 5; + const alakaData = new PokemonData(alakazam); + const alaka2 = new PlayerPokemon( + getPokemonSpecies(Species.ALAKAZAM), + 5, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + alakaData, + ); + for (const key of Object.keys(alakazam).filter(k => k in alakaData)) { + expect(alakazam[key]).toEqual(alaka2["key"]); + } + }); }); diff --git a/test/utils.test.ts b/test/utils.test.ts index 33f7906738c..fe93bdd6970 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,5 +1,6 @@ import { expect, describe, it, beforeAll } from "vitest"; import { randomString, padInt } from "#app/utils/common"; +import { deepMergeSpriteData } from "#app/utils/data"; import Phaser from "phaser"; @@ -9,6 +10,7 @@ describe("utils", () => { type: Phaser.HEADLESS, }); }); + describe("randomString", () => { it("should return a string of the specified length", () => { const str = randomString(10); @@ -46,4 +48,33 @@ describe("utils", () => { expect(result).toBe("1"); }); }); + describe("deepMergeSpriteData", () => { + it("should merge two objects' common properties", () => { + const dest = { a: 1, b: 2 }; + const source = { a: 3, b: 3, e: 4 }; + deepMergeSpriteData(dest, source); + expect(dest).toEqual({ a: 3, b: 3 }); + }); + + it("does nothing for identical objects", () => { + const dest = { a: 1, b: 2 }; + const source = { a: 1, b: 2 }; + deepMergeSpriteData(dest, source); + expect(dest).toEqual({ a: 1, b: 2 }); + }); + + it("should preserve missing and mistyped properties", () => { + const dest = { a: 1, c: 56, d: "test" }; + const source = { a: "apple", b: 3, d: "no hablo español" }; + deepMergeSpriteData(dest, source); + expect(dest).toEqual({ a: 1, c: 56, d: "no hablo español" }); + }); + + it("should copy arrays verbatim even with mismatches", () => { + const dest = { a: 1, b: [{ d: 1 }, { d: 2 }, { d: 3 }] }; + const source = { a: 3, b: [{ c: [4, 5] }, { p: [7, 8] }], e: 4 }; + deepMergeSpriteData(dest, source); + expect(dest).toEqual({ a: 3, b: [{ c: [4, 5] }, { p: [7, 8] }] }); + }); + }); });