From f63492d54569843016453e2ba1c7424c1d436f87 Mon Sep 17 00:00:00 2001 From: Frederico Santos Date: Wed, 4 Sep 2024 01:56:37 +0100 Subject: [PATCH 01/22] chore: Update merge_group configuration in GitHub workflows --- .github/workflows/eslint.yml | 2 ++ .github/workflows/github-pages.yml | 2 ++ .github/workflows/tests.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml index 9068f1ae9a2..2850418bc59 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint.yml @@ -11,6 +11,8 @@ on: branches: - main # Trigger on pull request events targeting the main branch - beta # Trigger on pull request events targeting the beta branch + merge_group: + types: [checks_requested] jobs: run-linters: # Define a job named "run-linters" diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 3b7617c45f4..a092ccb425a 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -8,6 +8,8 @@ on: branches: - main - beta + merge_group: + types: [checks_requested] jobs: pages: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9ce1d1c5038..adac45519ab 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,6 +11,8 @@ on: branches: - main # Trigger on pull request events targeting the main branch - beta # Trigger on pull request events targeting the beta branch + merge_group: + types: [checks_requested] jobs: run-tests: # Define a job named "run-tests" From 200deef0edc496d57f1d494825b23c127802c813 Mon Sep 17 00:00:00 2001 From: "Adrian T." <68144167+torranx@users.noreply.github.com> Date: Wed, 4 Sep 2024 09:07:56 +0800 Subject: [PATCH 02/22] [P1 Bug][UI/UX] Address shop cursor target feedbacks (#4009) * address shop cursor target feedbacks * make rewards left-most * fix tests breaking * Update src/test/items/dire_hit.test.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * Update src/test/items/temp_stat_stage_booster.test.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * Update src/locales/ja/settings.json Co-authored-by: Chapybara-jp * update default value * stylistic change --------- Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> Co-authored-by: Chapybara-jp --- src/battle-scene.ts | 2 +- src/enums/shop-cursor-target.ts | 12 ++-- src/locales/de/settings.json | 2 +- src/locales/en/settings.json | 2 +- src/locales/es/settings.json | 2 +- src/locales/fr/settings.json | 2 +- src/locales/it/settings.json | 2 +- src/locales/ja/settings.json | 2 +- src/locales/ko/settings.json | 2 +- src/locales/pt_BR/settings.json | 2 +- src/locales/zh_CN/settings.json | 2 +- src/system/settings/settings.ts | 67 ++++++++++++------- src/test/items/dire_hit.test.ts | 5 +- .../items/temp_stat_stage_booster.test.ts | 5 +- src/ui/modifier-select-ui-handler.ts | 3 + 15 files changed, 69 insertions(+), 43 deletions(-) diff --git a/src/battle-scene.ts b/src/battle-scene.ts index c9f7362728a..4cc3f69ebee 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -130,7 +130,7 @@ export default class BattleScene extends SceneBase { public gameSpeed: integer = 1; public damageNumbersMode: integer = 0; public reroll: boolean = false; - public shopCursorTarget: number = ShopCursorTarget.CHECK_TEAM; + public shopCursorTarget: number = ShopCursorTarget.REWARDS; public showMovesetFlyout: boolean = true; public showArenaFlyout: boolean = true; public showTimeOfDayWidget: boolean = true; diff --git a/src/enums/shop-cursor-target.ts b/src/enums/shop-cursor-target.ts index d2f72fed0d6..11f524399b2 100644 --- a/src/enums/shop-cursor-target.ts +++ b/src/enums/shop-cursor-target.ts @@ -1,13 +1,13 @@ /** - * Determines the cursor target when entering the shop phase. + * Determines the row cursor target when entering the shop phase. */ export enum ShopCursorTarget { - /** Cursor points to Reroll */ + /** Cursor points to Reroll row */ REROLL, - /** Cursor points to Items */ - ITEMS, - /** Cursor points to Shop */ + /** Cursor points to Rewards row */ + REWARDS, + /** Cursor points to Shop row */ SHOP, - /** Cursor points to Check Team */ + /** Cursor points to Check Team row */ CHECK_TEAM } diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index d72a026cf5a..31406f28d17 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -100,7 +100,7 @@ "moveTouchControls": "Bewegung Touch Steuerung", "shopOverlayOpacity": "Shop Overlay Deckkraft", "shopCursorTarget": "Shop-Cursor Ziel", - "items": "Items", + "rewards": "Items", "reroll": "Neu rollen", "shop": "Shop", "checkTeam": "Team überprüfen" diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 6528f0368fe..301ebea9b2b 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -100,7 +100,7 @@ "moveTouchControls": "Move Touch Controls", "shopOverlayOpacity": "Shop Overlay Opacity", "shopCursorTarget": "Shop Cursor Target", - "items": "Items", + "rewards": "Rewards", "reroll": "Reroll", "shop": "Shop", "checkTeam": "Check Team" diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 9c16fbb0fd6..dc441d48eb8 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -100,7 +100,7 @@ "moveTouchControls": "Controles táctiles", "shopOverlayOpacity": "Opacidad de la fase de compra", "shopCursorTarget": "Cursor de la tienda", - "items": "Objetos", + "rewards": "Objetos", "reroll": "Actualizar", "shop": "Tienda", "checkTeam": "Ver equipo" diff --git a/src/locales/fr/settings.json b/src/locales/fr/settings.json index 181a593cc99..c752b336b6e 100644 --- a/src/locales/fr/settings.json +++ b/src/locales/fr/settings.json @@ -100,7 +100,7 @@ "moveTouchControls": "Déplacer les contrôles tactiles", "shopOverlayOpacity": "Opacité boutique", "shopCursorTarget": "Choix après relance", - "items": "Obj. gratuits", + "rewards": "Obj. gratuits", "reroll": "Relance", "shop": "Boutique", "checkTeam": "Équipe" diff --git a/src/locales/it/settings.json b/src/locales/it/settings.json index 381503f21bd..c09f5e22d4d 100644 --- a/src/locales/it/settings.json +++ b/src/locales/it/settings.json @@ -8,7 +8,7 @@ "moveTouchControls": "Move Touch Controls", "shopOverlayOpacity": "Opacità Finestra Negozio", "shopCursorTarget": "Target Cursore Negozio", - "items": "Oggetti", + "rewards": "Oggetti", "reroll": "Rerolla", "shop": "Negozio", "checkTeam": "Squadra" diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json index 4cb10c670de..55d39ee70a4 100644 --- a/src/locales/ja/settings.json +++ b/src/locales/ja/settings.json @@ -100,7 +100,7 @@ "moveTouchControls": "タッチ移動操作", "shopOverlayOpacity": "ショップオーバレイ不透明度", "shopCursorTarget": "ショップカーソル初位置", - "items": "アイテム", + "rewards": "ご褒美", "reroll": "選択肢変更", "shop": "ショップ", "checkTeam": "手持ちを確認" diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index b7fc01cb148..c10046385e1 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -100,7 +100,7 @@ "moveTouchControls": "터치 컨트롤 이동", "shopOverlayOpacity": "상점 오버레이 투명도", "shopCursorTarget": "상점 커서 위치", - "items": "아이템", + "rewards": "아이템", "reroll": "갱신", "shop": "상점", "checkTeam": "파티 확인" diff --git a/src/locales/pt_BR/settings.json b/src/locales/pt_BR/settings.json index 58ccb45f86d..74f3918bed8 100644 --- a/src/locales/pt_BR/settings.json +++ b/src/locales/pt_BR/settings.json @@ -100,7 +100,7 @@ "moveTouchControls": "Mover Controles de Toque", "shopOverlayOpacity": "Opacidade da Loja", "shopCursorTarget": "Alvo do Cursor da Loja", - "items": "Itens", + "rewards": "Itens", "reroll": "Atualizar", "shop": "Loja", "checkTeam": "Checar Time" diff --git a/src/locales/zh_CN/settings.json b/src/locales/zh_CN/settings.json index 3ae0fa8204c..dd001213b9e 100644 --- a/src/locales/zh_CN/settings.json +++ b/src/locales/zh_CN/settings.json @@ -99,7 +99,7 @@ "moveTouchControls": "移动触摸控制", "shopOverlayOpacity": "商店显示不透明度", "shopCursorTarget": "商店指针位置", - "items": "道具", + "rewards": "道具", "reroll": "刷新", "shop": "购买", "checkTeam": "检查队伍" diff --git a/src/system/settings/settings.ts b/src/system/settings/settings.ts index 7b0fea95a98..6b46b6fe96c 100644 --- a/src/system/settings/settings.ts +++ b/src/system/settings/settings.ts @@ -25,6 +25,7 @@ const VOLUME_OPTIONS: SettingOption[] = new Array(11).fill(null).map((_, i) => i value: "Mute", label: getTranslation("settings:mute") }); + const SHOP_OVERLAY_OPACITY_OPTIONS: SettingOption[] = new Array(9).fill(null).map((_, i) => { const value = ((i + 1) * 10).toString(); return { @@ -32,6 +33,7 @@ const SHOP_OVERLAY_OPACITY_OPTIONS: SettingOption[] = new Array(9).fill(null).ma label: value, }; }); + const OFF_ON: SettingOption[] = [ { value: "Off", @@ -53,6 +55,40 @@ const AUTO_DISABLED: SettingOption[] = [ } ]; +const SHOP_CURSOR_TARGET_OPTIONS: SettingOption[] = [ + { + value: "Rewards", + label: i18next.t("settings:rewards") + }, + { + value: "Shop", + label: i18next.t("settings:shop") + }, + { + value: "Reroll", + label: i18next.t("settings:reroll") + }, + { + value: "Check Team", + label: i18next.t("settings:checkTeam") + } +]; + +const shopCursorTargetIndexMap = SHOP_CURSOR_TARGET_OPTIONS.map(option => { + switch (option.value) { + case "Rewards": + return ShopCursorTarget.REWARDS; + case "Shop": + return ShopCursorTarget.SHOP; + case "Reroll": + return ShopCursorTarget.REROLL; + case "Check Team": + return ShopCursorTarget.CHECK_TEAM; + default: + throw new Error(`Unknown value: ${option.value}`); + } +}); + /** * Types for helping separate settings to different menus */ @@ -103,7 +139,7 @@ export const SettingKeys = { Damage_Numbers: "DAMAGE_NUMBERS", Move_Animations: "MOVE_ANIMATIONS", Show_Stats_on_Level_Up: "SHOW_LEVEL_UP_STATS", - Reroll_Target: "REROLL_TARGET", + Shop_Cursor_Target: "SHOP_CURSOR_TARGET", Candy_Upgrade_Notification: "CANDY_UPGRADE_NOTIFICATION", Candy_Upgrade_Display: "CANDY_UPGRADE_DISPLAY", Move_Info: "MOVE_INFO", @@ -596,27 +632,10 @@ export const Setting: Array = [ isHidden: () => !hasTouchscreen() }, { - key: SettingKeys.Reroll_Target, + key: SettingKeys.Shop_Cursor_Target, label: i18next.t("settings:shopCursorTarget"), - options: [ - { - value:"Reroll", - label: i18next.t("settings:reroll") - }, - { - value:"Items", - label: i18next.t("settings:items") - }, - { - value:"Shop", - label: i18next.t("settings:shop") - }, - { - value:"Check Team", - label: i18next.t("settings:checkTeam") - } - ], - default: ShopCursorTarget.CHECK_TEAM, + options: SHOP_CURSOR_TARGET_OPTIONS, + default: 0, type: SettingType.DISPLAY }, { @@ -758,8 +777,10 @@ export function setSetting(scene: BattleScene, setting: string, value: integer): case SettingKeys.Show_Stats_on_Level_Up: scene.showLevelUpStats = Setting[index].options[value].value === "On"; break; - case SettingKeys.Reroll_Target: - scene.shopCursorTarget = value; + case SettingKeys.Shop_Cursor_Target: + const selectedValue = shopCursorTargetIndexMap[value]; + scene.shopCursorTarget = selectedValue; + break; case SettingKeys.EXP_Gains_Speed: scene.expGainsSpeed = value; break; diff --git a/src/test/items/dire_hit.test.ts b/src/test/items/dire_hit.test.ts index c43091d1f03..02f7c0d06a4 100644 --- a/src/test/items/dire_hit.test.ts +++ b/src/test/items/dire_hit.test.ts @@ -13,6 +13,7 @@ import { Button } from "#app/enums/buttons"; import { CommandPhase } from "#app/phases/command-phase"; import { NewBattlePhase } from "#app/phases/new-battle-phase"; import { TurnInitPhase } from "#app/phases/turn-init-phase"; +import { ShopCursorTarget } from "#app/enums/shop-cursor-target"; describe("Items - Dire Hit", () => { let phaserGame: Phaser.Game; @@ -77,8 +78,8 @@ describe("Items - Dire Hit", () => { game.onNextPrompt("SelectModifierPhase", Mode.MODIFIER_SELECT, () => { const handler = game.scene.ui.getHandler() as ModifierSelectUiHandler; // Traverse to first modifier slot - handler.processInput(Button.LEFT); - handler.processInput(Button.UP); + handler.setCursor(0); + handler.setRowCursor(ShopCursorTarget.REWARDS); handler.processInput(Button.ACTION); }, () => game.isCurrentPhase(CommandPhase) || game.isCurrentPhase(NewBattlePhase), true); diff --git a/src/test/items/temp_stat_stage_booster.test.ts b/src/test/items/temp_stat_stage_booster.test.ts index e5b95c6c3b6..c81703220db 100644 --- a/src/test/items/temp_stat_stage_booster.test.ts +++ b/src/test/items/temp_stat_stage_booster.test.ts @@ -16,6 +16,7 @@ import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; import { TurnInitPhase } from "#app/phases/turn-init-phase"; import { BattleEndPhase } from "#app/phases/battle-end-phase"; import { EnemyCommandPhase } from "#app/phases/enemy-command-phase"; +import { ShopCursorTarget } from "#app/enums/shop-cursor-target"; describe("Items - Temporary Stat Stage Boosters", () => { @@ -154,8 +155,8 @@ describe("Items - Temporary Stat Stage Boosters", () => { game.onNextPrompt("SelectModifierPhase", Mode.MODIFIER_SELECT, () => { const handler = game.scene.ui.getHandler() as ModifierSelectUiHandler; // Traverse to first modifier slot - handler.processInput(Button.LEFT); - handler.processInput(Button.UP); + handler.setCursor(0); + handler.setRowCursor(ShopCursorTarget.REWARDS); handler.processInput(Button.ACTION); }, () => game.isCurrentPhase(CommandPhase) || game.isCurrentPhase(NewBattlePhase), true); diff --git a/src/ui/modifier-select-ui-handler.ts b/src/ui/modifier-select-ui-handler.ts index 6e9a33df5d8..ca5d27f96a4 100644 --- a/src/ui/modifier-select-ui-handler.ts +++ b/src/ui/modifier-select-ui-handler.ts @@ -257,6 +257,9 @@ export default class ModifierSelectUiHandler extends AwaitableUiHandler { if (this.scene.shopCursorTarget === ShopCursorTarget.CHECK_TEAM) { this.setRowCursor(0); this.setCursor(2); + } else if ((this.scene.shopCursorTarget === ShopCursorTarget.SHOP) && this.scene.gameMode.hasNoShop) { + this.setRowCursor(ShopCursorTarget.REWARDS); + this.setCursor(0); } else { this.setRowCursor(this.scene.shopCursorTarget); this.setCursor(0); From 1055386949f016161555a0fa18eaf83dffa95de9 Mon Sep 17 00:00:00 2001 From: MokaStitcher <54149968+MokaStitcher@users.noreply.github.com> Date: Wed, 4 Sep 2024 10:58:39 +0200 Subject: [PATCH 03/22] starter select defaults to shiny with highest variant again (#4001) --- src/test/ui/starter-select.test.ts | 24 ++++++----------------- src/ui/starter-select-ui-handler.ts | 30 ++++++++++++++++------------- 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/src/test/ui/starter-select.test.ts b/src/test/ui/starter-select.test.ts index 8ef1ea16b4a..6d26ebfd6b3 100644 --- a/src/test/ui/starter-select.test.ts +++ b/src/test/ui/starter-select.test.ts @@ -53,9 +53,6 @@ describe("UI - Starter select", () => { const handler = game.scene.ui.getHandler() as StarterSelectUiHandler; handler.processInput(Button.RIGHT); handler.processInput(Button.LEFT); - handler.processInput(Button.CYCLE_SHINY); - handler.processInput(Button.V); - handler.processInput(Button.V); handler.processInput(Button.ACTION); game.phaseInterceptor.unlock(); }); @@ -117,9 +114,6 @@ describe("UI - Starter select", () => { handler.processInput(Button.RIGHT); handler.processInput(Button.LEFT); handler.processInput(Button.CYCLE_GENDER); - handler.processInput(Button.CYCLE_SHINY); - handler.processInput(Button.V); - handler.processInput(Button.V); handler.processInput(Button.ACTION); game.phaseInterceptor.unlock(); }); @@ -184,9 +178,6 @@ describe("UI - Starter select", () => { handler.processInput(Button.CYCLE_GENDER); handler.processInput(Button.CYCLE_NATURE); handler.processInput(Button.CYCLE_ABILITY); - handler.processInput(Button.CYCLE_SHINY); - handler.processInput(Button.V); - handler.processInput(Button.V); handler.processInput(Button.ACTION); game.phaseInterceptor.unlock(); }); @@ -227,11 +218,12 @@ describe("UI - Starter select", () => { expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR); expect(game.scene.getParty()[0].shiny).toBe(true); expect(game.scene.getParty()[0].variant).toBe(2); + expect(game.scene.getParty()[0].gender).toBe(Gender.FEMALE); expect(game.scene.getParty()[0].nature).toBe(Nature.LONELY); expect(game.scene.getParty()[0].getAbility().id).toBe(Abilities.CHLOROPHYLL); }, 20000); - it("Bulbasaur - shiny - variant 2 female lonely chlorophyl", async() => { + it("Bulbasaur - shiny - variant 2 female", async() => { await game.importData("src/test/utils/saves/everything.prsv"); const caughtCount = Object.keys(game.scene.gameData.dexData).filter((key) => { const species = game.scene.gameData.dexData[key]; @@ -249,9 +241,6 @@ describe("UI - Starter select", () => { handler.processInput(Button.RIGHT); handler.processInput(Button.LEFT); handler.processInput(Button.CYCLE_GENDER); - handler.processInput(Button.CYCLE_SHINY); - handler.processInput(Button.V); - handler.processInput(Button.V); handler.processInput(Button.ACTION); game.phaseInterceptor.unlock(); }); @@ -313,6 +302,7 @@ describe("UI - Starter select", () => { handler.processInput(Button.RIGHT); handler.processInput(Button.LEFT); handler.processInput(Button.ACTION); + handler.processInput(Button.CYCLE_SHINY); game.phaseInterceptor.unlock(); }); await game.phaseInterceptor.run(SelectStarterPhase); @@ -371,7 +361,7 @@ describe("UI - Starter select", () => { const handler = game.scene.ui.getHandler() as StarterSelectUiHandler; handler.processInput(Button.RIGHT); handler.processInput(Button.LEFT); - handler.processInput(Button.CYCLE_SHINY); + handler.processInput(Button.V); handler.processInput(Button.V); handler.processInput(Button.ACTION); game.phaseInterceptor.unlock(); @@ -415,7 +405,7 @@ describe("UI - Starter select", () => { expect(game.scene.getParty()[0].variant).toBe(1); }, 20000); - it("Bulbasaur - shiny - variant 2", async() => { + it("Bulbasaur - shiny - variant 0", async() => { await game.importData("src/test/utils/saves/everything.prsv"); const caughtCount = Object.keys(game.scene.gameData.dexData).filter((key) => { const species = game.scene.gameData.dexData[key]; @@ -432,8 +422,6 @@ describe("UI - Starter select", () => { const handler = game.scene.ui.getHandler() as StarterSelectUiHandler; handler.processInput(Button.RIGHT); handler.processInput(Button.LEFT); - handler.processInput(Button.CYCLE_SHINY); - handler.processInput(Button.V); handler.processInput(Button.V); handler.processInput(Button.ACTION); game.phaseInterceptor.unlock(); @@ -474,7 +462,7 @@ describe("UI - Starter select", () => { expect(game.scene.getParty()[0].species.speciesId).toBe(Species.BULBASAUR); expect(game.scene.getParty()[0].shiny).toBe(true); - expect(game.scene.getParty()[0].variant).toBe(2); + expect(game.scene.getParty()[0].variant).toBe(0); }, 20000); it("Check if first pokemon in party is caterpie from gen 1 and 1rd row, 3rd column", async() => { diff --git a/src/ui/starter-select-ui-handler.ts b/src/ui/starter-select-ui-handler.ts index 4906503c803..6b75c46bd45 100644 --- a/src/ui/starter-select-ui-handler.ts +++ b/src/ui/starter-select-ui-handler.ts @@ -1853,10 +1853,14 @@ export default class StarterSelectUiHandler extends MessageUiHandler { switch (button) { case Button.CYCLE_SHINY: if (this.canCycleShiny) { - const newVariant = starterAttributes.variant ? starterAttributes.variant as Variant : props.variant; - starterAttributes.shiny = starterAttributes.shiny ? !starterAttributes.shiny : true; - this.setSpeciesDetails(this.lastSpecies, !props.shiny, undefined, undefined, props.shiny ? 0 : newVariant, undefined, undefined); + starterAttributes.shiny = starterAttributes.shiny !== undefined ? !starterAttributes.shiny : false; + if (starterAttributes.shiny) { + // Change to shiny, we need to get the proper default variant + const newProps = this.scene.gameData.getSpeciesDexAttrProps(this.lastSpecies, this.getCurrentDexProps(this.lastSpecies.speciesId)); + const newVariant = starterAttributes.variant ? starterAttributes.variant as Variant : newProps.variant; + this.setSpeciesDetails(this.lastSpecies, true, undefined, undefined, newVariant, undefined, undefined); + this.scene.playSound("se/sparkle"); // Set the variant label to the shiny tint const tint = getVariantTint(newVariant); @@ -1864,6 +1868,7 @@ export default class StarterSelectUiHandler extends MessageUiHandler { this.pokemonShinyIcon.setTint(tint); this.pokemonShinyIcon.setVisible(true); } else { + this.setSpeciesDetails(this.lastSpecies, false, undefined, undefined, 0, undefined, undefined); this.pokemonShinyIcon.setVisible(false); success = true; } @@ -3487,23 +3492,22 @@ export default class StarterSelectUiHandler extends MessageUiHandler { props += DexAttr.MALE; } /* This part is very similar to above, but instead of for gender, it checks for shiny within starter preferences. - * If they're not there, it checks the caughtAttr for shiny only (i.e. SHINY === true && NON_SHINY === false) + * If they're not there, it enables shiny state by default if any shiny was caught */ - if (this.starterPreferences[speciesId]?.shiny || ((caughtAttr & DexAttr.SHINY) > 0n && (caughtAttr & DexAttr.NON_SHINY) === 0n)) { + if (this.starterPreferences[speciesId]?.shiny || ((caughtAttr & DexAttr.SHINY) > 0n && this.starterPreferences[speciesId]?.shiny !== false)) { props += DexAttr.SHINY; - if (this.starterPreferences[speciesId]?.variant) { + if (this.starterPreferences[speciesId]?.variant !== undefined) { props += BigInt(Math.pow(2, this.starterPreferences[speciesId]?.variant)) * DexAttr.DEFAULT_VARIANT; } else { /* This calculates the correct variant if there's no starter preferences for it. - * This gets the lowest tier variant that you've caught (in line with other mechanics) and adds it to the temp props + * This gets the highest tier variant that you've caught and adds it to the temp props */ - if ((caughtAttr & DexAttr.DEFAULT_VARIANT) > 0) { - props += DexAttr.DEFAULT_VARIANT; - } - if ((caughtAttr & DexAttr.VARIANT_2) > 0) { - props += DexAttr.VARIANT_2; - } else if ((caughtAttr & DexAttr.VARIANT_3) > 0) { + if ((caughtAttr & DexAttr.VARIANT_3) > 0) { props += DexAttr.VARIANT_3; + } else if ((caughtAttr & DexAttr.VARIANT_2) > 0) { + props += DexAttr.VARIANT_2; + } else { + props += DexAttr.DEFAULT_VARIANT; } } } else { From 370468002946c433f25a75dd758dd5bd6ec5e002 Mon Sep 17 00:00:00 2001 From: Lugiad Date: Wed, 4 Sep 2024 18:28:10 +0200 Subject: [PATCH 04/22] Update dialogue.json (#4021) --- src/locales/en/dialogue.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/locales/en/dialogue.json b/src/locales/en/dialogue.json index cd544395a27..5565d2258c2 100644 --- a/src/locales/en/dialogue.json +++ b/src/locales/en/dialogue.json @@ -413,7 +413,7 @@ }, "ariana": { "encounter": { - "1": "Hold it right there! We can't someone on the loose.\n$It's harmful to Team Rocket's pride, you see.", + "1": "Hold it right there!\nWe can't have someone on the loose.\n$It's harmful to Team Rocket's pride, you see.", "2": "I don't know or care if what I'm doing is right or wrong...\n$I just put my faith in Giovanni and do as I am told", "3": "Your trip ends here. I'm going to take you down!" }, From e822c91a1641a17b6c5da3e49d38a6f1440efe01 Mon Sep 17 00:00:00 2001 From: James Diefenbach <105332964+j-diefenbach@users.noreply.github.com> Date: Thu, 5 Sep 2024 02:59:25 +1000 Subject: [PATCH 05/22] [Enhancement] Skip egg hatching and show summary (#3726) * cherry picked commits / manual copy * better dex tracking for summary after regular egg hatching * ui changes * updated egg hatch bg, added candy tracker, icon anims for new shiny or new form unlock * added i18 line, reset overrides * touchup * code cleanup, documentation and slight refactor * sprite display fix * load interrupts, simple sfx and no summary for small egg amounts * Garbage Collection + Eslint/Docs approved. * time logging and optimisation * skip redundant load * more time logs and fix pre-load issues * more detailed loading logs * changed loading to be on demand from cursor nav * fix missing variant icon fallback * removing redundant time logs and code touchup * code cleanup * Comments so developer doesn't get bugged about garbage collecton * remove logs n stuff * lang settings touchup and final touchup plus uploading blank egg summary bg * fix nits, js imports, extra docs, magic numbers changed * extra docs and spacing nits * Update Github --------- Co-authored-by: James Diefenbach Co-authored-by: Frederico Santos Co-authored-by: frutescens Co-authored-by: Mumble <171087428+frutescens@users.noreply.github.com> --- public/images/ui/egg_summary_bg.png | Bin 0 -> 2160 bytes public/images/ui/egg_summary_bg_blank.png | Bin 0 -> 1628 bytes public/images/ui/icon_egg_move.png | Bin 0 -> 237 bytes public/images/ui/legacy/egg_summary_bg.png | Bin 0 -> 2160 bytes public/images/ui/legacy/icon_egg_move.png | Bin 0 -> 237 bytes src/battle-scene.ts | 23 ++ src/data/egg-hatch-data.ts | 98 +++++++ src/loading-scene.ts | 2 + src/locales/en/battle.json | 1 + src/phases/egg-hatch-phase.ts | 52 ++-- src/phases/egg-lapse-phase.ts | 129 ++++++++- src/phases/egg-summary-phase.ts | 50 ++++ src/system/game-data.ts | 24 +- src/ui/egg-summary-ui-handler.ts | 320 +++++++++++++++++++++ src/ui/pokemon-hatch-info-container.ts | 189 ++++++++++++ src/ui/pokemon-info-container.ts | 98 +++++-- src/ui/ui.ts | 3 + 17 files changed, 913 insertions(+), 76 deletions(-) create mode 100644 public/images/ui/egg_summary_bg.png create mode 100644 public/images/ui/egg_summary_bg_blank.png create mode 100644 public/images/ui/icon_egg_move.png create mode 100644 public/images/ui/legacy/egg_summary_bg.png create mode 100644 public/images/ui/legacy/icon_egg_move.png create mode 100644 src/data/egg-hatch-data.ts create mode 100644 src/phases/egg-summary-phase.ts create mode 100644 src/ui/egg-summary-ui-handler.ts create mode 100644 src/ui/pokemon-hatch-info-container.ts diff --git a/public/images/ui/egg_summary_bg.png b/public/images/ui/egg_summary_bg.png new file mode 100644 index 0000000000000000000000000000000000000000..27e367212aaae20dc43f3f03c9fc122b9d99e8fe GIT binary patch literal 2160 zcmeHJX;e~a7``Bh6l&PyQaYxh)>L$7YKn0IQ%r3!+cZrAZ9A0)R-zP0SW{yV3(EE!?`+RAd-t_+{oGP=b#ZlJd*uMGw+m3z2H7H?3iM=#aKx4ySq z<|boL+7i{0bE`dp;m?=%D3ygno=<7z?WDHk93mpBcte&^oL;IRtmY+-Nn7>A?tY^y z29uPO)H+tduDQw7_chQ=2NjhxlTmK!FqE(<(|fgyy?e{{>BVw3sjjgxL;ih^?$r+B zXkT+a-rrwCd z=$u|JG^r4)qpzs|m6oYqvGW?s<_?Q;`*9-QTV6@TGstZf;v;MB(xFeu6%`B+}SUtLEo5jW%>rL}GO4fTpIi$6zotcmxdJG%%bn7(GW(nW%@=geC*w zZrqWPku4mKM7K?febv0ZTrKAlE5+Fq9|9q}O-N}lnbKl6o5o0--}QpS^s3l99FuOK zf$h#Ea5?V@0Z(333y4!`)dog8@ide*&euH}o)eUr+??*na3BG#VT9-q1j#tCr-jD4ZU!Ia?RJ0%!E#l1e$U=nWu2WK!2ywI;OOL;y zy)7yqaDND!&E=lM($nX2FW^w;H>z1Bnpgjpnis<2>_5YW@;uRx4)rA0y>^x1bKjJE zg6O56>O-6R?QRG-pyF&unvK8)>_KY#FJpEov-*6`i0U0G^;9k9O?L2W3D00q`HTxV+Vy>*S3> zCx0M!O z&Ws4Zo=kYmcuP*5?s#?{>ii*Sx?ZnTLIIdA(7Ec-_#C%-4<5LcT_2ChtiMRKg7L}- zTn~5q%wQ7fdgR;_@DLI_hzxNwI0!=iEpZY~tSZ$y7aq1fYbTrlMLY7b)ElHSVeynZ zjm5)Y7J=I|SDGc?RdGrhaq<(G(HJVV3`>VY>{XwUQY2}KOn<|qiIES4Tjccqd>@tS zvFJhXhL8Q%hr@+!yJ^*vykwQK?`^lH8bv_Ws^XxM>QJBEs zS2~n4#+ZL0s`yL@#Qu0~R%)ta@xyDKSyAJ*=j$F{75kci>{nV`u7gTJeW=Z6!}I5L zGcPRMHQsj!DV9uTdMpB0MMXu4T_P>TF7^*}Z=Vhfb=PMOAeAfvjqPs;Y)i&qy2R-X z>S|n{_T32;U~6MzZnG59=$;y(GO1dib)E&1Z33ue?;V!LP^h4 literal 0 HcmV?d00001 diff --git a/public/images/ui/egg_summary_bg_blank.png b/public/images/ui/egg_summary_bg_blank.png new file mode 100644 index 0000000000000000000000000000000000000000..09bcb63cfa3b515afe0207812b0b46774f79ad80 GIT binary patch literal 1628 zcmeAS@N?(olHy`uVBq!ia0y~yU~~Yow{Wll$)``g3IZv{;vjb?hIQv;UNSJSiFvv> zhE&XXd)IK1l)DJqgKgan0!Ak+m}DFm3bpfb)(XfvD^5H$@s#*M_Zd8!ZWb6>7rc#7 zzM3W8xvX-}`|f$GME0y*`}eB7?X15~|Nj2|U!Rr_|b z>|CCHX5YH9n_3_GPyFL&oAT+~;W^O{7(Oq3_fds^d(lZng%E~MW#3CCwMXy#;$#rQ zu3*ztfq|Iuya$;3DV7l@EW7>kg0QCdcLPJ>FjtglqFs}1t2dzP==S##->b+2#b6*X3`SgK`k#C#{~zIJK<&Cv-vrUvvMWQe)^MC^y$ zt9d_fORjJRJ95PVhbXYiM!CQ!!xb9IHjH{=-Md#*KYOXev2dOzLs5NA{DOI&toL%v z9&P@eOKem_rL3YXV|ebQgdm|j}HsiTz?HR@$$=%Khs@z*ojJbcr!3%aylri^<0bv%B@r)9I|OztT6~+;X=oyfil4OY`V= zuDZDErKy{5{;6k*(VMRO{h{@*ee?g^1d96s#U+z_o6MEe4-&sVJ=CfKVDNPHb6Mw<&;$Sif4F%}28J29*~C-V}>S?=lL7@`rJ zoZ`TI?bx;dj!y39C`Mb@JW4}PIASEAB=}Dhx;GQI>6#Fao1Y;h(-l_ z{uy=uKXT8ww=+j5!Z2dtmKk5_zpt>_(6YIgamKysCrqoHCS)4C?kNeCI2XH*IjrGK zoZ;TyM#0ELe`hgg&%-7goKnmp6n9BK|2Dl*R873>VYzbL`2#jAimdKI;Vst04~^DCIA2c literal 0 HcmV?d00001 diff --git a/public/images/ui/legacy/egg_summary_bg.png b/public/images/ui/legacy/egg_summary_bg.png new file mode 100644 index 0000000000000000000000000000000000000000..27e367212aaae20dc43f3f03c9fc122b9d99e8fe GIT binary patch literal 2160 zcmeHJX;e~a7``Bh6l&PyQaYxh)>L$7YKn0IQ%r3!+cZrAZ9A0)R-zP0SW{yV3(EE!?`+RAd-t_+{oGP=b#ZlJd*uMGw+m3z2H7H?3iM=#aKx4ySq z<|boL+7i{0bE`dp;m?=%D3ygno=<7z?WDHk93mpBcte&^oL;IRtmY+-Nn7>A?tY^y z29uPO)H+tduDQw7_chQ=2NjhxlTmK!FqE(<(|fgyy?e{{>BVw3sjjgxL;ih^?$r+B zXkT+a-rrwCd z=$u|JG^r4)qpzs|m6oYqvGW?s<_?Q;`*9-QTV6@TGstZf;v;MB(xFeu6%`B+}SUtLEo5jW%>rL}GO4fTpIi$6zotcmxdJG%%bn7(GW(nW%@=geC*w zZrqWPku4mKM7K?febv0ZTrKAlE5+Fq9|9q}O-N}lnbKl6o5o0--}QpS^s3l99FuOK zf$h#Ea5?V@0Z(333y4!`)dog8@ide*&euH}o)eUr+??*na3BG#VT9-q1j#tCr-jD4ZU!Ia?RJ0%!E#l1e$U=nWu2WK!2ywI;OOL;y zy)7yqaDND!&E=lM($nX2FW^w;H>z1Bnpgjpnis<2>_5YW@;uRx4)rA0y>^x1bKjJE zg6O56>O-6R?QRG-pyF&unvK8)>_KY#FJpEov-*6`i0U0G^;9k9O?L2W3D00q`HTxV+Vy>*S3> zCx0M!O z&Ws4Zo=kYmcuP*5?s#?{>ii*Sx?ZnTLIIdA(7Ec-_#C%-4<5LcT_2ChtiMRKg7L}- zTn~5q%wQ7fdgR;_@DLI_hzxNwI0!=iEpZY~tSZ$y7aq1fYbTrlMLY7b)ElHSVeynZ zjm5)Y7J=I|SDGc?RdGrhaq<(G(HJVV3`>VY>{XwUQY2}KOn<|qiIES4Tjccqd>@tS zvFJhXhL8Q%hr@+!yJ^*vykwQK?`^lH8bv_Ws^XxM>QJBEs zS2~n4#+ZL0s`yL@#Qu0~R%)ta@xyDKSyAJ*=j$F{75kci>{nV`u7gTJeW=Z6!}I5L zGcPRMHQsj!DV9uTdMpB0MMXu4T_P>TF7^*}Z=Vhfb=PMOAeAfvjqPs;Y)i&qy2R-X z>S|n{_T32;U~6MzZnG59=$;y(GO1dib)E&1Z33ue?;V!LP^h4 literal 0 HcmV?d00001 diff --git a/public/images/ui/legacy/icon_egg_move.png b/public/images/ui/legacy/icon_egg_move.png new file mode 100644 index 0000000000000000000000000000000000000000..6af186e9b0c21a3952b80fc00ed2a7494cee18eb GIT binary patch literal 237 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4F%}28J29*~C-V}>S?=lL7@`rJ zoZ`TI?bx;dj!y39C`Mb@JW4}PIASEAB=}Dhx;GQI>6#Fao1Y;h(-l_ z{uy=uKXT8ww=+j5!Z2dtmKk5_zpt>_(6YIgamKysCrqoHCS)4C?kNeCI2XH*IjrGK zoZ;TyM#0ELe`hgg&%-7goKnmp6n9BK|2Dl*R873>VYzbL`2#jAimdKI;Vst04~^DCIA2c literal 0 HcmV?d00001 diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 4cc3f69ebee..c8100e0d3b9 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -2742,6 +2742,29 @@ export default class BattleScene extends SceneBase { (window as any).gameInfo = gameInfo; } + /** + * This function retrieves the sprite and audio keys for active Pokemon. + * Active Pokemon include both enemy and player Pokemon of the current wave. + * Note: Questions on garbage collection go to @frutescens + * @returns a string array of active sprite and audio keys that should not be deleted + */ + getActiveKeys(): string[] { + const keys: string[] = []; + const playerParty = this.getParty(); + playerParty.forEach(p => { + keys.push("pkmn__" + p.species.getSpriteId(p.gender === Gender.FEMALE, p.species.formIndex, p.shiny, p.variant)); + keys.push("pkmn__" + p.species.getSpriteId(p.gender === Gender.FEMALE, p.species.formIndex, p.shiny, p.variant, true)); + keys.push("cry/" + p.species.getCryKey(p.species.formIndex)); + }); + // enemyParty has to be operated on separately from playerParty because playerPokemon =/= enemyPokemon + const enemyParty = this.getEnemyParty(); + enemyParty.forEach(p => { + keys.push(p.species.getSpriteKey(p.gender === Gender.FEMALE, p.species.formIndex, p.shiny, p.variant)); + keys.push("cry/" + p.species.getCryKey(p.species.formIndex)); + }); + return keys; + } + /** * Initialized the 2nd phase of the final boss (e.g. form-change for Eternatus) * @param pokemon The (enemy) pokemon diff --git a/src/data/egg-hatch-data.ts b/src/data/egg-hatch-data.ts new file mode 100644 index 00000000000..e754a9205c4 --- /dev/null +++ b/src/data/egg-hatch-data.ts @@ -0,0 +1,98 @@ +import BattleScene from "#app/battle-scene"; +import { PlayerPokemon } from "#app/field/pokemon"; +import { DexEntry, StarterDataEntry } from "#app/system/game-data"; + +/** + * Stores data associated with a specific egg and the hatched pokemon + * Allows hatch info to be stored at hatch then retrieved for display during egg summary + */ +export class EggHatchData { + /** the pokemon that hatched from the file (including shiny, IVs, ability) */ + public pokemon: PlayerPokemon; + /** index of the egg move from the hatched pokemon (not stored in PlayerPokemon) */ + public eggMoveIndex: number; + /** boolean indicating if the egg move for the hatch is new */ + public eggMoveUnlocked: boolean; + /** stored copy of the hatched pokemon's dex entry before it was updated due to hatch */ + public dexEntryBeforeUpdate: DexEntry; + /** stored copy of the hatched pokemon's starter entry before it was updated due to hatch */ + public starterDataEntryBeforeUpdate: StarterDataEntry; + /** reference to the battle scene to get gamedata and update dex */ + private scene: BattleScene; + + constructor(scene: BattleScene, pokemon: PlayerPokemon, eggMoveIndex: number) { + this.scene = scene; + this.pokemon = pokemon; + this.eggMoveIndex = eggMoveIndex; + } + + /** + * Sets the boolean for if the egg move for the hatch is a new unlock + * @param unlocked True if the EM is new + */ + setEggMoveUnlocked(unlocked: boolean) { + this.eggMoveUnlocked = unlocked; + } + + /** + * Stores a copy of the current DexEntry of the pokemon and StarterDataEntry of its starter + * Used before updating the dex, so comparing the pokemon to these entries will show the new attributes + */ + setDex() { + const currDexEntry = this.scene.gameData.dexData[this.pokemon.species.speciesId]; + const currStarterDataEntry = this.scene.gameData.starterData[this.pokemon.species.getRootSpeciesId()]; + this.dexEntryBeforeUpdate = { + seenAttr: currDexEntry.seenAttr, + caughtAttr: currDexEntry.caughtAttr, + natureAttr: currDexEntry.natureAttr, + seenCount: currDexEntry.seenCount, + caughtCount: currDexEntry.caughtCount, + hatchedCount: currDexEntry.hatchedCount, + ivs: [...currDexEntry.ivs] + }; + this.starterDataEntryBeforeUpdate = { + moveset: currStarterDataEntry.moveset, + eggMoves: currStarterDataEntry.eggMoves, + candyCount: currStarterDataEntry.candyCount, + friendship: currStarterDataEntry.friendship, + abilityAttr: currStarterDataEntry.abilityAttr, + passiveAttr: currStarterDataEntry.passiveAttr, + valueReduction: currStarterDataEntry.valueReduction, + classicWinCount: currStarterDataEntry.classicWinCount + }; + } + + /** + * Gets the dex entry before update + * @returns Dex Entry corresponding to this pokemon before the pokemon was added / updated to dex + */ + getDex(): DexEntry { + return this.dexEntryBeforeUpdate; + } + + /** + * Gets the starter dex entry before update + * @returns Starter Dex Entry corresponding to this pokemon before the pokemon was added / updated to dex + */ + getStarterEntry(): StarterDataEntry { + return this.starterDataEntryBeforeUpdate; + } + + /** + * Update the pokedex data corresponding with the new hatch's pokemon data + * Also sets whether the egg move is a new unlock or not + * @param showMessage boolean to show messages for the new catches and egg moves (false by default) + * @returns + */ + updatePokemon(showMessage : boolean = false) { + return new Promise(resolve => { + this.scene.gameData.setPokemonCaught(this.pokemon, true, true, showMessage).then(() => { + this.scene.gameData.updateSpeciesDexIvs(this.pokemon.species.speciesId, this.pokemon.ivs); + this.scene.gameData.setEggMoveUnlocked(this.pokemon.species, this.eggMoveIndex, showMessage).then((value) => { + this.setEggMoveUnlocked(value); + resolve(); + }); + }); + }); + } +} diff --git a/src/loading-scene.ts b/src/loading-scene.ts index 432dbcd7469..f6bc41f744d 100644 --- a/src/loading-scene.ts +++ b/src/loading-scene.ts @@ -78,6 +78,7 @@ export class LoadingScene extends SceneBase { this.loadAtlas("overlay_hp_boss", "ui"); this.loadImage("overlay_exp", "ui"); this.loadImage("icon_owned", "ui"); + this.loadImage("icon_egg_move", "ui"); this.loadImage("ability_bar_left", "ui"); this.loadImage("bgm_bar", "ui"); this.loadImage("party_exp_bar", "ui"); @@ -272,6 +273,7 @@ export class LoadingScene extends SceneBase { this.loadImage("gacha_knob", "egg"); this.loadImage("egg_list_bg", "ui"); + this.loadImage("egg_summary_bg", "ui"); this.loadImage("end_m", "cg"); this.loadImage("end_f", "cg"); diff --git a/src/locales/en/battle.json b/src/locales/en/battle.json index 662678e7673..918fb38b520 100644 --- a/src/locales/en/battle.json +++ b/src/locales/en/battle.json @@ -61,6 +61,7 @@ "skipItemQuestion": "Are you sure you want to skip taking an item?", "itemStackFull": "The stack for {{fullItemName}} is full.\nYou will receive {{itemName}} instead.", "eggHatching": "Oh?", + "eggSkipPrompt": "Skip to egg summary?", "ivScannerUseQuestion": "Use IV Scanner on {{pokemonName}}?", "wildPokemonWithAffix": "Wild {{pokemonName}}", "foePokemonWithAffix": "Foe {{pokemonName}}", diff --git a/src/phases/egg-hatch-phase.ts b/src/phases/egg-hatch-phase.ts index a5b0252d4de..4b03aa62f02 100644 --- a/src/phases/egg-hatch-phase.ts +++ b/src/phases/egg-hatch-phase.ts @@ -1,23 +1,29 @@ -import BattleScene, { AnySound } from "#app/battle-scene.js"; -import { Egg, EGG_SEED } from "#app/data/egg.js"; -import { EggCountChangedEvent } from "#app/events/egg.js"; -import { PlayerPokemon } from "#app/field/pokemon.js"; -import { getPokemonNameWithAffix } from "#app/messages.js"; -import { Phase } from "#app/phase.js"; -import { achvs } from "#app/system/achv.js"; -import EggCounterContainer from "#app/ui/egg-counter-container.js"; -import EggHatchSceneHandler from "#app/ui/egg-hatch-scene-handler.js"; -import PokemonInfoContainer from "#app/ui/pokemon-info-container.js"; -import { Mode } from "#app/ui/ui.js"; +import BattleScene, { AnySound } from "#app/battle-scene"; +import { Egg } from "#app/data/egg"; +import { EggCountChangedEvent } from "#app/events/egg"; +import { PlayerPokemon } from "#app/field/pokemon"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { Phase } from "#app/phase"; +import { achvs } from "#app/system/achv"; +import EggCounterContainer from "#app/ui/egg-counter-container"; +import EggHatchSceneHandler from "#app/ui/egg-hatch-scene-handler"; +import PokemonInfoContainer from "#app/ui/pokemon-info-container"; +import { Mode } from "#app/ui/ui"; import i18next from "i18next"; import SoundFade from "phaser3-rex-plugins/plugins/soundfade"; -import * as Utils from "#app/utils.js"; +import * as Utils from "#app/utils"; +import { EggLapsePhase } from "./egg-lapse-phase"; +import { EggHatchData } from "#app/data/egg-hatch-data"; + + /** * Class that represents egg hatching */ export class EggHatchPhase extends Phase { /** The egg that is hatching */ private egg: Egg; + /** The new EggHatchData for the egg/pokemon that hatches */ + private eggHatchData: EggHatchData; /** The number of eggs that are hatching */ private eggsToHatchCount: integer; @@ -58,10 +64,11 @@ export class EggHatchPhase extends Phase { private skipped: boolean; /** The sound effect being played when the egg is hatched */ private evolutionBgm: AnySound; + private eggLapsePhase: EggLapsePhase; - constructor(scene: BattleScene, egg: Egg, eggsToHatchCount: integer) { + constructor(scene: BattleScene, hatchScene: EggLapsePhase, egg: Egg, eggsToHatchCount: integer) { super(scene); - + this.eggLapsePhase = hatchScene; this.egg = egg; this.eggsToHatchCount = eggsToHatchCount; } @@ -307,6 +314,7 @@ export class EggHatchPhase extends Phase { * Function to do the logic and animation of completing a hatch and revealing the Pokemon */ doReveal(): void { + // set the previous dex data so info container can show new unlocks in egg summary const isShiny = this.pokemon.isShiny(); if (this.pokemon.species.subLegendary) { this.scene.validateAchv(achvs.HATCH_SUB_LEGENDARY); @@ -345,13 +353,13 @@ export class EggHatchPhase extends Phase { this.scene.ui.showText(i18next.t("egg:hatchFromTheEgg", { pokemonName: getPokemonNameWithAffix(this.pokemon) }), null, () => { this.scene.gameData.updateSpeciesDexIvs(this.pokemon.species.speciesId, this.pokemon.ivs); this.scene.gameData.setPokemonCaught(this.pokemon, true, true).then(() => { - this.scene.gameData.setEggMoveUnlocked(this.pokemon.species, this.eggMoveIndex).then(() => { + this.scene.gameData.setEggMoveUnlocked(this.pokemon.species, this.eggMoveIndex).then((value) => { + this.eggHatchData.setEggMoveUnlocked(value); this.scene.ui.showText("", 0); this.end(); }); }); }, null, true, 3000); - //this.scene.time.delayedCall(Utils.fixedInt(4250), () => this.scene.playBgm()); }); }); this.scene.tweens.add({ @@ -435,17 +443,11 @@ export class EggHatchPhase extends Phase { /** * Generates a Pokemon to be hatched by the egg + * Also stores the generated pokemon in this.eggHatchData * @returns the hatched PlayerPokemon */ generatePokemon(): PlayerPokemon { - let ret: PlayerPokemon; - - this.scene.executeWithSeedOffset(() => { - ret = this.egg.generatePlayerPokemon(this.scene); - this.eggMoveIndex = this.egg.eggMoveIndex; - - }, this.egg.id, EGG_SEED.toString()); - - return ret!; + this.eggHatchData = this.eggLapsePhase.generatePokemon(this.egg); + return this.eggHatchData.pokemon; } } diff --git a/src/phases/egg-lapse-phase.ts b/src/phases/egg-lapse-phase.ts index 50d7106f229..1adb1568166 100644 --- a/src/phases/egg-lapse-phase.ts +++ b/src/phases/egg-lapse-phase.ts @@ -1,11 +1,23 @@ -import BattleScene from "#app/battle-scene.js"; -import { Egg } from "#app/data/egg.js"; -import { Phase } from "#app/phase.js"; +import BattleScene from "#app/battle-scene"; +import { Egg, EGG_SEED } from "#app/data/egg"; +import { Phase } from "#app/phase"; import i18next from "i18next"; import Overrides from "#app/overrides"; import { EggHatchPhase } from "./egg-hatch-phase"; +import { Mode } from "#app/ui/ui"; +import { achvs } from "#app/system/achv"; +import { PlayerPokemon } from "#app/field/pokemon"; +import { EggSummaryPhase } from "./egg-summary-phase"; +import { EggHatchData } from "#app/data/egg-hatch-data"; +/** + * Phase that handles updating eggs, and hatching any ready eggs + * Also handles prompts for skipping animation, and calling the egg summary phase + */ export class EggLapsePhase extends Phase { + + private eggHatchData: EggHatchData[] = []; + private readonly minEggsToPromptSkip: number = 5; constructor(scene: BattleScene) { super(scene); } @@ -16,20 +28,111 @@ export class EggLapsePhase extends Phase { const eggsToHatch: Egg[] = this.scene.gameData.eggs.filter((egg: Egg) => { return Overrides.EGG_IMMEDIATE_HATCH_OVERRIDE ? true : --egg.hatchWaves < 1; }); + const eggsToHatchCount: number = eggsToHatch.length; + this.eggHatchData= []; - let eggCount: integer = eggsToHatch.length; + if (eggsToHatchCount > 0) { - if (eggCount) { - this.scene.queueMessage(i18next.t("battle:eggHatching")); - - for (const egg of eggsToHatch) { - this.scene.unshiftPhase(new EggHatchPhase(this.scene, egg, eggCount)); - if (eggCount > 0) { - eggCount--; - } + if (eggsToHatchCount >= this.minEggsToPromptSkip) { + this.scene.ui.showText(i18next.t("battle:eggHatching"), 0, () => { + // show prompt for skip + this.scene.ui.showText(i18next.t("battle:eggSkipPrompt"), 0); + this.scene.ui.setModeWithoutClear(Mode.CONFIRM, () => { + this.hatchEggsSkipped(eggsToHatch); + this.showSummary(); + }, () => { + this.hatchEggsRegular(eggsToHatch); + this.showSummary(); + } + ); + }, 100, true); + } else { + // regular hatches, no summary + this.scene.queueMessage(i18next.t("battle:eggHatching")); + this.hatchEggsRegular(eggsToHatch); + this.end(); } - + } else { + this.end(); } + } + + /** + * Hatches eggs normally one by one, showing animations + * @param eggsToHatch list of eggs to hatch + */ + hatchEggsRegular(eggsToHatch: Egg[]) { + let eggsToHatchCount: number = eggsToHatch.length; + for (const egg of eggsToHatch) { + this.scene.unshiftPhase(new EggHatchPhase(this.scene, this, egg, eggsToHatchCount)); + eggsToHatchCount--; + } + } + + /** + * Hatches eggs with no animations + * @param eggsToHatch list of eggs to hatch + */ + hatchEggsSkipped(eggsToHatch: Egg[]) { + for (const egg of eggsToHatch) { + this.hatchEggSilently(egg); + } + } + + showSummary() { + this.scene.unshiftPhase(new EggSummaryPhase(this.scene, this.eggHatchData)); this.end(); } + + /** + * Hatches an egg and stores it in the local EggHatchData array without animations + * Also validates the achievements for the hatched pokemon and removes the egg + * @param egg egg to hatch + */ + hatchEggSilently(egg: Egg) { + const eggIndex = this.scene.gameData.eggs.findIndex(e => e.id === egg.id); + if (eggIndex === -1) { + return this.end(); + } + this.scene.gameData.eggs.splice(eggIndex, 1); + + const data = this.generatePokemon(egg); + const pokemon = data.pokemon; + if (pokemon.fusionSpecies) { + pokemon.clearFusionSpecies(); + } + + if (pokemon.species.subLegendary) { + this.scene.validateAchv(achvs.HATCH_SUB_LEGENDARY); + } + if (pokemon.species.legendary) { + this.scene.validateAchv(achvs.HATCH_LEGENDARY); + } + if (pokemon.species.mythical) { + this.scene.validateAchv(achvs.HATCH_MYTHICAL); + } + if (pokemon.isShiny()) { + this.scene.validateAchv(achvs.HATCH_SHINY); + } + + } + + /** + * Generates a Pokemon and creates a new EggHatchData instance for the given egg + * @param egg the egg to hatch + * @returns the hatched PlayerPokemon + */ + generatePokemon(egg: Egg): EggHatchData { + let ret: PlayerPokemon; + let newHatchData: EggHatchData; + this.scene.executeWithSeedOffset(() => { + ret = egg.generatePlayerPokemon(this.scene); + newHatchData = new EggHatchData(this.scene, ret, egg.eggMoveIndex); + newHatchData.setDex(); + this.eggHatchData.push(newHatchData); + + }, egg.id, EGG_SEED.toString()); + return newHatchData!; + } + } diff --git a/src/phases/egg-summary-phase.ts b/src/phases/egg-summary-phase.ts new file mode 100644 index 00000000000..190af17c724 --- /dev/null +++ b/src/phases/egg-summary-phase.ts @@ -0,0 +1,50 @@ +import BattleScene from "#app/battle-scene"; +import { Phase } from "#app/phase"; +import { Mode } from "#app/ui/ui"; +import EggHatchSceneHandler from "#app/ui/egg-hatch-scene-handler"; +import { EggHatchData } from "#app/data/egg-hatch-data"; + +/** + * Class that represents the egg summary phase + * It does some of the function for updating egg data + * Phase is handled mostly by the egg-hatch-scene-handler UI + */ +export class EggSummaryPhase extends Phase { + private eggHatchData: EggHatchData[]; + private eggHatchHandler: EggHatchSceneHandler; + + constructor(scene: BattleScene, eggHatchData: EggHatchData[]) { + super(scene); + this.eggHatchData = eggHatchData; + } + + start() { + super.start(); + + // updates next pokemon once the current update has been completed + const updateNextPokemon = (i: number) => { + if (i >= this.eggHatchData.length) { + this.scene.ui.setModeForceTransition(Mode.EGG_HATCH_SUMMARY, this.eggHatchData).then(() => { + this.scene.fadeOutBgm(undefined, false); + this.eggHatchHandler = this.scene.ui.getHandler() as EggHatchSceneHandler; + }); + + } else { + this.eggHatchData[i].setDex(); + this.eggHatchData[i].updatePokemon().then(() => { + if (i < this.eggHatchData.length) { + updateNextPokemon(i + 1); + } + }); + } + }; + updateNextPokemon(0); + + } + + end() { + this.eggHatchHandler.clear(); + this.scene.ui.setModeForceTransition(Mode.MESSAGE).then(() => {}); + super.end(); + } +} diff --git a/src/system/game-data.ts b/src/system/game-data.ts index 50cc6177a84..1a47294906e 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -1553,11 +1553,11 @@ export class GameData { } } - setPokemonCaught(pokemon: Pokemon, incrementCount: boolean = true, fromEgg: boolean = false): Promise { - return this.setPokemonSpeciesCaught(pokemon, pokemon.species, incrementCount, fromEgg); + setPokemonCaught(pokemon: Pokemon, incrementCount: boolean = true, fromEgg: boolean = false, showMessage: boolean = true): Promise { + return this.setPokemonSpeciesCaught(pokemon, pokemon.species, incrementCount, fromEgg, showMessage); } - setPokemonSpeciesCaught(pokemon: Pokemon, species: PokemonSpecies, incrementCount: boolean = true, fromEgg: boolean = false): Promise { + setPokemonSpeciesCaught(pokemon: Pokemon, species: PokemonSpecies, incrementCount: boolean = true, fromEgg: boolean = false, showMessage: boolean = true): Promise { return new Promise(resolve => { const dexEntry = this.dexData[species.speciesId]; const caughtAttr = dexEntry.caughtAttr; @@ -1616,13 +1616,17 @@ export class GameData { const checkPrevolution = () => { if (hasPrevolution) { const prevolutionSpecies = pokemonPrevolutions[species.speciesId]; - return this.setPokemonSpeciesCaught(pokemon, getPokemonSpecies(prevolutionSpecies), incrementCount, fromEgg).then(() => resolve()); + this.setPokemonSpeciesCaught(pokemon, getPokemonSpecies(prevolutionSpecies), incrementCount, fromEgg, showMessage).then(() => resolve()); } else { resolve(); } }; if (newCatch && speciesStarters.hasOwnProperty(species.speciesId)) { + if (!showMessage) { + resolve(); + return; + } this.scene.playSound("level_up_fanfare"); this.scene.ui.showText(i18next.t("battle:addedAsAStarter", { pokemonName: species.name }), null, () => checkPrevolution(), null, true); } else { @@ -1668,7 +1672,7 @@ export class GameData { this.starterData[species.speciesId].candyCount += count; } - setEggMoveUnlocked(species: PokemonSpecies, eggMoveIndex: integer): Promise { + setEggMoveUnlocked(species: PokemonSpecies, eggMoveIndex: integer, showMessage: boolean = true): Promise { return new Promise(resolve => { const speciesId = species.speciesId; if (!speciesEggMoves.hasOwnProperty(speciesId) || !speciesEggMoves[speciesId][eggMoveIndex]) { @@ -1688,11 +1692,15 @@ export class GameData { } this.starterData[speciesId].eggMoves |= value; - + if (!showMessage) { + resolve(true); + return; + } this.scene.playSound("level_up_fanfare"); - const moveName = allMoves[speciesEggMoves[speciesId][eggMoveIndex]].name; - this.scene.ui.showText(eggMoveIndex === 3 ? i18next.t("egg:rareEggMoveUnlock", { moveName: moveName }) : i18next.t("egg:eggMoveUnlock", { moveName: moveName }), null, () => resolve(true), null, true); + this.scene.ui.showText(eggMoveIndex === 3 ? i18next.t("egg:rareEggMoveUnlock", { moveName: moveName }) : i18next.t("egg:eggMoveUnlock", { moveName: moveName }), null, (() => { + resolve(true); + }), null, true); }); } diff --git a/src/ui/egg-summary-ui-handler.ts b/src/ui/egg-summary-ui-handler.ts new file mode 100644 index 00000000000..af82ab33438 --- /dev/null +++ b/src/ui/egg-summary-ui-handler.ts @@ -0,0 +1,320 @@ +import BattleScene from "../battle-scene"; +import { Mode } from "./ui"; +import PokemonIconAnimHandler, { PokemonIconAnimMode } from "./pokemon-icon-anim-handler"; +import MessageUiHandler from "./message-ui-handler"; +import { getEggTierForSpecies } from "../data/egg"; +import {Button} from "#enums/buttons"; +import { Gender } from "#app/data/gender"; +import { getVariantTint } from "#app/data/variant"; +import { EggTier } from "#app/enums/egg-type"; +import PokemonHatchInfoContainer from "./pokemon-hatch-info-container"; +import { EggSummaryPhase } from "#app/phases/egg-summary-phase"; +import { DexAttr } from "#app/system/game-data"; +import { EggHatchData } from "#app/data/egg-hatch-data"; + +const iconContainerX = 115; +const iconContainerY = 9; +const numCols = 11; +const iconSize = 18; + +/** + * UI Handler for the egg summary. + * Handles navigation and display of each pokemon as a list + * Also handles display of the pokemon-hatch-info-container + */ +export default class EggSummaryUiHandler extends MessageUiHandler { + /** holds all elements in the scene */ + private eggHatchContainer: Phaser.GameObjects.Container; + /** holds the icon containers and info container */ + private summaryContainer: Phaser.GameObjects.Container; + /** container for the mini pokemon sprites */ + private pokemonIconSpritesContainer: Phaser.GameObjects.Container; + /** container for the icons displayed alongside the mini icons (e.g. shiny, HA capsule) */ + private pokemonIconsContainer: Phaser.GameObjects.Container; + /** hatch info container that displays the current pokemon / hatch (main element on left hand side) */ + private infoContainer: PokemonHatchInfoContainer; + /** handles jumping animations for the pokemon sprite icons */ + private iconAnimHandler: PokemonIconAnimHandler; + private eggHatchBg: Phaser.GameObjects.Image; + private cursorObj: Phaser.GameObjects.Image; + private eggHatchData: EggHatchData[]; + + + /** + * Allows subscribers to listen for events + * + * Current Events: + * - {@linkcode EggEventType.EGG_COUNT_CHANGED} {@linkcode EggCountChangedEvent} + */ + public readonly eventTarget: EventTarget = new EventTarget(); + + constructor(scene: BattleScene) { + super(scene, Mode.EGG_HATCH_SUMMARY); + } + + + setup() { + const ui = this.getUi(); + + this.summaryContainer = this.scene.add.container(0, -this.scene.game.canvas.height / 6); + this.summaryContainer.setVisible(false); + ui.add(this.summaryContainer); + + this.eggHatchContainer = this.scene.add.container(0, -this.scene.game.canvas.height / 6); + this.eggHatchContainer.setVisible(false); + ui.add(this.eggHatchContainer); + + this.iconAnimHandler = new PokemonIconAnimHandler(); + this.iconAnimHandler.setup(this.scene); + + this.eggHatchBg = this.scene.add.image(0, 0, "egg_summary_bg"); + this.eggHatchBg.setOrigin(0, 0); + this.eggHatchContainer.add(this.eggHatchBg); + + this.pokemonIconsContainer = this.scene.add.container(iconContainerX, iconContainerY); + this.pokemonIconSpritesContainer = this.scene.add.container(iconContainerX, iconContainerY); + this.summaryContainer.add(this.pokemonIconsContainer); + this.summaryContainer.add(this.pokemonIconSpritesContainer); + + this.cursorObj = this.scene.add.image(0, 0, "select_cursor"); + this.cursorObj.setOrigin(0, 0); + this.summaryContainer.add(this.cursorObj); + + this.infoContainer = new PokemonHatchInfoContainer(this.scene, this.summaryContainer); + this.infoContainer.setup(); + this.infoContainer.changeToEggSummaryLayout(); + this.infoContainer.setVisible(true); + this.summaryContainer.add(this.infoContainer); + + this.cursor = -1; + } + + clear() { + super.clear(); + this.cursor = -1; + this.summaryContainer.setVisible(false); + this.pokemonIconSpritesContainer.removeAll(true); + this.pokemonIconsContainer.removeAll(true); + this.eggHatchBg.setVisible(false); + this.getUi().hideTooltip(); + // Note: Questions on garbage collection go to @frutescens + const activeKeys = this.scene.getActiveKeys(); + // Removing unnecessary sprites from animation manager + const animKeys = Object.keys(this.scene.anims["anims"]["entries"]); + animKeys.forEach(key => { + if (key.startsWith("pkmn__") && !activeKeys.includes(key)) { + this.scene.anims.remove(key); + } + }); + // Removing unnecessary cries from audio cache + const audioKeys = Object.keys(this.scene.cache.audio.entries.entries); + audioKeys.forEach(key => { + if (key.startsWith("cry/") && !activeKeys.includes(key)) { + delete this.scene.cache.audio.entries.entries[key]; + } + }); + // Clears eggHatchData in EggSummaryUiHandler + this.eggHatchData.length = 0; + // Removes Pokemon icons in EggSummaryUiHandler + this.iconAnimHandler.removeAll(); + console.log("Egg Summary Handler cleared"); + } + + /** + * @param args EggHatchData[][] + * args[0]: list of EggHatchData for each egg/pokemon hatched + */ + show(args: EggHatchData[][]): boolean { + super.show(args); + if (args.length >= 1) { + // sort the egg hatch data by egg tier then by species number (then by order hatched) + this.eggHatchData = args[0].sort(function sortHatchData(a: EggHatchData, b: EggHatchData) { + const speciesA = a.pokemon.species; + const speciesB = b.pokemon.species; + if (getEggTierForSpecies(speciesA) < getEggTierForSpecies(speciesB)) { + return -1; + } else if (getEggTierForSpecies(speciesA) > getEggTierForSpecies(speciesB)) { + return 1; + } else { + if (speciesA.speciesId < speciesB.speciesId) { + return -1; + } else if (speciesA.speciesId > speciesB.speciesId) { + return 1; + } else { + return 0; + } + } + } + + ); + } + + this.getUi().bringToTop(this.summaryContainer); + this.summaryContainer.setVisible(true); + this.eggHatchContainer.setVisible(true); + this.pokemonIconsContainer.setVisible(true); + this.eggHatchBg.setVisible(true); + this.infoContainer.hideDisplayPokemon(); + + this.eggHatchData.forEach( (value: EggHatchData, i: number) => { + const x = (i % numCols) * iconSize; + const y = Math.floor(i / numCols) * iconSize; + + const displayPokemon = value.pokemon; + const offset = 2; + const rightSideX = 12; + + const bg = this.scene.add.image(x+2, y+5, "passive_bg"); + bg.setOrigin(0, 0); + bg.setScale(0.75); + bg.setVisible(true); + this.pokemonIconsContainer.add(bg); + + // set tint for passive bg + switch (getEggTierForSpecies(displayPokemon.species)) { + case EggTier.COMMON: + bg.setVisible(false); + break; + case EggTier.GREAT: + bg.setTint(0xabafff); + break; + case EggTier.ULTRA: + bg.setTint(0xffffaa); + break; + case EggTier.MASTER: + bg.setTint(0xdfffaf); + break; + } + const species = displayPokemon.species; + const female = displayPokemon.gender === Gender.FEMALE; + const formIndex = displayPokemon.formIndex; + const variant = displayPokemon.variant; + const isShiny = displayPokemon.shiny; + + // set pokemon icon (and replace with base sprite if there is a mismatch) + const icon = this.scene.add.sprite(x - offset, y + offset, species.getIconAtlasKey(formIndex, isShiny, variant)); + icon.setScale(0.5); + icon.setOrigin(0, 0); + icon.setFrame(species.getIconId(female, formIndex, isShiny, variant)); + + if (icon.frame.name !== species.getIconId(female, formIndex, isShiny, variant)) { + console.log(`${species.name}'s variant icon does not exist. Replacing with default.`); + icon.setTexture(species.getIconAtlasKey(formIndex, false, variant)); + icon.setFrame(species.getIconId(female, formIndex, false, variant)); + } + this.pokemonIconSpritesContainer.add(icon); + this.iconAnimHandler.addOrUpdate(icon, PokemonIconAnimMode.NONE); + + const shiny = this.scene.add.image(x + rightSideX, y + offset * 2, "shiny_star_small"); + shiny.setScale(0.5); + shiny.setVisible(displayPokemon.shiny); + shiny.setTint(getVariantTint(displayPokemon.variant)); + this.pokemonIconsContainer.add(shiny); + + const ha = this.scene.add.image(x + rightSideX, y + 7, "ha_capsule"); + ha.setScale(0.5); + ha.setVisible((displayPokemon.hasAbility(displayPokemon.species.abilityHidden))); + this.pokemonIconsContainer.add(ha); + + const pb = this.scene.add.image(x + rightSideX, y + offset * 7, "icon_owned"); + pb.setOrigin(0, 0); + pb.setScale(0.5); + + // add animation for new unlocks (new catch or new shiny or new form) + const dexEntry = value.dexEntryBeforeUpdate; + const caughtAttr = dexEntry.caughtAttr; + const newShiny = BigInt(1 << (displayPokemon.shiny ? 1 : 0)); + const newVariant = BigInt(1 << (displayPokemon.variant + 4)); + const newShinyOrVariant = ((newShiny & caughtAttr) === BigInt(0)) || ((newVariant & caughtAttr) === BigInt(0)); + const newForm = (BigInt(1 << displayPokemon.formIndex) * DexAttr.DEFAULT_FORM & caughtAttr) === BigInt(0); + + pb.setVisible(!caughtAttr || newForm); + if (!caughtAttr || newShinyOrVariant || newForm) { + this.iconAnimHandler.addOrUpdate(icon, PokemonIconAnimMode.PASSIVE); + } + this.pokemonIconsContainer.add(pb); + + const em = this.scene.add.image(x, y + offset, "icon_egg_move"); + em.setOrigin(0, 0); + em.setScale(0.5); + em.setVisible(value.eggMoveUnlocked); + this.pokemonIconsContainer.add(em); + }); + + this.setCursor(0); + this.scene.playSoundWithoutBgm("evolution_fanfare"); + return true; + } + + processInput(button: Button): boolean { + const ui = this.getUi(); + + let success = false; + const error = false; + if (button === Button.CANCEL) { + const phase = this.scene.getCurrentPhase(); + if (phase instanceof EggSummaryPhase) { + phase.end(); + } + ui.revertMode(); + success = true; + } else { + const count = this.eggHatchData.length; + const rows = Math.ceil(count / numCols); + const row = Math.floor(this.cursor / numCols); + switch (button) { + case Button.UP: + if (row) { + success = this.setCursor(this.cursor - numCols); + } + break; + case Button.DOWN: + if (row < rows - 2 || (row < rows - 1 && this.cursor % numCols <= (count - 1) % numCols)) { + success = this.setCursor(this.cursor + numCols); + } + break; + case Button.LEFT: + if (this.cursor % numCols) { + success = this.setCursor(this.cursor - 1); + } + break; + case Button.RIGHT: + if (this.cursor % numCols < (row < rows - 1 ? 10 : (count - 1) % numCols)) { + success = this.setCursor(this.cursor + 1); + } + break; + } + } + + if (success) { + ui.playSelect(); + } else if (error) { + ui.playError(); + } + + return success || error; + } + + setCursor(cursor: number): boolean { + let changed = false; + + const lastCursor = this.cursor; + + changed = super.setCursor(cursor); + + if (changed) { + this.cursorObj.setPosition(iconContainerX - 1 + iconSize * (cursor % numCols), iconContainerY + 1 + iconSize * Math.floor(cursor / numCols)); + + if (lastCursor > -1) { + this.iconAnimHandler.addOrUpdate(this.pokemonIconSpritesContainer.getAt(lastCursor) as Phaser.GameObjects.Sprite, PokemonIconAnimMode.NONE); + } + this.iconAnimHandler.addOrUpdate(this.pokemonIconSpritesContainer.getAt(cursor) as Phaser.GameObjects.Sprite, PokemonIconAnimMode.ACTIVE); + + this.infoContainer.showHatchInfo(this.eggHatchData[cursor]); + + } + + return changed; + } + +} diff --git a/src/ui/pokemon-hatch-info-container.ts b/src/ui/pokemon-hatch-info-container.ts new file mode 100644 index 00000000000..f8a9adced36 --- /dev/null +++ b/src/ui/pokemon-hatch-info-container.ts @@ -0,0 +1,189 @@ +import PokemonInfoContainer from "./pokemon-info-container"; +import BattleScene from "../battle-scene"; +import { Gender } from "../data/gender"; +import { Type } from "../data/type"; +import * as Utils from "../utils"; +import { TextStyle, addTextObject } from "./text"; +import { speciesEggMoves } from "#app/data/egg-moves"; +import { allMoves } from "#app/data/move"; +import { Species } from "#app/enums/species"; +import { getEggTierForSpecies } from "#app/data/egg"; +import { starterColors } from "../battle-scene"; +import { argbFromRgba } from "@material/material-color-utilities"; +import { EggHatchData } from "#app/data/egg-hatch-data"; +import { PlayerPokemon } from "#app/field/pokemon"; +import { getPokemonSpeciesForm } from "#app/data/pokemon-species"; + +/** + * Class for the hatch info summary of each pokemon + * Holds an info container as well as an additional egg sprite, name, egg moves and main sprite + */ +export default class PokemonHatchInfoContainer extends PokemonInfoContainer { + private currentPokemonSprite: Phaser.GameObjects.Sprite; + private pokemonNumberText: Phaser.GameObjects.Text; + private pokemonNameText: Phaser.GameObjects.Text; + private pokemonEggMovesContainer: Phaser.GameObjects.Container; + private pokemonEggMoveContainers: Phaser.GameObjects.Container[]; + private pokemonEggMoveBgs: Phaser.GameObjects.NineSlice[]; + private pokemonEggMoveLabels: Phaser.GameObjects.Text[]; + private pokemonHatchedIcon : Phaser.GameObjects.Sprite; + private pokemonListContainer: Phaser.GameObjects.Container; + private pokemonCandyIcon: Phaser.GameObjects.Sprite; + private pokemonCandyOverlayIcon: Phaser.GameObjects.Sprite; + private pokemonCandyCountText: Phaser.GameObjects.Text; + + constructor(scene: BattleScene, listContainer : Phaser.GameObjects.Container, x: number = 115, y: number = 9,) { + super(scene, x, y); + this.pokemonListContainer = listContainer; + + } + setup(): void { + super.setup(); + super.changeToEggSummaryLayout(); + + this.currentPokemonSprite = this.scene.add.sprite(54, 80, "pkmn__sub"); + this.currentPokemonSprite.setScale(0.8); + this.currentPokemonSprite.setPipeline(this.scene.spritePipeline, { tone: [ 0.0, 0.0, 0.0, 0.0 ], ignoreTimeTint: true }); + this.pokemonListContainer.add(this.currentPokemonSprite); + + // setup name and number + this.pokemonNumberText = addTextObject(this.scene, 80, 107.5, "0000", TextStyle.SUMMARY, {fontSize: 74}); + this.pokemonNumberText.setOrigin(0, 0); + this.pokemonListContainer.add(this.pokemonNumberText); + + this.pokemonNameText = addTextObject(this.scene, 7, 107.5, "", TextStyle.SUMMARY, {fontSize: 74}); + this.pokemonNameText.setOrigin(0, 0); + this.pokemonListContainer.add(this.pokemonNameText); + + // setup egg icon and candy count + this.pokemonHatchedIcon = this.scene.add.sprite(-5, 90, "egg_icons"); + this.pokemonHatchedIcon.setOrigin(0, 0.2); + this.pokemonHatchedIcon.setScale(0.8); + this.pokemonListContainer.add(this.pokemonHatchedIcon); + + this.pokemonCandyIcon = this.scene.add.sprite(4.5, 40, "candy"); + this.pokemonCandyIcon.setScale(0.5); + this.pokemonCandyIcon.setOrigin(0, 0); + this.pokemonListContainer.add(this.pokemonCandyIcon); + + this.pokemonCandyOverlayIcon = this.scene.add.sprite(4.5, 40, "candy_overlay"); + this.pokemonCandyOverlayIcon.setScale(0.5); + this.pokemonCandyOverlayIcon.setOrigin(0, 0); + this.pokemonListContainer.add(this.pokemonCandyOverlayIcon); + + this.pokemonCandyCountText = addTextObject(this.scene, 14, 40, "x0", TextStyle.SUMMARY, { fontSize: "56px" }); + this.pokemonCandyCountText.setOrigin(0, 0); + this.pokemonListContainer.add(this.pokemonCandyCountText); + + // setup egg moves + this.pokemonEggMoveContainers = []; + this.pokemonEggMoveBgs = []; + this.pokemonEggMoveLabels = []; + this.pokemonEggMovesContainer = this.scene.add.container(0, 200); + this.pokemonEggMovesContainer.setVisible(false); + this.pokemonEggMovesContainer.setScale(0.5); + + for (let m = 0; m < 4; m++) { + const eggMoveContainer = this.scene.add.container(0, 0 + 6 * m); + + const eggMoveBg = this.scene.add.nineslice(70, 0, "type_bgs", "unknown", 92, 14, 2, 2, 2, 2); + eggMoveBg.setOrigin(1, 0); + + const eggMoveLabel = addTextObject(this.scene, 70 -eggMoveBg.width / 2, 0, "???", TextStyle.PARTY); + eggMoveLabel.setOrigin(0.5, 0); + + this.pokemonEggMoveBgs.push(eggMoveBg); + this.pokemonEggMoveLabels.push(eggMoveLabel); + + eggMoveContainer.add(eggMoveBg); + eggMoveContainer.add(eggMoveLabel); + eggMoveContainer.setScale(0.44); + + this.pokemonEggMoveContainers.push(eggMoveContainer); + + this.pokemonEggMovesContainer.add(eggMoveContainer); + } + + super.add(this.pokemonEggMoveContainers); + + } + + /** + * Disable the sprite (and replace with substitute) + */ + hideDisplayPokemon() { + this.currentPokemonSprite.setVisible(false); + } + + /** + * Display a given pokemon sprite with animations + * assumes the specific pokemon sprite has already been loaded + */ + displayPokemon(pokemon: PlayerPokemon) { + const species = pokemon.species; + const female = pokemon.gender === Gender.FEMALE; + const formIndex = pokemon.formIndex; + const shiny = pokemon.shiny; + const variant = pokemon.variant; + this.currentPokemonSprite.setVisible(false); + species.loadAssets(this.scene, female, formIndex, shiny, variant, true).then(() => { + + getPokemonSpeciesForm(species.speciesId, pokemon.formIndex).cry(this.scene); + this.currentPokemonSprite.play(species.getSpriteKey(female, formIndex, shiny, variant)); + this.currentPokemonSprite.setPipelineData("shiny", shiny); + this.currentPokemonSprite.setPipelineData("variant", variant); + this.currentPokemonSprite.setPipelineData("spriteKey", species.getSpriteKey(female, formIndex, shiny, variant)); + this.currentPokemonSprite.setVisible(true); + }); + } + + /** + * Updates the info container with the appropriate dex data and starter entry from the hatchInfo + * Also updates the displayed name, number, egg moves and main animated sprite for the pokemon + * @param hatchInfo The EggHatchData of the pokemon / new hatch to show + */ + showHatchInfo(hatchInfo: EggHatchData) { + this.pokemonEggMovesContainer.setVisible(true); + + const pokemon = hatchInfo.pokemon; + const species = pokemon.species; + this.displayPokemon(pokemon); + + super.show(pokemon, false, 1, hatchInfo.getDex(), hatchInfo.getStarterEntry(), true); + const colorScheme = starterColors[species.speciesId]; + + this.pokemonCandyIcon.setTint(argbFromRgba(Utils.rgbHexToRgba(colorScheme[0]))); + this.pokemonCandyIcon.setVisible(true); + this.pokemonCandyOverlayIcon.setTint(argbFromRgba(Utils.rgbHexToRgba(colorScheme[1]))); + this.pokemonCandyOverlayIcon.setVisible(true); + this.pokemonCandyCountText.setText(`x${this.scene.gameData.starterData[species.speciesId].candyCount}`); + this.pokemonCandyCountText.setVisible(true); + + this.pokemonNumberText.setText(Utils.padInt(species.speciesId, 4)); + this.pokemonNameText.setText(species.name); + + const hasEggMoves = species && speciesEggMoves.hasOwnProperty(species.speciesId); + + for (let em = 0; em < 4; em++) { + const eggMove = hasEggMoves ? allMoves[speciesEggMoves[species.speciesId][em]] : null; + const eggMoveUnlocked = eggMove && this.scene.gameData.starterData[species.speciesId].eggMoves & Math.pow(2, em); + this.pokemonEggMoveBgs[em].setFrame(Type[eggMove ? eggMove.type : Type.UNKNOWN].toString().toLowerCase()); + + this.pokemonEggMoveLabels[em].setText(eggMove && eggMoveUnlocked ? eggMove.name : "???"); + if (!(eggMove && hatchInfo.starterDataEntryBeforeUpdate.eggMoves & Math.pow(2, em)) && eggMoveUnlocked) { + this.pokemonEggMoveLabels[em].setText("(+) " + eggMove.name); + } + } + + // will always have at least one egg move + this.pokemonEggMovesContainer.setVisible(true); + + if (species.speciesId === Species.MANAPHY || species.speciesId === Species.PHIONE) { + this.pokemonHatchedIcon.setFrame("manaphy"); + } else { + this.pokemonHatchedIcon.setFrame(getEggTierForSpecies(species)); + } + + } + +} diff --git a/src/ui/pokemon-info-container.ts b/src/ui/pokemon-info-container.ts index edb85ecff7a..49bfd4d7293 100644 --- a/src/ui/pokemon-info-container.ts +++ b/src/ui/pokemon-info-container.ts @@ -6,7 +6,7 @@ import { getNatureName } from "../data/nature"; import { Type } from "../data/type"; import Pokemon from "../field/pokemon"; import i18next from "i18next"; -import { DexAttr } from "../system/game-data"; +import { DexAttr, DexEntry, StarterDataEntry } from "../system/game-data"; import * as Utils from "../utils"; import ConfirmUiHandler from "./confirm-ui-handler"; import { StatsContainer } from "./stats-container"; @@ -24,7 +24,7 @@ const languageSettings: { [key: string]: LanguageSetting } = { infoContainerTextSize: "64px" }, "de": { - infoContainerTextSize: "64px" + infoContainerTextSize: "64px", }, "es": { infoContainerTextSize: "64px" @@ -63,6 +63,7 @@ export default class PokemonInfoContainer extends Phaser.GameObjects.Container { private pokemonMovesContainers: Phaser.GameObjects.Container[]; private pokemonMoveBgs: Phaser.GameObjects.NineSlice[]; private pokemonMoveLabels: Phaser.GameObjects.Text[]; + private infoBg; private numCharsBeforeCutoff = 16; @@ -83,9 +84,9 @@ export default class PokemonInfoContainer extends Phaser.GameObjects.Container { const currentLanguage = i18next.resolvedLanguage!; // TODO: is this bang correct? const langSettingKey = Object.keys(languageSettings).find(lang => currentLanguage?.includes(lang))!; // TODO: is this bang correct? const textSettings = languageSettings[langSettingKey]; - const infoBg = addWindow(this.scene, 0, 0, this.infoWindowWidth, 132); - infoBg.setOrigin(0.5, 0.5); - infoBg.setName("window-info-bg"); + this.infoBg = addWindow(this.scene, 0, 0, this.infoWindowWidth, 132); + this.infoBg.setOrigin(0.5, 0.5); + this.infoBg.setName("window-info-bg"); this.pokemonMovesContainer = this.scene.add.container(6, 14); this.pokemonMovesContainer.setName("pkmn-moves"); @@ -133,7 +134,7 @@ export default class PokemonInfoContainer extends Phaser.GameObjects.Container { this.statsContainer = new StatsContainer(this.scene, -48, -64, true); - this.add(infoBg); + this.add(this.infoBg); this.add(this.statsContainer); // The position should be set per language @@ -207,9 +208,16 @@ export default class PokemonInfoContainer extends Phaser.GameObjects.Container { this.setVisible(false); } - show(pokemon: Pokemon, showMoves: boolean = false, speedMultiplier: number = 1): Promise { + show(pokemon: Pokemon, showMoves: boolean = false, speedMultiplier: number = 1, dexEntry?: DexEntry, starterEntry?: StarterDataEntry, eggInfo = false): Promise { return new Promise(resolve => { - const caughtAttr = BigInt(pokemon.scene.gameData.dexData[pokemon.species.speciesId].caughtAttr); + if (!dexEntry) { + dexEntry = pokemon.scene.gameData.dexData[pokemon.species.speciesId]; + } + if (!starterEntry) { + starterEntry = pokemon.scene.gameData.starterData[pokemon.species.getRootSpeciesId()]; + } + + const caughtAttr = BigInt(dexEntry.caughtAttr); if (pokemon.gender > Gender.GENDERLESS) { this.pokemonGenderText.setText(getGenderSymbol(pokemon.gender)); this.pokemonGenderText.setColor(getGenderColor(pokemon.gender)); @@ -268,7 +276,7 @@ export default class PokemonInfoContainer extends Phaser.GameObjects.Container { const opponentPokemonAbilityIndex = (opponentPokemonOneNormalAbility && pokemon.abilityIndex === 1) ? 2 : pokemon.abilityIndex; const opponentPokemonAbilityAttr = 1 << opponentPokemonAbilityIndex; - const rootFormHasHiddenAbility = pokemon.scene.gameData.starterData[pokemon.species.getRootSpeciesId()].abilityAttr & opponentPokemonAbilityAttr; + const rootFormHasHiddenAbility = starterEntry.abilityAttr & opponentPokemonAbilityAttr; if (!rootFormHasHiddenAbility) { this.pokemonAbilityLabelText.setColor(getTextColor(TextStyle.SUMMARY_BLUE, false, this.scene.uiTheme)); @@ -280,7 +288,7 @@ export default class PokemonInfoContainer extends Phaser.GameObjects.Container { this.pokemonNatureText.setText(getNatureName(pokemon.getNature(), true, false, false, this.scene.uiTheme)); - const dexNatures = pokemon.scene.gameData.dexData[pokemon.species.speciesId].natureAttr; + const dexNatures = dexEntry.natureAttr; const newNature = 1 << (pokemon.nature + 1); if (!(dexNatures & newNature)) { @@ -324,31 +332,31 @@ export default class PokemonInfoContainer extends Phaser.GameObjects.Container { } const starterSpeciesId = pokemon.species.getRootSpeciesId(); - const originalIvs: integer[] | null = this.scene.gameData.dexData[starterSpeciesId].caughtAttr - ? this.scene.gameData.dexData[starterSpeciesId].ivs - : null; + const originalIvs: integer[] | null = eggInfo ? (dexEntry.caughtAttr ? dexEntry.ivs : null) : (this.scene.gameData.dexData[starterSpeciesId].caughtAttr + ? this.scene.gameData.dexData[starterSpeciesId].ivs : null); this.statsContainer.updateIvs(pokemon.ivs, originalIvs!); // TODO: is this bang correct? - - this.scene.tweens.add({ - targets: this, - duration: Utils.fixedInt(Math.floor(750 / speedMultiplier)), - ease: "Cubic.easeInOut", - x: this.initialX - this.infoWindowWidth, - onComplete: () => { - resolve(); - } - }); - - if (showMoves) { + if (!eggInfo) { this.scene.tweens.add({ - delay: Utils.fixedInt(Math.floor(325 / speedMultiplier)), - targets: this.pokemonMovesContainer, - duration: Utils.fixedInt(Math.floor(325 / speedMultiplier)), + targets: this, + duration: Utils.fixedInt(Math.floor(750 / speedMultiplier)), ease: "Cubic.easeInOut", - x: this.movesContainerInitialX - 57, - onComplete: () => resolve() + x: this.initialX - this.infoWindowWidth, + onComplete: () => { + resolve(); + } }); + + if (showMoves) { + this.scene.tweens.add({ + delay: Utils.fixedInt(Math.floor(325 / speedMultiplier)), + targets: this.pokemonMovesContainer, + duration: Utils.fixedInt(Math.floor(325 / speedMultiplier)), + ease: "Cubic.easeInOut", + x: this.movesContainerInitialX - 57, + onComplete: () => resolve() + }); + } } for (let m = 0; m < 4; m++) { @@ -364,6 +372,36 @@ export default class PokemonInfoContainer extends Phaser.GameObjects.Container { }); } + changeToEggSummaryLayout() { + // The position should be set per language (and shifted for new layout) + const currentLanguage = i18next.resolvedLanguage!; // TODO: is this bang correct? + const langSettingKey = Object.keys(languageSettings).find(lang => currentLanguage?.includes(lang))!; // TODO: is this bang correct? + const textSettings = languageSettings[langSettingKey]; + + const eggLabelTextOffset = 43; + const infoContainerLabelXPos = (textSettings?.infoContainerLabelXPos || -18) + eggLabelTextOffset; + const infoContainerTextXPos = (textSettings?.infoContainerTextXPos || -14) + eggLabelTextOffset; + + this.x = this.initialX - this.infoWindowWidth; + + this.pokemonGenderText.setPosition(89, -2); + this.pokemonGenderNewText.setPosition(79, -2); + this.pokemonShinyIcon.setPosition(82, 87); + this.pokemonShinyNewIcon.setPosition(72, 87); + + this.pokemonFormLabelText.setPosition(infoContainerLabelXPos, 152); + this.pokemonFormText.setPosition(infoContainerTextXPos, 152); + this.pokemonAbilityLabelText.setPosition(infoContainerLabelXPos, 110); + this.pokemonAbilityText.setPosition(infoContainerTextXPos, 110); + this.pokemonNatureLabelText.setPosition(infoContainerLabelXPos, 125); + this.pokemonNatureText.setPosition(infoContainerTextXPos, 125); + + this.statsContainer.setScale(0.7); + this.statsContainer.setPosition(30, -3); + this.infoBg.setVisible(false); + this.pokemonMovesContainer.setVisible(false); + } + makeRoomForConfirmUi(speedMultiplier: number = 1, fromCatch: boolean = false): Promise { const xPosition = fromCatch ? this.initialX - this.infoWindowWidth - 65 : this.initialX - this.infoWindowWidth - ConfirmUiHandler.windowWidth; return new Promise(resolve => { diff --git a/src/ui/ui.ts b/src/ui/ui.ts index 8ec91b59480..6c988b43043 100644 --- a/src/ui/ui.ts +++ b/src/ui/ui.ts @@ -49,6 +49,7 @@ import RenameFormUiHandler from "./rename-form-ui-handler"; import AdminUiHandler from "./admin-ui-handler"; import RunHistoryUiHandler from "./run-history-ui-handler"; import RunInfoUiHandler from "./run-info-ui-handler"; +import EggSummaryUiHandler from "./egg-summary-ui-handler"; import TestDialogueUiHandler from "#app/ui/test-dialogue-ui-handler"; import AutoCompleteUiHandler from "./autocomplete-ui-handler"; @@ -66,6 +67,7 @@ export enum Mode { STARTER_SELECT, EVOLUTION_SCENE, EGG_HATCH_SCENE, + EGG_HATCH_SUMMARY, CONFIRM, OPTION_SELECT, MENU, @@ -171,6 +173,7 @@ export default class UI extends Phaser.GameObjects.Container { new StarterSelectUiHandler(scene), new EvolutionSceneHandler(scene), new EggHatchSceneHandler(scene), + new EggSummaryUiHandler(scene), new ConfirmUiHandler(scene), new OptionSelectUiHandler(scene), new MenuUiHandler(scene), From 11ac929a4dda2fb5825cec6f715d3e35e8eec88c Mon Sep 17 00:00:00 2001 From: MokaStitcher <54149968+MokaStitcher@users.noreply.github.com> Date: Wed, 4 Sep 2024 19:00:31 +0200 Subject: [PATCH 06/22] fix getting the highest ivs for the iv scanner (#4022) --- src/ui/battle-message-ui-handler.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/ui/battle-message-ui-handler.ts b/src/ui/battle-message-ui-handler.ts index 4c2b798558a..3bea0f21433 100644 --- a/src/ui/battle-message-ui-handler.ts +++ b/src/ui/battle-message-ui-handler.ts @@ -215,12 +215,11 @@ export default class BattleMessageUiHandler extends MessageUiHandler { getTopIvs(ivs: integer[], shownIvsCount: integer): Stat[] { let shownStats: Stat[] = []; if (shownIvsCount < 6) { - let highestIv = -1; + const statsPool = PERMANENT_STATS.slice(); + // Sort the stats from highest to lowest iv + statsPool.sort((s1, s2) => ivs[s2] - ivs[s1]); for (let i = 0; i < shownIvsCount; i++) { - if (ivs[i] > highestIv) { - shownStats.push(PERMANENT_STATS[i]); - highestIv = ivs[i]; - } + shownStats.push(statsPool[i]); } } else { shownStats = PERMANENT_STATS.slice(); From 207b3e1eb70c39245d4266c53ca9e4ab7861e31e Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Wed, 4 Sep 2024 10:56:57 -0700 Subject: [PATCH 07/22] [Test] Add `forceEnemyMove` Game Manager util (#3678) * Add `forceEnemyMove` test util * fix ceaseless edge test * Apply flx's suggestions Co-authored-by: flx-sta <50131232+flx-sta@users.noreply.github.com> * Rewrite Follow Me test * Reorganize new imports in game manager * Rewrite Rage Powder + Spotlight tests --------- Co-authored-by: flx-sta <50131232+flx-sta@users.noreply.github.com> --- src/phases/enemy-command-phase.ts | 4 ++ src/test/moves/ceaseless_edge.test.ts | 2 +- src/test/moves/focus_punch.test.ts | 2 +- src/test/moves/follow_me.test.ts | 56 ++++++++++++++++----------- src/test/moves/rage_powder.test.ts | 24 ++++++------ src/test/moves/spikes.test.ts | 2 +- src/test/moves/spotlight.test.ts | 39 ++++++++----------- src/test/utils/gameManager.ts | 32 ++++++++++++++- 8 files changed, 98 insertions(+), 63 deletions(-) diff --git a/src/phases/enemy-command-phase.ts b/src/phases/enemy-command-phase.ts index d9bb08d6fae..91ee0456cd4 100644 --- a/src/phases/enemy-command-phase.ts +++ b/src/phases/enemy-command-phase.ts @@ -77,4 +77,8 @@ export class EnemyCommandPhase extends FieldPhase { this.end(); } + + getFieldIndex(): number { + return this.fieldIndex; + } } diff --git a/src/test/moves/ceaseless_edge.test.ts b/src/test/moves/ceaseless_edge.test.ts index 34ecf8f39f6..8511b3179c6 100644 --- a/src/test/moves/ceaseless_edge.test.ts +++ b/src/test/moves/ceaseless_edge.test.ts @@ -110,7 +110,7 @@ describe("Moves - Ceaseless Edge", () => { const hpBeforeSpikes = game.scene.currentBattle.enemyParty[1].hp; // Check HP of pokemon that WILL BE switched in (index 1) - game.forceOpponentToSwitch(); + game.forceEnemyToSwitch(); game.move.select(Moves.SPLASH); await game.phaseInterceptor.to(TurnEndPhase, false); expect(game.scene.currentBattle.enemyParty[0].hp).toBeLessThan(hpBeforeSpikes); diff --git a/src/test/moves/focus_punch.test.ts b/src/test/moves/focus_punch.test.ts index 99399623a1c..249647f0294 100644 --- a/src/test/moves/focus_punch.test.ts +++ b/src/test/moves/focus_punch.test.ts @@ -123,7 +123,7 @@ describe("Moves - Focus Punch", () => { await game.startBattle([Species.CHARIZARD]); - game.forceOpponentToSwitch(); + game.forceEnemyToSwitch(); game.move.select(Moves.FOCUS_PUNCH); await game.phaseInterceptor.to(TurnStartPhase); diff --git a/src/test/moves/follow_me.test.ts b/src/test/moves/follow_me.test.ts index 64fc9c16256..7d0c4fdb546 100644 --- a/src/test/moves/follow_me.test.ts +++ b/src/test/moves/follow_me.test.ts @@ -28,48 +28,55 @@ describe("Moves - Follow Me", () => { game = new GameManager(phaserGame); game.override.battleType("double"); game.override.starterSpecies(Species.AMOONGUSS); + game.override.ability(Abilities.BALL_FETCH); game.override.enemySpecies(Species.SNORLAX); game.override.startingLevel(100); game.override.enemyLevel(100); game.override.moveset([Moves.FOLLOW_ME, Moves.RAGE_POWDER, Moves.SPOTLIGHT, Moves.QUICK_ATTACK]); - game.override.enemyMoveset([Moves.TACKLE, Moves.TACKLE, Moves.TACKLE, Moves.TACKLE]); + game.override.enemyMoveset([Moves.TACKLE, Moves.FOLLOW_ME, Moves.SPLASH]); }); test( "move should redirect enemy attacks to the user", async () => { - await game.startBattle([Species.AMOONGUSS, Species.CHARIZARD]); + await game.classicMode.startBattle([Species.AMOONGUSS, Species.CHARIZARD]); const playerPokemon = game.scene.getPlayerField(); - const playerStartingHp = playerPokemon.map(p => p.hp); - game.move.select(Moves.FOLLOW_ME); game.move.select(Moves.QUICK_ATTACK, 1, BattlerIndex.ENEMY); + + // Force both enemies to target the player Pokemon that did not use Follow Me + await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER_2); + await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER_2); + await game.phaseInterceptor.to(TurnEndPhase, false); - expect(playerPokemon[0].hp).toBeLessThan(playerStartingHp[0]); - expect(playerPokemon[1].hp).toBe(playerStartingHp[1]); + expect(playerPokemon[0].hp).toBeLessThan(playerPokemon[0].getMaxHp()); + expect(playerPokemon[1].hp).toBe(playerPokemon[1].getMaxHp()); }, TIMEOUT ); test( "move should redirect enemy attacks to the first ally that uses it", async () => { - await game.startBattle([Species.AMOONGUSS, Species.CHARIZARD]); + await game.classicMode.startBattle([Species.AMOONGUSS, Species.CHARIZARD]); const playerPokemon = game.scene.getPlayerField(); - const playerStartingHp = playerPokemon.map(p => p.hp); - game.move.select(Moves.FOLLOW_ME); game.move.select(Moves.FOLLOW_ME, 1); + + // Each player is targeted by an enemy + await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER); + await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER_2); + await game.phaseInterceptor.to(TurnEndPhase, false); playerPokemon.sort((a, b) => a.getEffectiveStat(Stat.SPD) - b.getEffectiveStat(Stat.SPD)); - expect(playerPokemon[1].hp).toBeLessThan(playerStartingHp[1]); - expect(playerPokemon[0].hp).toBe(playerStartingHp[0]); + expect(playerPokemon[1].hp).toBeLessThan(playerPokemon[1].getMaxHp()); + expect(playerPokemon[0].hp).toBe(playerPokemon[0].getMaxHp()); }, TIMEOUT ); @@ -78,21 +85,23 @@ describe("Moves - Follow Me", () => { async () => { game.override.ability(Abilities.STALWART); game.override.moveset([Moves.QUICK_ATTACK]); - game.override.enemyMoveset([Moves.FOLLOW_ME, Moves.FOLLOW_ME, Moves.FOLLOW_ME, Moves.FOLLOW_ME]); - await game.startBattle([Species.AMOONGUSS, Species.CHARIZARD]); + await game.classicMode.startBattle([Species.AMOONGUSS, Species.CHARIZARD]); const enemyPokemon = game.scene.getEnemyField(); - const enemyStartingHp = enemyPokemon.map(p => p.hp); - game.move.select(Moves.QUICK_ATTACK, 0, BattlerIndex.ENEMY); game.move.select(Moves.QUICK_ATTACK, 1, BattlerIndex.ENEMY_2); + + // Target doesn't need to be specified if the move is self-targeted + await game.forceEnemyMove(Moves.FOLLOW_ME); + await game.forceEnemyMove(Moves.SPLASH); + await game.phaseInterceptor.to(TurnEndPhase, false); // If redirection was bypassed, both enemies should be damaged - expect(enemyPokemon[0].hp).toBeLessThan(enemyStartingHp[0]); - expect(enemyPokemon[1].hp).toBeLessThan(enemyStartingHp[1]); + expect(enemyPokemon[0].hp).toBeLessThan(enemyPokemon[0].getMaxHp()); + expect(enemyPokemon[1].hp).toBeLessThan(enemyPokemon[1].getMaxHp()); }, TIMEOUT ); @@ -100,21 +109,22 @@ describe("Moves - Follow Me", () => { "move effect should be bypassed by Snipe Shot", async () => { game.override.moveset([Moves.SNIPE_SHOT]); - game.override.enemyMoveset([Moves.FOLLOW_ME, Moves.FOLLOW_ME, Moves.FOLLOW_ME, Moves.FOLLOW_ME]); - await game.startBattle([Species.AMOONGUSS, Species.CHARIZARD]); + await game.classicMode.startBattle([Species.AMOONGUSS, Species.CHARIZARD]); const enemyPokemon = game.scene.getEnemyField(); - const enemyStartingHp = enemyPokemon.map(p => p.hp); - game.move.select(Moves.SNIPE_SHOT, 0, BattlerIndex.ENEMY); game.move.select(Moves.SNIPE_SHOT, 1, BattlerIndex.ENEMY_2); + + await game.forceEnemyMove(Moves.FOLLOW_ME); + await game.forceEnemyMove(Moves.SPLASH); + await game.phaseInterceptor.to(TurnEndPhase, false); // If redirection was bypassed, both enemies should be damaged - expect(enemyPokemon[0].hp).toBeLessThan(enemyStartingHp[0]); - expect(enemyPokemon[1].hp).toBeLessThan(enemyStartingHp[1]); + expect(enemyPokemon[0].hp).toBeLessThan(enemyPokemon[0].getMaxHp()); + expect(enemyPokemon[1].hp).toBeLessThan(enemyPokemon[1].getMaxHp()); }, TIMEOUT ); }); diff --git a/src/test/moves/rage_powder.test.ts b/src/test/moves/rage_powder.test.ts index 3e78c6fe0c9..3e9f422fda8 100644 --- a/src/test/moves/rage_powder.test.ts +++ b/src/test/moves/rage_powder.test.ts @@ -1,5 +1,4 @@ import { BattlerIndex } from "#app/battle"; -import { TurnEndPhase } from "#app/phases/turn-end-phase"; import { Abilities } from "#enums/abilities"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; @@ -31,27 +30,27 @@ describe("Moves - Rage Powder", () => { game.override.startingLevel(100); game.override.enemyLevel(100); game.override.moveset([Moves.FOLLOW_ME, Moves.RAGE_POWDER, Moves.SPOTLIGHT, Moves.QUICK_ATTACK]); - game.override.enemyMoveset([Moves.TACKLE, Moves.TACKLE, Moves.TACKLE, Moves.TACKLE]); + game.override.enemyMoveset([Moves.RAGE_POWDER, Moves.TACKLE, Moves.SPLASH]); }); test( "move effect should be bypassed by Grass type", async () => { - game.override.enemyMoveset([Moves.RAGE_POWDER, Moves.RAGE_POWDER, Moves.RAGE_POWDER, Moves.RAGE_POWDER]); - - await game.startBattle([Species.AMOONGUSS, Species.VENUSAUR]); + await game.classicMode.startBattle([Species.AMOONGUSS, Species.VENUSAUR]); const enemyPokemon = game.scene.getEnemyField(); - const enemyStartingHp = enemyPokemon.map(p => p.hp); - game.move.select(Moves.QUICK_ATTACK, 0, BattlerIndex.ENEMY); game.move.select(Moves.QUICK_ATTACK, 1, BattlerIndex.ENEMY_2); - await game.phaseInterceptor.to(TurnEndPhase, false); + + await game.forceEnemyMove(Moves.RAGE_POWDER); + await game.forceEnemyMove(Moves.SPLASH); + + await game.phaseInterceptor.to("BerryPhase", false); // If redirection was bypassed, both enemies should be damaged - expect(enemyPokemon[0].hp).toBeLessThan(enemyStartingHp[0]); - expect(enemyPokemon[1].hp).toBeLessThan(enemyStartingHp[1]); + expect(enemyPokemon[0].hp).toBeLessThan(enemyPokemon[0].getMaxHp()); + expect(enemyPokemon[1].hp).toBeLessThan(enemyPokemon[0].getMaxHp()); }, TIMEOUT ); @@ -59,10 +58,9 @@ describe("Moves - Rage Powder", () => { "move effect should be bypassed by Overcoat", async () => { game.override.ability(Abilities.OVERCOAT); - game.override.enemyMoveset([Moves.RAGE_POWDER, Moves.RAGE_POWDER, Moves.RAGE_POWDER, Moves.RAGE_POWDER]); // Test with two non-Grass type player Pokemon - await game.startBattle([Species.BLASTOISE, Species.CHARIZARD]); + await game.classicMode.startBattle([Species.BLASTOISE, Species.CHARIZARD]); const enemyPokemon = game.scene.getEnemyField(); @@ -70,7 +68,7 @@ describe("Moves - Rage Powder", () => { game.move.select(Moves.QUICK_ATTACK, 0, BattlerIndex.ENEMY); game.move.select(Moves.QUICK_ATTACK, 1, BattlerIndex.ENEMY_2); - await game.phaseInterceptor.to(TurnEndPhase, false); + await game.phaseInterceptor.to("BerryPhase", false); // If redirection was bypassed, both enemies should be damaged expect(enemyPokemon[0].hp).toBeLessThan(enemyStartingHp[0]); diff --git a/src/test/moves/spikes.test.ts b/src/test/moves/spikes.test.ts index 05ea717ebbe..fa2e7521152 100644 --- a/src/test/moves/spikes.test.ts +++ b/src/test/moves/spikes.test.ts @@ -73,7 +73,7 @@ describe("Moves - Spikes", () => { await game.toNextTurn(); game.move.select(Moves.SPLASH); - game.forceOpponentToSwitch(); + game.forceEnemyToSwitch(); await game.toNextTurn(); const enemy = game.scene.getEnemyParty()[0]; diff --git a/src/test/moves/spotlight.test.ts b/src/test/moves/spotlight.test.ts index e4dc8815f6d..aef44369642 100644 --- a/src/test/moves/spotlight.test.ts +++ b/src/test/moves/spotlight.test.ts @@ -1,5 +1,4 @@ import { BattlerIndex } from "#app/battle"; -import { Stat } from "#enums/stat"; import { TurnEndPhase } from "#app/phases/turn-end-phase"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; @@ -31,52 +30,46 @@ describe("Moves - Spotlight", () => { game.override.startingLevel(100); game.override.enemyLevel(100); game.override.moveset([Moves.FOLLOW_ME, Moves.RAGE_POWDER, Moves.SPOTLIGHT, Moves.QUICK_ATTACK]); - game.override.enemyMoveset([Moves.TACKLE, Moves.TACKLE, Moves.TACKLE, Moves.TACKLE]); + game.override.enemyMoveset([Moves.FOLLOW_ME, Moves.SPLASH]); }); test( "move should redirect attacks to the target", async () => { - await game.startBattle([Species.AMOONGUSS, Species.CHARIZARD]); + await game.classicMode.startBattle([Species.AMOONGUSS, Species.CHARIZARD]); const enemyPokemon = game.scene.getEnemyField(); - const enemyStartingHp = enemyPokemon.map(p => p.hp); - game.move.select(Moves.SPOTLIGHT, 0, BattlerIndex.ENEMY); game.move.select(Moves.QUICK_ATTACK, 1, BattlerIndex.ENEMY_2); + + await game.forceEnemyMove(Moves.SPLASH); + await game.forceEnemyMove(Moves.SPLASH); + await game.phaseInterceptor.to(TurnEndPhase, false); - expect(enemyPokemon[0].hp).toBeLessThan(enemyStartingHp[0]); - expect(enemyPokemon[1].hp).toBe(enemyStartingHp[1]); + expect(enemyPokemon[0].hp).toBeLessThan(enemyPokemon[0].getMaxHp()); + expect(enemyPokemon[1].hp).toBe(enemyPokemon[1].getMaxHp()); }, TIMEOUT ); test( "move should cause other redirection moves to fail", async () => { - game.override.enemyMoveset([Moves.FOLLOW_ME, Moves.FOLLOW_ME, Moves.FOLLOW_ME, Moves.FOLLOW_ME]); - - await game.startBattle([Species.AMOONGUSS, Species.CHARIZARD]); + await game.classicMode.startBattle([Species.AMOONGUSS, Species.CHARIZARD]); const enemyPokemon = game.scene.getEnemyField(); - /** - * Spotlight will target the slower enemy. In this situation without Spotlight being used, - * the faster enemy would normally end up with the Center of Attention tag. - */ - enemyPokemon.sort((a, b) => b.getEffectiveStat(Stat.SPD) - a.getEffectiveStat(Stat.SPD)); - const spotTarget = enemyPokemon[1].getBattlerIndex(); - const attackTarget = enemyPokemon[0].getBattlerIndex(); + game.move.select(Moves.SPOTLIGHT, 0, BattlerIndex.ENEMY); + game.move.select(Moves.QUICK_ATTACK, 1, BattlerIndex.ENEMY_2); - const enemyStartingHp = enemyPokemon.map(p => p.hp); + await game.forceEnemyMove(Moves.SPLASH); + await game.forceEnemyMove(Moves.FOLLOW_ME); - game.move.select(Moves.SPOTLIGHT, 0, spotTarget); - game.move.select(Moves.QUICK_ATTACK, 1, attackTarget); - await game.phaseInterceptor.to(TurnEndPhase, false); + await game.phaseInterceptor.to("BerryPhase", false); - expect(enemyPokemon[1].hp).toBeLessThan(enemyStartingHp[1]); - expect(enemyPokemon[0].hp).toBe(enemyStartingHp[0]); + expect(enemyPokemon[0].hp).toBeLessThan(enemyPokemon[0].getMaxHp()); + expect(enemyPokemon[1].hp).toBe(enemyPokemon[1].getMaxHp()); }, TIMEOUT ); }); diff --git a/src/test/utils/gameManager.ts b/src/test/utils/gameManager.ts index 998d10ddf12..f367fc70936 100644 --- a/src/test/utils/gameManager.ts +++ b/src/test/utils/gameManager.ts @@ -2,6 +2,8 @@ import { updateUserInfo } from "#app/account"; import { BattlerIndex } from "#app/battle"; import BattleScene from "#app/battle-scene"; import { BattleStyle } from "#app/enums/battle-style"; +import { Moves } from "#app/enums/moves"; +import { getMoveTargets } from "#app/data/move"; import { EnemyPokemon, PlayerPokemon } from "#app/field/pokemon"; import Trainer from "#app/field/trainer"; import { GameModes, getGameMode } from "#app/game-mode"; @@ -9,6 +11,7 @@ import { ModifierTypeOption, modifierTypes } from "#app/modifier/modifier-type"; import overrides from "#app/overrides"; import { CommandPhase } from "#app/phases/command-phase"; import { EncounterPhase } from "#app/phases/encounter-phase"; +import { EnemyCommandPhase } from "#app/phases/enemy-command-phase"; import { FaintPhase } from "#app/phases/faint-phase"; import { LoginPhase } from "#app/phases/login-phase"; import { MovePhase } from "#app/phases/move-phase"; @@ -243,7 +246,34 @@ export default class GameManager { }, () => this.isCurrentPhase(CommandPhase) || this.isCurrentPhase(NewBattlePhase) || this.isCurrentPhase(CheckSwitchPhase)); } - forceOpponentToSwitch() { + /** + * Forces the next enemy selecting a move to use the given move in its moveset against the + * given target (if applicable). + * @param moveId {@linkcode Moves} the move the enemy will use + * @param target {@linkcode BattlerIndex} the target on which the enemy will use the given move + */ + async forceEnemyMove(moveId: Moves, target?: BattlerIndex) { + // Wait for the next EnemyCommandPhase to start + await this.phaseInterceptor.to(EnemyCommandPhase, false); + const enemy = this.scene.getEnemyField()[(this.scene.getCurrentPhase() as EnemyCommandPhase).getFieldIndex()]; + const legalTargets = getMoveTargets(enemy, moveId); + + vi.spyOn(enemy, "getNextMove").mockReturnValueOnce({ + move: moveId, + targets: (target && !legalTargets.multiple && legalTargets.targets.includes(target)) + ? [target] + : enemy.getNextTargets(moveId) + }); + + /** + * Run the EnemyCommandPhase to completion. + * This allows this function to be called consecutively to + * force a move for each enemy in a double battle. + */ + await this.phaseInterceptor.to(EnemyCommandPhase); + } + + forceEnemyToSwitch() { const originalMatchupScore = Trainer.prototype.getPartyMemberMatchupScores; Trainer.prototype.getPartyMemberMatchupScores = () => { Trainer.prototype.getPartyMemberMatchupScores = originalMatchupScore; From fde32cea6c9376a64f6d8dcb4e77cef9b4a0a265 Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Wed, 4 Sep 2024 10:58:35 -0700 Subject: [PATCH 08/22] [Misc][Bug] Add `isBatonPassable` property to `BattlerTag`s (#3472) * Add `isTransferrable` property to `BattlerTag`s * Update Baton Pass to check `isTransferrable` for `BattlerTag`s * Don't mark Salt Cure as transferrable * Add Destiny Bond, remove `GroundedTag` and `ExposedTag` * Fix daily mode test * Add test * Rename `isTransferrable` to `isBatonPassable` --- src/data/battler-tags.ts | 26 +++++++++-------- src/field/pokemon.ts | 6 +--- src/test/moves/baton_pass.test.ts | 46 +++++++++++++++++++------------ 3 files changed, 43 insertions(+), 35 deletions(-) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 6e53ef00f45..66bcc7b9c3c 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -39,13 +39,15 @@ export class BattlerTag { public turnCount: number; public sourceMove: Moves; public sourceId?: number; + public isBatonPassable: boolean; - constructor(tagType: BattlerTagType, lapseType: BattlerTagLapseType | BattlerTagLapseType[], turnCount: number, sourceMove?: Moves, sourceId?: number) { + constructor(tagType: BattlerTagType, lapseType: BattlerTagLapseType | BattlerTagLapseType[], turnCount: number, sourceMove?: Moves, sourceId?: number, isBatonPassable: boolean = false) { this.tagType = tagType; this.lapseTypes = Array.isArray(lapseType) ? lapseType : [ lapseType ]; this.turnCount = turnCount; this.sourceMove = sourceMove!; // TODO: is this bang correct? this.sourceId = sourceId; + this.isBatonPassable = isBatonPassable; } canAdd(pokemon: Pokemon): boolean { @@ -206,7 +208,7 @@ export class ShellTrapTag extends BattlerTag { export class TrappedTag extends BattlerTag { constructor(tagType: BattlerTagType, lapseType: BattlerTagLapseType, turnCount: number, sourceMove: Moves, sourceId: number) { - super(tagType, lapseType, turnCount, sourceMove, sourceId); + super(tagType, lapseType, turnCount, sourceMove, sourceId, true); } canAdd(pokemon: Pokemon): boolean { @@ -326,7 +328,7 @@ export class InterruptedTag extends BattlerTag { */ export class ConfusedTag extends BattlerTag { constructor(turnCount: number, sourceMove: Moves) { - super(BattlerTagType.CONFUSED, BattlerTagLapseType.MOVE, turnCount, sourceMove); + super(BattlerTagType.CONFUSED, BattlerTagLapseType.MOVE, turnCount, sourceMove, undefined, true); } canAdd(pokemon: Pokemon): boolean { @@ -386,7 +388,7 @@ export class ConfusedTag extends BattlerTag { */ export class DestinyBondTag extends BattlerTag { constructor(sourceMove: Moves, sourceId: number) { - super(BattlerTagType.DESTINY_BOND, BattlerTagLapseType.PRE_MOVE, 1, sourceMove, sourceId); + super(BattlerTagType.DESTINY_BOND, BattlerTagLapseType.PRE_MOVE, 1, sourceMove, sourceId, true); } /** @@ -505,7 +507,7 @@ export class SeedTag extends BattlerTag { private sourceIndex: number; constructor(sourceId: number) { - super(BattlerTagType.SEEDED, BattlerTagLapseType.TURN_END, 1, Moves.LEECH_SEED, sourceId); + super(BattlerTagType.SEEDED, BattlerTagLapseType.TURN_END, 1, Moves.LEECH_SEED, sourceId, true); } /** @@ -776,7 +778,7 @@ export class OctolockTag extends TrappedTag { export class AquaRingTag extends BattlerTag { constructor() { - super(BattlerTagType.AQUA_RING, BattlerTagLapseType.TURN_END, 1, Moves.AQUA_RING, undefined); + super(BattlerTagType.AQUA_RING, BattlerTagLapseType.TURN_END, 1, Moves.AQUA_RING, undefined, true); } onAdd(pokemon: Pokemon): void { @@ -808,7 +810,7 @@ export class AquaRingTag extends BattlerTag { /** Tag used to allow moves that interact with {@link Moves.MINIMIZE} to function */ export class MinimizeTag extends BattlerTag { constructor() { - super(BattlerTagType.MINIMIZED, BattlerTagLapseType.TURN_END, 1, Moves.MINIMIZE, undefined); + super(BattlerTagType.MINIMIZED, BattlerTagLapseType.TURN_END, 1, Moves.MINIMIZE); } canAdd(pokemon: Pokemon): boolean { @@ -1206,7 +1208,7 @@ export class SturdyTag extends BattlerTag { export class PerishSongTag extends BattlerTag { constructor(turnCount: number) { - super(BattlerTagType.PERISH_SONG, BattlerTagLapseType.TURN_END, turnCount, Moves.PERISH_SONG); + super(BattlerTagType.PERISH_SONG, BattlerTagLapseType.TURN_END, turnCount, Moves.PERISH_SONG, undefined, true); } canAdd(pokemon: Pokemon): boolean { @@ -1262,7 +1264,7 @@ export class AbilityBattlerTag extends BattlerTag { public ability: Abilities; constructor(tagType: BattlerTagType, ability: Abilities, lapseType: BattlerTagLapseType, turnCount: number) { - super(tagType, lapseType, turnCount, undefined); + super(tagType, lapseType, turnCount); this.ability = ability; } @@ -1438,7 +1440,7 @@ export class TypeImmuneTag extends BattlerTag { public immuneType: Type; constructor(tagType: BattlerTagType, sourceMove: Moves, immuneType: Type, length: number = 1) { - super(tagType, BattlerTagLapseType.TURN_END, length, sourceMove); + super(tagType, BattlerTagLapseType.TURN_END, length, sourceMove, undefined, true); this.immuneType = immuneType; } @@ -1502,7 +1504,7 @@ export class TypeBoostTag extends BattlerTag { export class CritBoostTag extends BattlerTag { constructor(tagType: BattlerTagType, sourceMove: Moves) { - super(tagType, BattlerTagLapseType.TURN_END, 1, sourceMove); + super(tagType, BattlerTagLapseType.TURN_END, 1, sourceMove, undefined, true); } onAdd(pokemon: Pokemon): void { @@ -1594,7 +1596,7 @@ export class CursedTag extends BattlerTag { private sourceIndex: number; constructor(sourceId: number) { - super(BattlerTagType.CURSED, BattlerTagLapseType.TURN_END, 1, Moves.CURSE, sourceId); + super(BattlerTagType.CURSED, BattlerTagLapseType.TURN_END, 1, Moves.CURSE, sourceId, true); } /** diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 405a26d4a16..e0a9a4a86ce 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2660,11 +2660,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } for (const tag of source.summonData.tags) { - - // bypass those can not be passed via Baton Pass - const excludeTagTypes = new Set([BattlerTagType.DROWSY, BattlerTagType.INFATUATED, BattlerTagType.FIRE_BOOST]); - - if (excludeTagTypes.has(tag.tagType)) { + if (!tag.isBatonPassable) { continue; } diff --git a/src/test/moves/baton_pass.test.ts b/src/test/moves/baton_pass.test.ts index 0643b73e481..1a4edafdd36 100644 --- a/src/test/moves/baton_pass.test.ts +++ b/src/test/moves/baton_pass.test.ts @@ -1,13 +1,13 @@ -import { Stat } from "#enums/stat"; -import { PostSummonPhase } from "#app/phases/post-summon-phase"; -import { TurnEndPhase } from "#app/phases/turn-end-phase"; +import { BattlerIndex } from "#app/battle"; import GameManager from "#app/test/utils/gameManager"; +import { Abilities } from "#enums/abilities"; +import { BattlerTagType } from "#enums/battler-tag-type"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; +import { Stat } from "#enums/stat"; +import { SPLASH_ONLY } from "#test/utils/testUtils"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { SPLASH_ONLY } from "../utils/testUtils"; - describe("Moves - Baton Pass", () => { let phaserGame: Phaser.Game; @@ -27,20 +27,17 @@ describe("Moves - Baton Pass", () => { game = new GameManager(phaserGame); game.override .battleType("single") - .enemySpecies(Species.DUGTRIO) - .startingLevel(1) - .startingWave(97) + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) .moveset([Moves.BATON_PASS, Moves.NASTY_PLOT, Moves.SPLASH]) + .ability(Abilities.BALL_FETCH) .enemyMoveset(SPLASH_ONLY) .disableCrits(); }); it("transfers all stat stages when player uses it", async() => { // arrange - await game.startBattle([ - Species.RAICHU, - Species.SHUCKLE - ]); + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); // round 1 - buff game.move.select(Moves.NASTY_PLOT); @@ -53,7 +50,7 @@ describe("Moves - Baton Pass", () => { // round 2 - baton pass game.move.select(Moves.BATON_PASS); game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to(TurnEndPhase); + await game.phaseInterceptor.to("TurnEndPhase"); // assert playerPokemon = game.scene.getPlayerPokemon()!; @@ -66,10 +63,7 @@ describe("Moves - Baton Pass", () => { game.override .startingWave(5) .enemyMoveset(new Array(4).fill([Moves.NASTY_PLOT])); - await game.startBattle([ - Species.RAICHU, - Species.SHUCKLE - ]); + await game.classicMode.startBattle([Species.RAICHU, Species.SHUCKLE]); // round 1 - ai buffs game.move.select(Moves.SPLASH); @@ -79,7 +73,7 @@ describe("Moves - Baton Pass", () => { game.scene.getEnemyPokemon()!.hp = 100; game.override.enemyMoveset(new Array(4).fill(Moves.BATON_PASS)); game.move.select(Moves.SPLASH); - await game.phaseInterceptor.to(PostSummonPhase, false); + await game.phaseInterceptor.to("PostSummonPhase", false); // assert // check buffs are still there @@ -94,4 +88,20 @@ describe("Moves - Baton Pass", () => { "PostSummonPhase" ]); }, 20000); + + it("doesn't transfer effects that aren't transferrable", async() => { + game.override.enemyMoveset(Array(4).fill(Moves.SALT_CURE)); + await game.classicMode.startBattle([Species.PIKACHU, Species.FEEBAS]); + + const [player1, player2] = game.scene.getParty(); + + game.move.select(Moves.BATON_PASS); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.phaseInterceptor.to("MoveEndPhase"); + expect(player1.findTag((t) => t.tagType === BattlerTagType.SALT_CURED)).toBeTruthy(); + game.doSelectPartyPokemon(1); + await game.toNextTurn(); + + expect(player2.findTag((t) => t.tagType === BattlerTagType.SALT_CURED)).toBeUndefined(); + }, 20000); }); From a537113c8f186de12fd177b0d3fed7e92c8026b1 Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Wed, 4 Sep 2024 13:45:56 -0700 Subject: [PATCH 09/22] Re-add lost i18n strings (#4024) --- src/locales/en/arena-flyout.json | 5 +++-- src/locales/en/arena-tag.json | 8 +++++++- src/locales/en/move-trigger.json | 5 +++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/locales/en/arena-flyout.json b/src/locales/en/arena-flyout.json index 141ed4f743d..043d4127eb8 100644 --- a/src/locales/en/arena-flyout.json +++ b/src/locales/en/arena-flyout.json @@ -39,5 +39,6 @@ "matBlock": "Mat Block", "craftyShield": "Crafty Shield", "tailwind": "Tailwind", - "happyHour": "Happy Hour" -} + "happyHour": "Happy Hour", + "safeguard": "Safeguard" +} \ No newline at end of file diff --git a/src/locales/en/arena-tag.json b/src/locales/en/arena-tag.json index ef0b55b691b..d8fed386b24 100644 --- a/src/locales/en/arena-tag.json +++ b/src/locales/en/arena-tag.json @@ -47,5 +47,11 @@ "tailwindOnRemovePlayer": "Your team's Tailwind petered out!", "tailwindOnRemoveEnemy": "The opposing team's Tailwind petered out!", "happyHourOnAdd": "Everyone is caught up in the happy atmosphere!", - "happyHourOnRemove": "The atmosphere returned to normal." + "happyHourOnRemove": "The atmosphere returned to normal.", + "safeguardOnAdd": "The whole field is cloaked in a mystical veil!", + "safeguardOnAddPlayer": "Your team cloaked itself in a mystical veil!", + "safeguardOnAddEnemy": "The opposing team cloaked itself in a mystical veil!", + "safeguardOnRemove": "The field is no longer protected by Safeguard!", + "safeguardOnRemovePlayer": "Your team is no longer protected by Safeguard!", + "safeguardOnRemoveEnemy": "The opposing team is no longer protected by Safeguard!" } \ No newline at end of file diff --git a/src/locales/en/move-trigger.json b/src/locales/en/move-trigger.json index 110d3dc68c7..e70fb9dcfb7 100644 --- a/src/locales/en/move-trigger.json +++ b/src/locales/en/move-trigger.json @@ -65,5 +65,6 @@ "suppressAbilities": "{{pokemonName}}'s ability\nwas suppressed!", "revivalBlessing": "{{pokemonName}} was revived!", "swapArenaTags": "{{pokemonName}} swapped the battle effects affecting each side of the field!", - "exposedMove": "{{pokemonName}} identified\n{{targetPokemonName}}!" -} + "exposedMove": "{{pokemonName}} identified\n{{targetPokemonName}}!", + "safeguard": "{{targetName}} is protected by Safeguard!" +} \ No newline at end of file From 8835ae0299b53520406ef5b22faaa2ebd6f4fc3e Mon Sep 17 00:00:00 2001 From: chaosgrimmon <31082757+chaosgrimmon@users.noreply.github.com> Date: Wed, 4 Sep 2024 16:46:33 -0400 Subject: [PATCH 10/22] [Sprite] Index egg skip UI (#4027) * [Sprite] Index egg skip UI * [Sprite] Index egg skip legacy UI --- public/images/ui/egg_summary_bg.png | Bin 2160 -> 1064 bytes public/images/ui/icon_egg_move.png | Bin 237 -> 179 bytes public/images/ui/legacy/egg_summary_bg.png | Bin 2160 -> 1064 bytes public/images/ui/legacy/icon_egg_move.png | Bin 237 -> 179 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/public/images/ui/egg_summary_bg.png b/public/images/ui/egg_summary_bg.png index 27e367212aaae20dc43f3f03c9fc122b9d99e8fe..658f5df0e96bd901d105690d73500b965788df5c 100644 GIT binary patch literal 1064 zcmeAS@N?(olHy`uVBq!ia0y~yU~~Yow{S26$xBwO%0P;-ILO_JVcj{Imq3ndfKP}k zkQNXSkdTm2P*Bj&&@eDCu&}UjbaeFe^b8CPjEsydE-XxZ{JHY+@8*WauCA_Gvu3SY zwQAR{U6(Ike(>PI>({S;{P@9eIb|i#An}qQzhH*{Siy$w;`s$>aLg4O+qW}LMB*T*(CZmX}11V z-tGfNIW~V4r0>+M%!`;4!?JJBmCAY3-DBmy{#yPtFup$C{;g8M&MQw>_uB8Tkxl<8 z@wP&_Am&`?@vnjPCwKpevN!z2wdd6A*Keb*X@)m`J^Sq1=R4EP?=3S5Ki^Uxop4sM z;Bvg2i_t%63z==%7w2!j^8Ubocg^-swc4pdn(>zdmqj%C`=7M?=lkH|DbB{#2R>Rv z+54%i;gs>$3Gn#A#I%()PR-*`*8yejD?)Ayjx$|$JhYHoB{5Ke^FHw`KJGhwoc2N% zzL(Z{ScD|JKKN$K0SO_XF~%xyHaQ51u}<0`DO47zz}d~@d4q>@x6XtX*G83`ww72i zk3)+bgv^`|=|L==t(Z6+s9Iw}%h?GVPBVE%hoe*%i zlhscp=c$&#l1FE_|1bmn_3qQ2Ip5B;@xN!;(|lmd_iHsZ`l;U=KHt0hRlj_3SzN-i z-`wn%Y7DA%Ht?9XDXu^8{JBQs%MP&{RR-3&25c-=96s#aZ*m&fp1-~KK*t^KC~v&{QzlWMkq;D0a@;)|+8yK@UnVrl zaoZeka9{^9=jv|wTDe<%2H(6^Td|G(6ES2C_OEHbbpC$YdRjDR_I99Jfn?J=0~V^gHDV>50~V>=D{PDxUOy z1=-kgl~rtB>u#YLeVN%PC|V=b{-Jw`46o-A77eKgQcDPU2J n!vdE|`Cq{`#zhtELiP-TmYXMaF?{6)W=;lAS3j3^P6L$7YKn0IQ%r3!+cZrAZ9A0)R-zP0SW{yV3(EE!?`+RAd-t_+{oGP=b#ZlJd*uMGw+m3z2H7H?3iM=#aKx4ySq z<|boL+7i{0bE`dp;m?=%D3ygno=<7z?WDHk93mpBcte&^oL;IRtmY+-Nn7>A?tY^y z29uPO)H+tduDQw7_chQ=2NjhxlTmK!FqE(<(|fgyy?e{{>BVw3sjjgxL;ih^?$r+B zXkT+a-rrwCd z=$u|JG^r4)qpzs|m6oYqvGW?s<_?Q;`*9-QTV6@TGstZf;v;MB(xFeu6%`B+}SUtLEo5jW%>rL}GO4fTpIi$6zotcmxdJG%%bn7(GW(nW%@=geC*w zZrqWPku4mKM7K?febv0ZTrKAlE5+Fq9|9q}O-N}lnbKl6o5o0--}QpS^s3l99FuOK zf$h#Ea5?V@0Z(333y4!`)dog8@ide*&euH}o)eUr+??*na3BG#VT9-q1j#tCr-jD4ZU!Ia?RJ0%!E#l1e$U=nWu2WK!2ywI;OOL;y zy)7yqaDND!&E=lM($nX2FW^w;H>z1Bnpgjpnis<2>_5YW@;uRx4)rA0y>^x1bKjJE zg6O56>O-6R?QRG-pyF&unvK8)>_KY#FJpEov-*6`i0U0G^;9k9O?L2W3D00q`HTxV+Vy>*S3> zCx0M!O z&Ws4Zo=kYmcuP*5?s#?{>ii*Sx?ZnTLIIdA(7Ec-_#C%-4<5LcT_2ChtiMRKg7L}- zTn~5q%wQ7fdgR;_@DLI_hzxNwI0!=iEpZY~tSZ$y7aq1fYbTrlMLY7b)ElHSVeynZ zjm5)Y7J=I|SDGc?RdGrhaq<(G(HJVV3`>VY>{XwUQY2}KOn<|qiIES4Tjccqd>@tS zvFJhXhL8Q%hr@+!yJ^*vykwQK?`^lH8bv_Ws^XxM>QJBEs zS2~n4#+ZL0s`yL@#Qu0~R%)ta@xyDKSyAJ*=j$F{75kci>{nV`u7gTJeW=Z6!}I5L zGcPRMHQsj!DV9uTdMpB0MMXu4T_P>TF7^*}Z=Vhfb=PMOAeAfvjqPs;Y)i&qy2R-X z>S|n{_T32;U~6MzZnG59=$;y(GO1dib)E&1Z33ue?;V!LP^h4 diff --git a/public/images/ui/icon_egg_move.png b/public/images/ui/icon_egg_move.png index 6af186e9b0c21a3952b80fc00ed2a7494cee18eb..a5b0bff4ace05ee65752673f6ecb0ff05ad415c8 100644 GIT binary patch delta 162 zcmaFMxS4T+WIZzj1H-O2_WeMLu{g-xiDBJ2nU_G0Xn;?ME0C5JleRXtPAcduuAjPe z&CdV-|A+Pm%>YWUmjw9*GXVKOu(W>JZ=jH+r;B3<$4uKEPd)|%4kp$s|Lu29(`z}b zq9dqm)OW(?kV>l8Y{gJXbzN!c+ikn=?*E-8&FIthyC#8A*OB4evfPeaKvNhzUHx3v IIVCg!08Ak}jQ{`u delta 220 zcmV<203-ji0qp^h8Gi-<0050L&%FQu00DDSM?wIu&K&6g005^+L_t(2Q)6U61J=gY z|3N}W&mLt&mV$F&3M_o0!15=yEMs`^_yNPyS5LuezywHx^_&7|vb3165Xl%w93Qje!waVNXLA zLrKFlutH=uSRuL_=k}L@6($#U;ddim&%itcq9Zb^z$SsL0|AuaK@M0@2p}tf$pHZG W(p91VZ8D_*0000PI>({S;{P@9eIb|i#An}qQzhH*{Siy$w;`s$>aLg4O+qW}LMB*T*(CZmX}11V z-tGfNIW~V4r0>+M%!`;4!?JJBmCAY3-DBmy{#yPtFup$C{;g8M&MQw>_uB8Tkxl<8 z@wP&_Am&`?@vnjPCwKpevN!z2wdd6A*Keb*X@)m`J^Sq1=R4EP?=3S5Ki^Uxop4sM z;Bvg2i_t%63z==%7w2!j^8Ubocg^-swc4pdn(>zdmqj%C`=7M?=lkH|DbB{#2R>Rv z+54%i;gs>$3Gn#A#I%()PR-*`*8yejD?)Ayjx$|$JhYHoB{5Ke^FHw`KJGhwoc2N% zzL(Z{ScD|JKKN$K0SO_XF~%xyHaQ51u}<0`DO47zz}d~@d4q>@x6XtX*G83`ww72i zk3)+bgv^`|=|L==t(Z6+s9Iw}%h?GVPBVE%hoe*%i zlhscp=c$&#l1FE_|1bmn_3qQ2Ip5B;@xN!;(|lmd_iHsZ`l;U=KHt0hRlj_3SzN-i z-`wn%Y7DA%Ht?9XDXu^8{JBQs%MP&{RR-3&25c-=96s#aZ*m&fp1-~KK*t^KC~v&{QzlWMkq;D0a@;)|+8yK@UnVrl zaoZeka9{^9=jv|wTDe<%2H(6^Td|G(6ES2C_OEHbbpC$YdRjDR_I99Jfn?J=0~V^gHDV>50~V>=D{PDxUOy z1=-kgl~rtB>u#YLeVN%PC|V=b{-Jw`46o-A77eKgQcDPU2J n!vdE|`Cq{`#zhtELiP-TmYXMaF?{6)W=;lAS3j3^P6L$7YKn0IQ%r3!+cZrAZ9A0)R-zP0SW{yV3(EE!?`+RAd-t_+{oGP=b#ZlJd*uMGw+m3z2H7H?3iM=#aKx4ySq z<|boL+7i{0bE`dp;m?=%D3ygno=<7z?WDHk93mpBcte&^oL;IRtmY+-Nn7>A?tY^y z29uPO)H+tduDQw7_chQ=2NjhxlTmK!FqE(<(|fgyy?e{{>BVw3sjjgxL;ih^?$r+B zXkT+a-rrwCd z=$u|JG^r4)qpzs|m6oYqvGW?s<_?Q;`*9-QTV6@TGstZf;v;MB(xFeu6%`B+}SUtLEo5jW%>rL}GO4fTpIi$6zotcmxdJG%%bn7(GW(nW%@=geC*w zZrqWPku4mKM7K?febv0ZTrKAlE5+Fq9|9q}O-N}lnbKl6o5o0--}QpS^s3l99FuOK zf$h#Ea5?V@0Z(333y4!`)dog8@ide*&euH}o)eUr+??*na3BG#VT9-q1j#tCr-jD4ZU!Ia?RJ0%!E#l1e$U=nWu2WK!2ywI;OOL;y zy)7yqaDND!&E=lM($nX2FW^w;H>z1Bnpgjpnis<2>_5YW@;uRx4)rA0y>^x1bKjJE zg6O56>O-6R?QRG-pyF&unvK8)>_KY#FJpEov-*6`i0U0G^;9k9O?L2W3D00q`HTxV+Vy>*S3> zCx0M!O z&Ws4Zo=kYmcuP*5?s#?{>ii*Sx?ZnTLIIdA(7Ec-_#C%-4<5LcT_2ChtiMRKg7L}- zTn~5q%wQ7fdgR;_@DLI_hzxNwI0!=iEpZY~tSZ$y7aq1fYbTrlMLY7b)ElHSVeynZ zjm5)Y7J=I|SDGc?RdGrhaq<(G(HJVV3`>VY>{XwUQY2}KOn<|qiIES4Tjccqd>@tS zvFJhXhL8Q%hr@+!yJ^*vykwQK?`^lH8bv_Ws^XxM>QJBEs zS2~n4#+ZL0s`yL@#Qu0~R%)ta@xyDKSyAJ*=j$F{75kci>{nV`u7gTJeW=Z6!}I5L zGcPRMHQsj!DV9uTdMpB0MMXu4T_P>TF7^*}Z=Vhfb=PMOAeAfvjqPs;Y)i&qy2R-X z>S|n{_T32;U~6MzZnG59=$;y(GO1dib)E&1Z33ue?;V!LP^h4 diff --git a/public/images/ui/legacy/icon_egg_move.png b/public/images/ui/legacy/icon_egg_move.png index 6af186e9b0c21a3952b80fc00ed2a7494cee18eb..a5b0bff4ace05ee65752673f6ecb0ff05ad415c8 100644 GIT binary patch delta 162 zcmaFMxS4T+WIZzj1H-O2_WeMLu{g-xiDBJ2nU_G0Xn;?ME0C5JleRXtPAcduuAjPe z&CdV-|A+Pm%>YWUmjw9*GXVKOu(W>JZ=jH+r;B3<$4uKEPd)|%4kp$s|Lu29(`z}b zq9dqm)OW(?kV>l8Y{gJXbzN!c+ikn=?*E-8&FIthyC#8A*OB4evfPeaKvNhzUHx3v IIVCg!08Ak}jQ{`u delta 220 zcmV<203-ji0qp^h8Gi-<0050L&%FQu00DDSM?wIu&K&6g005^+L_t(2Q)6U61J=gY z|3N}W&mLt&mV$F&3M_o0!15=yEMs`^_yNPyS5LuezywHx^_&7|vb3165Xl%w93Qje!waVNXLA zLrKFlutH=uSRuL_=k}L@6($#U;ddim&%itcq9Zb^z$SsL0|AuaK@M0@2p}tf$pHZG W(p91VZ8D_*0000 Date: Wed, 4 Sep 2024 16:16:47 -0700 Subject: [PATCH 11/22] I have a brain made of cheese!!! (#4028) Co-authored-by: frutescens --- src/battle-scene.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/battle-scene.ts b/src/battle-scene.ts index c8100e0d3b9..d4c33663c14 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -2755,12 +2755,18 @@ export default class BattleScene extends SceneBase { keys.push("pkmn__" + p.species.getSpriteId(p.gender === Gender.FEMALE, p.species.formIndex, p.shiny, p.variant)); keys.push("pkmn__" + p.species.getSpriteId(p.gender === Gender.FEMALE, p.species.formIndex, p.shiny, p.variant, true)); keys.push("cry/" + p.species.getCryKey(p.species.formIndex)); + if (p.fusionSpecies && p.getSpeciesForm() !== p.getFusionSpeciesForm()) { + keys.push("cry/"+p.getFusionSpeciesForm().getCryKey(p.fusionSpecies.formIndex)); + } }); // enemyParty has to be operated on separately from playerParty because playerPokemon =/= enemyPokemon const enemyParty = this.getEnemyParty(); enemyParty.forEach(p => { keys.push(p.species.getSpriteKey(p.gender === Gender.FEMALE, p.species.formIndex, p.shiny, p.variant)); keys.push("cry/" + p.species.getCryKey(p.species.formIndex)); + if (p.fusionSpecies && p.getSpeciesForm() !== p.getFusionSpeciesForm()) { + keys.push("cry/"+p.getFusionSpeciesForm().getCryKey(p.fusionSpecies.formIndex)); + } }); return keys; } From f3ced7e81406b1464487b6d0b059809c0845e705 Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Wed, 4 Sep 2024 16:49:01 -0700 Subject: [PATCH 12/22] [Bug] Fix inconsistencies with move type resolution for some ability triggers (#3988) * Fix inconsistencies with ability triggers on variable-type moves * Fix aura effects not accounting for the move user * Fix Wonder Guard evaluating move type as if the defender used the move * Some additional test coverage for move-type-changing effects --- src/data/ability.ts | 43 ++++++++++++++---------- src/data/move.ts | 3 +- src/test/abilities/disguise.test.ts | 19 +++++++++++ src/test/abilities/steely_spirit.test.ts | 30 +++++++++++++---- 4 files changed, 68 insertions(+), 27 deletions(-) diff --git a/src/data/ability.ts b/src/data/ability.ts index 16ae7a2b2d2..925a7efb79b 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -310,7 +310,7 @@ export class ReceivedMoveDamageMultiplierAbAttr extends PreDefendAbAttr { export class ReceivedTypeDamageMultiplierAbAttr extends ReceivedMoveDamageMultiplierAbAttr { constructor(moveType: Type, damageMultiplier: number) { - super((user, target, move) => move.type === moveType, damageMultiplier); + super((target, user, move) => user.getMoveType(move) === moveType, damageMultiplier); } } @@ -455,7 +455,7 @@ export class NonSuperEffectiveImmunityAbAttr extends TypeImmunityAbAttr { } applyPreDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, cancelled: Utils.BooleanHolder, args: any[]): boolean { - if (move instanceof AttackMove && pokemon.getAttackTypeEffectiveness(pokemon.getMoveType(move), attacker) < 2) { + if (move instanceof AttackMove && pokemon.getAttackTypeEffectiveness(attacker.getMoveType(move), attacker) < 2) { cancelled.value = true; // Suppresses "No Effect" message (args[0] as Utils.NumberHolder).value = 0; return true; @@ -1462,7 +1462,7 @@ export class MovePowerBoostAbAttr extends VariableMovePowerAbAttr { export class MoveTypePowerBoostAbAttr extends MovePowerBoostAbAttr { constructor(boostedType: Type, powerMultiplier?: number) { - super((pokemon, defender, move) => move.type === boostedType, powerMultiplier || 1.5); + super((pokemon, defender, move) => pokemon?.getMoveType(move) === boostedType, powerMultiplier || 1.5); } } @@ -1546,7 +1546,7 @@ export class PreAttackFieldMoveTypePowerBoostAbAttr extends FieldMovePowerBoostA * @param powerMultiplier - The multiplier to apply to the move's power, defaults to 1.5 if not provided. */ constructor(boostedType: Type, powerMultiplier?: number) { - super((pokemon, defender, move) => move.type === boostedType, powerMultiplier || 1.5); + super((pokemon, defender, move) => pokemon?.getMoveType(move) === boostedType, powerMultiplier || 1.5); } } @@ -5100,9 +5100,9 @@ export function initAbilities() { .ignorable(), new Ability(Abilities.TINTED_LENS, 4) //@ts-ignore - .attr(DamageBoostAbAttr, 2, (user, target, move) => target.getAttackTypeEffectiveness(move.type, user) <= 0.5), // TODO: fix TS issues + .attr(DamageBoostAbAttr, 2, (user, target, move) => target?.getMoveEffectiveness(user, move) <= 0.5), // TODO: fix TS issues new Ability(Abilities.FILTER, 4) - .attr(ReceivedMoveDamageMultiplierAbAttr, (target, user, move) => target.getAttackTypeEffectiveness(move.type, user) >= 2, 0.75) + .attr(ReceivedMoveDamageMultiplierAbAttr, (target, user, move) => target.getMoveEffectiveness(user, move) >= 2, 0.75) .ignorable(), new Ability(Abilities.SLOW_START, 4) .attr(PostSummonAddBattlerTagAbAttr, BattlerTagType.SLOW_START, 5), @@ -5118,7 +5118,7 @@ export function initAbilities() { .attr(PostWeatherLapseHealAbAttr, 1, WeatherType.HAIL, WeatherType.SNOW) .partial(), // Healing not blocked by Heal Block new Ability(Abilities.SOLID_ROCK, 4) - .attr(ReceivedMoveDamageMultiplierAbAttr, (target, user, move) => target.getAttackTypeEffectiveness(move.type, user) >= 2, 0.75) + .attr(ReceivedMoveDamageMultiplierAbAttr, (target, user, move) => target.getMoveEffectiveness(user, move) >= 2, 0.75) .ignorable(), new Ability(Abilities.SNOW_WARNING, 4) .attr(PostSummonWeatherChangeAbAttr, WeatherType.SNOW) @@ -5236,10 +5236,13 @@ export function initAbilities() { new Ability(Abilities.MOXIE, 5) .attr(PostVictoryStatStageChangeAbAttr, Stat.ATK, 1), new Ability(Abilities.JUSTIFIED, 5) - .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => move.type === Type.DARK && move.category !== MoveCategory.STATUS, Stat.ATK, 1), + .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => user.getMoveType(move) === Type.DARK && move.category !== MoveCategory.STATUS, Stat.ATK, 1), new Ability(Abilities.RATTLED, 5) - .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => move.category !== MoveCategory.STATUS && (move.type === Type.DARK || move.type === Type.BUG || - move.type === Type.GHOST), Stat.SPD, 1) + .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => { + const moveType = user.getMoveType(move); + return move.category !== MoveCategory.STATUS + && (moveType === Type.DARK || moveType === Type.BUG || moveType === Type.GHOST); + }, Stat.SPD, 1) .attr(PostIntimidateStatStageChangeAbAttr, [Stat.SPD], 1), new Ability(Abilities.MAGIC_BOUNCE, 5) .ignorable() @@ -5313,7 +5316,7 @@ export function initAbilities() { .attr(UnsuppressableAbilityAbAttr) .attr(NoFusionAbilityAbAttr), new Ability(Abilities.GALE_WINGS, 6) - .attr(ChangeMovePriorityAbAttr, (pokemon, move) => pokemon.isFullHp() && move.type === Type.FLYING, 1), + .attr(ChangeMovePriorityAbAttr, (pokemon, move) => pokemon.isFullHp() && pokemon.getMoveType(move) === Type.FLYING, 1), new Ability(Abilities.MEGA_LAUNCHER, 6) .attr(MovePowerBoostAbAttr, (user, target, move) => move.hasFlag(MoveFlags.PULSE_MOVE), 1.5), new Ability(Abilities.GRASS_PELT, 6) @@ -5368,7 +5371,7 @@ export function initAbilities() { .condition(getSheerForceHitDisableAbCondition()) .unimplemented(), new Ability(Abilities.WATER_COMPACTION, 7) - .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => move.type === Type.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2), + .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => user.getMoveType(move) === Type.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2), new Ability(Abilities.MERCILESS, 7) .attr(ConditionalCritAbAttr, (user, target, move) => target?.status?.effect === StatusEffect.TOXIC || target?.status?.effect === StatusEffect.POISON), new Ability(Abilities.SHIELDS_DOWN, 7) @@ -5424,7 +5427,7 @@ export function initAbilities() { .attr(NoFusionAbilityAbAttr) // Add BattlerTagType.DISGUISE if the pokemon is in its disguised form .conditionalAttr(pokemon => pokemon.formIndex === 0, PostSummonAddBattlerTagAbAttr, BattlerTagType.DISGUISE, 0, false) - .attr(FormBlockDamageAbAttr, (target, user, move) => !!target.getTag(BattlerTagType.DISGUISE) && target.getAttackTypeEffectiveness(move.type, user) > 0, 0, BattlerTagType.DISGUISE, + .attr(FormBlockDamageAbAttr, (target, user, move) => !!target.getTag(BattlerTagType.DISGUISE) && target.getMoveEffectiveness(user, move) > 0, 0, BattlerTagType.DISGUISE, (pokemon, abilityName) => i18next.t("abilityTriggers:disguiseAvoidedDamage", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), abilityName: abilityName }), (pokemon) => Utils.toDmgValue(pokemon.getMaxHp() / 8)) .attr(PostBattleInitFormChangeAbAttr, () => 0) @@ -5469,7 +5472,7 @@ export function initAbilities() { .attr(AllyMoveCategoryPowerBoostAbAttr, [MoveCategory.SPECIAL], 1.3), new Ability(Abilities.FLUFFY, 7) .attr(ReceivedMoveDamageMultiplierAbAttr, (target, user, move) => move.hasFlag(MoveFlags.MAKES_CONTACT), 0.5) - .attr(ReceivedMoveDamageMultiplierAbAttr, (target, user, move) => move.type === Type.FIRE, 2) + .attr(ReceivedMoveDamageMultiplierAbAttr, (target, user, move) => user.getMoveType(move) === Type.FIRE, 2) .ignorable(), new Ability(Abilities.DAZZLING, 7) .attr(FieldPriorityMoveImmunityAbAttr) @@ -5519,10 +5522,10 @@ export function initAbilities() { new Ability(Abilities.SHADOW_SHIELD, 7) .attr(ReceivedMoveDamageMultiplierAbAttr, (target, user, move) => target.isFullHp(), 0.5), new Ability(Abilities.PRISM_ARMOR, 7) - .attr(ReceivedMoveDamageMultiplierAbAttr, (target, user, move) => target.getAttackTypeEffectiveness(move.type, user) >= 2, 0.75), + .attr(ReceivedMoveDamageMultiplierAbAttr, (target, user, move) => target.getMoveEffectiveness(user, move) >= 2, 0.75), new Ability(Abilities.NEUROFORCE, 7) //@ts-ignore - .attr(MovePowerBoostAbAttr, (user, target, move) => target.getAttackTypeEffectiveness(move.type, user) >= 2, 1.25), // TODO: fix TS issues + .attr(MovePowerBoostAbAttr, (user, target, move) => target?.getMoveEffectiveness(user, move) >= 2, 1.25), // TODO: fix TS issues new Ability(Abilities.INTREPID_SWORD, 8) .attr(PostSummonStatStageChangeAbAttr, [ Stat.ATK ], 1, true) .condition(getOncePerBattleCondition(Abilities.INTREPID_SWORD)), @@ -5553,7 +5556,11 @@ export function initAbilities() { new Ability(Abilities.STALWART, 8) .attr(BlockRedirectAbAttr), new Ability(Abilities.STEAM_ENGINE, 8) - .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => (move.type === Type.FIRE || move.type === Type.WATER) && move.category !== MoveCategory.STATUS, Stat.SPD, 6), + .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => { + const moveType = user.getMoveType(move); + return move.category !== MoveCategory.STATUS + && (moveType === Type.FIRE || moveType === Type.WATER); + }, Stat.SPD, 6), new Ability(Abilities.PUNK_ROCK, 8) .attr(MovePowerBoostAbAttr, (user, target, move) => move.hasFlag(MoveFlags.SOUND_BASED), 1.3) .attr(ReceivedMoveDamageMultiplierAbAttr, (target, user, move) => move.hasFlag(MoveFlags.SOUND_BASED), 0.5) @@ -5653,7 +5660,7 @@ export function initAbilities() { new Ability(Abilities.SEED_SOWER, 9) .attr(PostDefendTerrainChangeAbAttr, TerrainType.GRASSY), new Ability(Abilities.THERMAL_EXCHANGE, 9) - .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => move.type === Type.FIRE && move.category !== MoveCategory.STATUS, Stat.ATK, 1) + .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => user.getMoveType(move) === Type.FIRE && move.category !== MoveCategory.STATUS, Stat.ATK, 1) .attr(StatusEffectImmunityAbAttr, StatusEffect.BURN) .ignorable(), new Ability(Abilities.ANGER_SHELL, 9) diff --git a/src/data/move.ts b/src/data/move.ts index 14d7addead0..ddf043c554d 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -761,8 +761,7 @@ export default class Move implements Localizable { .flat(), ); for (const aura of fieldAuras) { - // The only relevant values are `move` and the `power` holder - aura.applyPreAttack(null, null, simulated, null, this, [power]); + aura.applyPreAttack(source, null, simulated, target, this, [power]); } const alliedField: Pokemon[] = source instanceof PlayerPokemon ? source.scene.getPlayerField() : source.scene.getEnemyField(); diff --git a/src/test/abilities/disguise.test.ts b/src/test/abilities/disguise.test.ts index f7c45e91724..ef145262954 100644 --- a/src/test/abilities/disguise.test.ts +++ b/src/test/abilities/disguise.test.ts @@ -1,4 +1,5 @@ import { toDmgValue } from "#app/utils"; +import { Abilities } from "#enums/abilities"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import { StatusEffect } from "#app/data/status-effect"; @@ -205,4 +206,22 @@ describe("Abilities - Disguise", () => { expect(game.scene.getCurrentPhase()?.constructor.name).toBe("CommandPhase"); expect(game.scene.currentBattle.waveIndex).toBe(2); }, TIMEOUT); + + it("activates when Aerilate circumvents immunity to the move's base type", async () => { + game.override.ability(Abilities.AERILATE); + game.override.moveset([Moves.TACKLE]); + + await game.classicMode.startBattle(); + + const mimikyu = game.scene.getEnemyPokemon()!; + const maxHp = mimikyu.getMaxHp(); + const disguiseDamage = toDmgValue(maxHp / 8); + + game.move.select(Moves.TACKLE); + + await game.phaseInterceptor.to("MoveEndPhase"); + + expect(mimikyu.formIndex).toBe(bustedForm); + expect(mimikyu.hp).toBe(maxHp - disguiseDamage); + }, TIMEOUT); }); diff --git a/src/test/abilities/steely_spirit.test.ts b/src/test/abilities/steely_spirit.test.ts index c632d0be777..7aaa0a42ae3 100644 --- a/src/test/abilities/steely_spirit.test.ts +++ b/src/test/abilities/steely_spirit.test.ts @@ -1,7 +1,6 @@ import { allAbilities } from "#app/data/ability"; import { allMoves } from "#app/data/move"; import { Abilities } from "#app/enums/abilities"; -import { MoveEffectPhase } from "#app/phases/move-effect-phase"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import GameManager from "#test/utils/gameManager"; @@ -37,7 +36,7 @@ describe("Abilities - Steely Spirit", () => { }); it("increases Steel-type moves' power used by the user and its allies by 50%", async () => { - await game.startBattle([Species.PIKACHU, Species.SHUCKLE]); + await game.classicMode.startBattle([Species.PIKACHU, Species.SHUCKLE]); const boostSource = game.scene.getPlayerField()[1]; const enemyToCheck = game.scene.getEnemyPokemon()!; @@ -47,13 +46,13 @@ describe("Abilities - Steely Spirit", () => { game.move.select(moveToCheck, 0, enemyToCheck.getBattlerIndex()); game.move.select(Moves.SPLASH, 1); - await game.phaseInterceptor.to(MoveEffectPhase); + await game.phaseInterceptor.to("MoveEffectPhase"); expect(allMoves[moveToCheck].calculateBattlePower).toHaveReturnedWith(ironHeadPower * steelySpiritMultiplier); }); it("stacks if multiple users with this ability are on the field.", async () => { - await game.startBattle([Species.PIKACHU, Species.PIKACHU]); + await game.classicMode.startBattle([Species.PIKACHU, Species.PIKACHU]); const enemyToCheck = game.scene.getEnemyPokemon()!; game.scene.getPlayerField().forEach(p => { @@ -64,13 +63,13 @@ describe("Abilities - Steely Spirit", () => { game.move.select(moveToCheck, 0, enemyToCheck.getBattlerIndex()); game.move.select(moveToCheck, 1, enemyToCheck.getBattlerIndex()); - await game.phaseInterceptor.to(MoveEffectPhase); + await game.phaseInterceptor.to("MoveEffectPhase"); expect(allMoves[moveToCheck].calculateBattlePower).toHaveReturnedWith(ironHeadPower * Math.pow(steelySpiritMultiplier, 2)); }); it("does not take effect when suppressed", async () => { - await game.startBattle([Species.PIKACHU, Species.SHUCKLE]); + await game.classicMode.startBattle([Species.PIKACHU, Species.SHUCKLE]); const boostSource = game.scene.getPlayerField()[1]; const enemyToCheck = game.scene.getEnemyPokemon()!; @@ -84,8 +83,25 @@ describe("Abilities - Steely Spirit", () => { game.move.select(moveToCheck, 0, enemyToCheck.getBattlerIndex()); game.move.select(Moves.SPLASH, 1); - await game.phaseInterceptor.to(MoveEffectPhase); + await game.phaseInterceptor.to("MoveEffectPhase"); expect(allMoves[moveToCheck].calculateBattlePower).toHaveReturnedWith(ironHeadPower); }); + + it("affects variable-type moves if their resolved type is Steel", async () => { + game.override + .ability(Abilities.STEELY_SPIRIT) + .moveset([Moves.REVELATION_DANCE]); + + const revelationDance = allMoves[Moves.REVELATION_DANCE]; + vi.spyOn(revelationDance, "calculateBattlePower"); + + await game.classicMode.startBattle([Species.KLINKLANG]); + + game.move.select(Moves.REVELATION_DANCE); + + await game.phaseInterceptor.to("MoveEffectPhase"); + + expect(revelationDance.calculateBattlePower).toHaveReturnedWith(revelationDance.power * 1.5); + }); }); From 834255447d429282f15fbb49c3b4abbec7fdf22e Mon Sep 17 00:00:00 2001 From: Tempoanon <163687446+Tempo-anon@users.noreply.github.com> Date: Thu, 5 Sep 2024 00:02:45 -0400 Subject: [PATCH 13/22] Fix mbh not using user's nickname (#4033) --- src/modifier/modifier.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index 84d8a1385af..f3219c8bf73 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -2488,7 +2488,7 @@ export class TurnHeldItemTransferModifier extends HeldItemTransferModifier { } getTransferMessage(pokemon: Pokemon, targetPokemon: Pokemon, item: ModifierTypes.ModifierType): string { - return i18next.t("modifier:turnHeldItemTransferApply", { pokemonNameWithAffix: getPokemonNameWithAffix(targetPokemon), itemName: item.name, pokemonName: pokemon.name, typeName: this.type.name }); + return i18next.t("modifier:turnHeldItemTransferApply", { pokemonNameWithAffix: getPokemonNameWithAffix(targetPokemon), itemName: item.name, pokemonName: pokemon.getNameToRender(), typeName: this.type.name }); } getMaxHeldItemCount(pokemon: Pokemon): integer { From 31d3bec55ebce5d0d7c9f138ac643077a5ca2985 Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Wed, 4 Sep 2024 21:54:51 -0700 Subject: [PATCH 14/22] [Test] Fix Rage Powder test failing randomly (#4038) * Fix random failure in Rage Powder test * Remove redundant override --- src/test/moves/rage_powder.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/test/moves/rage_powder.test.ts b/src/test/moves/rage_powder.test.ts index 3e9f422fda8..86bc48ef882 100644 --- a/src/test/moves/rage_powder.test.ts +++ b/src/test/moves/rage_powder.test.ts @@ -25,7 +25,6 @@ describe("Moves - Rage Powder", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override.battleType("double"); - game.override.starterSpecies(Species.AMOONGUSS); game.override.enemySpecies(Species.SNORLAX); game.override.startingLevel(100); game.override.enemyLevel(100); @@ -68,6 +67,10 @@ describe("Moves - Rage Powder", () => { game.move.select(Moves.QUICK_ATTACK, 0, BattlerIndex.ENEMY); game.move.select(Moves.QUICK_ATTACK, 1, BattlerIndex.ENEMY_2); + + await game.forceEnemyMove(Moves.RAGE_POWDER); + await game.forceEnemyMove(Moves.SPLASH); + await game.phaseInterceptor.to("BerryPhase", false); // If redirection was bypassed, both enemies should be damaged From 237aad2184320203646cff72f5f27859d4eb15a4 Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Wed, 4 Sep 2024 22:29:02 -0700 Subject: [PATCH 15/22] [Misc] Clean up the `Battle` class a bit (#3995) * Use default values in the `Battle` class Turn a couple of comments into tsdoc comments Replace a `!!` with `?? false` * Replace `integer` with `number` --- src/battle.ts | 77 +++++++++++++++++++++------------------------------ 1 file changed, 32 insertions(+), 45 deletions(-) diff --git a/src/battle.ts b/src/battle.ts index f9afbf09604..0f1245a4397 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -6,7 +6,7 @@ import Trainer, { TrainerVariant } from "./field/trainer"; import { GameMode } from "./game-mode"; import { MoneyMultiplierModifier, PokemonHeldItemModifier } from "./modifier/modifier"; import { PokeballType } from "./data/pokeball"; -import {trainerConfigs} from "#app/data/trainer-config"; +import { trainerConfigs } from "#app/data/trainer-config"; import { ArenaTagType } from "#enums/arena-tag-type"; import { BattleSpec } from "#enums/battle-spec"; import { Moves } from "#enums/moves"; @@ -31,7 +31,7 @@ export enum BattlerIndex { export interface TurnCommand { command: Command; - cursor?: integer; + cursor?: number; move?: QueuedMove; targets?: BattlerIndex[]; skip?: boolean; @@ -39,38 +39,40 @@ export interface TurnCommand { } interface TurnCommands { - [key: integer]: TurnCommand | null + [key: number]: TurnCommand | null } export default class Battle { protected gameMode: GameMode; - public waveIndex: integer; + public waveIndex: number; public battleType: BattleType; public battleSpec: BattleSpec; public trainer: Trainer | null; - public enemyLevels: integer[] | undefined; - public enemyParty: EnemyPokemon[]; - public seenEnemyPartyMemberIds: Set; + public enemyLevels: number[] | undefined; + public enemyParty: EnemyPokemon[] = []; + public seenEnemyPartyMemberIds: Set = new Set(); public double: boolean; - public started: boolean; - public enemySwitchCounter: integer; - public turn: integer; + public started: boolean = false; + public enemySwitchCounter: number = 0; + public turn: number = 0; public turnCommands: TurnCommands; - public playerParticipantIds: Set; - public battleScore: integer; - public postBattleLoot: PokemonHeldItemModifier[]; - public escapeAttempts: integer; + public playerParticipantIds: Set = new Set(); + public battleScore: number = 0; + public postBattleLoot: PokemonHeldItemModifier[] = []; + public escapeAttempts: number = 0; public lastMove: Moves; - public battleSeed: string; - private battleSeedState: string | null; - public moneyScattered: number; - public lastUsedPokeball: PokeballType | null; - public playerFaints: number; // The amount of times pokemon on the players side have fainted - public enemyFaints: number; // The amount of times pokemon on the enemies side have fainted + public battleSeed: string = Utils.randomString(16, true); + private battleSeedState: string | null = null; + public moneyScattered: number = 0; + public lastUsedPokeball: PokeballType | null = null; + /** The number of times a Pokemon on the player's side has fainted this battle */ + public playerFaints: number = 0; + /** The number of times a Pokemon on the enemy's side has fainted this battle */ + public enemyFaints: number = 0; - private rngCounter: integer = 0; + private rngCounter: number = 0; - constructor(gameMode: GameMode, waveIndex: integer, battleType: BattleType, trainer?: Trainer, double?: boolean) { + constructor(gameMode: GameMode, waveIndex: number, battleType: BattleType, trainer?: Trainer, double?: boolean) { this.gameMode = gameMode; this.waveIndex = waveIndex; this.battleType = battleType; @@ -79,22 +81,7 @@ export default class Battle { this.enemyLevels = battleType !== BattleType.TRAINER ? new Array(double ? 2 : 1).fill(null).map(() => this.getLevelForWave()) : trainer?.getPartyLevels(this.waveIndex); - this.enemyParty = []; - this.seenEnemyPartyMemberIds = new Set(); - this.double = !!double; - this.enemySwitchCounter = 0; - this.turn = 0; - this.playerParticipantIds = new Set(); - this.battleScore = 0; - this.postBattleLoot = []; - this.escapeAttempts = 0; - this.started = false; - this.battleSeed = Utils.randomString(16, true); - this.battleSeedState = null; - this.moneyScattered = 0; - this.lastUsedPokeball = null; - this.playerFaints = 0; - this.enemyFaints = 0; + this.double = double ?? false; } private initBattleSpec(): void { @@ -105,7 +92,7 @@ export default class Battle { this.battleSpec = spec; } - private getLevelForWave(): integer { + private getLevelForWave(): number { const levelWaveIndex = this.gameMode.getWaveForDifficulty(this.waveIndex); const baseLevel = 1 + levelWaveIndex / 2 + Math.pow(levelWaveIndex / 25, 2); const bossMultiplier = 1.2; @@ -138,7 +125,7 @@ export default class Battle { return rand / value; } - getBattlerCount(): integer { + getBattlerCount(): number { return this.double ? 2 : 1; } @@ -367,7 +354,7 @@ export default class Battle { return null; } - randSeedInt(scene: BattleScene, range: integer, min: integer = 0): integer { + randSeedInt(scene: BattleScene, range: number, min: number = 0): number { if (range <= 1) { return min; } @@ -392,7 +379,7 @@ export default class Battle { } export class FixedBattle extends Battle { - constructor(scene: BattleScene, waveIndex: integer, config: FixedBattleConfig) { + constructor(scene: BattleScene, waveIndex: number, config: FixedBattleConfig) { super(scene.gameMode, waveIndex, config.battleType, config.battleType === BattleType.TRAINER ? config.getTrainer(scene) : undefined, config.double); if (config.getEnemyParty) { this.enemyParty = config.getEnemyParty(scene); @@ -408,7 +395,7 @@ export class FixedBattleConfig { public double: boolean; public getTrainer: GetTrainerFunc; public getEnemyParty: GetEnemyPartyFunc; - public seedOffsetWaveIndex: integer; + public seedOffsetWaveIndex: number; setBattleType(battleType: BattleType): FixedBattleConfig { this.battleType = battleType; @@ -430,7 +417,7 @@ export class FixedBattleConfig { return this; } - setSeedOffsetWave(seedOffsetWaveIndex: integer): FixedBattleConfig { + setSeedOffsetWave(seedOffsetWaveIndex: number): FixedBattleConfig { this.seedOffsetWaveIndex = seedOffsetWaveIndex; return this; } @@ -476,7 +463,7 @@ function getRandomTrainerFunc(trainerPool: (TrainerType | TrainerType[])[], rand } export interface FixedBattleConfigs { - [key: integer]: FixedBattleConfig + [key: number]: FixedBattleConfig } /** * Youngster/Lass on 5 From 61ab52c2954859824315f7dbe7591fe0ae4a2b62 Mon Sep 17 00:00:00 2001 From: Opaque02 <66582645+Opaque02@users.noreply.github.com> Date: Thu, 5 Sep 2024 15:29:39 +1000 Subject: [PATCH 16/22] [Balance] Changed escape calculation (#3973) * Changed escape calculation as per Mega * Adding tests * Updates some tests * Updated all tests for bosses * Removed console log lines * Added some clarifying comments * Fixed docs * comment add * comment add * Convert comments into tsdoc comments Convert `integer`/`IntegerHolder` to `number`/`NumberHolder` Clean up tests a bit --------- Co-authored-by: damocleas Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/phases/attempt-run-phase.ts | 69 +++++- src/test/escape-calculations.test.ts | 303 +++++++++++++++++++++++++++ src/test/utils/phaseInterceptor.ts | 2 + 3 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 src/test/escape-calculations.test.ts diff --git a/src/phases/attempt-run-phase.ts b/src/phases/attempt-run-phase.ts index 817801985d2..9cf86fed592 100644 --- a/src/phases/attempt-run-phase.ts +++ b/src/phases/attempt-run-phase.ts @@ -1,31 +1,34 @@ -import BattleScene from "#app/battle-scene.js"; -import { applyAbAttrs, RunSuccessAbAttr } from "#app/data/ability.js"; -import { Stat } from "#app/enums/stat.js"; -import { StatusEffect } from "#app/enums/status-effect.js"; -import Pokemon from "#app/field/pokemon.js"; +import BattleScene from "#app/battle-scene"; +import { applyAbAttrs, RunSuccessAbAttr } from "#app/data/ability"; +import { Stat } from "#app/enums/stat"; +import { StatusEffect } from "#app/enums/status-effect"; +import Pokemon, { PlayerPokemon, EnemyPokemon } from "#app/field/pokemon"; import i18next from "i18next"; -import * as Utils from "#app/utils.js"; +import * as Utils from "#app/utils"; import { BattleEndPhase } from "./battle-end-phase"; import { NewBattlePhase } from "./new-battle-phase"; import { PokemonPhase } from "./pokemon-phase"; export class AttemptRunPhase extends PokemonPhase { - constructor(scene: BattleScene, fieldIndex: integer) { + constructor(scene: BattleScene, fieldIndex: number) { super(scene, fieldIndex); } start() { super.start(); - const playerPokemon = this.getPokemon(); + const playerField = this.scene.getPlayerField(); const enemyField = this.scene.getEnemyField(); - const enemySpeed = enemyField.reduce((total: integer, enemyPokemon: Pokemon) => total + enemyPokemon.getStat(Stat.SPD), 0) / enemyField.length; + const playerPokemon = this.getPokemon(); + + const escapeChance = new Utils.NumberHolder(0); + + this.attemptRunAway(playerField, enemyField, escapeChance); - const escapeChance = new Utils.IntegerHolder((((playerPokemon.getStat(Stat.SPD) * 128) / enemySpeed) + (30 * this.scene.currentBattle.escapeAttempts++)) % 256); applyAbAttrs(RunSuccessAbAttr, playerPokemon, null, false, escapeChance); - if (playerPokemon.randSeedInt(256) < escapeChance.value) { + if (Utils.randSeedInt(100) < escapeChance.value) { this.scene.playSound("se/flee"); this.scene.queueMessage(i18next.t("battle:runAwaySuccess"), null, true, 500); @@ -53,4 +56,48 @@ export class AttemptRunPhase extends PokemonPhase { this.end(); } + + attemptRunAway(playerField: PlayerPokemon[], enemyField: EnemyPokemon[], escapeChance: Utils.NumberHolder) { + /** Sum of the speed of all enemy pokemon on the field */ + const enemySpeed = enemyField.reduce((total: number, enemyPokemon: Pokemon) => total + enemyPokemon.getStat(Stat.SPD), 0); + /** Sum of the speed of all player pokemon on the field */ + const playerSpeed = playerField.reduce((total: number, playerPokemon: Pokemon) => total + playerPokemon.getStat(Stat.SPD), 0); + + /* The way the escape chance works is by looking at the difference between your speed and the enemy field's average speed as a ratio. The higher this ratio, the higher your chance of success. + * However, there is a cap for the ratio of your speed vs enemy speed which beyond that point, you won't gain any advantage. It also looks at how many times you've tried to escape. + * Again, the more times you've tried to escape, the higher your odds of escaping. Bosses and non-bosses are calculated differently - bosses are harder to escape from vs non-bosses + * Finally, there's a minimum and maximum escape chance as well so that escapes aren't guaranteed, yet they are never 0 either. + * The percentage chance to escape from a pokemon for both bosses and non bosses is linear and based on the minimum and maximum chances, and the speed ratio cap. + * + * At the time of writing, these conditions should be met: + * - The minimum escape chance should be 5% for bosses and non bosses + * - Bosses should have a maximum escape chance of 25%, whereas non-bosses should be 95% + * - The bonus per previous escape attempt should be 2% for bosses and 10% for non-bosses + * - The speed ratio cap should be 6x for bosses and 4x for non-bosses + * - The "default" escape chance when your speed equals the enemy speed should be 8.33% for bosses and 27.5% for non-bosses + * + * From the above, we can calculate the below values + */ + + let isBoss = false; + for (let e = 0; e < enemyField.length; e++) { + isBoss = isBoss || enemyField[e].isBoss(); // this line checks if any of the enemy pokemon on the field are bosses; if so, the calculation for escaping is different + } + + /** The ratio between the speed of your active pokemon and the speed of the enemy field */ + const speedRatio = playerSpeed / enemySpeed; + /** The max ratio before escape chance stops increasing. Increased if there is a boss on the field */ + const speedCap = isBoss ? 6 : 4; + /** Minimum percent chance to escape */ + const minChance = 5; + /** Maximum percent chance to escape. Decreased if a boss is on the field */ + const maxChance = isBoss ? 25 : 95; + /** How much each escape attempt increases the chance of the next attempt. Decreased if a boss is on the field */ + const escapeBonus = isBoss ? 2 : 10; + /** Slope of the escape chance curve */ + const escapeSlope = (maxChance - minChance) / speedCap; + + // This will calculate the escape chance given all of the above and clamp it to the range of [`minChance`, `maxChance`] + escapeChance.value = Phaser.Math.Clamp(Math.round((escapeSlope * speedRatio) + minChance + (escapeBonus * this.scene.currentBattle.escapeAttempts++)), minChance, maxChance); + } } diff --git a/src/test/escape-calculations.test.ts b/src/test/escape-calculations.test.ts new file mode 100644 index 00000000000..ecf22fc74aa --- /dev/null +++ b/src/test/escape-calculations.test.ts @@ -0,0 +1,303 @@ +import { AttemptRunPhase } from "#app/phases/attempt-run-phase"; +import { CommandPhase } from "#app/phases/command-phase"; +import { Command } from "#app/ui/command-ui-handler"; +import * as Utils from "#app/utils"; +import { Abilities } from "#enums/abilities"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Escape chance calculations", () => { + 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 + .battleType("single") + .enemySpecies(Species.BULBASAUR) + .enemyAbility(Abilities.INSOMNIA) + .ability(Abilities.INSOMNIA); + }); + + it("single non-boss opponent", async () => { + await game.classicMode.startBattle([Species.BULBASAUR]); + + const playerPokemon = game.scene.getPlayerField(); + const enemyField = game.scene.getEnemyField(); + const enemySpeed = 100; + // set enemyPokemon's speed to 100 + vi.spyOn(enemyField[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, enemySpeed]); + + const commandPhase = game.scene.getCurrentPhase() as CommandPhase; + commandPhase.handleCommand(Command.RUN, 0); + + await game.phaseInterceptor.to(AttemptRunPhase, false); + const phase = game.scene.getCurrentPhase() as AttemptRunPhase; + const escapePercentage = new Utils.NumberHolder(0); + + // this sets up an object for multiple attempts. The pokemonSpeedRatio is your speed divided by the enemy speed, the escapeAttempts are the number of escape attempts and the expectedEscapeChance is the chance it should be escaping + const escapeChances: { pokemonSpeedRatio: number, escapeAttempts: number, expectedEscapeChance: number }[] = [ + { pokemonSpeedRatio: 0.01, escapeAttempts: 0, expectedEscapeChance: 5 }, + { pokemonSpeedRatio: 0.1, escapeAttempts: 0, expectedEscapeChance: 7 }, + { pokemonSpeedRatio: 0.25, escapeAttempts: 0, expectedEscapeChance: 11 }, + { pokemonSpeedRatio: 0.5, escapeAttempts: 0, expectedEscapeChance: 16 }, + { pokemonSpeedRatio: 0.8, escapeAttempts: 0, expectedEscapeChance: 23 }, + { pokemonSpeedRatio: 1, escapeAttempts: 0, expectedEscapeChance: 28 }, + { pokemonSpeedRatio: 1.2, escapeAttempts: 0, expectedEscapeChance: 32 }, + { pokemonSpeedRatio: 1.5, escapeAttempts: 0, expectedEscapeChance: 39 }, + { pokemonSpeedRatio: 3, escapeAttempts: 0, expectedEscapeChance: 73 }, + { pokemonSpeedRatio: 3.8, escapeAttempts: 0, expectedEscapeChance: 91 }, + { pokemonSpeedRatio: 4, escapeAttempts: 0, expectedEscapeChance: 95 }, + { pokemonSpeedRatio: 4.2, escapeAttempts: 0, expectedEscapeChance: 95 }, + { pokemonSpeedRatio: 10, escapeAttempts: 0, expectedEscapeChance: 95 }, + + // retries section + { pokemonSpeedRatio: 0.4, escapeAttempts: 1, expectedEscapeChance: 24 }, + { pokemonSpeedRatio: 1.6, escapeAttempts: 2, expectedEscapeChance: 61 }, + { pokemonSpeedRatio: 3.7, escapeAttempts: 5, expectedEscapeChance: 95 }, + { pokemonSpeedRatio: 0.2, escapeAttempts: 2, expectedEscapeChance: 30 }, + { pokemonSpeedRatio: 1, escapeAttempts: 3, expectedEscapeChance: 58 }, + { pokemonSpeedRatio: 2.9, escapeAttempts: 0, expectedEscapeChance: 70 }, + { pokemonSpeedRatio: 0.01, escapeAttempts: 7, expectedEscapeChance: 75 }, + { pokemonSpeedRatio: 16.2, escapeAttempts: 4, expectedEscapeChance: 95 }, + { pokemonSpeedRatio: 2, escapeAttempts: 3, expectedEscapeChance: 80 }, + ]; + + for (let i = 0; i < escapeChances.length; i++) { + // sets the number of escape attempts to the required amount + game.scene.currentBattle.escapeAttempts = escapeChances[i].escapeAttempts; + // set playerPokemon's speed to a multiple of the enemySpeed + vi.spyOn(playerPokemon[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, escapeChances[i].pokemonSpeedRatio * enemySpeed]); + phase.attemptRunAway(playerPokemon, enemyField, escapePercentage); + expect(escapePercentage.value).toBe(escapeChances[i].expectedEscapeChance); + } + }, 20000); + + it("double non-boss opponent", async () => { + game.override.battleType("double"); + await game.classicMode.startBattle([Species.BULBASAUR, Species.ABOMASNOW]); + + const playerPokemon = game.scene.getPlayerField(); + const enemyField = game.scene.getEnemyField(); + const enemyASpeed = 70; + const enemyBSpeed = 30; + // gets the sum of the speed of the two pokemon + const totalEnemySpeed = enemyASpeed + enemyBSpeed; + // this is used to find the ratio of the player's first pokemon + const playerASpeedPercentage = 0.4; + // set enemyAPokemon's speed to 70 + vi.spyOn(enemyField[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, enemyASpeed]); + // set enemyBPokemon's speed to 30 + vi.spyOn(enemyField[1], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, enemyBSpeed]); + + const commandPhase = game.scene.getCurrentPhase() as CommandPhase; + commandPhase.handleCommand(Command.RUN, 0); + + await game.phaseInterceptor.to(AttemptRunPhase, false); + const phase = game.scene.getCurrentPhase() as AttemptRunPhase; + const escapePercentage = new Utils.NumberHolder(0); + + // this sets up an object for multiple attempts. The pokemonSpeedRatio is your speed divided by the enemy speed, the escapeAttempts are the number of escape attempts and the expectedEscapeChance is the chance it should be escaping + const escapeChances: { pokemonSpeedRatio: number, escapeAttempts: number, expectedEscapeChance: number }[] = [ + { pokemonSpeedRatio: 0.3, escapeAttempts: 0, expectedEscapeChance: 12 }, + { pokemonSpeedRatio: 0.7, escapeAttempts: 0, expectedEscapeChance: 21 }, + { pokemonSpeedRatio: 1.5, escapeAttempts: 0, expectedEscapeChance: 39 }, + { pokemonSpeedRatio: 3, escapeAttempts: 0, expectedEscapeChance: 73 }, + { pokemonSpeedRatio: 9, escapeAttempts: 0, expectedEscapeChance: 95 }, + { pokemonSpeedRatio: 0.01, escapeAttempts: 0, expectedEscapeChance: 5 }, + { pokemonSpeedRatio: 1, escapeAttempts: 0, expectedEscapeChance: 28 }, + { pokemonSpeedRatio: 4.3, escapeAttempts: 0, expectedEscapeChance: 95 }, + { pokemonSpeedRatio: 2.7, escapeAttempts: 0, expectedEscapeChance: 66 }, + { pokemonSpeedRatio: 2.1, escapeAttempts: 0, expectedEscapeChance: 52 }, + { pokemonSpeedRatio: 1.8, escapeAttempts: 0, expectedEscapeChance: 46 }, + { pokemonSpeedRatio: 6, escapeAttempts: 0, expectedEscapeChance: 95 }, + + // retries section + { pokemonSpeedRatio: 0.9, escapeAttempts: 1, expectedEscapeChance: 35 }, + { pokemonSpeedRatio: 3.6, escapeAttempts: 2, expectedEscapeChance: 95 }, + { pokemonSpeedRatio: 0.03, escapeAttempts: 7, expectedEscapeChance: 76 }, + { pokemonSpeedRatio: 0.02, escapeAttempts: 7, expectedEscapeChance: 75 }, + { pokemonSpeedRatio: 1, escapeAttempts: 5, expectedEscapeChance: 78 }, + { pokemonSpeedRatio: 0.7, escapeAttempts: 3, expectedEscapeChance: 51 }, + { pokemonSpeedRatio: 2.4, escapeAttempts: 9, expectedEscapeChance: 95 }, + { pokemonSpeedRatio: 1.8, escapeAttempts: 7, expectedEscapeChance: 95 }, + { pokemonSpeedRatio: 2, escapeAttempts: 10, expectedEscapeChance: 95 }, + + ]; + + for (let i = 0; i < escapeChances.length; i++) { + // sets the number of escape attempts to the required amount + game.scene.currentBattle.escapeAttempts = escapeChances[i].escapeAttempts; + // set the first playerPokemon's speed to a multiple of the enemySpeed + vi.spyOn(playerPokemon[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, Math.floor(escapeChances[i].pokemonSpeedRatio * totalEnemySpeed * playerASpeedPercentage)]); + // set the second playerPokemon's speed to the remaining value of speed + vi.spyOn(playerPokemon[1], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, escapeChances[i].pokemonSpeedRatio * totalEnemySpeed - playerPokemon[0].stats[5]]); + phase.attemptRunAway(playerPokemon, enemyField, escapePercentage); + // checks to make sure the escape values are the same + expect(escapePercentage.value).toBe(escapeChances[i].expectedEscapeChance); + // checks to make sure the sum of the player's speed for all pokemon is equal to the appropriate ratio of the total enemy speed + expect(playerPokemon[0].stats[5] + playerPokemon[1].stats[5]).toBe(escapeChances[i].pokemonSpeedRatio * totalEnemySpeed); + } + }, 20000); + + it("single boss opponent", async () => { + game.override.startingWave(10); + await game.classicMode.startBattle([Species.BULBASAUR]); + + const playerPokemon = game.scene.getPlayerField()!; + const enemyField = game.scene.getEnemyField()!; + const enemySpeed = 100; + // set enemyPokemon's speed to 100 + vi.spyOn(enemyField[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, enemySpeed]); + + const commandPhase = game.scene.getCurrentPhase() as CommandPhase; + commandPhase.handleCommand(Command.RUN, 0); + + await game.phaseInterceptor.to(AttemptRunPhase, false); + const phase = game.scene.getCurrentPhase() as AttemptRunPhase; + const escapePercentage = new Utils.NumberHolder(0); + + // this sets up an object for multiple attempts. The pokemonSpeedRatio is your speed divided by the enemy speed, the escapeAttempts are the number of escape attempts and the expectedEscapeChance is the chance it should be escaping + const escapeChances: { pokemonSpeedRatio: number, escapeAttempts: number, expectedEscapeChance: number }[] = [ + { pokemonSpeedRatio: 0.01, escapeAttempts: 0, expectedEscapeChance: 5 }, + { pokemonSpeedRatio: 0.1, escapeAttempts: 0, expectedEscapeChance: 5 }, + { pokemonSpeedRatio: 0.25, escapeAttempts: 0, expectedEscapeChance: 6 }, + { pokemonSpeedRatio: 0.5, escapeAttempts: 0, expectedEscapeChance: 7 }, + { pokemonSpeedRatio: 0.8, escapeAttempts: 0, expectedEscapeChance: 8 }, + { pokemonSpeedRatio: 1, escapeAttempts: 0, expectedEscapeChance: 8 }, + { pokemonSpeedRatio: 1.2, escapeAttempts: 0, expectedEscapeChance: 9 }, + { pokemonSpeedRatio: 1.5, escapeAttempts: 0, expectedEscapeChance: 10 }, + { pokemonSpeedRatio: 3, escapeAttempts: 0, expectedEscapeChance: 15 }, + { pokemonSpeedRatio: 3.8, escapeAttempts: 0, expectedEscapeChance: 18 }, + { pokemonSpeedRatio: 4, escapeAttempts: 0, expectedEscapeChance: 18 }, + { pokemonSpeedRatio: 4.2, escapeAttempts: 0, expectedEscapeChance: 19 }, + { pokemonSpeedRatio: 4.7, escapeAttempts: 0, expectedEscapeChance: 21 }, + { pokemonSpeedRatio: 5, escapeAttempts: 0, expectedEscapeChance: 22 }, + { pokemonSpeedRatio: 5.9, escapeAttempts: 0, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 6, escapeAttempts: 0, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 6.7, escapeAttempts: 0, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 10, escapeAttempts: 0, expectedEscapeChance: 25 }, + + // retries section + { pokemonSpeedRatio: 0.4, escapeAttempts: 1, expectedEscapeChance: 8 }, + { pokemonSpeedRatio: 1.6, escapeAttempts: 2, expectedEscapeChance: 14 }, + { pokemonSpeedRatio: 3.7, escapeAttempts: 5, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 0.2, escapeAttempts: 2, expectedEscapeChance: 10 }, + { pokemonSpeedRatio: 1, escapeAttempts: 3, expectedEscapeChance: 14 }, + { pokemonSpeedRatio: 2.9, escapeAttempts: 0, expectedEscapeChance: 15 }, + { pokemonSpeedRatio: 0.01, escapeAttempts: 7, expectedEscapeChance: 19 }, + { pokemonSpeedRatio: 16.2, escapeAttempts: 4, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 2, escapeAttempts: 3, expectedEscapeChance: 18 }, + { pokemonSpeedRatio: 4.5, escapeAttempts: 1, expectedEscapeChance: 22 }, + { pokemonSpeedRatio: 6.8, escapeAttempts: 6, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 5.2, escapeAttempts: 8, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 4.7, escapeAttempts: 10, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 5.1, escapeAttempts: 1, expectedEscapeChance: 24 }, + { pokemonSpeedRatio: 6, escapeAttempts: 0, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 5.9, escapeAttempts: 2, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 6.1, escapeAttempts: 3, expectedEscapeChance: 25 }, + + ]; + + for (let i = 0; i < escapeChances.length; i++) { + // sets the number of escape attempts to the required amount + game.scene.currentBattle.escapeAttempts = escapeChances[i].escapeAttempts; + // set playerPokemon's speed to a multiple of the enemySpeed + vi.spyOn(playerPokemon[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, escapeChances[i].pokemonSpeedRatio * enemySpeed]); + phase.attemptRunAway(playerPokemon, enemyField, escapePercentage); + expect(escapePercentage.value).toBe(escapeChances[i].expectedEscapeChance); + } + }, 20000); + + it("double boss opponent", async () => { + game.override.battleType("double"); + game.override.startingWave(10); + await game.classicMode.startBattle([Species.BULBASAUR, Species.ABOMASNOW]); + + const playerPokemon = game.scene.getPlayerField(); + const enemyField = game.scene.getEnemyField(); + const enemyASpeed = 70; + const enemyBSpeed = 30; + // gets the sum of the speed of the two pokemon + const totalEnemySpeed = enemyASpeed + enemyBSpeed; + // this is used to find the ratio of the player's first pokemon + const playerASpeedPercentage = 0.8; + // set enemyAPokemon's speed to 70 + vi.spyOn(enemyField[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, enemyASpeed]); + // set enemyBPokemon's speed to 30 + vi.spyOn(enemyField[1], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, enemyBSpeed]); + + const commandPhase = game.scene.getCurrentPhase() as CommandPhase; + commandPhase.handleCommand(Command.RUN, 0); + + await game.phaseInterceptor.to(AttemptRunPhase, false); + const phase = game.scene.getCurrentPhase() as AttemptRunPhase; + const escapePercentage = new Utils.NumberHolder(0); + + // this sets up an object for multiple attempts. The pokemonSpeedRatio is your speed divided by the enemy speed, the escapeAttempts are the number of escape attempts and the expectedEscapeChance is the chance it should be escaping + const escapeChances: { pokemonSpeedRatio: number, escapeAttempts: number, expectedEscapeChance: number }[] = [ + { pokemonSpeedRatio: 0.3, escapeAttempts: 0, expectedEscapeChance: 6 }, + { pokemonSpeedRatio: 0.7, escapeAttempts: 0, expectedEscapeChance: 7 }, + { pokemonSpeedRatio: 1.5, escapeAttempts: 0, expectedEscapeChance: 10 }, + { pokemonSpeedRatio: 3, escapeAttempts: 0, expectedEscapeChance: 15 }, + { pokemonSpeedRatio: 9, escapeAttempts: 0, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 0.01, escapeAttempts: 0, expectedEscapeChance: 5 }, + { pokemonSpeedRatio: 1, escapeAttempts: 0, expectedEscapeChance: 8 }, + { pokemonSpeedRatio: 4.3, escapeAttempts: 0, expectedEscapeChance: 19 }, + { pokemonSpeedRatio: 2.7, escapeAttempts: 0, expectedEscapeChance: 14 }, + { pokemonSpeedRatio: 2.1, escapeAttempts: 0, expectedEscapeChance: 12 }, + { pokemonSpeedRatio: 1.8, escapeAttempts: 0, expectedEscapeChance: 11 }, + { pokemonSpeedRatio: 6, escapeAttempts: 0, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 4, escapeAttempts: 0, expectedEscapeChance: 18 }, + { pokemonSpeedRatio: 5.7, escapeAttempts: 0, expectedEscapeChance: 24 }, + { pokemonSpeedRatio: 5, escapeAttempts: 0, expectedEscapeChance: 22 }, + { pokemonSpeedRatio: 6.1, escapeAttempts: 0, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 6.8, escapeAttempts: 0, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 10, escapeAttempts: 0, expectedEscapeChance: 25 }, + + // retries section + { pokemonSpeedRatio: 0.9, escapeAttempts: 1, expectedEscapeChance: 10 }, + { pokemonSpeedRatio: 3.6, escapeAttempts: 2, expectedEscapeChance: 21 }, + { pokemonSpeedRatio: 0.03, escapeAttempts: 7, expectedEscapeChance: 19 }, + { pokemonSpeedRatio: 0.02, escapeAttempts: 7, expectedEscapeChance: 19 }, + { pokemonSpeedRatio: 1, escapeAttempts: 5, expectedEscapeChance: 18 }, + { pokemonSpeedRatio: 0.7, escapeAttempts: 3, expectedEscapeChance: 13 }, + { pokemonSpeedRatio: 2.4, escapeAttempts: 9, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 1.8, escapeAttempts: 7, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 2, escapeAttempts: 10, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 3, escapeAttempts: 1, expectedEscapeChance: 17 }, + { pokemonSpeedRatio: 4.5, escapeAttempts: 3, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 3.7, escapeAttempts: 1, expectedEscapeChance: 19 }, + { pokemonSpeedRatio: 6.5, escapeAttempts: 1, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 12, escapeAttempts: 4, expectedEscapeChance: 25 }, + { pokemonSpeedRatio: 5.2, escapeAttempts: 2, expectedEscapeChance: 25 }, + + ]; + + for (let i = 0; i < escapeChances.length; i++) { + // sets the number of escape attempts to the required amount + game.scene.currentBattle.escapeAttempts = escapeChances[i].escapeAttempts; + // set the first playerPokemon's speed to a multiple of the enemySpeed + vi.spyOn(playerPokemon[0], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, Math.floor(escapeChances[i].pokemonSpeedRatio * totalEnemySpeed * playerASpeedPercentage)]); + // set the second playerPokemon's speed to the remaining value of speed + vi.spyOn(playerPokemon[1], "stats", "get").mockReturnValue([20, 20, 20, 20, 20, escapeChances[i].pokemonSpeedRatio * totalEnemySpeed - playerPokemon[0].stats[5]]); + phase.attemptRunAway(playerPokemon, enemyField, escapePercentage); + // checks to make sure the escape values are the same + expect(escapePercentage.value).toBe(escapeChances[i].expectedEscapeChance); + // checks to make sure the sum of the player's speed for all pokemon is equal to the appropriate ratio of the total enemy speed + expect(playerPokemon[0].stats[5] + playerPokemon[1].stats[5]).toBe(escapeChances[i].pokemonSpeedRatio * totalEnemySpeed); + } + }, 20000); +}); diff --git a/src/test/utils/phaseInterceptor.ts b/src/test/utils/phaseInterceptor.ts index 1141d0bf0d9..2eb5324a2aa 100644 --- a/src/test/utils/phaseInterceptor.ts +++ b/src/test/utils/phaseInterceptor.ts @@ -1,5 +1,6 @@ import { Phase } from "#app/phase"; import ErrorInterceptor from "#app/test/utils/errorInterceptor"; +import { AttemptRunPhase } from "#app/phases/attempt-run-phase"; import { BattleEndPhase } from "#app/phases/battle-end-phase"; import { BerryPhase } from "#app/phases/berry-phase"; import { CheckSwitchPhase } from "#app/phases/check-switch-phase"; @@ -100,6 +101,7 @@ export default class PhaseInterceptor { [EvolutionPhase, this.startPhase], [EndEvolutionPhase, this.startPhase], [LevelCapPhase, this.startPhase], + [AttemptRunPhase, this.startPhase], ]; private endBySetMode = [ From 1434a3edafc36f5e9ba6991cdc8079e0843efca5 Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Wed, 4 Sep 2024 22:31:32 -0700 Subject: [PATCH 17/22] Fix random failure in Parental Bond tests (#4036) Co-authored-by: flx-sta <50131232+flx-sta@users.noreply.github.com> --- src/test/abilities/parental_bond.test.ts | 333 ++++++++--------------- 1 file changed, 113 insertions(+), 220 deletions(-) diff --git a/src/test/abilities/parental_bond.test.ts b/src/test/abilities/parental_bond.test.ts index e3c6c8ec5bb..81a30524a5e 100644 --- a/src/test/abilities/parental_bond.test.ts +++ b/src/test/abilities/parental_bond.test.ts @@ -2,12 +2,6 @@ import { Stat } from "#enums/stat"; import { StatusEffect } from "#app/data/status-effect"; import { Type } from "#app/data/type"; import { BattlerTagType } from "#app/enums/battler-tag-type"; -import { BerryPhase } from "#app/phases/berry-phase"; -import { CommandPhase } from "#app/phases/command-phase"; -import { DamagePhase } from "#app/phases/damage-phase"; -import { MoveEffectPhase } from "#app/phases/move-effect-phase"; -import { MoveEndPhase } from "#app/phases/move-end-phase"; -import { TurnEndPhase } from "#app/phases/turn-end-phase"; import { toDmgValue } from "#app/utils"; import { Abilities } from "#enums/abilities"; import { Moves } from "#enums/moves"; @@ -15,7 +9,7 @@ import { Species } from "#enums/species"; import GameManager from "#test/utils/gameManager"; import { SPLASH_ONLY } from "#test/utils/testUtils"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; const TIMEOUT = 20 * 1000; @@ -39,36 +33,31 @@ describe("Abilities - Parental Bond", () => { game.override.disableCrits(); game.override.ability(Abilities.PARENTAL_BOND); game.override.enemySpecies(Species.SNORLAX); - game.override.enemyAbility(Abilities.INSOMNIA); + game.override.enemyAbility(Abilities.FUR_COAT); game.override.enemyMoveset(SPLASH_ONLY); game.override.startingLevel(100); game.override.enemyLevel(100); }); - test( - "ability should add second strike to attack move", + it( + "should add second strike to attack move", async () => { game.override.moveset([Moves.TACKLE]); - await game.startBattle([Species.CHARIZARD]); + await game.classicMode.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); let enemyStartingHp = enemyPokemon.hp; game.move.select(Moves.TACKLE); - await game.phaseInterceptor.to(MoveEffectPhase, false); - - await game.phaseInterceptor.to(DamagePhase); + await game.phaseInterceptor.to("DamagePhase"); const firstStrikeDamage = enemyStartingHp - enemyPokemon.hp; enemyStartingHp = enemyPokemon.hp; - await game.phaseInterceptor.to(BerryPhase, false); + await game.phaseInterceptor.to("BerryPhase", false); const secondStrikeDamage = enemyStartingHp - enemyPokemon.hp; @@ -77,556 +66,460 @@ describe("Abilities - Parental Bond", () => { }, TIMEOUT ); - test( - "ability should apply secondary effects to both strikes", + it( + "should apply secondary effects to both strikes", async () => { game.override.moveset([Moves.POWER_UP_PUNCH]); game.override.enemySpecies(Species.AMOONGUSS); - await game.startBattle([Species.CHARIZARD]); + await game.classicMode.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); game.move.select(Moves.POWER_UP_PUNCH); - await game.phaseInterceptor.to(BerryPhase, false); + await game.phaseInterceptor.to("BerryPhase", false); expect(leadPokemon.turnData.hitCount).toBe(2); expect(leadPokemon.getStatStage(Stat.ATK)).toBe(2); }, TIMEOUT ); - test( - "ability should not apply to Status moves", + it( + "should not apply to Status moves", async () => { game.override.moveset([Moves.BABY_DOLL_EYES]); - await game.startBattle([Species.CHARIZARD]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); + await game.classicMode.startBattle([Species.MAGIKARP]); const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); game.move.select(Moves.BABY_DOLL_EYES); - await game.phaseInterceptor.to(BerryPhase, false); + + await game.phaseInterceptor.to("BerryPhase", false); expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1); }, TIMEOUT ); - test( - "ability should not apply to multi-hit moves", + it( + "should not apply to multi-hit moves", async () => { game.override.moveset([Moves.DOUBLE_HIT]); - await game.startBattle([Species.CHARIZARD]); + await game.classicMode.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); game.move.select(Moves.DOUBLE_HIT); await game.move.forceHit(); - await game.phaseInterceptor.to(BerryPhase, false); + await game.phaseInterceptor.to("BerryPhase", false); expect(leadPokemon.turnData.hitCount).toBe(2); }, TIMEOUT ); - test( - "ability should not apply to self-sacrifice moves", + it( + "should not apply to self-sacrifice moves", async () => { game.override.moveset([Moves.SELF_DESTRUCT]); - await game.startBattle([Species.CHARIZARD]); + await game.classicMode.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); game.move.select(Moves.SELF_DESTRUCT); - await game.phaseInterceptor.to(DamagePhase, false); + await game.phaseInterceptor.to("DamagePhase", false); expect(leadPokemon.turnData.hitCount).toBe(1); }, TIMEOUT ); - test( - "ability should not apply to Rollout", + it( + "should not apply to Rollout", async () => { game.override.moveset([Moves.ROLLOUT]); - await game.startBattle([Species.CHARIZARD]); + await game.classicMode.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); game.move.select(Moves.ROLLOUT); await game.move.forceHit(); - await game.phaseInterceptor.to(DamagePhase, false); + await game.phaseInterceptor.to("DamagePhase", false); expect(leadPokemon.turnData.hitCount).toBe(1); }, TIMEOUT ); - test( - "ability should not apply multiplier to fixed-damage moves", + it( + "should not apply multiplier to fixed-damage moves", async () => { game.override.moveset([Moves.DRAGON_RAGE]); - await game.startBattle([Species.CHARIZARD]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); + await game.classicMode.startBattle([Species.MAGIKARP]); const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); - - const enemyStartingHp = enemyPokemon.hp; game.move.select(Moves.DRAGON_RAGE); - await game.phaseInterceptor.to(BerryPhase, false); + await game.phaseInterceptor.to("BerryPhase", false); - expect(enemyPokemon.hp).toBe(enemyStartingHp - 80); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp() - 80); }, TIMEOUT ); - test( - "ability should not apply multiplier to counter moves", + it( + "should not apply multiplier to counter moves", async () => { game.override.moveset([Moves.COUNTER]); - game.override.enemyMoveset([Moves.TACKLE, Moves.TACKLE, Moves.TACKLE, Moves.TACKLE]); + game.override.enemyMoveset(Array(4).fill(Moves.TACKLE)); - await game.startBattle([Species.CHARIZARD]); + await game.classicMode.startBattle([Species.SHUCKLE]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); - - const playerStartingHp = leadPokemon.hp; - const enemyStartingHp = enemyPokemon.hp; game.move.select(Moves.COUNTER); - await game.phaseInterceptor.to(DamagePhase); + await game.phaseInterceptor.to("DamagePhase"); - const playerDamage = playerStartingHp - leadPokemon.hp; + const playerDamage = leadPokemon.getMaxHp() - leadPokemon.hp; - await game.phaseInterceptor.to(BerryPhase, false); + await game.phaseInterceptor.to("BerryPhase", false); - expect(enemyPokemon.hp).toBe(enemyStartingHp - 4 * playerDamage); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp() - 4 * playerDamage); }, TIMEOUT ); - test( - "ability should not apply to multi-target moves", + it( + "should not apply to multi-target moves", async () => { game.override.battleType("double"); game.override.moveset([Moves.EARTHQUAKE]); + game.override.passiveAbility(Abilities.LEVITATE); - await game.startBattle([Species.CHARIZARD, Species.PIDGEOT]); + await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); const playerPokemon = game.scene.getPlayerField(); - expect(playerPokemon.length).toBe(2); - playerPokemon.forEach(p => expect(p).not.toBe(undefined)); - - const enemyPokemon = game.scene.getEnemyField(); - expect(enemyPokemon.length).toBe(2); - enemyPokemon.forEach(p => expect(p).not.toBe(undefined)); game.move.select(Moves.EARTHQUAKE); - await game.phaseInterceptor.to(CommandPhase); - game.move.select(Moves.EARTHQUAKE, 1); - await game.phaseInterceptor.to(BerryPhase, false); + + await game.phaseInterceptor.to("BerryPhase", false); playerPokemon.forEach(p => expect(p.turnData.hitCount).toBe(1)); }, TIMEOUT ); - test( - "ability should apply to multi-target moves when hitting only one target", + it( + "should apply to multi-target moves when hitting only one target", async () => { game.override.moveset([Moves.EARTHQUAKE]); - await game.startBattle([Species.CHARIZARD]); + await game.classicMode.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); game.move.select(Moves.EARTHQUAKE); - await game.phaseInterceptor.to(DamagePhase, false); + await game.phaseInterceptor.to("DamagePhase", false); expect(leadPokemon.turnData.hitCount).toBe(2); }, TIMEOUT ); - test( - "ability should only trigger post-target move effects once", + it( + "should only trigger post-target move effects once", async () => { game.override.moveset([Moves.MIND_BLOWN]); - await game.startBattle([Species.PIDGEOT]); + await game.classicMode.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); game.move.select(Moves.MIND_BLOWN); - await game.phaseInterceptor.to(DamagePhase, false); + await game.phaseInterceptor.to("DamagePhase", false); expect(leadPokemon.turnData.hitCount).toBe(2); // This test will time out if the user faints - await game.phaseInterceptor.to(BerryPhase, false); + await game.phaseInterceptor.to("BerryPhase", false); - expect(leadPokemon.hp).toBe(toDmgValue(leadPokemon.getMaxHp() / 2)); + expect(leadPokemon.hp).toBe(Math.ceil(leadPokemon.getMaxHp() / 2)); }, TIMEOUT ); - test( - "Burn Up only removes type after second strike with this ability", + it( + "Burn Up only removes type after the second strike", async () => { game.override.moveset([Moves.BURN_UP]); - await game.startBattle([Species.CHARIZARD]); + await game.classicMode.startBattle([Species.CHARIZARD]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); game.move.select(Moves.BURN_UP); - await game.phaseInterceptor.to(DamagePhase); + await game.phaseInterceptor.to("MoveEffectPhase"); expect(leadPokemon.turnData.hitCount).toBe(2); expect(enemyPokemon.hp).toBeGreaterThan(0); expect(leadPokemon.isOfType(Type.FIRE)).toBe(true); - await game.phaseInterceptor.to(BerryPhase, false); + await game.phaseInterceptor.to("BerryPhase", false); expect(leadPokemon.isOfType(Type.FIRE)).toBe(false); }, TIMEOUT ); - test( + it( "Moves boosted by this ability and Multi-Lens should strike 4 times", async () => { game.override.moveset([Moves.TACKLE]); game.override.startingHeldItems([{ name: "MULTI_LENS", count: 1 }]); - await game.startBattle([Species.CHARIZARD]); + await game.classicMode.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); game.move.select(Moves.TACKLE); - await game.phaseInterceptor.to(DamagePhase); + await game.phaseInterceptor.to("DamagePhase"); expect(leadPokemon.turnData.hitCount).toBe(4); }, TIMEOUT ); - test( + it( "Super Fang boosted by this ability and Multi-Lens should strike twice", async () => { game.override.moveset([Moves.SUPER_FANG]); game.override.startingHeldItems([{ name: "MULTI_LENS", count: 1 }]); - await game.startBattle([Species.CHARIZARD]); + await game.classicMode.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); - - const enemyStartingHp = enemyPokemon.hp; game.move.select(Moves.SUPER_FANG); await game.move.forceHit(); - await game.phaseInterceptor.to(DamagePhase); + await game.phaseInterceptor.to("DamagePhase"); expect(leadPokemon.turnData.hitCount).toBe(2); - await game.phaseInterceptor.to(MoveEndPhase, false); + await game.phaseInterceptor.to("MoveEndPhase", false); - expect(enemyPokemon.hp).toBe(Math.ceil(enemyStartingHp * 0.25)); + expect(enemyPokemon.hp).toBe(Math.ceil(enemyPokemon.getMaxHp() * 0.25)); }, TIMEOUT ); - test( + it( "Seismic Toss boosted by this ability and Multi-Lens should strike twice", async () => { game.override.moveset([Moves.SEISMIC_TOSS]); game.override.startingHeldItems([{ name: "MULTI_LENS", count: 1 }]); - await game.startBattle([Species.CHARIZARD]); + await game.classicMode.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); const enemyStartingHp = enemyPokemon.hp; game.move.select(Moves.SEISMIC_TOSS); await game.move.forceHit(); - await game.phaseInterceptor.to(DamagePhase); + await game.phaseInterceptor.to("DamagePhase"); expect(leadPokemon.turnData.hitCount).toBe(2); - await game.phaseInterceptor.to(MoveEndPhase, false); + await game.phaseInterceptor.to("MoveEndPhase", false); expect(enemyPokemon.hp).toBe(enemyStartingHp - 200); }, TIMEOUT ); - test( + it( "Hyper Beam boosted by this ability should strike twice, then recharge", async () => { game.override.moveset([Moves.HYPER_BEAM]); - await game.startBattle([Species.CHARIZARD]); + await game.classicMode.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); game.move.select(Moves.HYPER_BEAM); await game.move.forceHit(); - await game.phaseInterceptor.to(DamagePhase); + await game.phaseInterceptor.to("DamagePhase"); expect(leadPokemon.turnData.hitCount).toBe(2); expect(leadPokemon.getTag(BattlerTagType.RECHARGING)).toBeUndefined(); - await game.phaseInterceptor.to(TurnEndPhase); + await game.phaseInterceptor.to("TurnEndPhase"); expect(leadPokemon.getTag(BattlerTagType.RECHARGING)).toBeDefined(); }, TIMEOUT ); - /** TODO: Fix TRAPPED tag lapsing incorrectly, then run this test */ - test( + it( "Anchor Shot boosted by this ability should only trap the target after the second hit", async () => { game.override.moveset([Moves.ANCHOR_SHOT]); - await game.startBattle([Species.CHARIZARD]); + await game.classicMode.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); game.move.select(Moves.ANCHOR_SHOT); await game.move.forceHit(); - await game.phaseInterceptor.to(DamagePhase); + await game.phaseInterceptor.to("DamagePhase"); expect(leadPokemon.turnData.hitCount).toBe(2); - expect(enemyPokemon.getTag(BattlerTagType.TRAPPED)).toBeUndefined(); // Passes + expect(enemyPokemon.getTag(BattlerTagType.TRAPPED)).toBeUndefined(); - await game.phaseInterceptor.to(MoveEndPhase); - expect(enemyPokemon.getTag(BattlerTagType.TRAPPED)).toBeDefined(); // Passes + await game.phaseInterceptor.to("MoveEndPhase"); + expect(enemyPokemon.getTag(BattlerTagType.TRAPPED)).toBeDefined(); - await game.phaseInterceptor.to(TurnEndPhase); + await game.phaseInterceptor.to("TurnEndPhase"); - expect(enemyPokemon.getTag(BattlerTagType.TRAPPED)).toBeDefined(); // Fails :( + expect(enemyPokemon.getTag(BattlerTagType.TRAPPED)).toBeDefined(); }, TIMEOUT ); - test( + it( "Smack Down boosted by this ability should only ground the target after the second hit", async () => { game.override.moveset([Moves.SMACK_DOWN]); - await game.startBattle([Species.CHARIZARD]); + await game.classicMode.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); game.move.select(Moves.SMACK_DOWN); await game.move.forceHit(); - await game.phaseInterceptor.to(DamagePhase); + await game.phaseInterceptor.to("DamagePhase"); expect(leadPokemon.turnData.hitCount).toBe(2); expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined(); - await game.phaseInterceptor.to(TurnEndPhase); + await game.phaseInterceptor.to("TurnEndPhase"); expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined(); }, TIMEOUT ); - test( + it( "U-turn boosted by this ability should strike twice before forcing a switch", async () => { game.override.moveset([Moves.U_TURN]); - await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + await game.classicMode.startBattle([Species.MAGIKARP, Species.BLASTOISE]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); game.move.select(Moves.U_TURN); await game.move.forceHit(); - await game.phaseInterceptor.to(MoveEffectPhase); + await game.phaseInterceptor.to("MoveEffectPhase"); expect(leadPokemon.turnData.hitCount).toBe(2); // This will cause this test to time out if the switch was forced on the first hit. - await game.phaseInterceptor.to(MoveEffectPhase, false); + await game.phaseInterceptor.to("MoveEffectPhase", false); }, TIMEOUT ); - test( + it( "Wake-Up Slap boosted by this ability should only wake up the target after the second hit", async () => { game.override.moveset([Moves.WAKE_UP_SLAP]).enemyStatusEffect(StatusEffect.SLEEP); - await game.startBattle([Species.CHARIZARD]); + await game.classicMode.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); game.move.select(Moves.WAKE_UP_SLAP); await game.move.forceHit(); - await game.phaseInterceptor.to(DamagePhase); + await game.phaseInterceptor.to("DamagePhase"); expect(leadPokemon.turnData.hitCount).toBe(2); expect(enemyPokemon.status?.effect).toBe(StatusEffect.SLEEP); - await game.phaseInterceptor.to(BerryPhase, false); + await game.phaseInterceptor.to("BerryPhase", false); expect(enemyPokemon.status?.effect).toBeUndefined(); }, TIMEOUT ); - test( - "ability should not cause user to hit into King's Shield more than once", + it( + "should not cause user to hit into King's Shield more than once", async () => { game.override.moveset([Moves.TACKLE]); - game.override.enemyMoveset([Moves.KINGS_SHIELD, Moves.KINGS_SHIELD, Moves.KINGS_SHIELD, Moves.KINGS_SHIELD]); + game.override.enemyMoveset(Array(4).fill(Moves.KINGS_SHIELD)); - await game.startBattle([Species.CHARIZARD]); + await game.classicMode.startBattle([Species.MAGIKARP]); const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); - - const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); game.move.select(Moves.TACKLE); - await game.phaseInterceptor.to(BerryPhase, false); + await game.phaseInterceptor.to("BerryPhase", false); expect(leadPokemon.getStatStage(Stat.ATK)).toBe(-1); }, TIMEOUT ); - test( - "ability should not cause user to hit into Storm Drain more than once", + it( + "should not cause user to hit into Storm Drain more than once", async () => { game.override.moveset([Moves.WATER_GUN]); game.override.enemyAbility(Abilities.STORM_DRAIN); - await game.startBattle([Species.CHARIZARD]); - - const leadPokemon = game.scene.getPlayerPokemon()!; - expect(leadPokemon).not.toBe(undefined); + await game.classicMode.startBattle([Species.MAGIKARP]); const enemyPokemon = game.scene.getEnemyPokemon()!; - expect(enemyPokemon).not.toBe(undefined); game.move.select(Moves.WATER_GUN); - await game.phaseInterceptor.to(BerryPhase, false); + await game.phaseInterceptor.to("BerryPhase", false); expect(enemyPokemon.getStatStage(Stat.SPATK)).toBe(1); }, TIMEOUT ); - test( - "ability should not apply to multi-target moves with Multi-Lens", + it( + "should not apply to multi-target moves with Multi-Lens", async () => { game.override.battleType("double"); game.override.moveset([Moves.EARTHQUAKE, Moves.SPLASH]); + game.override.passiveAbility(Abilities.LEVITATE); game.override.startingHeldItems([{ name: "MULTI_LENS", count: 1 }]); - await game.startBattle([Species.CHARIZARD, Species.PIDGEOT]); - - const playerPokemon = game.scene.getPlayerField(); - expect(playerPokemon.length).toBe(2); - playerPokemon.forEach(p => expect(p).not.toBe(undefined)); + await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); const enemyPokemon = game.scene.getEnemyField(); - expect(enemyPokemon.length).toBe(2); - enemyPokemon.forEach(p => expect(p).not.toBe(undefined)); const enemyStartingHp = enemyPokemon.map(p => p.hp); game.move.select(Moves.EARTHQUAKE); - await game.phaseInterceptor.to(CommandPhase); - game.move.select(Moves.SPLASH, 1); - await game.phaseInterceptor.to(MoveEffectPhase, false); - - await game.phaseInterceptor.to(DamagePhase); + await game.phaseInterceptor.to("DamagePhase"); const enemyFirstHitDamage = enemyStartingHp.map((hp, i) => hp - enemyPokemon[i].hp); - await game.phaseInterceptor.to(BerryPhase, false); + await game.phaseInterceptor.to("BerryPhase", false); enemyPokemon.forEach((p, i) => expect(enemyStartingHp[i] - p.hp).toBe(2 * enemyFirstHitDamage[i])); - }, TIMEOUT ); }); From 13f38dce8d53d4a8d176c9de9b0e345ecb4cac49 Mon Sep 17 00:00:00 2001 From: Zach Day Date: Thu, 5 Sep 2024 04:44:22 -0400 Subject: [PATCH 18/22] [Move] Use BattlerTag for move-disabling effects (#2051) * Use BattlerTag for move-disabling effects * Fix RUN command causing freeze * Improve documentation * Clean up and document PokemonMove.isUsable * Fix isMoveDisabled missing return * Tags define the message shown when disabling interrupts a move * Fix -1 duration on Disable effect * Add tests for Disable * En loc and fix message functions * Fix Disable test * Fix broken imports * Fix test * All disable tests passing * Localize remaining strings * Move cancellation logic out of lapse; use use TURN_END for lapse type * Prevent disabling STRUGGLE * Inline struggle check function * Restore RechargingTag docs * Move cancellation logic back to tag Wanted to increase similarity to the existing code base to avoid that stupid hyper beam error but it's still happening here * Fix hyper beam test * Remove erroneous shit * Fill movesets with SPLASH for disable test * More robust condition for disable checking * Remove DisabledTag lapse * Simplify DisablingBattlerTag lapse * Cancel disable-interrupted moves instead of failing them * Avoid disabling virtual moves * Consistent access modifiers across Disable tags * Add abstract function for message when player tries to select the disabled move * Fix syntax mistake * Always disable last-used non-virtual move * Overhaul tests + add tests * Implement loadTag for DisabledTag * Update translations * Update translations * Reimplement phase changes * fix battlertag strings * Fix disable test not running * Update name of base class * Rename "disabling" to "restriction" * Fix sneaky string fuckup * Fix test failure * fix merge problems * fix merge problems * Update tests * rerun RNG test * Properly mock stats in test * Document everything in battlertag * More docs + typo fix * Update tests --------- Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/data/ability.ts | 13 +--- src/data/battler-tags.ts | 123 +++++++++++++++++++++++++++++ src/data/move.ts | 70 +---------------- src/enums/battler-tag-type.ts | 1 + src/field/pokemon.ts | 53 +++++++++++-- src/locales/en/battle.json | 1 + src/locales/en/battler-tags.json | 6 +- src/phases/command-phase.ts | 5 +- src/phases/move-phase.ts | 9 +-- src/phases/turn-end-phase.ts | 8 -- src/system/pokemon-data.ts | 2 - src/test/moves/disable.test.ts | 129 +++++++++++++++++++++++++++++++ 12 files changed, 315 insertions(+), 105 deletions(-) mode change 100644 => 100755 src/data/ability.ts create mode 100644 src/test/moves/disable.test.ts diff --git a/src/data/ability.ts b/src/data/ability.ts old mode 100644 new mode 100755 index 925a7efb79b..fde39ebb152 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -1085,7 +1085,7 @@ export class PostDefendMoveDisableAbAttr extends PostDefendAbAttr { } applyPostDefend(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean { - if (!attacker.summonData.disabledMove) { + if (attacker.getTag(BattlerTagType.DISABLED) === null) { if (move.checkFlag(MoveFlags.MAKES_CONTACT, attacker, pokemon) && (this.chance === -1 || pokemon.randSeedInt(100) < this.chance) && !attacker.isMax()) { if (simulated) { return true; @@ -1093,21 +1093,12 @@ export class PostDefendMoveDisableAbAttr extends PostDefendAbAttr { this.attacker = attacker; this.move = move; - - attacker.summonData.disabledMove = move.id; - attacker.summonData.disabledTurns = 4; + this.attacker.addTag(BattlerTagType.DISABLED, 4, 0, pokemon.id); return true; } } return false; } - - getTriggerMessage(pokemon: Pokemon, abilityName: string, ...args: any[]): string { - return i18next.t("abilityTriggers:postDefendMoveDisable", { - pokemonNameWithAffix: getPokemonNameWithAffix(this.attacker), - moveName: this.move.name, - }); - } } export class PostStatStageChangeStatStageChangeAbAttr extends PostStatStageChangeAbAttr { diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 66bcc7b9c3c..ef91dda7b63 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -98,6 +98,127 @@ export interface TerrainBattlerTag { terrainTypes: TerrainType[]; } +/** + * Base class for tags that restrict the usage of moves. This effect is generally referred to as "disabling" a move + * in-game. This is not to be confused with {@linkcode Moves.DISABLE}. + * + * Descendants can override {@linkcode isMoveRestricted} to restrict moves that + * match a condition. A restricted move gets cancelled before it is used. Players and enemies should not be allowed + * to select restricted moves. + */ +export abstract class MoveRestrictionBattlerTag extends BattlerTag { + constructor(tagType: BattlerTagType, turnCount: integer, sourceMove?: Moves, sourceId?: integer) { + super(tagType, [ BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END ], turnCount, sourceMove, sourceId); + } + + /** @override */ + override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { + if (lapseType === BattlerTagLapseType.PRE_MOVE) { + // Cancel the affected pokemon's selected move + const phase = pokemon.scene.getCurrentPhase() as MovePhase; + const move = phase.move; + + if (this.isMoveRestricted(move.moveId)) { + pokemon.scene.queueMessage(this.interruptedText(pokemon, move.moveId)); + phase.cancel(); + } + + return true; + } + + return super.lapse(pokemon, lapseType); + } + + /** + * Gets whether this tag is restricting a move. + * + * @param {Moves} move {@linkcode Moves} ID to check restriction for. + * @returns {boolean} `true` if the move is restricted by this tag, otherwise `false`. + */ + abstract isMoveRestricted(move: Moves): boolean; + + /** + * Gets the text to display when the player attempts to select a move that is restricted by this tag. + * + * @param {Pokemon} pokemon {@linkcode Pokemon} for which the player is attempting to select the restricted move + * @param {Moves} move {@linkcode Moves} ID of the move that is having its selection denied + * @returns {string} text to display when the player attempts to select the restricted move + */ + abstract selectionDeniedText(pokemon: Pokemon, move: Moves): string; + + /** + * Gets the text to display when a move's execution is prevented as a result of the restriction. + * Because restriction effects also prevent selection of the move, this situation can only arise if a + * pokemon first selects a move, then gets outsped by a pokemon using a move that restricts the selected move. + * + * @param {Pokemon} pokemon {@linkcode Pokemon} attempting to use the restricted move + * @param {Moves} move {@linkcode Moves} ID of the move being interrupted + * @returns {string} text to display when the move is interrupted + */ + abstract interruptedText(pokemon: Pokemon, move: Moves): string; +} + +/** + * Tag representing the "disabling" effect performed by {@linkcode Moves.DISABLE} and {@linkcode Abilities.CURSED_BODY}. + * When the tag is added, the last-used move of the tag holder is set as the disabled move. + */ +export class DisabledTag extends MoveRestrictionBattlerTag { + /** The move being disabled. Gets set when {@linkcode onAdd} is called for this tag. */ + private moveId: Moves = Moves.NONE; + + constructor(sourceId: number) { + super(BattlerTagType.DISABLED, 4, Moves.DISABLE, sourceId); + } + + /** @override */ + override isMoveRestricted(move: Moves): boolean { + return move === this.moveId; + } + + /** + * @override + * + * Ensures that move history exists on `pokemon` and has a valid move. If so, sets the {@link moveId} and shows a message. + * Otherwise the move ID will not get assigned and this tag will get removed next turn. + */ + override onAdd(pokemon: Pokemon): void { + super.onAdd(pokemon); + + const move = pokemon.getLastXMoves() + .find(m => m.move !== Moves.NONE && m.move !== Moves.STRUGGLE && !m.virtual); + if (move === undefined) { + return; + } + + this.moveId = move.move; + + pokemon.scene.queueMessage(i18next.t("battlerTags:disabledOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), moveName: allMoves[this.moveId].name })); + } + + /** @override */ + override onRemove(pokemon: Pokemon): void { + super.onRemove(pokemon); + + pokemon.scene.queueMessage(i18next.t("battlerTags:disabledLapse", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), moveName: allMoves[this.moveId].name })); + } + + /** @override */ + override selectionDeniedText(pokemon: Pokemon, move: Moves): string { + return i18next.t("battle:moveDisabled", { moveName: allMoves[move].name }); + } + + /** @override */ + override interruptedText(pokemon: Pokemon, move: Moves): string { + return i18next.t("battle:disableInterruptedMove", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), moveName: allMoves[move].name }); + } + + /** @override */ + override loadTag(source: BattlerTag | any): void { + super.loadTag(source); + this.moveId = source.moveId; + } +} + /** * BattlerTag that represents the "recharge" effects of moves like Hyper Beam. */ @@ -1995,6 +2116,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source return new StockpilingTag(sourceMove); case BattlerTagType.OCTOLOCK: return new OctolockTag(sourceId); + case BattlerTagType.DISABLED: + return new DisabledTag(sourceId); case BattlerTagType.IGNORE_GHOST: return new ExposedTag(tagType, sourceMove, Type.GHOST, [Type.NORMAL, Type.FIGHTING]); case BattlerTagType.IGNORE_DARK: diff --git a/src/data/move.ts b/src/data/move.ts index ddf043c554d..a591f12df90 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -4332,72 +4332,6 @@ export class TypelessAttr extends MoveAttr { } */ export class BypassRedirectAttr extends MoveAttr { } -export class DisableMoveAttr extends MoveEffectAttr { - constructor() { - super(false); - } - - apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - if (!super.apply(user, target, move, args)) { - return false; - } - - const moveQueue = target.getLastXMoves(); - let turnMove: TurnMove | undefined; - while (moveQueue.length) { - turnMove = moveQueue.shift(); - if (turnMove?.virtual) { - continue; - } - - const moveIndex = target.getMoveset().findIndex(m => m?.moveId === turnMove?.move); - if (moveIndex === -1) { - return false; - } - - const disabledMove = target.getMoveset()[moveIndex]; - target.summonData.disabledMove = disabledMove?.moveId!; // TODO: is this bang correct? - target.summonData.disabledTurns = 4; - - user.scene.queueMessage(i18next.t("abilityTriggers:postDefendMoveDisable", { pokemonNameWithAffix: getPokemonNameWithAffix(target), moveName: disabledMove?.getName()})); - - return true; - } - - return false; - } - - getCondition(): MoveConditionFunc { - return (user, target, move): boolean => { // TODO: Not sure what to do here - if (target.summonData.disabledMove || target.isMax()) { - return false; - } - - const moveQueue = target.getLastXMoves(); - let turnMove: TurnMove | undefined; - while (moveQueue.length) { - turnMove = moveQueue.shift(); - if (turnMove?.virtual) { - continue; - } - - const move = target.getMoveset().find(m => m?.moveId === turnMove?.move); - if (!move) { - continue; - } - - return true; - } - - return false; - }; - } - - getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): integer { - return -5; - } -} - export class FrenzyAttr extends MoveEffectAttr { constructor() { super(true, MoveEffectTrigger.HIT, false, true); @@ -4488,6 +4422,7 @@ export class AddBattlerTagAttr extends MoveEffectAttr { case BattlerTagType.INFATUATED: case BattlerTagType.NIGHTMARE: case BattlerTagType.DROWSY: + case BattlerTagType.DISABLED: return -5; case BattlerTagType.SEEDED: case BattlerTagType.SALT_CURED: @@ -6673,7 +6608,8 @@ export function initMoves() { new AttackMove(Moves.SONIC_BOOM, Type.NORMAL, MoveCategory.SPECIAL, -1, 90, 20, -1, 0, 1) .attr(FixedDamageAttr, 20), new StatusMove(Moves.DISABLE, Type.NORMAL, 100, 20, -1, 0, 1) - .attr(DisableMoveAttr) + .attr(AddBattlerTagAttr, BattlerTagType.DISABLED, false, true) + .condition((user, target, move) => target.getMoveHistory().reverse().find(m => m.move !== Moves.NONE && m.move !== Moves.STRUGGLE && !m.virtual) !== undefined) .condition(failOnMaxCondition), new AttackMove(Moves.ACID, Type.POISON, MoveCategory.SPECIAL, 40, 100, 30, 10, 0, 1) .attr(StatStageChangeAttr, [ Stat.SPDEF ], -1) diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index 20ceb1b331f..a2bcf9e4c0e 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -64,6 +64,7 @@ export enum BattlerTagType { STOCKPILING = "STOCKPILING", RECEIVE_DOUBLE_DAMAGE = "RECEIVE_DOUBLE_DAMAGE", ALWAYS_GET_HIT = "ALWAYS_GET_HIT", + DISABLED = "DISABLED", IGNORE_GHOST = "IGNORE_GHOST", IGNORE_DARK = "IGNORE_DARK", GULP_MISSILE_ARROKUDA = "GULP_MISSILE_ARROKUDA", diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index e0a9a4a86ce..269d0b1dba5 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -17,7 +17,7 @@ import { initMoveAnim, loadMoveAnimAssets } from "../data/battle-anims"; import { Status, StatusEffect, getRandomStatus } from "../data/status-effect"; import { pokemonEvolutions, pokemonPrevolutions, SpeciesFormEvolution, SpeciesEvolutionCondition, FusionSpeciesFormEvolution } from "../data/pokemon-evolutions"; import { reverseCompatibleTms, tmSpecies, tmPoolTiers } from "../data/tms"; -import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoostTag, TypeImmuneTag, getBattlerTag, SemiInvulnerableTag, TypeBoostTag, ExposedTag, DragonCheerTag, CritBoostTag, TrappedTag } from "../data/battler-tags"; +import { BattlerTag, BattlerTagLapseType, EncoreTag, GroundedTag, HighestStatBoostTag, TypeImmuneTag, getBattlerTag, SemiInvulnerableTag, TypeBoostTag, MoveRestrictionBattlerTag, ExposedTag, DragonCheerTag, CritBoostTag, TrappedTag } from "../data/battler-tags"; import { WeatherType } from "../data/weather"; import { ArenaTagSide, NoCritTag, WeakenMoveScreenTag } from "../data/arena-tag"; import { Ability, AbAttr, StatMultiplierAbAttr, BlockCritAbAttr, BonusCritAbAttr, BypassBurnDamageReductionAbAttr, FieldPriorityMoveImmunityAbAttr, IgnoreOpponentStatStagesAbAttr, MoveImmunityAbAttr, PreDefendFullHpEndureAbAttr, ReceivedMoveDamageMultiplierAbAttr, ReduceStatusEffectDurationAbAttr, StabBoostAbAttr, StatusEffectImmunityAbAttr, TypeImmunityAbAttr, WeightMultiplierAbAttr, allAbilities, applyAbAttrs, applyStatMultiplierAbAttrs, applyPreApplyBattlerTagAbAttrs, applyPreAttackAbAttrs, applyPreDefendAbAttrs, applyPreSetStatusAbAttrs, UnsuppressableAbilityAbAttr, SuppressFieldAbilitiesAbAttr, NoFusionAbilityAbAttr, MultCritAbAttr, IgnoreTypeImmunityAbAttr, DamageBoostAbAttr, IgnoreTypeStatusEffectImmunityAbAttr, ConditionalCritAbAttr, applyFieldStatMultiplierAbAttrs, FieldMultiplyStatAbAttr, AddSecondStrikeAbAttr, UserFieldStatusEffectImmunityAbAttr, UserFieldBattlerTagImmunityAbAttr, BattlerTagImmunityAbAttr, MoveTypeChangeAbAttr, FullHpResistTypeAbAttr, applyCheckTrappedAbAttrs, CheckTrappedAbAttr } from "../data/ability"; @@ -2670,6 +2670,33 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { this.updateInfo(); } + /** + * Gets whether the given move is currently disabled for this Pokemon. + * + * @param {Moves} moveId {@linkcode Moves} ID of the move to check + * @returns {boolean} `true` if the move is disabled for this Pokemon, otherwise `false` + * + * @see {@linkcode MoveRestrictionBattlerTag} + */ + isMoveRestricted(moveId: Moves): boolean { + return this.getRestrictingTag(moveId) !== null; + } + + /** + * Gets the {@link MoveRestrictionBattlerTag} that is restricting a move, if it exists. + * + * @param {Moves} moveId {@linkcode Moves} ID of the move to check + * @returns {MoveRestrictionBattlerTag | null} the first tag on this Pokemon that restricts the move, or `null` if the move is not restricted. + */ + getRestrictingTag(moveId: Moves): MoveRestrictionBattlerTag | null { + for (const tag of this.findTags(t => t instanceof MoveRestrictionBattlerTag)) { + if ((tag as MoveRestrictionBattlerTag).isMoveRestricted(moveId)) { + return tag as MoveRestrictionBattlerTag; + } + } + return null; + } + getMoveHistory(): TurnMove[] { return this.battleSummonData.moveHistory; } @@ -4458,8 +4485,6 @@ export interface AttackMoveResult { export class PokemonSummonData { public statStages: number[] = [ 0, 0, 0, 0, 0, 0, 0 ]; public moveQueue: QueuedMove[] = []; - public disabledMove: Moves = Moves.NONE; - public disabledTurns: number = 0; public tags: BattlerTag[] = []; public abilitySuppressed: boolean = false; public abilitiesApplied: Abilities[] = []; @@ -4540,7 +4565,7 @@ export type DamageResult = HitResult.EFFECTIVE | HitResult.SUPER_EFFECTIVE | Hit * It links to {@linkcode Move} class via the move ID. * Compared to {@linkcode Move}, this class also tracks if a move has received. * PP Ups, amount of PP used, and things like that. - * @see {@linkcode isUsable} - checks if move is disabled, out of PP, or not implemented. + * @see {@linkcode isUsable} - checks if move is restricted, out of PP, or not implemented. * @see {@linkcode getMove} - returns {@linkcode Move} object by looking it up via ID. * @see {@linkcode usePp} - removes a point of PP from the move. * @see {@linkcode getMovePp} - returns amount of PP a move currently has. @@ -4560,11 +4585,25 @@ export class PokemonMove { this.virtual = !!virtual; } - isUsable(pokemon: Pokemon, ignorePp?: boolean): boolean { - if (this.moveId && pokemon.summonData?.disabledMove === this.moveId) { + /** + * Checks whether the move can be selected or performed by a Pokemon, without consideration for the move's targets. + * The move is unusable if it is out of PP, restricted by an effect, or unimplemented. + * + * @param {Pokemon} pokemon {@linkcode Pokemon} that would be using this move + * @param {boolean} ignorePp If `true`, skips the PP check + * @param {boolean} ignoreRestrictionTags If `true`, skips the check for move restriction tags (see {@link MoveRestrictionBattlerTag}) + * @returns `true` if the move can be selected and used by the Pokemon, otherwise `false`. + */ + isUsable(pokemon: Pokemon, ignorePp?: boolean, ignoreRestrictionTags?: boolean): boolean { + if (this.moveId && !ignoreRestrictionTags && pokemon.isMoveRestricted(this.moveId)) { return false; } - return (ignorePp || this.ppUsed < this.getMovePp() || this.getMove().pp === -1) && !this.getMove().name.endsWith(" (N)"); + + if (this.getMove().name.endsWith(" (N)")) { + return false; + } + + return (ignorePp || this.ppUsed < this.getMovePp() || this.getMove().pp === -1); } getMove(): Move { diff --git a/src/locales/en/battle.json b/src/locales/en/battle.json index 918fb38b520..120ac749acb 100644 --- a/src/locales/en/battle.json +++ b/src/locales/en/battle.json @@ -44,6 +44,7 @@ "moveNotImplemented": "{{moveName}} is not yet implemented and cannot be selected.", "moveNoPP": "There's no PP left for\nthis move!", "moveDisabled": "{{moveName}} is disabled!", + "disableInterruptedMove": "{{pokemonNameWithAffix}}'s {{moveName}}\nis disabled!", "noPokeballForce": "An unseen force\nprevents using Poké Balls.", "noPokeballTrainer": "You can't catch\nanother trainer's Pokémon!", "noPokeballMulti": "You can only throw a Poké Ball\nwhen there is one Pokémon remaining!", diff --git a/src/locales/en/battler-tags.json b/src/locales/en/battler-tags.json index 94ea3b14958..222aee4087c 100644 --- a/src/locales/en/battler-tags.json +++ b/src/locales/en/battler-tags.json @@ -67,5 +67,7 @@ "saltCuredLapse": "{{pokemonNameWithAffix}} is hurt by {{moveName}}!", "cursedOnAdd": "{{pokemonNameWithAffix}} cut its own HP and put a curse on the {{pokemonName}}!", "cursedLapse": "{{pokemonNameWithAffix}} is afflicted by the Curse!", - "stockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!" -} \ No newline at end of file + "stockpilingOnAdd": "{{pokemonNameWithAffix}} stockpiled {{stockpiledCount}}!", + "disabledOnAdd": "{{pokemonNameWithAffix}}'s {{moveName}}\nwas disabled!", + "disabledLapse": "{{pokemonNameWithAffix}}'s {{moveName}}\nis no longer disabled." +} diff --git a/src/phases/command-phase.ts b/src/phases/command-phase.ts index 9681a6eeee8..47d212aa598 100644 --- a/src/phases/command-phase.ts +++ b/src/phases/command-phase.ts @@ -107,8 +107,9 @@ export class CommandPhase extends FieldPhase { // Decides between a Disabled, Not Implemented, or No PP translation message const errorMessage = - playerPokemon.summonData.disabledMove === move.moveId ? "battle:moveDisabled" : - move.getName().endsWith(" (N)") ? "battle:moveNotImplemented" : "battle:moveNoPP"; + playerPokemon.isMoveRestricted(move.moveId) + ? playerPokemon.getRestrictingTag(move.moveId)!.selectionDeniedText(playerPokemon, move.moveId) + : move.getName().endsWith(" (N)") ? "battle:moveNotImplemented" : "battle:moveNoPP"; const moveName = move.getName().replace(" (N)", ""); // Trims off the indicator this.scene.ui.showText(i18next.t(errorMessage, { moveName: moveName }), null, () => { diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index e2893d587a7..0ccf19a462f 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -44,8 +44,8 @@ export class MovePhase extends BattlePhase { this.cancelled = false; } - canMove(): boolean { - return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon, this.ignorePp) && !!this.targets.length; + canMove(ignoreDisableTags?: boolean): boolean { + return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon, this.ignorePp, ignoreDisableTags) && !!this.targets.length; } /**Signifies the current move should fail but still use PP */ @@ -63,10 +63,7 @@ export class MovePhase extends BattlePhase { console.log(Moves[this.move.moveId]); - if (!this.canMove()) { - if (this.move.moveId && this.pokemon.summonData?.disabledMove === this.move.moveId) { - this.scene.queueMessage(i18next.t("battle:moveDisabled", { moveName: this.move.getName() })); - } + if (!this.canMove(true)) { if (this.pokemon.isActive(true) && this.move.ppUsed >= this.move.getMovePp()) { // if the move PP was reduced from Spite or otherwise, the move fails this.fail(); this.showMoveText(); diff --git a/src/phases/turn-end-phase.ts b/src/phases/turn-end-phase.ts index 9f4de46b0fa..c8bd3398bb5 100644 --- a/src/phases/turn-end-phase.ts +++ b/src/phases/turn-end-phase.ts @@ -1,9 +1,7 @@ import BattleScene from "#app/battle-scene.js"; import { applyPostTurnAbAttrs, PostTurnAbAttr } from "#app/data/ability.js"; import { BattlerTagLapseType } from "#app/data/battler-tags.js"; -import { allMoves } from "#app/data/move.js"; import { TerrainType } from "#app/data/terrain.js"; -import { Moves } from "#app/enums/moves.js"; import { WeatherType } from "#app/enums/weather-type.js"; import { TurnEndEvent } from "#app/events/battle-scene.js"; import Pokemon from "#app/field/pokemon.js"; @@ -11,7 +9,6 @@ import { getPokemonNameWithAffix } from "#app/messages.js"; import { TurnHealModifier, EnemyTurnHealModifier, EnemyStatusEffectHealChanceModifier, TurnStatusEffectModifier, TurnHeldItemTransferModifier } from "#app/modifier/modifier.js"; import i18next from "i18next"; import { FieldPhase } from "./field-phase"; -import { MessagePhase } from "./message-phase"; import { PokemonHealPhase } from "./pokemon-heal-phase"; export class TurnEndPhase extends FieldPhase { @@ -28,11 +25,6 @@ export class TurnEndPhase extends FieldPhase { const handlePokemon = (pokemon: Pokemon) => { pokemon.lapseTags(BattlerTagLapseType.TURN_END); - if (pokemon.summonData.disabledMove && !--pokemon.summonData.disabledTurns) { - this.scene.pushPhase(new MessagePhase(this.scene, i18next.t("battle:notDisabled", { pokemonName: getPokemonNameWithAffix(pokemon), moveName: allMoves[pokemon.summonData.disabledMove].name }))); - pokemon.summonData.disabledMove = Moves.NONE; - } - this.scene.applyModifiers(TurnHealModifier, pokemon.isPlayer(), pokemon); if (this.scene.arena.terrain?.terrainType === TerrainType.GRASSY && pokemon.isGrounded()) { diff --git a/src/system/pokemon-data.ts b/src/system/pokemon-data.ts index 9a743ceb1d2..1fafcbf8acc 100644 --- a/src/system/pokemon-data.ts +++ b/src/system/pokemon-data.ts @@ -127,8 +127,6 @@ export default class PokemonData { this.summonData.stats = source.summonData.stats; this.summonData.statStages = source.summonData.statStages; this.summonData.moveQueue = source.summonData.moveQueue; - this.summonData.disabledMove = source.summonData.disabledMove; - this.summonData.disabledTurns = source.summonData.disabledTurns; this.summonData.abilitySuppressed = source.summonData.abilitySuppressed; this.summonData.abilitiesApplied = source.summonData.abilitiesApplied; diff --git a/src/test/moves/disable.test.ts b/src/test/moves/disable.test.ts new file mode 100644 index 00000000000..3d207035ce3 --- /dev/null +++ b/src/test/moves/disable.test.ts @@ -0,0 +1,129 @@ +import { BattlerIndex } from "#app/battle"; +import { MoveResult } from "#app/field/pokemon"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import { SPLASH_ONLY } from "#test/utils/testUtils"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - Disable", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + game.override + .battleType("single") + .ability(Abilities.BALL_FETCH) + .enemyAbility(Abilities.BALL_FETCH) + .moveset([Moves.DISABLE, Moves.SPLASH]) + .enemyMoveset(SPLASH_ONLY) + .starterSpecies(Species.PIKACHU) + .enemySpecies(Species.SHUCKLE); + }); + + it("restricts moves", async () => { + await game.classicMode.startBattle(); + + const enemyMon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.DISABLE); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toNextTurn(); + + expect(enemyMon.getMoveHistory()).toHaveLength(1); + expect(enemyMon.isMoveRestricted(Moves.SPLASH)).toBe(true); + }); + + it("fails if enemy has no move history", async() => { + await game.classicMode.startBattle(); + + const playerMon = game.scene.getPlayerPokemon()!; + const enemyMon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.DISABLE); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toNextTurn(); + + expect(playerMon.getMoveHistory()[0]).toMatchObject({ move: Moves.DISABLE, result: MoveResult.FAIL }); + expect(enemyMon.isMoveRestricted(Moves.SPLASH)).toBe(false); + }, 20000); + + it("causes STRUGGLE if all usable moves are disabled", async() => { + await game.classicMode.startBattle(); + + const enemyMon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.DISABLE); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toNextTurn(); + + game.move.select(Moves.SPLASH); + await game.toNextTurn(); + + const enemyHistory = enemyMon.getMoveHistory(); + expect(enemyHistory).toHaveLength(2); + expect(enemyHistory[0].move).toBe(Moves.SPLASH); + expect(enemyHistory[1].move).toBe(Moves.STRUGGLE); + }, 20000); + + it("cannot disable STRUGGLE", async() => { + game.override.enemyMoveset(Array(4).fill(Moves.STRUGGLE)); + await game.classicMode.startBattle(); + + const playerMon = game.scene.getPlayerPokemon()!; + const enemyMon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.DISABLE); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toNextTurn(); + + expect(playerMon.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(enemyMon.getLastXMoves()[0].move).toBe(Moves.STRUGGLE); + expect(enemyMon.isMoveRestricted(Moves.STRUGGLE)).toBe(false); + }, 20000); + + it("interrupts target's move when target moves after", async() => { + await game.classicMode.startBattle(); + + const enemyMon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.SPLASH); + await game.toNextTurn(); + + // Both mons just used Splash last turn; now have player use Disable. + game.move.select(Moves.DISABLE); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toNextTurn(); + + const enemyHistory = enemyMon.getMoveHistory(); + expect(enemyHistory).toHaveLength(2); + expect(enemyHistory[0]).toMatchObject({ move: Moves.SPLASH, result: MoveResult.SUCCESS }); + expect(enemyHistory[1].result).toBe(MoveResult.FAIL); + }, 20000); + + it("disables NATURE POWER, not the move invoked by it", async() => { + game.override.enemyMoveset(Array(4).fill(Moves.NATURE_POWER)); + await game.classicMode.startBattle(); + + const enemyMon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.DISABLE); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toNextTurn(); + + expect(enemyMon.isMoveRestricted(Moves.NATURE_POWER)).toBe(true); + expect(enemyMon.isMoveRestricted(enemyMon.getLastXMoves(2)[1].move)).toBe(false); + }, 20000); +}); From 1968680104cfa9874e425f614e9e83a2a635feab Mon Sep 17 00:00:00 2001 From: Leo Kim <47556641+KimJeongSun@users.noreply.github.com> Date: Thu, 5 Sep 2024 18:52:48 +0900 Subject: [PATCH 19/22] [Enhancement] add font size option per language on registration UI (#3849) * add language setting for register form ui * remove button fontsize option * fix tests * remove deprecated comments * update request changes * update requested changes from es * update requested changes from walker * Update src/locales/es/menu.json Co-authored-by: Asdar --------- Co-authored-by: Asdar --- src/locales/es/menu.json | 4 ++-- src/ui/registration-form-ui-handler.ts | 30 +++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/locales/es/menu.json b/src/locales/es/menu.json index bd2479a02df..3f2caafac21 100644 --- a/src/locales/es/menu.json +++ b/src/locales/es/menu.json @@ -14,14 +14,14 @@ "register": "Registrarse", "emptyUsername": "El usuario no puede estar vacío", "invalidLoginUsername": "El usuario no es válido", - "invalidRegisterUsername": "El usuario solo puede contener letras, números y guiones bajos", + "invalidRegisterUsername": "El usuario solo puede contener letras, números y guiones bajos.", "invalidLoginPassword": "La contraseña no es válida", "invalidRegisterPassword": "La contraseña debe tener 6 o más caracteres.", "usernameAlreadyUsed": "El usuario ya está en uso", "accountNonExistent": "El usuario no existe", "unmatchingPassword": "La contraseña no coincide", "passwordNotMatchingConfirmPassword": "Las contraseñas deben coincidir", - "confirmPassword": "Confirmar Contra.", + "confirmPassword": "Confirmar contraseña", "registrationAgeWarning": "Al registrarte, confirmas tener 13 o más años de edad.", "backToLogin": "Volver al Login", "failedToLoadSaveData": "No se han podido cargar los datos guardados. Por favor, recarga la página.\nSi el fallo continúa, por favor comprueba #announcements en nuestro Discord.", diff --git a/src/ui/registration-form-ui-handler.ts b/src/ui/registration-form-ui-handler.ts index 733aab79b05..0c4b54ac723 100644 --- a/src/ui/registration-form-ui-handler.ts +++ b/src/ui/registration-form-ui-handler.ts @@ -5,6 +5,20 @@ import { Mode } from "./ui"; import { TextStyle, addTextObject } from "./text"; import i18next from "i18next"; + +interface LanguageSetting { + inputFieldFontSize?: string, + warningMessageFontSize?: string, + errorMessageFontSize?: string, +} + +const languageSettings: { [key: string]: LanguageSetting } = { + "es":{ + inputFieldFontSize: "50px", + errorMessageFontSize: "40px", + } +}; + export default class RegistrationFormUiHandler extends FormModalUiHandler { getModalTitle(config?: ModalConfig): string { return i18next.t("menu:register"); @@ -50,7 +64,17 @@ export default class RegistrationFormUiHandler extends FormModalUiHandler { setup(): void { super.setup(); - const label = addTextObject(this.scene, 10, 87, i18next.t("menu:registrationAgeWarning"), TextStyle.TOOLTIP_CONTENT, { fontSize: "42px" }); + this.modalContainer.list.forEach((child: Phaser.GameObjects.GameObject) => { + if (child instanceof Phaser.GameObjects.Text && child !== this.titleText) { + const inputFieldFontSize = languageSettings[i18next.resolvedLanguage!]?.inputFieldFontSize; + if (inputFieldFontSize) { + child.setFontSize(inputFieldFontSize); + } + } + }); + + const warningMessageFontSize = languageSettings[i18next.resolvedLanguage!]?.warningMessageFontSize ?? "42px"; + const label = addTextObject(this.scene, 10, 87, i18next.t("menu:registrationAgeWarning"), TextStyle.TOOLTIP_CONTENT, { fontSize: warningMessageFontSize}); this.modalContainer.add(label); } @@ -68,6 +92,10 @@ export default class RegistrationFormUiHandler extends FormModalUiHandler { const onFail = error => { this.scene.ui.setMode(Mode.REGISTRATION_FORM, Object.assign(config, { errorMessage: error?.trim() })); this.scene.ui.playError(); + const errorMessageFontSize = languageSettings[i18next.resolvedLanguage!]?.errorMessageFontSize; + if (errorMessageFontSize) { + this.errorMessage.setFontSize(errorMessageFontSize); + } }; if (!this.inputs[0].text) { return onFail(i18next.t("menu:emptyUsername")); From 41bb12d0c06734c667918023c28f0caddd14738b Mon Sep 17 00:00:00 2001 From: "Dmitriy K." Date: Thu, 5 Sep 2024 06:08:00 -0400 Subject: [PATCH 20/22] skip pre-commit on merge and rebase, add pre-push (#1607) --- lefthook.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lefthook.yml b/lefthook.yml index 01ffebc69c7..662d3b5617b 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -2,6 +2,15 @@ pre-commit: parallel: true commands: eslint: - glob: '*.{js,jsx,ts,tsx}' + glob: "*.{js,jsx,ts,tsx}" run: npx eslint --fix {staged_files} - stage_fixed: true \ No newline at end of file + stage_fixed: true + skip: + - merge + - rebase + +pre-push: + commands: + eslint: + glob: "*.{js,ts,jsx,tsx}" + run: npx eslint --fix {push_files} \ No newline at end of file From c902369eed57b357924701f9d33f39ffe3a1a4b0 Mon Sep 17 00:00:00 2001 From: Lugiad Date: Thu, 5 Sep 2024 14:39:10 +0200 Subject: [PATCH 21/22] [Localization] Restoring "Safeguard" entries in arena-flyout.json of all locales where it existed (#4026) * Update arena-flyout.json * Update arena-flyout.json * Update arena-flyout.json * Update arena-flyout.json * Update arena-flyout.json * Update arena-flyout.json * Update arena-flyout.json * Update arena-flyout.json --- src/locales/de/arena-flyout.json | 5 +++-- src/locales/es/arena-flyout.json | 3 ++- src/locales/fr/arena-flyout.json | 5 +++-- src/locales/it/arena-flyout.json | 5 +++-- src/locales/ko/arena-flyout.json | 5 +++-- src/locales/pt_BR/arena-flyout.json | 3 ++- src/locales/zh_CN/arena-flyout.json | 5 +++-- 7 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/locales/de/arena-flyout.json b/src/locales/de/arena-flyout.json index 30d3e7febb3..a1f2254c642 100644 --- a/src/locales/de/arena-flyout.json +++ b/src/locales/de/arena-flyout.json @@ -36,5 +36,6 @@ "matBlock": "Tatami-Schild", "craftyShield": "Trickschutz", "tailwind": "Rückenwind", - "happyHour": "Goldene Zeiten" -} \ No newline at end of file + "happyHour": "Goldene Zeiten", + "safeguard": "Bodyguard" +} diff --git a/src/locales/es/arena-flyout.json b/src/locales/es/arena-flyout.json index e3ec1dc6d4a..64c9a489d73 100644 --- a/src/locales/es/arena-flyout.json +++ b/src/locales/es/arena-flyout.json @@ -36,5 +36,6 @@ "matBlock": "Escudo Tatami", "craftyShield": "Truco Defensa", "tailwind": "Viento Afín", - "happyHour": "Paga Extra" + "happyHour": "Paga Extra", + "safeguard": "Velo Sagrado" } diff --git a/src/locales/fr/arena-flyout.json b/src/locales/fr/arena-flyout.json index ce78643862e..e90de13b20a 100644 --- a/src/locales/fr/arena-flyout.json +++ b/src/locales/fr/arena-flyout.json @@ -36,5 +36,6 @@ "matBlock": "Tatamigaeshi", "craftyShield": "Vigilance", "tailwind": "Vent Arrière", - "happyHour": "Étrennes" -} \ No newline at end of file + "happyHour": "Étrennes", + "safeguard": "Rune Protect" +} diff --git a/src/locales/it/arena-flyout.json b/src/locales/it/arena-flyout.json index ac6dd4225cc..31c2a4c0015 100644 --- a/src/locales/it/arena-flyout.json +++ b/src/locales/it/arena-flyout.json @@ -36,5 +36,6 @@ "matBlock": "Ribaltappeto", "craftyShield": "Truccodifesa", "tailwind": "Ventoincoda", - "happyHour": "Cuccagna" -} \ No newline at end of file + "happyHour": "Cuccagna", + "safeguard": "Salvaguardia" +} diff --git a/src/locales/ko/arena-flyout.json b/src/locales/ko/arena-flyout.json index bfd24776cdc..e4b271691a5 100644 --- a/src/locales/ko/arena-flyout.json +++ b/src/locales/ko/arena-flyout.json @@ -36,5 +36,6 @@ "matBlock": "마룻바닥세워막기", "craftyShield": "트릭가드", "tailwind": "순풍", - "happyHour": "해피타임" -} \ No newline at end of file + "happyHour": "해피타임", + "safeguard": "신비의부적" +} diff --git a/src/locales/pt_BR/arena-flyout.json b/src/locales/pt_BR/arena-flyout.json index e221fa6c0a5..a4be2727b27 100644 --- a/src/locales/pt_BR/arena-flyout.json +++ b/src/locales/pt_BR/arena-flyout.json @@ -36,5 +36,6 @@ "matBlock": "Mat Block", "craftyShield": "Crafty Shield", "tailwind": "Tailwind", - "happyHour": "Happy Hour" + "happyHour": "Happy Hour", + "safeguard": "Safeguard" } diff --git a/src/locales/zh_CN/arena-flyout.json b/src/locales/zh_CN/arena-flyout.json index 7ddc304f404..fbce213c4aa 100644 --- a/src/locales/zh_CN/arena-flyout.json +++ b/src/locales/zh_CN/arena-flyout.json @@ -36,5 +36,6 @@ "matBlock": "掀榻榻米", "craftyShield": "戏法防守", "tailwind": "顺风", - "happyHour": "快乐时光" -} \ No newline at end of file + "happyHour": "快乐时光", + "safeguard": "神秘守护" +} From 01eb05469a559670de1c4bf5804ddcd58f2fd0ad Mon Sep 17 00:00:00 2001 From: Opaque02 <66582645+Opaque02@users.noreply.github.com> Date: Fri, 6 Sep 2024 00:00:19 +1000 Subject: [PATCH 22/22] [QoL] Username finder (#4040) * Added the ability to potentially get username from login screen * Accidentally made dev login enforced :) * Updated image --- public/images/ui/legacy/settings_icon.png | Bin 0 -> 261 bytes public/images/ui/settings_icon.png | Bin 0 -> 261 bytes src/loading-scene.ts | 1 + src/ui/login-form-ui-handler.ts | 99 ++++++++++++++++++---- 4 files changed, 83 insertions(+), 17 deletions(-) create mode 100644 public/images/ui/legacy/settings_icon.png create mode 100644 public/images/ui/settings_icon.png diff --git a/public/images/ui/legacy/settings_icon.png b/public/images/ui/legacy/settings_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..21680cce7fcdc15309a67cc2465c6e5ee921cd56 GIT binary patch literal 261 zcmeAS@N?(olHy`uVBq!ia0vp^;vmey3?#3AQJDm!7>k44ofy`glX(f`u%tWsIx;Y9 z?C1WI$O`0h2Ka=yM#aRKnOgvPWpUylqnJyA{DS}gXSjZf7pRtjv%n*=n1O-sFbFdq z&tH)OQtIjA7@`q8x8G6dfC3NG+W++>lUrB3?3rE^5Mi=5d79fPJ0b5i2{Rw1tSB!m z3NO07uGwDm_Y=JXp$790MX=uY-Mle>j`Jgt-2z5oiPL@^w*Q>}UwC;Gh1;^xF5?nCq9p#v3_#E=aWDeFnGH9xvXk44ofy`glX(f`u%tWsIx;Y9 z?C1WI$O`0h2Ka=yM#aRKnOgvPWpUylqnJyA{DS}gXSjZf7pRtjv%n*=n1O-sFbFdq z&tH)OQtIjA7@`q8x8G6dfC3NG+W++>lUrB3?3rE^5Mi=5d79fPJ0b5i2{Rw1tSB!m z3NO07uGwDm_Y=JXp$790MX=uY-Mle>j`Jgt-2z5oiPL@^w*Q>}UwC;Gh1;^xF5?nCq9p#v3_#E=aWDeFnGH9xvX { - const redirectUri = encodeURIComponent(`${import.meta.env.VITE_SERVER_URL}/auth/google/callback`); - const googleId = import.meta.env.VITE_GOOGLE_CLIENT_ID; - const googleUrl = `https://accounts.google.com/o/oauth2/auth?client_id=${googleId}&redirect_uri=${redirectUri}&response_type=code&scope=openid`; - window.open(googleUrl, "_self"); - }); this.googleImage = googleImage; const discordImage = this.scene.add.image(20, 0, "discord"); @@ -46,12 +46,7 @@ export default class LoginFormUiHandler extends FormModalUiHandler { discordImage.setScale(0.07); discordImage.setInteractive(); discordImage.setName("discord-icon"); - discordImage.on("pointerdown", () => { - const redirectUri = encodeURIComponent(`${import.meta.env.VITE_SERVER_URL}/auth/discord/callback`); - const discordId = import.meta.env.VITE_DISCORD_CLIENT_ID; - const discordUrl = `https://discord.com/api/oauth2/authorize?client_id=${discordId}&redirect_uri=${redirectUri}&response_type=code&scope=identify&prompt=none`; - window.open(discordUrl, "_self"); - }); + this.discordImage = discordImage; this.externalPartyContainer.add(this.googleImage); @@ -60,6 +55,17 @@ export default class LoginFormUiHandler extends FormModalUiHandler { this.externalPartyContainer.add(this.googleImage); this.externalPartyContainer.add(this.discordImage); this.externalPartyContainer.setVisible(false); + + const usernameInfoImage = this.scene.add.image(20, 0, "settings_icon"); + usernameInfoImage.setOrigin(0, 0); + usernameInfoImage.setScale(0.5); + usernameInfoImage.setInteractive(); + usernameInfoImage.setName("username-info-icon"); + this.usernameInfoImage = usernameInfoImage; + + this.infoContainer.add(this.usernameInfoImage); + this.getUi().add(this.infoContainer); + this.infoContainer.setVisible(false); } getModalTitle(config?: ModalConfig): string { @@ -104,9 +110,8 @@ export default class LoginFormUiHandler extends FormModalUiHandler { show(args: any[]): boolean { if (super.show(args)) { - this.processExternalProvider(); - const config = args[0] as ModalConfig; + this.processExternalProvider(config); const originalLoginAction = this.submitAction; this.submitAction = (_) => { // Prevent overlapping overrides on action modification @@ -146,22 +151,73 @@ export default class LoginFormUiHandler extends FormModalUiHandler { clear() { super.clear(); this.externalPartyContainer.setVisible(false); + this.infoContainer.setVisible(false); this.discordImage.off("pointerdown"); this.googleImage.off("pointerdown"); + this.usernameInfoImage.off("pointerdown"); } - processExternalProvider() : void { + processExternalProvider(config: ModalConfig) : void { this.externalPartyTitle.setText(i18next.t("menu:orUse") ?? ""); this.externalPartyTitle.setX(20+this.externalPartyTitle.text.length); this.externalPartyTitle.setVisible(true); this.externalPartyContainer.setPositionRelative(this.modalContainer, 175, 0); this.externalPartyContainer.setVisible(true); - this.externalPartyBg.setSize(this.externalPartyTitle.text.length+50, this.modalBg.height); + this.externalPartyBg.setSize(this.externalPartyTitle.text.length + 50, this.modalBg.height); this.getUi().moveTo(this.externalPartyContainer, this.getUi().length - 1); this.googleImage.setPosition(this.externalPartyBg.width/3.1, this.externalPartyBg.height-60); this.discordImage.setPosition(this.externalPartyBg.width/3.1, this.externalPartyBg.height-40); + this.infoContainer.setPosition(5, -76); + this.infoContainer.setVisible(true); + this.getUi().moveTo(this.infoContainer, this.getUi().length - 1); + this.usernameInfoImage.setPositionRelative(this.infoContainer, 0, 0); + + this.discordImage.on("pointerdown", () => { + const redirectUri = encodeURIComponent(`${import.meta.env.VITE_SERVER_URL}/auth/discord/callback`); + const discordId = import.meta.env.VITE_DISCORD_CLIENT_ID; + const discordUrl = `https://discord.com/api/oauth2/authorize?client_id=${discordId}&redirect_uri=${redirectUri}&response_type=code&scope=identify&prompt=none`; + window.open(discordUrl, "_self"); + }); + + this.googleImage.on("pointerdown", () => { + const redirectUri = encodeURIComponent(`${import.meta.env.VITE_SERVER_URL}/auth/google/callback`); + const googleId = import.meta.env.VITE_GOOGLE_CLIENT_ID; + const googleUrl = `https://accounts.google.com/o/oauth2/auth?client_id=${googleId}&redirect_uri=${redirectUri}&response_type=code&scope=openid`; + window.open(googleUrl, "_self"); + }); + + const onFail = error => { + this.scene.ui.setMode(Mode.LOADING, { buttonActions: [] }); + this.scene.ui.setModeForceTransition(Mode.LOGIN_FORM, Object.assign(config, { errorMessage: error?.trim() })); + this.scene.ui.playError(); + }; + + this.usernameInfoImage.on("pointerdown", () => { + const localStorageKeys = Object.keys(localStorage); // this gets the keys for localStorage + const keyToFind = "data_"; + const dataKeys = localStorageKeys.filter(ls => ls.indexOf(keyToFind) >= 0); + if (dataKeys.length > 0 && dataKeys.length <= 2) { + const options: OptionSelectItem[] = []; + for (let i = 0; i < dataKeys.length; i++) { + options.push({ + label: dataKeys[i].replace(keyToFind, ""), + handler: () => { + this.scene.ui.revertMode(); + return true; + } + }); + } + this.scene.ui.setOverlayMode(Mode.OPTION_SELECT, { + options: options, + delay: 1000 + }); + } else { + return onFail("You have too many save files to use this"); + } + }); + this.externalPartyContainer.setAlpha(0); this.scene.tweens.add({ targets: this.externalPartyContainer, @@ -170,5 +226,14 @@ export default class LoginFormUiHandler extends FormModalUiHandler { y: "-=24", alpha: 1 }); + + this.infoContainer.setAlpha(0); + this.scene.tweens.add({ + targets: this.infoContainer, + duration: Utils.fixedInt(1000), + ease: "Sine.easeInOut", + y: "-=24", + alpha: 1 + }); } }