Compare commits

...

16 Commits

Author SHA1 Message Date
Dean
057f6662d1
Merge ce3fe897c3 into f42237d415 2025-08-14 13:28:35 -04:00
Bertie690
f42237d415
[Refactor] Removed map(x => x) (#6256)
* Enforced a few usages of `toCamelCase`

* Removed `map(x => x)`

* Removed more maps and sufff

* Update test/mystery-encounter/encounters/weird-dream-encounter.test.ts

* Update game-data.ts types to work
2025-08-14 10:25:44 -07:00
fabske0
b44f0a4176
[Refactor] Remove bgm param from arena constructor (#6254) 2025-08-14 16:52:56 +00:00
Sirz Benjie
076ef81691
[Bug] [UI/UX] [Beta] Fix icons not showing in save slot selection (#6262)
Fix icons not showing in save slot selection
2025-08-13 20:49:46 -05:00
fabske0
23271901cf
[Docs] Add locale key naming info to localization.md (#6260) 2025-08-14 01:12:00 +00:00
Inês Simões
1517e0512e
[UI/UX] [Feature] Save Management Tool (Rename/Delete Saves) (#5978)
* Implement Name Run Feat
Modified load session ui component, adding a submenu when selecting a 3
slot. This menu has 4 options:
Load Game -> Behaves as before, allowing the player to continue
progress from the last saved state in the slot.

Rename Run -> Overlays a rename form, allowing the player to type a
name for the run, checking for string validity, with the option to
cancel or confirm (Rename).

Delete Run -> Prompts user confirmation to delete save data, removing
the current save slot from the users save data.

Cancel -> Hides menu overlay.

Modified game data to implement a function to accept and store
runNameText to the users data.

Modified run info ui component, to display the chosen name when
viewing run information.

Example: When loading the game, the user can choose the Load Game
menu option, then select a save slot, prompting the menu, then choose
"Rename Run" and type the name "Monotype Water Run" then confirm,
thus being able to better organize their save files.

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Implement Rename Input Design and Tests for Name Run Feat
Created a test to verify Name Run Feature behaviour in the
backend (rename_run.test.ts), checking possible errors and
 expected behaviours.

Created a UiHandler RenameRunFormUiHandler
(rename-run-ui-handler.ts), creating a frontend input
overlay for the Name Run Feature.

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Fixed formating and best practices issues:
Rewrote renameSession to be more inline with other
API call funtions, removed debugging comments and
whitespaces.

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Minor Sanitization for aesthetics
Deleting the input when closing the overlay for
aesthetics purpose

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Fixed minor rebase alterations.

Signed-off-by: Matheus Alves matheus.r.noya.alves@tecnico.ulisboa.pt
Co-authored-by: Inês Simões ines.p.simoes@tecnico.ulisboa.pt

* Implemented Default Name Logic
Altered logic in save-slot-select-ui-handler.ts to
support default naming of runs based on the run
game mode with decideFallback function.

In game-data.ts, to prevent inconsistent naming,
added check for unfilled input, ignoring empty
rename requests.

Signed-off-by: Matheus Alves matheus.r.noya.alves@tecnico.ulisboa.pt
Co-authored-by: Inês Simões ines.p.simoes@tecnico.ulisboa.pt

* Replace fallback name logic: use first active challenge instead
of game mode

Previously used game mode as the fallback name, updated to use the
first active challenge instead (e.g. Monogen or Mono Type), which
better reflects the run's theme.
Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Rebasing and conflict resolution

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Lint fix

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Inês Simões <ines.p.simoes@tecnico.ulisboa.pt>

* Minor compile fix

* Dependency resolved

* Format name respected

* Add all active challenges to default challenge session name if possible

If more than 3 challenges are active, only the first 3 are added
to the name (to prevent the text going off-screen)
and then "..." is appended to the end to indicate
there were more challenges active than the ones listed

* Allow deleting malformed sessions

---------

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Signed-off-by: Matheus Alves matheus.r.noya.alves@tecnico.ulisboa.pt
Co-authored-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Wlowscha <54003515+Wlowscha@users.noreply.github.com>
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
2025-08-13 20:08:12 -05:00
Dean
ce3fe897c3
Update src/data/arena-tag.ts
Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>
2025-08-05 13:31:48 -07:00
Dean
605d61cec9 Update test import 2025-08-02 19:27:03 -07:00
Dean
d55cfbcf38 Update tag for serialization 2025-08-02 17:36:58 -07:00
Dean
8f9fca50a1 Merge branch 'beta' into pr/emdeann/6027 2025-08-02 16:56:54 -07:00
Dean
6018e11233 Use message directly instead of as key in tag 2025-08-02 16:55:41 -07:00
Dean
d798ebb545 Code review 2025-07-23 11:49:42 -07:00
Dean
f771e29b0f Fix test 2025-07-15 20:04:39 -07:00
Dean
d2f8495c1f Merge branch 'beta' of https://github.com/pagefaultgames/pokerogue into hwish 2025-07-15 20:00:33 -07:00
Dean
6fae3cbacd Implement PendingHealTag 2025-06-23 16:54:50 -07:00
Dean
b552779316 Remove NCPQ 2025-06-22 21:21:05 -07:00
26 changed files with 906 additions and 227 deletions

View File

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

View File

@ -104,6 +104,7 @@ import {
getLuckString, getLuckString,
getLuckTextTint, getLuckTextTint,
getPartyLuckValue, getPartyLuckValue,
type ModifierType,
PokemonHeldItemModifierType, PokemonHeldItemModifierType,
} from "#modifiers/modifier-type"; } from "#modifiers/modifier-type";
import { MysteryEncounter } from "#mystery-encounters/mystery-encounter"; import { MysteryEncounter } from "#mystery-encounters/mystery-encounter";
@ -1203,7 +1204,9 @@ export class BattleScene extends SceneBase {
this.updateScoreText(); this.updateScoreText();
this.scoreText.setVisible(false); this.scoreText.setVisible(false);
[this.luckLabelText, this.luckText].map(t => t.setVisible(false)); [this.luckLabelText, this.luckText].forEach(t => {
t.setVisible(false);
});
this.newArena(Overrides.STARTING_BIOME_OVERRIDE || BiomeId.TOWN); this.newArena(Overrides.STARTING_BIOME_OVERRIDE || BiomeId.TOWN);
@ -1237,8 +1240,7 @@ export class BattleScene extends SceneBase {
Object.values(mp) Object.values(mp)
.flat() .flat()
.map(mt => mt.modifierType) .map(mt => mt.modifierType)
.filter(mt => "localize" in mt) .filter((mt): mt is ModifierType & Localizable => "localize" in mt && typeof mt.localize === "function"),
.map(lpb => lpb as unknown as Localizable),
), ),
]; ];
for (const item of localizable) { for (const item of localizable) {
@ -1513,8 +1515,8 @@ export class BattleScene extends SceneBase {
return this.currentBattle; return this.currentBattle;
} }
newArena(biome: BiomeId, playerFaints?: number): Arena { newArena(biome: BiomeId, playerFaints = 0): Arena {
this.arena = new Arena(biome, BiomeId[biome].toLowerCase(), playerFaints); this.arena = new Arena(biome, playerFaints);
this.eventTarget.dispatchEvent(new NewArenaEvent()); this.eventTarget.dispatchEvent(new NewArenaEvent());
this.arenaBg.pipelineData = { this.arenaBg.pipelineData = {
@ -2711,7 +2713,9 @@ export class BattleScene extends SceneBase {
} }
} }
this.party.map(p => p.updateInfo(instant)); this.party.forEach(p => {
p.updateInfo(instant);
});
} else { } else {
const args = [this]; const args = [this];
if (modifier.shouldApply(...args)) { if (modifier.shouldApply(...args)) {

View File

@ -74,6 +74,7 @@ import {
randSeedItem, randSeedItem,
toDmgValue, toDmgValue,
} from "#utils/common"; } from "#utils/common";
import { toCamelCase } from "#utils/strings";
import i18next from "i18next"; import i18next from "i18next";
export class Ability implements Localizable { export class Ability implements Localizable {
@ -109,13 +110,9 @@ export class Ability implements Localizable {
} }
localize(): void { localize(): void {
const i18nKey = AbilityId[this.id] const i18nKey = toCamelCase(AbilityId[this.id]);
.split("_")
.filter(f => f)
.map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()))
.join("") as string;
this.name = this.id ? `${i18next.t(`ability:${i18nKey}.name`) as string}${this.nameAppend}` : ""; this.name = this.id ? `${i18next.t(`ability:${i18nKey}.name`)}${this.nameAppend}` : "";
this.description = this.id ? (i18next.t(`ability:${i18nKey}.description`) as string) : ""; this.description = this.id ? (i18next.t(`ability:${i18nKey}.description`) as string) : "";
} }

View File

@ -10,6 +10,7 @@ import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side"; import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type"; import { ArenaTagType } from "#enums/arena-tag-type";
import type { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type";
import { HitResult } from "#enums/hit-result"; import { HitResult } from "#enums/hit-result";
import { CommonAnim } from "#enums/move-anims-common"; import { CommonAnim } from "#enums/move-anims-common";
@ -28,7 +29,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, isNullOrUndefined, NumberHolder, toDmgValue } from "#utils/common";
import i18next from "i18next"; import i18next from "i18next";
/** /**
@ -1583,6 +1584,138 @@ export class SuppressAbilitiesTag extends SerializableArenaTag {
} }
} }
/**
* Interface containing data related to a queued healing effect from
* {@link https://bulbapedia.bulbagarden.net/wiki/Healing_Wish_(move) | Healing Wish}
* or {@link https://bulbapedia.bulbagarden.net/wiki/Lunar_Dance_(move) | Lunar Dance}.
*/
interface PendingHealEffect {
/** The {@linkcode Pokemon.id | PID} of the {@linkcode Pokemon} that created the effect. */
readonly sourceId: number;
/** The {@linkcode MoveId} of the move that created the effect. */
readonly moveId: MoveId;
/** If `true`, also restores the target's PP when the effect activates. */
readonly restorePP: boolean;
/** The message to display when the effect activates */
readonly healMessage: string;
}
/**
* Arena tag to contain stored healing effects, namely from
* {@link https://bulbapedia.bulbagarden.net/wiki/Healing_Wish_(move) | Healing Wish}
* and {@link https://bulbapedia.bulbagarden.net/wiki/Lunar_Dance_(move) | Lunar Dance}.
* When a damaged Pokemon first enters the effect's {@linkcode BattlerIndex | field position},
* their HP is fully restored, and they are cured of any non-volatile status condition.
* If the effect is from Lunar Dance, their PP is also restored.
*/
export class PendingHealTag extends SerializableArenaTag {
public readonly tagType = ArenaTagType.PENDING_HEAL;
/** All pending healing effects, organized by {@linkcode BattlerIndex} */
public readonly pendingHeals: Partial<Record<BattlerIndex, PendingHealEffect[]>> = {};
constructor() {
super(0);
}
/**
* Adds a pending healing effect to the field. Effects under the same move *and*
* target index as an existing effect are ignored.
* @param targetIndex - The {@linkcode BattlerIndex} under which the effect applies
* @param healEffect - The {@linkcode PendingHealEffect | data} for the pending heal effect
*/
public queueHeal(targetIndex: BattlerIndex, healEffect: PendingHealEffect): void {
const existingHealEffects = this.pendingHeals[targetIndex];
if (existingHealEffects) {
if (!existingHealEffects.some(he => he.moveId === healEffect.moveId)) {
existingHealEffects.push(healEffect);
}
} else {
this.pendingHeals[targetIndex] = [healEffect];
}
}
/** Removes default on-remove message */
override onRemove(_arena: Arena): void {}
/** This arena tag is removed at the end of the turn if no pending healing effects are on the field */
override lapse(_arena: Arena): boolean {
for (const key in this.pendingHeals) {
if (this.pendingHeals[key].length > 0) {
return true;
}
}
return false;
}
/**
* Applies a pending healing effect on the given target index. If an effect is found for
* the index, the Pokemon at that index is healed to full HP, is cured of any non-volatile status,
* and has its PP fully restored (if the effect is from Lunar Dance).
* @param arena - The {@linkcode Arena} containing this tag
* @param simulated - If `true`, suppresses changes to game state
* @param pokemon - The {@linkcode Pokemon} receiving the healing effect
* @returns `true` if the target Pokemon was healed by this effect
* @todo This should also be called when a Pokemon moves into a new position via Ally Switch
*/
override apply(arena: Arena, simulated: boolean, pokemon: Pokemon): boolean {
const targetIndex = pokemon.getBattlerIndex();
const targetEffects = this.pendingHeals[targetIndex];
if (simulated) {
return !!targetEffects?.length;
}
const healEffect = targetEffects?.find(effect => this.canApply(effect, pokemon));
if (targetEffects && healEffect) {
const { sourceId, moveId, restorePP, healMessage } = healEffect;
const sourcePokemon = globalScene.getPokemonById(sourceId);
if (!sourcePokemon) {
console.warn(`Source of pending ${allMoves[moveId].name} effect is undefined!`);
targetEffects.splice(targetEffects.indexOf(healEffect), 1);
// Re-evaluate after the invalid heal effect is removed
return this.apply(arena, simulated, pokemon);
}
globalScene.phaseManager.unshiftNew(
"PokemonHealPhase",
targetIndex,
pokemon.getMaxHp(),
healMessage,
true,
false,
false,
true,
false,
restorePP,
);
targetEffects.splice(targetEffects.indexOf(healEffect), 1);
}
return !isNullOrUndefined(healEffect);
}
/**
* Determines if the given {@linkcode PendingHealEffect} can immediately heal
* the given target {@linkcode Pokemon}.
* @param healEffect - The {@linkcode PendingHealEffect} to evaluate
* @param pokemon - The {@linkcode Pokemon} to evaluate against
* @returns `true` if the Pokemon can be healed by the effect
*/
private canApply(healEffect: PendingHealEffect, pokemon: Pokemon): boolean {
return (
!pokemon.isFullHp() ||
!isNullOrUndefined(pokemon.status) ||
(healEffect.restorePP && pokemon.getMoveset().some(mv => mv.ppUsed > 0))
);
}
override loadTag(source: BaseArenaTag & Pick<PendingHealTag, "tagType" | "pendingHeals">): void {
super.loadTag(source);
(this as Mutable<this>).pendingHeals = source.pendingHeals;
}
}
// TODO: swap `sourceMove` and `sourceId` and make `sourceMove` an optional parameter // TODO: swap `sourceMove` and `sourceId` and make `sourceMove` an optional parameter
export function getArenaTag( export function getArenaTag(
tagType: ArenaTagType, tagType: ArenaTagType,
@ -1646,6 +1779,8 @@ export function getArenaTag(
return new FairyLockTag(turnCount, sourceId); return new FairyLockTag(turnCount, sourceId);
case ArenaTagType.NEUTRALIZING_GAS: case ArenaTagType.NEUTRALIZING_GAS:
return new SuppressAbilitiesTag(sourceId); return new SuppressAbilitiesTag(sourceId);
case ArenaTagType.PENDING_HEAL:
return new PendingHealTag();
default: default:
return null; return null;
} }
@ -1694,5 +1829,6 @@ export type ArenaTagTypeMap = {
[ArenaTagType.GRASS_WATER_PLEDGE]: GrassWaterPledgeTag; [ArenaTagType.GRASS_WATER_PLEDGE]: GrassWaterPledgeTag;
[ArenaTagType.FAIRY_LOCK]: FairyLockTag; [ArenaTagType.FAIRY_LOCK]: FairyLockTag;
[ArenaTagType.NEUTRALIZING_GAS]: SuppressAbilitiesTag; [ArenaTagType.NEUTRALIZING_GAS]: SuppressAbilitiesTag;
[ArenaTagType.PENDING_HEAL]: PendingHealTag;
[ArenaTagType.NONE]: NoneTag; [ArenaTagType.NONE]: NoneTag;
}; };

View File

@ -1866,17 +1866,16 @@ interface PokemonPrevolutions {
export const pokemonPrevolutions: PokemonPrevolutions = {}; export const pokemonPrevolutions: PokemonPrevolutions = {};
export function initPokemonPrevolutions(): void { export function initPokemonPrevolutions(): void {
const megaFormKeys = [ SpeciesFormKey.MEGA, "", SpeciesFormKey.MEGA_X, "", SpeciesFormKey.MEGA_Y ].map(sfk => sfk as string); // TODO: Why do we have empty strings in our array?
const prevolutionKeys = Object.keys(pokemonEvolutions); const megaFormKeys = [ SpeciesFormKey.MEGA, "", SpeciesFormKey.MEGA_X, "", SpeciesFormKey.MEGA_Y ];
prevolutionKeys.forEach(pk => { for (const [pk, evolutions] of Object.entries(pokemonEvolutions)) {
const evolutions = pokemonEvolutions[pk];
for (const ev of evolutions) { for (const ev of evolutions) {
if (ev.evoFormKey && megaFormKeys.indexOf(ev.evoFormKey) > -1) { if (ev.evoFormKey && megaFormKeys.indexOf(ev.evoFormKey) > -1) {
continue; continue;
} }
pokemonPrevolutions[ev.speciesId] = Number.parseInt(pk) as SpeciesId; pokemonPrevolutions[ev.speciesId] = Number.parseInt(pk) as SpeciesId;
} }
}); }
} }

View File

@ -6,7 +6,7 @@ import { loggedInUser } from "#app/account";
import type { GameMode } from "#app/game-mode"; import type { GameMode } from "#app/game-mode";
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages"; import { getPokemonNameWithAffix } from "#app/messages";
import type { ArenaTrapTag } from "#data/arena-tag"; import type { ArenaTrapTag, PendingHealTag } from "#data/arena-tag";
import { WeakenMoveTypeTag } from "#data/arena-tag"; import { WeakenMoveTypeTag } from "#data/arena-tag";
import { MoveChargeAnim } from "#data/battle-anims"; import { MoveChargeAnim } from "#data/battle-anims";
import { import {
@ -90,7 +90,7 @@ import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindS
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";
import { toTitleCase } from "#utils/strings"; import { toCamelCase, toTitleCase } from "#utils/strings";
import i18next from "i18next"; import i18next from "i18next";
import { applyChallenges } from "#utils/challenge-utils"; import { applyChallenges } from "#utils/challenge-utils";
@ -162,10 +162,16 @@ export abstract class Move implements Localizable {
} }
localize(): void { localize(): void {
const i18nKey = MoveId[this.id].split("_").filter(f => f).map((f, i) => i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()).join("") as unknown as string; const i18nKey = toCamelCase(MoveId[this.id])
this.name = this.id ? `${i18next.t(`move:${i18nKey}.name`)}${this.nameAppend}` : ""; if (this.id === MoveId.NONE) {
this.effect = this.id ? `${i18next.t(`move:${i18nKey}.effect`)}${this.nameAppend}` : ""; this.name = "";
this.effect = ""
return;
}
this.name = `${i18next.t(`move:${i18nKey}.name`)}${this.nameAppend}`;
this.effect = `${i18next.t(`move:${i18nKey}.effect`)}${this.nameAppend}`;
} }
/** /**
@ -2104,24 +2110,15 @@ export class SacrificialFullRestoreAttr extends SacrificialAttr {
return false; return false;
} }
// We don't know which party member will be chosen, so pick the highest max HP in the party // Add a tag to the field if it doesn't already exist, then queue a delayed healing effect in the user's current slot.
const party = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty(); globalScene.arena.addTag(ArenaTagType.PENDING_HEAL, 0, move.id, user.id); // Arguments after first go completely unused
const maxPartyMemberHp = party.map(p => p.getMaxHp()).reduce((maxHp: number, hp: number) => Math.max(hp, maxHp), 0); const tag = globalScene.arena.getTag(ArenaTagType.PENDING_HEAL) as PendingHealTag;
tag.queueHeal(user.getBattlerIndex(), {
const pm = globalScene.phaseManager; sourceId: user.id,
moveId: move.id,
pm.pushPhase( restorePP: this.restorePP,
pm.create("PokemonHealPhase", healMessage: i18next.t(this.moveMessage, { pokemonName: getPokemonNameWithAffix(user) }),
user.getBattlerIndex(), });
maxPartyMemberHp,
i18next.t(this.moveMessage, { pokemonName: getPokemonNameWithAffix(user) }),
true,
false,
false,
true,
false,
this.restorePP),
true);
return true; return true;
} }

View File

@ -12,6 +12,7 @@ import { WeatherType } from "#enums/weather-type";
import type { Pokemon } from "#field/pokemon"; import type { Pokemon } from "#field/pokemon";
import type { PokemonFormChangeItemModifier } from "#modifiers/modifier"; import type { PokemonFormChangeItemModifier } from "#modifiers/modifier";
import { type Constructor, coerceArray } from "#utils/common"; import { type Constructor, coerceArray } from "#utils/common";
import { toCamelCase } from "#utils/strings";
import i18next from "i18next"; import i18next from "i18next";
export abstract class SpeciesFormChangeTrigger { export abstract class SpeciesFormChangeTrigger {
@ -143,11 +144,7 @@ export class SpeciesFormChangeMoveLearnedTrigger extends SpeciesFormChangeTrigge
super(); super();
this.move = move; this.move = move;
this.known = known; this.known = known;
const moveKey = MoveId[this.move] const moveKey = toCamelCase(MoveId[this.move]);
.split("_")
.filter(f => f)
.map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()))
.join("") as unknown as string;
this.description = known this.description = known
? i18next.t("pokemonEvolutions:Forms.moveLearned", { ? i18next.t("pokemonEvolutions:Forms.moveLearned", {
move: i18next.t(`move:${moveKey}.name`), move: i18next.t(`move:${moveKey}.name`),

View File

@ -36,5 +36,6 @@ export enum ArenaTagType {
WATER_FIRE_PLEDGE = "WATER_FIRE_PLEDGE", WATER_FIRE_PLEDGE = "WATER_FIRE_PLEDGE",
GRASS_WATER_PLEDGE = "GRASS_WATER_PLEDGE", GRASS_WATER_PLEDGE = "GRASS_WATER_PLEDGE",
FAIRY_LOCK = "FAIRY_LOCK", FAIRY_LOCK = "FAIRY_LOCK",
NEUTRALIZING_GAS = "NEUTRALIZING_GAS" NEUTRALIZING_GAS = "NEUTRALIZING_GAS",
PENDING_HEAL = "PENDING_HEAL"
} }

View File

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

View File

@ -54,7 +54,7 @@ export class Arena {
public bgm: string; public bgm: string;
public ignoreAbilities: boolean; public ignoreAbilities: boolean;
public ignoringEffectSource: BattlerIndex | null; public ignoringEffectSource: BattlerIndex | null;
public playerTerasUsed: number; public playerTerasUsed = 0;
/** /**
* Saves the number of times a party pokemon faints during a arena encounter. * Saves the number of times a party pokemon faints during a arena encounter.
* {@linkcode globalScene.currentBattle.enemyFaints} is the corresponding faint counter for the enemy (this resets every wave). * {@linkcode globalScene.currentBattle.enemyFaints} is the corresponding faint counter for the enemy (this resets every wave).
@ -68,12 +68,11 @@ export class Arena {
public readonly eventTarget: EventTarget = new EventTarget(); public readonly eventTarget: EventTarget = new EventTarget();
constructor(biome: BiomeId, bgm: string, playerFaints = 0) { constructor(biome: BiomeId, playerFaints = 0) {
this.biomeType = biome; this.biomeType = biome;
this.bgm = bgm; this.bgm = BiomeId[biome].toLowerCase();
this.trainerPool = biomeTrainerPools[biome]; this.trainerPool = biomeTrainerPools[biome];
this.updatePoolsForTimeOfDay(); this.updatePoolsForTimeOfDay();
this.playerTerasUsed = 0;
this.playerFaints = playerFaints; this.playerFaints = playerFaints;
} }

View File

@ -447,7 +447,9 @@ export class LoadingScene extends SceneBase {
); );
if (!mobile) { if (!mobile) {
loadingGraphics.map(g => g.setVisible(false)); loadingGraphics.forEach(g => {
g.setVisible(false);
});
} }
const intro = this.add.video(0, 0); const intro = this.add.video(0, 0);

View File

@ -121,8 +121,8 @@ export class ModifierBar extends Phaser.GameObjects.Container {
} }
updateModifierOverflowVisibility(ignoreLimit: boolean) { updateModifierOverflowVisibility(ignoreLimit: boolean) {
const modifierIcons = this.getAll().reverse(); const modifierIcons = this.getAll().reverse() as Phaser.GameObjects.Container[];
for (const modifier of modifierIcons.map(m => m as Phaser.GameObjects.Container).slice(iconOverflowIndex)) { for (const modifier of modifierIcons.slice(iconOverflowIndex)) {
modifier.setVisible(ignoreLimit); modifier.setVisible(ignoreLimit);
} }
} }

View File

@ -229,7 +229,6 @@ export class PhaseManager {
/** overrides default of inserting phases to end of phaseQueuePrepend array. Useful for inserting Phases "out of order" */ /** overrides default of inserting phases to end of phaseQueuePrepend array. Useful for inserting Phases "out of order" */
private phaseQueuePrependSpliceIndex = -1; private phaseQueuePrependSpliceIndex = -1;
private nextCommandPhaseQueue: Phase[] = [];
/** Storage for {@linkcode PhasePriorityQueue}s which hold phases whose order dynamically changes */ /** Storage for {@linkcode PhasePriorityQueue}s which hold phases whose order dynamically changes */
private dynamicPhaseQueues: PhasePriorityQueue[]; private dynamicPhaseQueues: PhasePriorityQueue[];
@ -285,13 +284,12 @@ export class PhaseManager {
/** /**
* Adds a phase to nextCommandPhaseQueue, as long as boolean passed in is false * Adds a phase to nextCommandPhaseQueue, as long as boolean passed in is false
* @param phase {@linkcode Phase} the phase to add * @param phase {@linkcode Phase} the phase to add
* @param defer boolean on which queue to add to, defaults to false, and adds to phaseQueue
*/ */
pushPhase(phase: Phase, defer = false): void { pushPhase(phase: Phase): void {
if (this.getDynamicPhaseType(phase) !== undefined) { if (this.getDynamicPhaseType(phase) !== undefined) {
this.pushDynamicPhase(phase); this.pushDynamicPhase(phase);
} else { } else {
(!defer ? this.phaseQueue : this.nextCommandPhaseQueue).push(phase); this.phaseQueue.push(phase);
} }
} }
@ -318,7 +316,7 @@ export class PhaseManager {
* Clears all phase-related stuff, including all phase queues, the current and standby phases, and a splice index * Clears all phase-related stuff, including all phase queues, the current and standby phases, and a splice index
*/ */
clearAllPhases(): void { clearAllPhases(): void {
for (const queue of [this.phaseQueue, this.phaseQueuePrepend, this.conditionalQueue, this.nextCommandPhaseQueue]) { for (const queue of [this.phaseQueue, this.phaseQueuePrepend, this.conditionalQueue]) {
queue.splice(0, queue.length); queue.splice(0, queue.length);
} }
this.dynamicPhaseQueues.forEach(queue => queue.clear()); this.dynamicPhaseQueues.forEach(queue => queue.clear());
@ -600,10 +598,6 @@ export class PhaseManager {
* Moves everything from nextCommandPhaseQueue to phaseQueue (keeping order) * Moves everything from nextCommandPhaseQueue to phaseQueue (keeping order)
*/ */
private populatePhaseQueue(): void { private populatePhaseQueue(): void {
if (this.nextCommandPhaseQueue.length) {
this.phaseQueue.push(...this.nextCommandPhaseQueue);
this.nextCommandPhaseQueue.splice(0, this.nextCommandPhaseQueue.length);
}
this.phaseQueue.push(new TurnInitPhase()); this.phaseQueue.push(new TurnInitPhase());
} }

View File

@ -63,7 +63,8 @@ export class PokemonHealPhase extends CommonAnimPhase {
} }
const hasMessage = !!this.message; const hasMessage = !!this.message;
const healOrDamage = !pokemon.isFullHp() || this.hpHealed < 0; const canRestorePP = this.fullRestorePP && pokemon.getMoveset().some(mv => mv.ppUsed > 0);
const healOrDamage = !pokemon.isFullHp() || this.hpHealed < 0 || canRestorePP;
const healBlock = pokemon.getTag(BattlerTagType.HEAL_BLOCK) as HealBlockTag; const healBlock = pokemon.getTag(BattlerTagType.HEAL_BLOCK) as HealBlockTag;
let lastStatusEffect = StatusEffect.NONE; let lastStatusEffect = StatusEffect.NONE;

View File

@ -2,6 +2,7 @@ import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { ArenaTrapTag } from "#data/arena-tag"; import { ArenaTrapTag } from "#data/arena-tag";
import { MysteryEncounterPostSummonTag } from "#data/battler-tags"; import { MysteryEncounterPostSummonTag } from "#data/battler-tags";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerTagType } from "#enums/battler-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type";
import { StatusEffect } from "#enums/status-effect"; import { StatusEffect } from "#enums/status-effect";
import { PokemonPhase } from "#phases/pokemon-phase"; import { PokemonPhase } from "#phases/pokemon-phase";
@ -16,6 +17,9 @@ export class PostSummonPhase extends PokemonPhase {
if (pokemon.status?.effect === StatusEffect.TOXIC) { if (pokemon.status?.effect === StatusEffect.TOXIC) {
pokemon.status.toxicTurnCount = 0; pokemon.status.toxicTurnCount = 0;
} }
globalScene.arena.applyTags(ArenaTagType.PENDING_HEAL, false, pokemon);
globalScene.arena.applyTags(ArenaTrapTag, false, pokemon); globalScene.arena.applyTags(ArenaTrapTag, false, pokemon);
// If this is mystery encounter and has post summon phase tag, apply post summon effects // If this is mystery encounter and has post summon phase tag, apply post summon effects

View File

@ -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,
@ -206,10 +207,12 @@ export interface StarterData {
[key: number]: StarterDataEntry; [key: number]: StarterDataEntry;
} }
export interface TutorialFlags { // TODO: Rework into a bitmask
[key: string]: boolean; export type TutorialFlags = {
} [key in Tutorial]: boolean;
};
// TODO: Rework into a bitmask
export interface SeenDialogues { export interface SeenDialogues {
[key: string]: boolean; [key: string]: boolean;
} }
@ -822,52 +825,51 @@ export class GameData {
return true; // TODO: is `true` the correct return value? return true; // TODO: is `true` the correct return value?
} }
private loadGamepadSettings(): boolean { private loadGamepadSettings(): void {
Object.values(SettingGamepad) Object.values(SettingGamepad).forEach(setting => {
.map(setting => setting as SettingGamepad) setSettingGamepad(setting, settingGamepadDefaults[setting]);
.forEach(setting => setSettingGamepad(setting, settingGamepadDefaults[setting])); });
if (!localStorage.hasOwnProperty("settingsGamepad")) { if (!localStorage.hasOwnProperty("settingsGamepad")) {
return false; return;
} }
const settingsGamepad = JSON.parse(localStorage.getItem("settingsGamepad")!); // TODO: is this bang correct? const settingsGamepad = JSON.parse(localStorage.getItem("settingsGamepad")!); // TODO: is this bang correct?
for (const setting of Object.keys(settingsGamepad)) { for (const setting of Object.keys(settingsGamepad)) {
setSettingGamepad(setting as SettingGamepad, settingsGamepad[setting]); setSettingGamepad(setting as SettingGamepad, settingsGamepad[setting]);
} }
return true; // TODO: is `true` the correct return value?
} }
public saveTutorialFlag(tutorial: Tutorial, flag: boolean): boolean { /**
const key = getDataTypeKey(GameDataType.TUTORIALS); * Save the specified tutorial as having the specified completion status.
let tutorials: object = {}; * @param tutorial - The {@linkcode Tutorial} whose completion status is being saved
if (localStorage.hasOwnProperty(key)) { * @param status - The completion status to set
tutorials = JSON.parse(localStorage.getItem(key)!); // TODO: is this bang correct? */
public saveTutorialFlag(tutorial: Tutorial, status: boolean): void {
// Grab the prior save data tutorial
const saveDataKey = getDataTypeKey(GameDataType.TUTORIALS);
const tutorials: TutorialFlags = localStorage.hasOwnProperty(saveDataKey)
? JSON.parse(localStorage.getItem(saveDataKey)!)
: {};
// TODO: We shouldn't be storing this like that
for (const key of Object.values(Tutorial)) {
if (key === tutorial) {
tutorials[key] = status;
} else {
tutorials[key] ??= false;
}
} }
Object.keys(Tutorial) localStorage.setItem(saveDataKey, JSON.stringify(tutorials));
.map(t => t as Tutorial)
.forEach(t => {
const key = Tutorial[t];
if (key === tutorial) {
tutorials[key] = flag;
} else {
tutorials[key] ??= false;
}
});
localStorage.setItem(key, JSON.stringify(tutorials));
return true;
} }
public getTutorialFlags(): TutorialFlags { public getTutorialFlags(): TutorialFlags {
const key = getDataTypeKey(GameDataType.TUTORIALS); const key = getDataTypeKey(GameDataType.TUTORIALS);
const ret: TutorialFlags = {}; const ret: TutorialFlags = Object.values(Tutorial).reduce((acc, tutorial) => {
Object.values(Tutorial) acc[Tutorial[tutorial]] = false;
.map(tutorial => tutorial as Tutorial) return acc;
.forEach(tutorial => (ret[Tutorial[tutorial]] = false)); }, {} as TutorialFlags);
if (!localStorage.hasOwnProperty(key)) { if (!localStorage.hasOwnProperty(key)) {
return ret; return ret;
@ -979,6 +981,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) => {

View File

@ -287,6 +287,12 @@ export class ArenaFlyout extends Phaser.GameObjects.Container {
switch (arenaEffectChangedEvent.constructor) { switch (arenaEffectChangedEvent.constructor) {
case TagAddedEvent: { case TagAddedEvent: {
const tagAddedEvent = arenaEffectChangedEvent as TagAddedEvent; const tagAddedEvent = arenaEffectChangedEvent as TagAddedEvent;
const excludedTags = [ArenaTagType.PENDING_HEAL];
if (excludedTags.includes(tagAddedEvent.arenaTagType)) {
return;
}
const isArenaTrapTag = globalScene.arena.getTag(tagAddedEvent.arenaTagType) instanceof ArenaTrapTag; const isArenaTrapTag = globalScene.arena.getTag(tagAddedEvent.arenaTagType) instanceof ArenaTrapTag;
let arenaEffectType: ArenaEffectType; let arenaEffectType: ArenaEffectType;

View 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;
}
}

View File

@ -26,6 +26,7 @@ import { addBBCodeTextObject, addTextObject, getTextColor } from "#ui/text";
import { UiHandler } from "#ui/ui-handler"; import { UiHandler } from "#ui/ui-handler";
import { addWindow } from "#ui/ui-theme"; import { addWindow } from "#ui/ui-theme";
import { formatFancyLargeNumber, formatLargeNumber, formatMoney, getPlayTimeString } from "#utils/common"; import { formatFancyLargeNumber, formatLargeNumber, formatMoney, getPlayTimeString } from "#utils/common";
import { toCamelCase } from "#utils/strings";
import i18next from "i18next"; import i18next from "i18next";
import RoundRectangle from "phaser3-rex-plugins/plugins/roundrectangle"; import RoundRectangle from "phaser3-rex-plugins/plugins/roundrectangle";
@ -207,6 +208,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);
} }
/** /**
@ -702,10 +707,7 @@ export class RunInfoUiHandler extends UiHandler {
rules.push(i18next.t("challenges:inverseBattle.shortName")); rules.push(i18next.t("challenges:inverseBattle.shortName"));
break; break;
default: { default: {
const localizationKey = Challenges[this.runInfo.challenges[i].id] const localizationKey = toCamelCase(Challenges[this.runInfo.challenges[i].id]);
.split("_")
.map((f, i) => (i ? `${f[0]}${f.slice(1).toLowerCase()}` : f.toLowerCase()))
.join("");
rules.push(i18next.t(`challenges:${localizationKey}.name`)); rules.push(i18next.t(`challenges:${localizationKey}.name`));
break; break;
} }

View File

@ -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) {
originalCallback?.(cursor); manageDataOptions.push({
label: i18next.t("menu:loadGame"),
handler: () => {
globalScene.ui.revertMode();
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);
@ -427,13 +594,9 @@ class SessionSlot extends Phaser.GameObjects.Container {
TextStyle.PARTY, TextStyle.PARTY,
{ fontSize: "54px", color: "#f8f8f8" }, { fontSize: "54px", color: "#f8f8f8" },
); );
text.setShadow(0, 0, undefined); text.setShadow(0, 0, undefined).setStroke("#424242", 14).setOrigin(1, 0);
text.setStroke("#424242", 14);
text.setOrigin(1, 0);
iconContainer.add(icon);
iconContainer.add(text);
iconContainer.add([icon, text]);
pokemonIconsContainer.add(iconContainer); pokemonIconsContainer.add(iconContainer);
pokemon.destroy(); pokemon.destroy();
@ -441,7 +604,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,22 +627,33 @@ 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
// Ignore the results if the view was exited .getSession(this.slotId)
if (!this.active) { .then(async sessionData => {
return; // Ignore the results if the view was exited
} if (!this.active) {
if (!sessionData) { return;
this.hasData = false; }
this.loadingLabel.setText(i18next.t("saveSlotSelectUiHandler:empty")); this.hasData = !!sessionData;
resolve(false); if (!sessionData) {
return; this.loadingLabel.setText(i18next.t("saveSlotSelectUiHandler:empty"));
} resolve(false);
this.hasData = true; return;
this.saveData = sessionData; }
await this.setupWithData(sessionData); this.saveData = sessionData;
resolve(true); 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);
});
}); });
} }
} }

View File

@ -31,7 +31,7 @@ export class TestDialogueUiHandler extends FormModalUiHandler {
// we check for null or undefined here as per above - the typeof is still an object but the value is null so we need to exit out of this and pass the null key // we check for null or undefined here as per above - the typeof is still an object but the value is null so we need to exit out of this and pass the null key
// Return in the format expected by i18next // Return in the format expected by i18next
return middleKey ? `${topKey}:${middleKey.map(m => m).join(".")}.${t}` : `${topKey}:${t}`; return middleKey ? `${topKey}:${middleKey.join(".")}.${t}` : `${topKey}:${t}`;
} }
}) })
.filter(t => t); .filter(t => t);

View File

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

View File

@ -0,0 +1,245 @@
import { AbilityId } from "#enums/ability-id";
import { ArenaTagType } from "#enums/arena-tag-type";
import { Challenges } from "#enums/challenges";
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 { StatusEffect } from "#enums/status-effect";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Lunar Dance and Healing Wish", () => {
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("double").enemyAbility(AbilityId.BALL_FETCH).enemyMoveset(MoveId.SPLASH);
});
describe.each([
{ moveName: "Healing Wish", moveId: MoveId.HEALING_WISH },
{ moveName: "Lunar Dance", moveId: MoveId.LUNAR_DANCE },
])("$moveName", ({ moveId }) => {
it("should sacrifice the user to restore the switched in Pokemon's HP", async () => {
await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER, SpeciesId.SQUIRTLE]);
const [bulbasaur, charmander, squirtle] = game.scene.getPlayerParty();
squirtle.hp = 1;
game.move.use(MoveId.SPLASH, 0);
game.move.use(moveId, 1);
game.doSelectPartyPokemon(2);
await game.toNextTurn();
expect(bulbasaur.isFullHp()).toBe(true);
expect(charmander.isFainted()).toBe(true);
expect(squirtle.isFullHp()).toBe(true);
});
it("should sacrifice the user to cure the switched in Pokemon's status", async () => {
game.override.statusEffect(StatusEffect.BURN);
await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER, SpeciesId.SQUIRTLE]);
const [bulbasaur, charmander, squirtle] = game.scene.getPlayerParty();
game.move.use(MoveId.SPLASH, 0);
game.move.use(moveId, 1);
game.doSelectPartyPokemon(2);
await game.toNextTurn();
expect(bulbasaur.status?.effect).toBe(StatusEffect.BURN);
expect(charmander.isFainted()).toBe(true);
expect(squirtle.status?.effect).toBeUndefined();
});
it("should fail if the user has no non-fainted allies in their party", async () => {
game.override.battleStyle("single");
await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER]);
const [bulbasaur, charmander] = game.scene.getPlayerParty();
game.move.use(MoveId.MEMENTO);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
expect(bulbasaur.isFainted()).toBe(true);
expect(charmander.isActive(true)).toBe(true);
game.move.use(moveId);
await game.toEndOfTurn();
expect(charmander.isFullHp());
expect(charmander.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should fail if the user has no challenge-eligible allies", async () => {
game.override.battleStyle("single");
// Mono normal challenge
game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, PokemonType.NORMAL + 1, 0);
await game.challengeMode.startBattle([SpeciesId.RATICATE, SpeciesId.ODDISH]);
const raticate = game.field.getPlayerPokemon();
game.move.use(moveId);
await game.toNextTurn();
expect(raticate.isFullHp()).toBe(true);
expect(raticate.getLastXMoves()[0].result).toEqual(MoveResult.FAIL);
});
it("should store its effect if the switched-in Pokemon would be unaffected", async () => {
game.override.battleStyle("single");
await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER, SpeciesId.SQUIRTLE]);
const [bulbasaur, charmander, squirtle] = game.scene.getPlayerParty();
squirtle.hp = 1;
game.move.use(moveId);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
// Bulbasaur fainted and stored a healing effect
expect(bulbasaur.isFainted()).toBe(true);
expect(charmander.isFullHp()).toBe(true);
expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase");
expect(game.scene.arena.getTag(ArenaTagType.PENDING_HEAL)).toBeDefined();
await game.toNextTurn();
// Switch to damaged Squirtle. HW/LD's effect should activate
await game.toEndOfTurn();
expect(squirtle.isFullHp()).toBe(true);
expect(game.scene.arena.getTag(ArenaTagType.PENDING_HEAL)).toBeUndefined();
// Set Charmander's HP to 1, then switch back to Charmander.
// HW/LD shouldn't activate again
charmander.hp = 1;
game.doSwitchPokemon(2);
await game.toEndOfTurn();
expect(charmander.hp).toBe(1);
});
it("should only store one charge of the effect at a time", async () => {
game.override.battleStyle("single");
await game.classicMode.startBattle([
SpeciesId.BULBASAUR,
SpeciesId.CHARMANDER,
SpeciesId.SQUIRTLE,
SpeciesId.PIKACHU,
]);
const [bulbasaur, charmander, squirtle, pikachu] = game.scene.getPlayerParty();
[squirtle, pikachu].forEach(p => (p.hp = 1));
// Use HW/LD and send in Charmander. HW/LD's effect should be stored
game.move.use(moveId);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
expect(bulbasaur.isFainted()).toBe(true);
expect(charmander.isFullHp()).toBe(true);
expect(charmander.isFullHp());
expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase");
expect(game.scene.arena.getTag(ArenaTagType.PENDING_HEAL)).toBeDefined();
// Use HW/LD again, sending in Squirtle. HW/LD should activate and heal Squirtle
game.move.use(moveId);
game.doSelectPartyPokemon(2);
expect(charmander.isFainted()).toBe(true);
expect(squirtle.isFullHp()).toBe(true);
expect(squirtle.isFullHp());
// Switch again to Pikachu. HW/LD's effect shouldn't be present
game.doSwitchPokemon(3);
expect(pikachu.isFullHp()).toBe(false);
});
});
it("Lunar Dance should sacrifice the user to restore the switched in Pokemon's PP", async () => {
game.override.battleStyle("single");
await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.CHARMANDER]);
const [bulbasaur, charmander] = game.scene.getPlayerParty();
game.move.use(MoveId.SPLASH);
await game.toNextTurn();
game.doSwitchPokemon(1);
await game.toNextTurn();
game.move.use(MoveId.LUNAR_DANCE);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
expect(charmander.isFainted()).toBeTruthy();
bulbasaur.getMoveset().forEach(mv => expect(mv.ppUsed).toBe(0));
});
it("should stack with each other", async () => {
game.override.battleStyle("single");
await game.classicMode.startBattle([
SpeciesId.BULBASAUR,
SpeciesId.CHARMANDER,
SpeciesId.SQUIRTLE,
SpeciesId.PIKACHU,
]);
const [bulbasaur, charmander, squirtle, pikachu] = game.scene.getPlayerParty();
[squirtle, pikachu].forEach(p => {
p.hp = 1;
p.getMoveset().forEach(mv => (mv.ppUsed = 1));
});
game.move.use(MoveId.LUNAR_DANCE);
game.doSelectPartyPokemon(1);
await game.toNextTurn();
expect(bulbasaur.isFainted()).toBe(true);
expect(charmander.isFullHp()).toBe(true);
expect(game.phaseInterceptor.log).not.toContain("PokemonHealPhase");
expect(game.scene.arena.getTag(ArenaTagType.PENDING_HEAL)).toBeDefined();
game.move.use(MoveId.HEALING_WISH);
game.doSelectPartyPokemon(2);
// Lunar Dance should apply first since it was used first, restoring Squirtle's HP and PP
await game.toNextTurn();
expect(squirtle.isFullHp()).toBe(true);
squirtle.getMoveset().forEach(mv => expect(mv.ppUsed).toBe(0));
expect(game.scene.arena.getTag(ArenaTagType.PENDING_HEAL)).toBeDefined();
game.doSwitchPokemon(3);
// Healing Wish should apply on the next switch, restoring Pikachu's HP
await game.toEndOfTurn();
expect(pikachu.isFullHp()).toBe(true);
pikachu.getMoveset().forEach(mv => expect(mv.ppUsed).toBe(1));
expect(game.scene.arena.getTag(ArenaTagType.PENDING_HEAL)).toBeUndefined();
});
});

View File

@ -1,73 +0,0 @@
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Lunar Dance", () => {
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
.statusEffect(StatusEffect.BURN)
.battleStyle("double")
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
it("should full restore HP, PP and status of switched in pokemon, then fail second use because no remaining backup pokemon in party", async () => {
await game.classicMode.startBattle([SpeciesId.BULBASAUR, SpeciesId.ODDISH, SpeciesId.RATTATA]);
const [bulbasaur, oddish, rattata] = game.scene.getPlayerParty();
game.move.changeMoveset(bulbasaur, [MoveId.LUNAR_DANCE, MoveId.SPLASH]);
game.move.changeMoveset(oddish, [MoveId.LUNAR_DANCE, MoveId.SPLASH]);
game.move.changeMoveset(rattata, [MoveId.LUNAR_DANCE, MoveId.SPLASH]);
game.move.select(MoveId.SPLASH, 0);
game.move.select(MoveId.SPLASH, 1);
await game.toNextTurn();
// Bulbasaur should still be burned and have used a PP for splash and not at max hp
expect(bulbasaur.status?.effect).toBe(StatusEffect.BURN);
expect(bulbasaur.moveset[1]?.ppUsed).toBe(1);
expect(bulbasaur.hp).toBeLessThan(bulbasaur.getMaxHp());
// Switch out Bulbasaur for Rattata so we can swtich bulbasaur back in with lunar dance
game.doSwitchPokemon(2);
game.move.select(MoveId.SPLASH, 1);
await game.toNextTurn();
game.move.select(MoveId.SPLASH, 0);
game.move.select(MoveId.LUNAR_DANCE);
game.doSelectPartyPokemon(2);
await game.phaseInterceptor.to("SwitchPhase", false);
await game.toNextTurn();
// Bulbasaur should NOT have any status and have full PP for splash and be at max hp
expect(bulbasaur.status?.effect).toBeUndefined();
expect(bulbasaur.moveset[1]?.ppUsed).toBe(0);
expect(bulbasaur.isFullHp()).toBe(true);
game.move.select(MoveId.SPLASH, 0);
game.move.select(MoveId.LUNAR_DANCE);
await game.toNextTurn();
// Using Lunar dance again should fail because nothing in party and rattata should be alive
expect(rattata.status?.effect).toBe(StatusEffect.BURN);
expect(rattata.hp).toBeLessThan(rattata.getMaxHp());
});
});

View File

@ -112,7 +112,7 @@ describe("Weird Dream - Mystery Encounter", () => {
it("should transform the new party into new species, 2 at +90/+110, the rest at +40/50 BST", async () => { it("should transform the new party into new species, 2 at +90/+110, the rest at +40/50 BST", async () => {
await game.runToMysteryEncounter(MysteryEncounterType.WEIRD_DREAM, defaultParty); await game.runToMysteryEncounter(MysteryEncounterType.WEIRD_DREAM, defaultParty);
const pokemonPrior = scene.getPlayerParty().map(pokemon => pokemon); const pokemonPrior = scene.getPlayerParty().slice();
const bstsPrior = pokemonPrior.map(species => species.getSpeciesForm().getBaseStatTotal()); const bstsPrior = pokemonPrior.map(species => species.getSpeciesForm().getBaseStatTotal());
await runMysteryEncounterToEnd(game, 1); await runMysteryEncounterToEnd(game, 1);

View 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();
});
});
});