Compare commits

...

12 Commits

Author SHA1 Message Date
Bertie690
0172ecaff8
Merge 59f9c3e68d into 076ef81691 2025-08-13 23:59:00 -04:00
Sirz Benjie
076ef81691
[Bug] [UI/UX] [Beta] Fix icons not showing in save slot selection (#6262)
Fix icons not showing in save slot selection
2025-08-13 20:49:46 -05:00
fabske0
23271901cf
[Docs] Add locale key naming info to localization.md (#6260) 2025-08-14 01:12:00 +00:00
Inês Simões
1517e0512e
[UI/UX] [Feature] Save Management Tool (Rename/Delete Saves) (#5978)
* Implement Name Run Feat
Modified load session ui component, adding a submenu when selecting a 3
slot. This menu has 4 options:
Load Game -> Behaves as before, allowing the player to continue
progress from the last saved state in the slot.

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

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

Cancel -> Hides menu overlay.

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

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

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

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

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

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

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

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

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

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

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

* Fixed minor rebase alterations.

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

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

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

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

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

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

* Rebasing and conflict resolution

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

* Lint fix

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

* Minor compile fix

* Dependency resolved

* Format name respected

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

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

* Allow deleting malformed sessions

---------

Signed-off-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Signed-off-by: Matheus Alves matheus.r.noya.alves@tecnico.ulisboa.pt
Co-authored-by: Matheus Alves <matheus.r.noya.alves@tecnico.ulisboa.pt>
Co-authored-by: Wlowscha <54003515+Wlowscha@users.noreply.github.com>
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
2025-08-13 20:08:12 -05:00
Bertie690
59f9c3e68d
Update vitest.setup.ts
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
2025-08-05 00:00:34 -04:00
NightKev
8f9edf38e8
Merge branch 'beta' into mock-console-log 2025-08-04 20:59:02 -07:00
NightKev
0d0f8e3be3
Remove redundant comment 2025-08-04 20:58:23 -07:00
NightKev
938a4cf451
Fix typo 2025-08-04 20:58:11 -07:00
NightKev
989cfbc223 Fix Focus Punch test 2025-08-04 20:56:59 -07:00
Bertie690
63da1126a7 Added minor comment 2025-08-04 23:16:01 -04:00
Bertie690
b5b44ea0b1 Cleaned up handling of colors and such 2025-08-04 23:04:40 -04:00
Bertie690
56b5461c95 Added mock console and fixed up many many things 2025-08-04 19:03:00 -04:00
22 changed files with 885 additions and 220 deletions

View File

@ -90,9 +90,13 @@ If this feature requires new text, the text should be integrated into the code w
- For any feature pulled from the mainline Pokémon games (e.g. a Move or Ability implementation), it's best practice to include a source link for any added text.
[Poké Corpus](https://abcboy101.github.io/poke-corpus/) is a great resource for finding text from the mainline games; otherwise, a video/picture showing the text being displayed should suffice.
- You should also [notify the current Head of Translation](#notifying-translation) to ensure a fast response.
3. At this point, you may begin [testing locales integration in your main PR](#documenting-locales-changes).
4. The Translation Team will approve the locale PR (after corrections, if necessary), then merge it into `pokerogue-locales`.
5. The Dev Team will approve your main PR for your feature, then merge it into PokéRogue's beta environment.
3. Your locales should use the following format:
- File names should be in `kebab-case`. Example: `trainer-names.json`
- Key names should be in `camelCase`. Example: `aceTrainer`
- If you make use of i18next's inbuilt [context support](https://www.i18next.com/translation-function/context), you need to use `snake_case` for the context key. Example: `aceTrainer_male`
4. At this point, you may begin [testing locales integration in your main PR](#documenting-locales-changes).
5. The Translation Team will approve the locale PR (after corrections, if necessary), then merge it into `pokerogue-locales`.
6. The Dev Team will approve your main PR for your feature, then merge it into PokéRogue's beta environment.
[^2]: For those wondering, the reason for choosing English specifically is due to it being the master language set in Pontoon (the program used by the Translation Team to perform locale updates).
If a key is present in any language _except_ the master language, it won't appear anywhere else in the translation tool, rendering missing English keys quite a hassle.

View File

@ -38,6 +38,7 @@ export enum UiMode {
UNAVAILABLE,
CHALLENGE_SELECT,
RENAME_POKEMON,
RENAME_RUN,
RUN_HISTORY,
RUN_INFO,
TEST_DIALOGUE,

View File

@ -4,6 +4,7 @@ import { initEggMoves } from "#balance/egg-moves";
import { initPokemonPrevolutions, initPokemonStarters } from "#balance/pokemon-evolutions";
import { initSpecies } from "#balance/pokemon-species";
import { initChallenges } from "#data/challenge";
import { allMoves } from "#data/data-lists";
import { initTrainerTypeDialogue } from "#data/dialogue";
import { initPokemonForms } from "#data/pokemon-forms";
import { initModifierPools } from "#modifiers/init-modifier-pools";
@ -16,6 +17,9 @@ import { initStatsKeys } from "#ui/game-stats-ui-handler";
/** Initialize the game. */
export function initializeGame() {
if (allMoves.length > 0) {
return;
}
initModifierTypes();
initModifierPools();
initAchievements();

View File

@ -118,7 +118,10 @@ export class MovePhase extends BattlePhase {
public start(): void {
super.start();
console.log(MoveId[this.move.moveId], enumValueToKey(MoveUseMode, this.useMode));
console.log(
`%cMove: ${MoveId[this.move.moveId]}; Use Mode: ${enumValueToKey(MoveUseMode, this.useMode)}`,
"color:RebeccaPurple",
);
// Check if move is unusable (e.g. running out of PP due to a mid-turn Spite
// or the user no longer being on field), ending the phase early if not.

View File

@ -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<boolean> {
return new Promise(async resolve => {
if (slotId < 0) {
return resolve(false);
}
const sessionData: SessionSaveData | null = await this.getSession(slotId);
if (!sessionData) {
return resolve(false);
}
if (newName === "") {
return resolve(true);
}
sessionData.runNameText = newName;
const updatedDataStr = JSON.stringify(sessionData);
const encrypted = encrypt(updatedDataStr, bypassLogin);
const secretId = this.secretId;
const trainerId = this.trainerId;
if (bypassLogin) {
localStorage.setItem(
`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`,
encrypt(updatedDataStr, bypassLogin),
);
resolve(true);
return;
}
pokerogueApi.savedata.session
.update({ slot: slotId, trainerId, secretId, clientSessionId }, encrypted)
.then(error => {
if (error) {
console.error("Failed to update session name:", error);
resolve(false);
} else {
localStorage.setItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, encrypted);
updateUserInfo().then(success => {
if (success !== null && !success) {
return resolve(false);
}
});
resolve(true);
}
});
});
}
loadSession(slotId: number, sessionData?: SessionSaveData): Promise<boolean> {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: TODO: fix this
return new Promise(async (resolve, reject) => {

View File

@ -0,0 +1,54 @@
import i18next from "i18next";
import type { InputFieldConfig } from "./form-modal-ui-handler";
import { FormModalUiHandler } from "./form-modal-ui-handler";
import type { ModalConfig } from "./modal-ui-handler";
export class RenameRunFormUiHandler extends FormModalUiHandler {
getModalTitle(_config?: ModalConfig): string {
return i18next.t("menu:renamerun");
}
getWidth(_config?: ModalConfig): number {
return 160;
}
getMargin(_config?: ModalConfig): [number, number, number, number] {
return [0, 0, 48, 0];
}
getButtonLabels(_config?: ModalConfig): string[] {
return [i18next.t("menu:rename"), i18next.t("menu:cancel")];
}
getReadableErrorMessage(error: string): string {
const colonIndex = error?.indexOf(":");
if (colonIndex > 0) {
error = error.slice(0, colonIndex);
}
return super.getReadableErrorMessage(error);
}
override getInputFieldConfigs(): InputFieldConfig[] {
return [{ label: i18next.t("menu:runName") }];
}
show(args: any[]): boolean {
if (!super.show(args)) {
return false;
}
if (this.inputs?.length) {
this.inputs.forEach(input => {
input.text = "";
});
}
const config = args[0] as ModalConfig;
this.submitAction = _ => {
this.sanitizeInputs();
const sanitizedName = btoa(encodeURIComponent(this.inputs[0].text));
config.buttonActions[0](sanitizedName);
return true;
};
return true;
}
}

View File

@ -207,6 +207,10 @@ export 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);
}
/**

View File

@ -1,12 +1,14 @@
import { GameMode } from "#app/game-mode";
import { globalScene } from "#app/global-scene";
import { Button } from "#enums/buttons";
import { GameModes } from "#enums/game-modes";
import { TextStyle } from "#enums/text-style";
import { UiMode } from "#enums/ui-mode";
// biome-ignore lint/performance/noNamespaceImport: See `src/system/game-data.ts`
import * as Modifier from "#modifiers/modifier";
import type { SessionSaveData } from "#system/game-data";
import type { PokemonData } from "#system/pokemon-data";
import type { OptionSelectConfig } from "#ui/abstract-option-select-ui-handler";
import { MessageUiHandler } from "#ui/message-ui-handler";
import { RunDisplayMode } from "#ui/run-info-ui-handler";
import { addTextObject } from "#ui/text";
@ -15,7 +17,7 @@ import { fixedInt, formatLargeNumber, getPlayTimeString, isNullOrUndefined } fro
import i18next from "i18next";
const SESSION_SLOTS_COUNT = 5;
const SLOTS_ON_SCREEN = 3;
const SLOTS_ON_SCREEN = 2;
export enum SaveSlotUiMode {
LOAD,
@ -33,6 +35,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
private uiMode: SaveSlotUiMode;
private saveSlotSelectCallback: SaveSlotSelectCallback | null;
protected manageDataConfig: OptionSelectConfig;
private scrollCursor = 0;
@ -101,6 +104,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
processInput(button: Button): boolean {
const ui = this.getUi();
const manageDataOptions: any[] = [];
let success = false;
let error = false;
@ -109,14 +113,115 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
const originalCallback = this.saveSlotSelectCallback;
if (button === Button.ACTION) {
const cursor = this.cursor + this.scrollCursor;
if (this.uiMode === SaveSlotUiMode.LOAD && !this.sessionSlots[cursor].hasData) {
const sessionSlot = this.sessionSlots[cursor];
if (this.uiMode === SaveSlotUiMode.LOAD && !sessionSlot.hasData) {
error = true;
} else {
switch (this.uiMode) {
case SaveSlotUiMode.LOAD:
this.saveSlotSelectCallback = null;
originalCallback?.(cursor);
if (!sessionSlot.malformed) {
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;
@ -161,6 +266,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
}
} else {
this.saveSlotSelectCallback = null;
ui.showText("", 0);
originalCallback?.(-1);
success = true;
}
@ -267,33 +373,34 @@ export 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;
const session = this.sessionSlots[cursorPosition];
const hasData = session.hasData && !session.malformed;
// If the session slot lacks session data, it does not move from its default, central position.
// 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);
@ -311,7 +418,8 @@ export 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);
}
}
@ -340,7 +448,7 @@ export 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",
});
@ -374,12 +482,14 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
class SessionSlot extends Phaser.GameObjects.Container {
public slotId: number;
public hasData: boolean;
/** Indicates the save slot ran into an error while being loaded */
public malformed: boolean;
private slotWindow: Phaser.GameObjects.NineSlice;
private loadingLabel: Phaser.GameObjects.Text;
public saveData: SessionSaveData;
constructor(slotId: number) {
super(globalScene, 0, slotId * 56);
super(globalScene, 0, slotId * 76);
this.slotId = slotId;
@ -387,32 +497,89 @@ 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);
}
/**
* Generates a name for sessions that don't have a name yet.
* @param data - The {@linkcode SessionSaveData} being checked
* @returns The default name for the given data.
*/
decideFallback(data: SessionSaveData): string {
let fallbackName = `${GameMode.getModeName(data.gameMode)}`;
switch (data.gameMode) {
case GameModes.CLASSIC:
fallbackName += ` (${globalScene.gameData.gameStats.classicSessionsPlayed + 1})`;
break;
case GameModes.ENDLESS:
case GameModes.SPLICED_ENDLESS:
fallbackName += ` (${globalScene.gameData.gameStats.endlessSessionsPlayed + 1})`;
break;
case GameModes.DAILY: {
const runDay = new Date(data.timestamp).toLocaleDateString();
fallbackName += ` (${runDay})`;
break;
}
case GameModes.CHALLENGE: {
const activeChallenges = data.challenges.filter(c => c.value !== 0);
if (activeChallenges.length === 0) {
break;
}
fallbackName = "";
for (const challenge of activeChallenges.slice(0, 3)) {
if (fallbackName !== "") {
fallbackName += ", ";
}
fallbackName += challenge.toChallenge().getName();
}
if (activeChallenges.length > 3) {
fallbackName += ", ...";
} else if (fallbackName === "") {
// Something went wrong when retrieving the names of the active challenges,
// so fall back to just naming the run "Challenge"
fallbackName = `${GameMode.getModeName(data.gameMode)}`;
}
break;
}
}
return fallbackName;
}
async setupWithData(data: SessionSaveData) {
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:unknown")} - ${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);
@ -427,13 +594,9 @@ class SessionSlot extends Phaser.GameObjects.Container {
TextStyle.PARTY,
{ fontSize: "54px", color: "#f8f8f8" },
);
text.setShadow(0, 0, undefined);
text.setStroke("#424242", 14);
text.setOrigin(1, 0);
iconContainer.add(icon);
iconContainer.add(text);
text.setShadow(0, 0, undefined).setStroke("#424242", 14).setOrigin(1, 0);
iconContainer.add([icon, text]);
pokemonIconsContainer.add(iconContainer);
pokemon.destroy();
@ -441,7 +604,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) {
@ -464,22 +627,33 @@ class SessionSlot extends Phaser.GameObjects.Container {
load(): Promise<boolean> {
return new Promise<boolean>(resolve => {
globalScene.gameData.getSession(this.slotId).then(async sessionData => {
// Ignore the results if the view was exited
if (!this.active) {
return;
}
if (!sessionData) {
this.hasData = false;
this.loadingLabel.setText(i18next.t("saveSlotSelectUiHandler:empty"));
resolve(false);
return;
}
this.hasData = true;
this.saveData = sessionData;
await this.setupWithData(sessionData);
resolve(true);
});
globalScene.gameData
.getSession(this.slotId)
.then(async sessionData => {
// Ignore the results if the view was exited
if (!this.active) {
return;
}
this.hasData = !!sessionData;
if (!sessionData) {
this.loadingLabel.setText(i18next.t("saveSlotSelectUiHandler:empty"));
resolve(false);
return;
}
this.saveData = sessionData;
this.setupWithData(sessionData);
resolve(true);
})
.catch(e => {
if (!this.active) {
return;
}
console.warn(`Failed to load session slot #${this.slotId}:`, e);
this.loadingLabel.setText(i18next.t("menu:failedToLoadSession"));
this.hasData = true;
this.malformed = true;
resolve(true);
});
});
}
}

View File

@ -60,6 +60,7 @@ import { addWindow } from "#ui/ui-theme";
import { UnavailableModalUiHandler } from "#ui/unavailable-modal-ui-handler";
import { executeIf } from "#utils/common";
import i18next from "i18next";
import { RenameRunFormUiHandler } from "./rename-run-ui-handler";
const transitionModes = [
UiMode.SAVE_SLOT,
@ -98,6 +99,7 @@ const noTransitionModes = [
UiMode.SESSION_RELOAD,
UiMode.UNAVAILABLE,
UiMode.RENAME_POKEMON,
UiMode.RENAME_RUN,
UiMode.TEST_DIALOGUE,
UiMode.AUTO_COMPLETE,
UiMode.ADMIN,
@ -168,6 +170,7 @@ export class UI extends Phaser.GameObjects.Container {
new UnavailableModalUiHandler(),
new GameChallengesUiHandler(),
new RenameFormUiHandler(),
new RenameRunFormUiHandler(),
new RunHistoryUiHandler(),
new RunInfoUiHandler(),
new TestDialogueUiHandler(UiMode.TEST_DIALOGUE),

View File

@ -99,7 +99,7 @@ describe("Abilities - Cud Chew", () => {
expect(abDisplaySpy.mock.calls[1][2]).toBe(false);
// should display messgae
expect(game.textInterceptor.getLatestMessage()).toBe(
expect(game.textInterceptor.logs).toContain(
i18next.t("battle:hpIsFull", {
pokemonName: getPokemonNameWithAffix(farigiraf),
}),

View File

@ -9,7 +9,7 @@ import { TurnStartPhase } from "#phases/turn-start-phase";
import { GameManager } from "#test/test-utils/game-manager";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Focus Punch", () => {
let phaserGame: Phaser.Game;
@ -125,8 +125,8 @@ describe("Moves - Focus Punch", () => {
game.move.select(MoveId.FOCUS_PUNCH);
await game.phaseInterceptor.to("MoveEndPhase", true);
await game.phaseInterceptor.to("MessagePhase", false);
const consoleSpy = vi.spyOn(console, "log");
await game.phaseInterceptor.to("MoveEndPhase", true);
expect(consoleSpy).nthCalledWith(1, i18next.t("moveTriggers:lostFocus", { pokemonName: "Charizard" }));
expect(game.textInterceptor.logs).toContain(i18next.t("moveTriggers:lostFocus", { pokemonName: "Charizard" }));
expect(game.textInterceptor.logs).not.toContain(i18next.t("battle:attackFailed"));
});
});

View File

@ -0,0 +1,82 @@
import * as account from "#app/account";
import * as bypassLoginModule from "#app/global-vars/bypass-login";
import { pokerogueApi } from "#app/plugins/api/pokerogue-api";
import type { SessionSaveData } from "#app/system/game-data";
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("System - Rename Run", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([MoveId.SPLASH])
.battleStyle("single")
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
describe("renameSession", () => {
beforeEach(() => {
vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(false);
vi.spyOn(account, "updateUserInfo").mockImplementation(async () => [true, 1]);
});
it("should return false if slotId < 0", async () => {
const result = await game.scene.gameData.renameSession(-1, "Named Run");
expect(result).toEqual(false);
});
it("should return false if getSession returns null", async () => {
vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue(null as unknown as SessionSaveData);
const result = await game.scene.gameData.renameSession(-1, "Named Run");
expect(result).toEqual(false);
});
it("should return true if bypassLogin is true", async () => {
vi.spyOn(bypassLoginModule, "bypassLogin", "get").mockReturnValue(true);
vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData);
const result = await game.scene.gameData.renameSession(0, "Named Run");
expect(result).toEqual(true);
});
it("should return false if api returns error", async () => {
vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData);
vi.spyOn(pokerogueApi.savedata.session, "update").mockResolvedValue("Unknown Error!");
const result = await game.scene.gameData.renameSession(0, "Named Run");
expect(result).toEqual(false);
});
it("should return true if api is succesfull", async () => {
vi.spyOn(game.scene.gameData, "getSession").mockResolvedValue({} as SessionSaveData);
vi.spyOn(pokerogueApi.savedata.session, "update").mockResolvedValue("");
const result = await game.scene.gameData.renameSession(0, "Named Run");
expect(result).toEqual(true);
expect(account.updateUserInfo).toHaveBeenCalled();
});
});
});

View File

@ -6,17 +6,13 @@ import * as bypassLoginModule from "#app/global-vars/bypass-login";
import { MoveAnim } from "#data/battle-anims";
import { Pokemon } from "#field/pokemon";
import { version } from "#package.json";
import { blobToString } from "#test/test-utils/game-manager-utils";
import { MockClock } from "#test/test-utils/mocks/mock-clock";
import { MockFetch } from "#test/test-utils/mocks/mock-fetch";
import { MockGameObjectCreator } from "#test/test-utils/mocks/mock-game-object-creator";
import { MockLoader } from "#test/test-utils/mocks/mock-loader";
import { MockTextureManager } from "#test/test-utils/mocks/mock-texture-manager";
import { MockTimedEventManager } from "#test/test-utils/mocks/mock-timed-event-manager";
import { MockContainer } from "#test/test-utils/mocks/mocks-container/mock-container";
import { PokedexMonContainer } from "#ui/pokedex-mon-container";
import { sessionIdKey } from "#utils/common";
import { setCookie } from "#utils/cookies";
import fs from "node:fs";
import Phaser from "phaser";
import { vi } from "vitest";
@ -28,20 +24,6 @@ const GamepadPlugin = Phaser.Input.Gamepad.GamepadPlugin;
const EventEmitter = Phaser.Events.EventEmitter;
const UpdateList = Phaser.GameObjects.UpdateList;
window.URL.createObjectURL = (blob: Blob) => {
blobToString(blob).then((data: string) => {
localStorage.setItem("toExport", data);
});
return null;
};
navigator.getGamepads = () => [];
global.fetch = vi.fn(MockFetch);
setCookie(sessionIdKey, "fake_token");
window.matchMedia = () => ({
matches: false,
});
export class GameWrapper {
public game: Phaser.Game;
public scene: BattleScene;
@ -99,6 +81,7 @@ export class GameWrapper {
removeAll: () => null,
};
// TODO: Can't we just turn on `noAudio` in audio config?
this.scene.sound = {
play: () => null,
pause: () => null,

View File

@ -19,6 +19,7 @@ import type { ModifierOverride } from "#modifiers/modifier-type";
import type { Variant } from "#sprites/variant";
import { GameManagerHelper } from "#test/test-utils/helpers/game-manager-helper";
import { coerceArray, shiftCharCodes } from "#utils/common";
import chalk from "chalk";
import { vi } from "vitest";
/**
@ -665,6 +666,6 @@ export class OverridesHelper extends GameManagerHelper {
}
private log(...params: any[]) {
console.log("Overrides:", ...params);
console.log(chalk.hex("#b0b01eff")("Overrides:", ...params));
}
}

View File

@ -1,80 +0,0 @@
const originalLog = console.log;
const originalError = console.error;
const originalDebug = console.debug;
const originalWarn = console.warn;
const blacklist = ["Phaser", "variant icon does not exist", 'Texture "%s" not found'];
const whitelist = ["Phase"];
export class MockConsoleLog {
constructor(
private logDisabled = false,
private phaseText = false,
) {}
private logs: any[] = [];
private notified: any[] = [];
public log(...args) {
const argsStr = this.getStr(args);
this.logs.push(argsStr);
if (this.logDisabled && !this.phaseText) {
return;
}
if ((this.phaseText && !whitelist.some(b => argsStr.includes(b))) || blacklist.some(b => argsStr.includes(b))) {
return;
}
originalLog(args);
}
public error(...args) {
const argsStr = this.getStr(args);
this.logs.push(argsStr);
originalError(args); // Appelle le console.error originel
}
public debug(...args) {
const argsStr = this.getStr(args);
this.logs.push(argsStr);
if (this.logDisabled && !this.phaseText) {
return;
}
if (!whitelist.some(b => argsStr.includes(b)) || blacklist.some(b => argsStr.includes(b))) {
return;
}
originalDebug(args);
}
warn(...args) {
const argsStr = this.getStr(args);
this.logs.push(args);
if (this.logDisabled && !this.phaseText) {
return;
}
if (!whitelist.some(b => argsStr.includes(b)) || blacklist.some(b => argsStr.includes(b))) {
return;
}
originalWarn(args);
}
notify(msg) {
originalLog(msg);
this.notified.push(msg);
}
getLogs() {
return this.logs;
}
clearLogs() {
this.logs = [];
}
getStr(...args) {
return args
.map(arg => {
if (typeof arg === "object" && arg !== null) {
// Handle objects including arrays
return JSON.stringify(arg, (_key, value) => (typeof value === "bigint" ? value.toString() : value));
}
if (typeof arg === "bigint") {
// Handle BigInt values
return arg.toString();
}
return arg.toString();
})
.join(";");
}
}

View File

@ -0,0 +1,150 @@
{
"AliceBlue": "f0f8ff",
"AntiqueWhite": "faebd7",
"Aqua": "00ffff",
"Aquamarine": "7fffd4",
"Azure": "f0ffff",
"Beige": "f5f5dc",
"Bisque": "ffe4c4",
"Black": "000000",
"BlanchedAlmond": "ffebcd",
"Blue": "0000ff",
"BlueViolet": "8a2be2",
"Brown": "a52a2a",
"BurlyWood": "deb887",
"CadetBlue": "5f9ea0",
"Chartreuse": "7fff00",
"Chocolate": "d2691e",
"Coral": "ff7f50",
"CornflowerBlue": "6495ed",
"Cornsilk": "fff8dc",
"Crimson": "dc143c",
"Cyan": "00ffff",
"DarkBlue": "00008b",
"DarkCyan": "008b8b",
"DarkGoldenRod": "b8860b",
"DarkGray": "a9a9a9",
"DarkGrey": "a9a9a9",
"DarkGreen": "006400",
"DarkKhaki": "bdb76b",
"DarkMagenta": "8b008b",
"DarkOliveGreen": "556b2f",
"DarkOrange": "ff8c00",
"DarkOrchid": "9932cc",
"DarkRed": "8b0000",
"DarkSalmon": "e9967a",
"DarkSeaGreen": "8fbc8f",
"DarkSlateBlue": "483d8b",
"DarkSlateGray": "2f4f4f",
"DarkSlateGrey": "2f4f4f",
"DarkTurquoise": "00ced1",
"DarkViolet": "9400d3",
"DeepPink": "ff1493",
"DeepSkyBlue": "00bfff",
"DimGray": "696969",
"DimGrey": "696969",
"DodgerBlue": "1e90ff",
"FireBrick": "b22222",
"FloralWhite": "fffaf0",
"ForestGreen": "228b22",
"Fuchsia": "ff00ff",
"Gainsboro": "dcdcdc",
"GhostWhite": "f8f8ff",
"Gold": "ffd700",
"GoldenRod": "daa520",
"Gray": "808080",
"Grey": "808080",
"Green": "008000",
"GreenYellow": "adff2f",
"HoneyDew": "f0fff0",
"HotPink": "ff69b4",
"IndianRed": "cd5c5c",
"Indigo": "4b0082",
"Ivory": "fffff0",
"Khaki": "f0e68c",
"Lavender": "e6e6fa",
"LavenderBlush": "fff0f5",
"LawnGreen": "7cfc00",
"LemonChiffon": "fffacd",
"LightBlue": "add8e6",
"LightCoral": "f08080",
"LightCyan": "e0ffff",
"LightGoldenRodYellow": "fafad2",
"LightGray": "d3d3d3",
"LightGrey": "d3d3d3",
"LightGreen": "90ee90",
"LightPink": "ffb6c1",
"LightSalmon": "ffa07a",
"LightSeaGreen": "20b2aa",
"LightSkyBlue": "87cefa",
"LightSlateGray": "778899",
"LightSlateGrey": "778899",
"LightSteelBlue": "b0c4de",
"LightYellow": "ffffe0",
"Lime": "00ff00",
"LimeGreen": "32cd32",
"Linen": "faf0e6",
"Magenta": "ff00ff",
"Maroon": "800000",
"MediumAquaMarine": "66cdaa",
"MediumBlue": "0000cd",
"MediumOrchid": "ba55d3",
"MediumPurple": "9370db",
"MediumSeaGreen": "3cb371",
"MediumSlateBlue": "7b68ee",
"MediumSpringGreen": "00fa9a",
"MediumTurquoise": "48d1cc",
"MediumVioletRed": "c71585",
"MidnightBlue": "191970",
"MintCream": "f5fffa",
"MistyRose": "ffe4e1",
"Moccasin": "ffe4b5",
"NavajoWhite": "ffdead",
"Navy": "000080",
"OldLace": "fdf5e6",
"Olive": "808000",
"OliveDrab": "6b8e23",
"Orange": "ffa500",
"OrangeRed": "ff4500",
"Orchid": "da70d6",
"PaleGoldenRod": "eee8aa",
"PaleGreen": "98fb98",
"PaleTurquoise": "afeeee",
"PaleVioletRed": "db7093",
"PapayaWhip": "ffefd5",
"PeachPuff": "ffdab9",
"Peru": "cd853f",
"Pink": "ffc0cb",
"Plum": "dda0dd",
"PowderBlue": "b0e0e6",
"Purple": "800080",
"RebeccaPurple": "663399",
"Red": "ff0000",
"RosyBrown": "bc8f8f",
"RoyalBlue": "4169e1",
"SaddleBrown": "8b4513",
"Salmon": "fa8072",
"SandyBrown": "f4a460",
"SeaGreen": "2e8b57",
"SeaShell": "fff5ee",
"Sienna": "a0522d",
"Silver": "c0c0c0",
"SkyBlue": "87ceeb",
"SlateBlue": "6a5acd",
"SlateGray": "708090",
"SlateGrey": "708090",
"Snow": "fffafa",
"SpringGreen": "00ff7f",
"SteelBlue": "4682b4",
"Tan": "d2b48c",
"Teal": "008080",
"Thistle": "d8bfd8",
"Tomato": "ff6347",
"Turquoise": "40e0d0",
"Violet": "ee82ee",
"Wheat": "f5deb3",
"White": "ffffff",
"WhiteSmoke": "f5f5f5",
"Yellow": "ffff00",
"YellowGreen": "9acd32"
}

View File

@ -0,0 +1,61 @@
import { hslToHex } from "#utils/common";
import chalk, { type ChalkInstance, type ForegroundColorName, foregroundColorNames } from "chalk";
import colorMap from "./color-map.json";
export function inferColorFormat(data: [string, ...unknown[]]): ChalkInstance {
// Remove all CSS format strings and find the first one containing something vaguely resembling a color
data[0] = data[0].replaceAll("%c", "");
const args = data.slice(1).filter(t => typeof t === "string");
const color = findColorPrefix(args);
// If the color is within Chalk's native roster, use it directly.
if ((foregroundColorNames as string[]).includes(color)) {
return chalk[color as ForegroundColorName];
}
// Otherwise, coerce it to hex before feeding it in.
return getColor(color);
}
/**
* Find the first string with a "color:" CSS directive in an argument list.
* @param args - The arguments containing the color directive
* @returns The found color, or `"green"` if none were found
*/
function findColorPrefix(args: string[]): string {
for (const arg of args) {
const match = /color:\s*(.+?)(?:;|$)/g.exec(arg);
if (match === null) {
continue;
}
return match[1];
}
return "green";
}
/**
* Coerce an arbitrary CSS color string to a Chalk instance.
* @param color - The color to coerce
* @returns The Chalk color equivalent.
*/
function getColor(color: string): ChalkInstance {
if (/^#([a-z0-9]{3,4}|[a-z0-9]{6}|[a-z0-9]{8})$/i.test(color)) {
// already in hex
return chalk.hex(color);
}
const rgbMatch = /^rgba?\((\d{1,3})%?,\s*(\d{1,3})%?,?\s*(\d{1,3})%?,\s*/i.exec(color);
if (rgbMatch) {
const [red, green, blue] = rgbMatch;
return chalk.rgb(+red, +green, +blue);
}
const hslMatch = /^hslv?\((\d{1,3}),\s*(\d{1,3})%,\s*(\d{1,3})%\)$/i.exec(color);
if (hslMatch) {
const [hue, saturation, light] = hslMatch;
return chalk.hex(hslToHex(+hue, +saturation / 100, +light / 100));
}
return chalk.hex(colorMap[color] ?? "#00ff95ff");
}

View File

@ -0,0 +1,140 @@
import { inferColorFormat } from "#test/test-utils/mocks/mock-console/infer-color";
import { coerceArray } from "#utils/common";
import { Console } from "node:console";
import { stderr, stdout } from "node:process";
import util from "node:util";
import chalk, { type ChalkInstance } from "chalk";
// Tell chalk we support truecolor
chalk.level = 3;
// TODO: Review this
const blacklist = [
"variant icon does not exist", // Repetitive warnings about icons not found
'Texture "%s" not found', // Repetitive warnings about textures not found
"type: 'Pokemon',", // Large Pokemon objects
"gameVersion: ", // Large session-data and system-data objects
"Phaser v", // Phaser version text
"Seed:", // Stuff about wave seed (we should really stop logging this shit)
"Wave Seed:", // Stuff about wave seed (we should really stop logging this shit)
];
const whitelist = ["Start Phase"];
/**
* The {@linkcode MockConsole} is a wrapper around the global {@linkcode console} object.
* It automatically colors text and such.
*/
export class MockConsole extends Console {
/**
* A list of warnings that are queued to be displayed after all tests in the same file are finished.
*/
private static queuedWarnings: unknown[][] = [];
/**
* Queue a warning to be printed after all tests in the same file are finished.
*/
// TODO: Add some warnings
public static queuePostTestWarning(...data: unknown[]): void {
MockConsole.queuedWarnings.push(data);
}
constructor() {
super(stdout, stderr, false);
}
/**
* Print all post-test warnings that have been queued, and then clears the queue.
*/
public static printPostTestWarnings() {
for (const data of MockConsole.queuedWarnings) {
console.warn(...data);
}
MockConsole.queuedWarnings = [];
}
/**
* Check whether a given set of data is in the blacklist to be barred from logging.
* @param data - The data being logged
* @returns Whether `data` is blacklisted from console logging
*/
private checkBlacklist(data: unknown[]): boolean {
const dataStr = this.getStr(data);
return !whitelist.some(b => dataStr.includes(b)) && blacklist.some(b => dataStr.includes(b));
}
public trace(...data: unknown[]) {
if (this.checkBlacklist(data)) {
return;
}
// TODO: Figure out how to add color to the full trace text
super.trace(...this.format(chalk.hex("#b700ff"), data));
}
public debug(...data: unknown[]) {
if (this.checkBlacklist(data)) {
return;
}
super.debug(...this.format(chalk.hex("#874600ff"), data));
}
public log(...data: unknown[]): void {
if (this.checkBlacklist(data)) {
return;
}
let formatter: ChalkInstance | undefined;
if (data.some(d => typeof d === "string" && d.includes("color:"))) {
// Infer the color format from the arguments, then remove everything but the message.
formatter = inferColorFormat(data as [string, ...unknown[]]);
data.splice(1);
} else if (data[0] === "[UI]") {
// Cyan for UI debug messages
formatter = chalk.hex("#009dffff");
} else if (typeof data[0] === "string" && data[0].startsWith("=====")) {
// Orange logging for "New Turn"/etc messages
formatter = chalk.hex("#ffad00ff");
}
super.log(...this.format(formatter, data));
}
public warn(...data: unknown[]) {
if (this.checkBlacklist(data)) {
return;
}
super.warn(...this.format(chalk.yellow, data));
}
public error(...data: unknown[]) {
if (this.checkBlacklist(data)) {
return;
}
super.error(...this.format(chalk.redBright, data));
}
/**
* Returns a human-readable string representation of `data`.
*/
private getStr(data: unknown) {
return util.inspect(data, { sorted: true, breakLength: 120 });
}
/**
* Stringify the given data in a manner fit for logging.
* @param color - A Chalk instance or other transformation function used to transform the output,
* or `undefined` to not transform it at all.
* @param data - The data that the format should be applied to.
* @returns A stringified copy of `data` with {@linkcode color} applied to each individual argument.
* @todo Do we need to apply color to each entry or just run it through `util.format`?
*/
private format(color: ((s: unknown) => unknown) | undefined, data: unknown | unknown[]): unknown[] {
data = coerceArray(data);
color ??= a => a;
return (data as unknown[]).map(a => color(typeof a === "function" || typeof a === "object" ? this.getStr(a) : a));
}
}

View File

@ -1,4 +1,5 @@
import type { MockGameObject } from "#test/test-utils/mocks/mock-game-object";
import type { TextInterceptor } from "#test/test-utils/text-interceptor";
import { UI } from "#ui/ui";
export class MockText implements MockGameObject {
@ -82,13 +83,14 @@ export class MockText implements MockGameObject {
showText(
text: string,
delay?: number | null,
_delay?: number | null,
callback?: Function | null,
callbackDelay?: number | null,
prompt?: boolean | null,
promptDelay?: number | null,
_callbackDelay?: number | null,
_prompt?: boolean | null,
_promptDelay?: number | null,
) {
this.scene.messageWrapper.showText(text, delay, callback, callbackDelay, prompt, promptDelay);
// TODO: this is a very bad way to pass calls around
(this.scene.messageWrapper as TextInterceptor).showText(text);
if (callback) {
callback();
}
@ -96,13 +98,13 @@ export class MockText implements MockGameObject {
showDialogue(
keyOrText: string,
name: string | undefined,
delay: number | null = 0,
name: string,
_delay: number | null,
callback: Function,
callbackDelay?: number,
promptDelay?: number,
_callbackDelay?: number,
_promptDelay?: number,
) {
this.scene.messageWrapper.showDialogue(keyOrText, name, delay, callback, callbackDelay, promptDelay);
(this.scene.messageWrapper as TextInterceptor).showDialogue(keyOrText, name);
if (callback) {
callback();
}

View File

@ -1,9 +1,7 @@
import { SESSION_ID_COOKIE_NAME } from "#app/constants";
import { initializeGame } from "#app/init/init";
import { initI18n } from "#plugins/i18n";
import { blobToString } from "#test/test-utils/game-manager-utils";
import { manageListeners } from "#test/test-utils/listeners-manager";
import { MockConsoleLog } from "#test/test-utils/mocks/mock-console-log";
import { MockConsole } from "#test/test-utils/mocks/mock-console/mock-console";
import { mockContext } from "#test/test-utils/mocks/mock-context-canvas";
import { mockLocalStorage } from "#test/test-utils/mocks/mock-local-storage";
import { MockImage } from "#test/test-utils/mocks/mocks-container/mock-image";
@ -12,40 +10,35 @@ import Phaser from "phaser";
import BBCodeText from "phaser3-rex-plugins/plugins/bbcodetext";
import InputText from "phaser3-rex-plugins/plugins/inputtext";
let wasInitialized = false;
/**
* Run initialization code upon starting a new file, both per-suite and per-instance oncess.
* Run per-suite initialization code upon starting a new file.
*/
export function initTests(): void {
setupStubs();
if (!wasInitialized) {
initTestFile();
wasInitialized = true;
}
manageListeners();
}
/**
* Initialize various values at the beginning of each testing instance.
*/
function initTestFile(): void {
initI18n();
initializeGame();
}
/**
* Setup various stubs for testing.
* @todo Move this into a dedicated stub file instead of running it once per test instance
* @todo review these to see which are actually necessary
* @todo Investigate why this resets on new test suite start
*/
function setupStubs(): void {
Object.defineProperty(window, "localStorage", {
value: mockLocalStorage(),
});
Object.defineProperty(window, "console", {
value: new MockConsoleLog(false),
console.log(console instanceof MockConsole);
console.log(Phaser.GameObjects.Image instanceof MockImage);
Object.defineProperties(global, {
localStorage: {
value: mockLocalStorage(),
},
console: {
value: new MockConsole(),
},
matchMedia: {
value: () => ({
matches: false,
}),
},
});
Object.defineProperty(document, "fonts", {
writable: true,
@ -69,11 +62,6 @@ function setupStubs(): void {
navigator.getGamepads = () => [];
setCookie(SESSION_ID_COOKIE_NAME, "fake_token");
window.matchMedia = () =>
({
matches: false,
}) as any;
/**
* Sets this object's position relative to another object with a given offset
* @param guideObject - The {@linkcode Phaser.GameObjects.GameObject} to base the position off of

View File

@ -1,39 +1,49 @@
import type { BattleScene } from "#app/battle-scene";
import chalk from "chalk";
/**
* Class will intercept any text or dialogue message calls and log them for test purposes
* The {@linkcode TextInterceptor} is a wrapper class that intercepts and logs any messages
* that would be displayed on-screen.
*/
export class TextInterceptor {
private scene;
public logs: string[] = [];
constructor(scene) {
this.scene = scene;
/** A log containing messages having been displayed on screen, sorted in FIFO order. */
public readonly logs: string[] = [];
constructor(scene: BattleScene) {
// @ts-expect-error: Find another more sanitary way of doing this
scene.messageWrapper = this;
}
showText(
text: string,
_delay?: number,
_callback?: Function,
_callbackDelay?: number,
_prompt?: boolean,
_promptDelay?: number,
): void {
console.log(text);
/** Clear the current content of the TextInterceptor. */
public clearLogs(): void {
this.logs.splice(0);
}
showText(text: string): void {
// NB: We do not format the raw _logs_ themselves as tests will be actively checking it.
console.log(this.formatText(text));
this.logs.push(text);
}
showDialogue(
text: string,
name: string,
_delay?: number,
_callback?: Function,
_callbackDelay?: number,
_promptDelay?: number,
): void {
console.log(name, text);
showDialogue(text: string, name: string): void {
console.log(`${name}: \n${this.formatText(text)}`);
this.logs.push(name, text);
}
getLatestMessage(): string {
return this.logs.pop() ?? "";
/**
* Format text to be displayed to the test console, as follows:
* 1. Replaces new lines and new text boxes (marked by `$`) with indented new lines.
* 2. Removes all `@c{}`, `@d{}`, `@s{}`, and `@f{}` flags from the text.
* 3. Makes text blue
* @param text - The unformatted text
* @returns The formatted text
*/
private formatText(text: string): string {
return chalk.blue(
text
.replace(/\n/g, " ")
.replace(/\$/g, "\n ")
.replace(/@\w{.*?}/g, ""),
);
}
}

View File

@ -1,5 +1,8 @@
import "vitest-canvas-mock";
import { initializeGame } from "#app/init/init";
import { MockConsole } from "#test/test-utils/mocks/mock-console/mock-console";
import { initTests } from "#test/test-utils/test-file-initialization";
import chalk from "chalk";
import { afterAll, beforeAll, vi } from "vitest";
/** Set the timezone to UTC for tests. */
@ -14,6 +17,20 @@ vi.mock("#app/overrides", async importOriginal => {
} satisfies typeof import("#app/overrides");
});
//#region Mocking
/** Mock the override import to always return default values, ignoring any custom overrides. */
vi.mock("#app/overrides", async importOriginal => {
const { defaultOverrides } = await importOriginal<typeof import("#app/overrides")>();
return {
default: defaultOverrides,
// Export `defaultOverrides` as a *copy*.
// This ensures we can easily reset `overrides` back to its default values after modifying it.
defaultOverrides: { ...defaultOverrides },
} satisfies typeof import("#app/overrides");
});
/**
* This is a hacky way to mock the i18n backend requests (with the help of {@link https://mswjs.io/ | msw}).
* The reason to put it inside of a mock is to elevate it.
@ -30,8 +47,11 @@ vi.mock("i18next", async importOriginal => {
const filename = req.params[0];
try {
const json = await import(`../public/locales/en/${req.params[0]}`);
console.log("Loaded locale", filename);
const localeFiles = import.meta.glob("../public/locales/en/**/*.json", { eager: true });
const json = localeFiles[`../public/locales/en/${filename}`] || {};
if (import.meta.env.VITE_I18N_DEBUG === "1") {
console.log("Loaded locale", filename);
}
return HttpResponse.json(json);
} catch (err) {
console.log(`Failed to load locale ${filename}!`, err);
@ -48,13 +68,25 @@ vi.mock("i18next", async importOriginal => {
return await importOriginal();
});
/** Ensure that i18n is initialized on all calls. */
// TODO: Initialize i18n directly on import instead of initializing it during importing of trainer code
vi.mock("#app/plugins/i18n", async importOriginal => {
const importedStuff = await importOriginal<typeof import("#app/plugins/i18n")>();
const { initI18n } = importedStuff;
await initI18n();
return importedStuff;
});
global.testFailed = false;
initializeGame();
beforeAll(() => {
initTests();
});
afterAll(() => {
global.server.close();
console.log("Closing i18n MSW server!");
MockConsole.printPostTestWarnings();
console.log(chalk.hex("#dfb8d8")("Closing i18n MSW server!"));
});