diff --git a/src/enums/ui-mode.ts b/src/enums/ui-mode.ts index dcf6bd2a238..ac6480098d8 100644 --- a/src/enums/ui-mode.ts +++ b/src/enums/ui-mode.ts @@ -38,6 +38,7 @@ export enum UiMode { UNAVAILABLE, CHALLENGE_SELECT, RENAME_POKEMON, + RENAME_RUN, RUN_HISTORY, RUN_INFO, TEST_DIALOGUE, diff --git a/src/system/game-data.ts b/src/system/game-data.ts index e933c5704f9..ef0b6f0a89d 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -127,6 +127,7 @@ export interface SessionSaveData { battleType: BattleType; trainer: TrainerData; gameVersion: string; + runNameText: string; timestamp: number; challenges: ChallengeData[]; mysteryEncounterType: MysteryEncounterType | -1; // Only defined when current wave is ME, @@ -979,6 +980,54 @@ export class GameData { }); } + async renameSession(slotId: number, newName: string): Promise { + return new Promise(async resolve => { + if (slotId < 0) { + return resolve(false); + } + const sessionData: SessionSaveData | null = await this.getSession(slotId); + + if (!sessionData) { + return resolve(false); + } + + if (newName === "") { + return resolve(true); + } + + sessionData.runNameText = newName; + const updatedDataStr = JSON.stringify(sessionData); + const encrypted = encrypt(updatedDataStr, bypassLogin); + const secretId = this.secretId; + const trainerId = this.trainerId; + + if (!bypassLogin) { + pokerogueApi.savedata.session + .update({ slot: slotId, trainerId, secretId, clientSessionId }, encrypted) + .then(error => { + if (error) { + console.error("Failed to update session name:", error); + resolve(false); + } else { + localStorage.setItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, encrypted); + updateUserInfo().then(success => { + if (success !== null && !success) { + return resolve(false); + } + }); + resolve(true); + } + }); + } else { + localStorage.setItem( + `sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, + encrypt(updatedDataStr, bypassLogin), + ); + resolve(true); + } + }); + } + loadSession(slotId: number, sessionData?: SessionSaveData): Promise { // biome-ignore lint/suspicious/noAsyncPromiseExecutor: return new Promise(async (resolve, reject) => { diff --git a/src/ui/rename-run-ui-handler.ts b/src/ui/rename-run-ui-handler.ts new file mode 100644 index 00000000000..0033079de63 --- /dev/null +++ b/src/ui/rename-run-ui-handler.ts @@ -0,0 +1,54 @@ +import type { InputFieldConfig } from "./form-modal-ui-handler"; +import { FormModalUiHandler } from "./form-modal-ui-handler"; +import type { ModalConfig } from "./modal-ui-handler"; +import i18next from "i18next"; + +export default class RenameRunFormUiHandler extends FormModalUiHandler { + getModalTitle(_config?: ModalConfig): string { + return i18next.t("menu:renamerun"); + } + + getWidth(_config?: ModalConfig): number { + return 160; + } + + getMargin(_config?: ModalConfig): [number, number, number, number] { + return [0, 0, 48, 0]; + } + + getButtonLabels(_config?: ModalConfig): string[] { + return [i18next.t("menu:rename"), i18next.t("menu:cancel")]; + } + + getReadableErrorMessage(error: string): string { + const colonIndex = error?.indexOf(":"); + if (colonIndex > 0) { + error = error.slice(0, colonIndex); + } + + return super.getReadableErrorMessage(error); + } + + override getInputFieldConfigs(): InputFieldConfig[] { + return [{ label: i18next.t("menu:runName") }]; + } + + show(args: any[]): boolean { + if (super.show(args)) { + if (this.inputs?.length) { + this.inputs.forEach(input => { + input.text = ""; + }); + } + const config = args[0] as ModalConfig; + this.submitAction = _ => { + this.sanitizeInputs(); + const sanitizedName = btoa(encodeURIComponent(this.inputs[0].text)); + config.buttonActions[0](sanitizedName); + return true; + }; + return true; + } + return false; + } +} diff --git a/src/ui/run-info-ui-handler.ts b/src/ui/run-info-ui-handler.ts index 78ab4a40407..3e33c3967b4 100644 --- a/src/ui/run-info-ui-handler.ts +++ b/src/ui/run-info-ui-handler.ts @@ -206,6 +206,10 @@ export default class RunInfoUiHandler extends UiHandler { headerText.setOrigin(0, 0); headerText.setPositionRelative(headerBg, 8, 4); this.runContainer.add(headerText); + const runName = addTextObject(0, 0, this.runInfo.runNameText, TextStyle.WINDOW); + runName.setOrigin(0, 0); + runName.setPositionRelative(headerBg, 60, 4); + this.runContainer.add(runName); } /** diff --git a/src/ui/save-slot-select-ui-handler.ts b/src/ui/save-slot-select-ui-handler.ts index 7b4d46203c9..38beda43b19 100644 --- a/src/ui/save-slot-select-ui-handler.ts +++ b/src/ui/save-slot-select-ui-handler.ts @@ -6,15 +6,17 @@ import { GameMode } from "../game-mode"; import * as Modifier from "#app/modifier/modifier"; import type { SessionSaveData } from "../system/game-data"; import type PokemonData from "../system/pokemon-data"; +import type { OptionSelectConfig } from "#app/ui/abstact-option-select-ui-handler"; import { isNullOrUndefined, fixedInt, getPlayTimeString, formatLargeNumber } from "#app/utils/common"; import MessageUiHandler from "./message-ui-handler"; import { TextStyle, addTextObject } from "./text"; import { UiMode } from "#enums/ui-mode"; import { addWindow } from "./ui-theme"; import { RunDisplayMode } from "#app/ui/run-info-ui-handler"; +import { GameModes } from "#enums/game-modes"; const SESSION_SLOTS_COUNT = 5; -const SLOTS_ON_SCREEN = 3; +const SLOTS_ON_SCREEN = 2; export enum SaveSlotUiMode { LOAD, @@ -32,6 +34,7 @@ export default class SaveSlotSelectUiHandler extends MessageUiHandler { private uiMode: SaveSlotUiMode; private saveSlotSelectCallback: SaveSlotSelectCallback | null; + protected manageDataConfig: OptionSelectConfig; private scrollCursor = 0; @@ -100,6 +103,7 @@ export default class SaveSlotSelectUiHandler extends MessageUiHandler { processInput(button: Button): boolean { const ui = this.getUi(); + const manageDataOptions: any[] = []; let success = false; let error = false; @@ -113,9 +117,107 @@ export default class SaveSlotSelectUiHandler extends MessageUiHandler { } else { switch (this.uiMode) { case SaveSlotUiMode.LOAD: - this.saveSlotSelectCallback = null; - originalCallback?.(cursor); + manageDataOptions.push({ + label: i18next.t("menu:loadGame"), + handler: () => { + globalScene.ui.revertMode(); + originalCallback?.(cursor); + return true; + }, + keepOpen: false, + }); + + manageDataOptions.push({ + label: i18next.t("saveSlotSelectUiHandler:renameRun"), + handler: () => { + globalScene.ui.revertMode(); + ui.setOverlayMode( + UiMode.RENAME_RUN, + { + buttonActions: [ + (sanitizedName: string) => { + const name = decodeURIComponent(atob(sanitizedName)); + globalScene.gameData.renameSession(cursor, name).then(response => { + if (response[0] === false) { + globalScene.reset(true); + } else { + this.clearSessionSlots(); + this.cursorObj = null; + this.populateSessionSlots(); + this.setScrollCursor(0); + this.setCursor(0); + ui.revertMode(); + ui.showText("", 0); + } + }); + }, + () => { + ui.revertMode(); + }, + ], + }, + "", + ); + return true; + }, + }); + + this.manageDataConfig = { + xOffset: 0, + yOffset: 48, + options: manageDataOptions, + maxOptions: 4, + }; + + manageDataOptions.push({ + label: i18next.t("saveSlotSelectUiHandler:deleteRun"), + handler: () => { + globalScene.ui.revertMode(); + ui.showText(i18next.t("saveSlotSelectUiHandler:deleteData"), null, () => { + ui.setOverlayMode( + UiMode.CONFIRM, + () => { + globalScene.gameData.tryClearSession(cursor).then(response => { + if (response[0] === false) { + globalScene.reset(true); + } else { + this.clearSessionSlots(); + this.cursorObj = null; + this.populateSessionSlots(); + this.setScrollCursor(0); + this.setCursor(0); + ui.revertMode(); + ui.showText("", 0); + } + }); + }, + () => { + ui.revertMode(); + ui.showText("", 0); + }, + false, + 0, + 19, + import.meta.env.DEV ? 300 : 2000, + ); + }); + return true; + }, + keepOpen: false, + }); + + manageDataOptions.push({ + label: i18next.t("menuUiHandler:cancel"), + handler: () => { + globalScene.ui.revertMode(); + return true; + }, + keepOpen: true, + }); + + ui.setOverlayMode(UiMode.MENU_OPTION_SELECT, this.manageDataConfig); break; + case SaveSlotUiMode.SAVE: { const saveAndCallback = () => { const originalCallback = this.saveSlotSelectCallback; @@ -160,6 +262,7 @@ export default class SaveSlotSelectUiHandler extends MessageUiHandler { } } else { this.saveSlotSelectCallback = null; + ui.showText("", 0); originalCallback?.(-1); success = true; } @@ -266,33 +369,33 @@ export default class SaveSlotSelectUiHandler extends MessageUiHandler { this.cursorObj = globalScene.add.container(0, 0); const cursorBox = globalScene.add.nineslice( 0, - 0, + 15, "select_cursor_highlight_thick", undefined, - 296, - 44, + 294, + this.sessionSlots[prevSlotIndex ?? 0]?.saveData?.runNameText ? 50 : 60, 6, 6, 6, 6, ); const rightArrow = globalScene.add.image(0, 0, "cursor"); - rightArrow.setPosition(160, 0); + rightArrow.setPosition(160, 15); rightArrow.setName("rightArrow"); this.cursorObj.add([cursorBox, rightArrow]); this.sessionSlotsContainer.add(this.cursorObj); } const cursorPosition = cursor + this.scrollCursor; - const cursorIncrement = cursorPosition * 56; + const cursorIncrement = cursorPosition * 76; if (this.sessionSlots[cursorPosition] && this.cursorObj) { const hasData = this.sessionSlots[cursorPosition].hasData; // If the session slot lacks session data, it does not move from its default, central position. // Only session slots with session data will move leftwards and have a visible arrow. if (!hasData) { - this.cursorObj.setPosition(151, 26 + cursorIncrement); + this.cursorObj.setPosition(151, 20 + cursorIncrement); this.sessionSlots[cursorPosition].setPosition(0, cursorIncrement); } else { - this.cursorObj.setPosition(145, 26 + cursorIncrement); + this.cursorObj.setPosition(145, 20 + cursorIncrement); this.sessionSlots[cursorPosition].setPosition(-6, cursorIncrement); } this.setArrowVisibility(hasData); @@ -310,7 +413,8 @@ export default class SaveSlotSelectUiHandler extends MessageUiHandler { revertSessionSlot(slotIndex: number): void { const sessionSlot = this.sessionSlots[slotIndex]; if (sessionSlot) { - sessionSlot.setPosition(0, slotIndex * 56); + const valueHeight = 76; + sessionSlot.setPosition(0, slotIndex * valueHeight); } } @@ -339,7 +443,7 @@ export default class SaveSlotSelectUiHandler extends MessageUiHandler { this.setCursor(this.cursor, prevSlotIndex); globalScene.tweens.add({ targets: this.sessionSlotsContainer, - y: this.sessionSlotsContainerInitialY - 56 * scrollCursor, + y: this.sessionSlotsContainerInitialY - 76 * scrollCursor, duration: fixedInt(325), ease: "Sine.easeInOut", }); @@ -373,12 +477,12 @@ export default class SaveSlotSelectUiHandler extends MessageUiHandler { class SessionSlot extends Phaser.GameObjects.Container { public slotId: number; public hasData: boolean; + private slotWindow: Phaser.GameObjects.NineSlice; private loadingLabel: Phaser.GameObjects.Text; - public saveData: SessionSaveData; constructor(slotId: number) { - super(globalScene, 0, slotId * 56); + super(globalScene, 0, slotId * 76); this.slotId = slotId; @@ -386,32 +490,63 @@ class SessionSlot extends Phaser.GameObjects.Container { } setup() { - const slotWindow = addWindow(0, 0, 304, 52); - this.add(slotWindow); + this.slotWindow = addWindow(0, 0, 304, 70); + this.add(this.slotWindow); - this.loadingLabel = addTextObject(152, 26, i18next.t("saveSlotSelectUiHandler:loading"), TextStyle.WINDOW); + this.loadingLabel = addTextObject(152, 33, i18next.t("saveSlotSelectUiHandler:loading"), TextStyle.WINDOW); this.loadingLabel.setOrigin(0.5, 0.5); this.add(this.loadingLabel); } + decideFallback(data: SessionSaveData) { + let fallbackName; + switch (data.gameMode) { + case GameModes.CLASSIC: + fallbackName = `${GameMode.getModeName(data.gameMode)} (${globalScene.gameData.gameStats.classicSessionsPlayed + 1})`; + break; + case GameModes.ENDLESS: + case GameModes.SPLICED_ENDLESS: + fallbackName = `${GameMode.getModeName(data.gameMode)} (${globalScene.gameData.gameStats.endlessSessionsPlayed + 1})`; + break; + case GameModes.DAILY: + const runDay = new Date(data.timestamp).toLocaleDateString(); + fallbackName = `${GameMode.getModeName(data.gameMode)} (${runDay})`; + break; + case GameModes.CHALLENGE: + fallbackName = `${GameMode.getModeName(data.gameMode)}`; + break; + } + return fallbackName; + } + async setupWithData(data: SessionSaveData) { + const hasName = data?.runNameText; this.remove(this.loadingLabel, true); + if (hasName) { + const nameLabel = addTextObject(8, 5, data.runNameText, TextStyle.WINDOW); + this.add(nameLabel); + } else { + const fallbackName = this.decideFallback(data); + await globalScene.gameData.renameSession(this.slotId, fallbackName); + const nameLabel = addTextObject(8, 5, fallbackName, TextStyle.WINDOW); + this.add(nameLabel); + } const gameModeLabel = addTextObject( 8, - 5, + 19, `${GameMode.getModeName(data.gameMode) || i18next.t("gameMode:unkown")} - ${i18next.t("saveSlotSelectUiHandler:wave")} ${data.waveIndex}`, TextStyle.WINDOW, ); this.add(gameModeLabel); - const timestampLabel = addTextObject(8, 19, new Date(data.timestamp).toLocaleString(), TextStyle.WINDOW); + const timestampLabel = addTextObject(8, 33, new Date(data.timestamp).toLocaleString(), TextStyle.WINDOW); this.add(timestampLabel); - const playTimeLabel = addTextObject(8, 33, getPlayTimeString(data.playTime), TextStyle.WINDOW); + const playTimeLabel = addTextObject(8, 47, getPlayTimeString(data.playTime), TextStyle.WINDOW); this.add(playTimeLabel); - const pokemonIconsContainer = globalScene.add.container(144, 4); + const pokemonIconsContainer = globalScene.add.container(144, 16); data.party.forEach((p: PokemonData, i: number) => { const iconContainer = globalScene.add.container(26 * i, 0); iconContainer.setScale(0.75); @@ -440,7 +575,7 @@ class SessionSlot extends Phaser.GameObjects.Container { this.add(pokemonIconsContainer); - const modifierIconsContainer = globalScene.add.container(148, 30); + const modifierIconsContainer = globalScene.add.container(148, 38); modifierIconsContainer.setScale(0.5); let visibleModifierIndex = 0; for (const m of data.modifiers) { diff --git a/src/ui/ui.ts b/src/ui/ui.ts index ad496df6382..a5d6cd8f5b9 100644 --- a/src/ui/ui.ts +++ b/src/ui/ui.ts @@ -58,6 +58,7 @@ import PokedexScanUiHandler from "./pokedex-scan-ui-handler"; import PokedexPageUiHandler from "./pokedex-page-ui-handler"; import { NavigationManager } from "./settings/navigationMenu"; import { UiMode } from "#enums/ui-mode"; +import RenameRunFormUiHandler from "./rename-run-ui-handler"; const transitionModes = [ UiMode.SAVE_SLOT, @@ -96,6 +97,7 @@ const noTransitionModes = [ UiMode.SESSION_RELOAD, UiMode.UNAVAILABLE, UiMode.RENAME_POKEMON, + UiMode.RENAME_RUN, UiMode.TEST_DIALOGUE, UiMode.AUTO_COMPLETE, UiMode.ADMIN, @@ -165,6 +167,7 @@ export default class UI extends Phaser.GameObjects.Container { new UnavailableModalUiHandler(), new GameChallengesUiHandler(), new RenameFormUiHandler(), + new RenameRunFormUiHandler(), new RunHistoryUiHandler(), new RunInfoUiHandler(), new TestDialogueUiHandler(UiMode.TEST_DIALOGUE), diff --git a/test/system/rename_run.test.ts b/test/system/rename_run.test.ts new file mode 100644 index 00000000000..38906129e15 --- /dev/null +++ b/test/system/rename_run.test.ts @@ -0,0 +1,82 @@ +import * as bypassLoginModule from "#app/global-vars/bypass-login"; +import { pokerogueApi } from "#app/plugins/api/pokerogue-api"; +import type { SessionSaveData } from "#app/system/game-data"; +import { MoveId } from "#enums/move-id"; +import GameManager from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import * as account from "#app/account"; +import { AbilityId } from "#enums/ability-id"; + +describe("System - Rename Run", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset([MoveId.SPLASH]) + .battleStyle("single") + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + describe("renameSession", () => { + beforeEach(() => { + vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(false); + vi.spyOn(account, "updateUserInfo").mockImplementation(async () => [true, 1]); + }); + + it("should return false if slotId < 0", async () => { + const result = await game.scene.gameData.renameSession(-1, "Named Run"); + + expect(result).toEqual(false); + }); + + it("should return false if getSession returns null", async () => { + vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue(null as unknown as SessionSaveData); + + const result = await game.scene.gameData.renameSession(-1, "Named Run"); + + expect(result).toEqual(false); + }); + + it("should return true if bypassLogin is true", async () => { + vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(true); + vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData); + + const result = await game.scene.gameData.renameSession(0, "Named Run"); + + expect(result).toEqual(true); + }); + + it("should return false if api returns error", async () => { + vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData); + vi.spyOn(pokerogueApi.savedata.session, "update").mockResolvedValue("Unknown Error!"); + + const result = await game.scene.gameData.renameSession(0, "Named Run"); + + expect(result).toEqual(false); + }); + + it("should return true if api is succesfull", async () => { + vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData); + vi.spyOn(pokerogueApi.savedata.session, "update").mockResolvedValue(""); + + const result = await game.scene.gameData.renameSession(0, "Named Run"); + + expect(result).toEqual(true); + expect(account.updateUserInfo).toHaveBeenCalled(); + }); + }); +});