diff --git a/src/scripts/main.js b/src/scripts/main.js index 5f72bbc2..bbc26271 100644 --- a/src/scripts/main.js +++ b/src/scripts/main.js @@ -25,6 +25,7 @@ import * as detailingFuncs from './modules/details/legacy'; new successHandlers.KingsGiveawayAjaxHandler(logger, submitConvertible), new successHandlers.SBFactoryAjaxHandler(logger, submitConvertible), new successHandlers.SEHAjaxHandler(logger, submitConvertible), + new successHandlers.SpookyShuffleAjaxHandler(logger, submitConvertible), ]; async function main() { diff --git a/src/scripts/modules/ajax-handlers/index.ts b/src/scripts/modules/ajax-handlers/index.ts index 592756a1..1c29abc5 100644 --- a/src/scripts/modules/ajax-handlers/index.ts +++ b/src/scripts/modules/ajax-handlers/index.ts @@ -2,3 +2,4 @@ export * from "./golem"; export * from "./kingsGiveaway"; export * from "./springEggHunt"; export * from "./sbFactory"; +export * from "./spookyShuffle"; diff --git a/src/scripts/modules/ajax-handlers/spookyShuffle.ts b/src/scripts/modules/ajax-handlers/spookyShuffle.ts new file mode 100644 index 00000000..3150c3ef --- /dev/null +++ b/src/scripts/modules/ajax-handlers/spookyShuffle.ts @@ -0,0 +1,151 @@ +import {AjaxSuccessHandler} from "./ajaxSuccessHandler"; +import {HgItem} from "@scripts/types/mhct"; +import {LoggerService} from "@scripts/util/logger"; +import {SpookyShuffleResponse, TitleRange} from "./spookyShuffle.types"; +import {CustomConvertibleIds} from "@scripts/util/constants"; +import {parseHgInt} from "@scripts/util/number"; +import * as hgFuncs from "@scripts/util/hgFunctions"; + +export class SpookyShuffleAjaxHandler extends AjaxSuccessHandler { + /** + * Create a new instance of SpookyShuffleAjaxHandler + * @param logger logger to log events + * @param submitConvertibleCallback delegate to submit convertibles to mhct + */ + constructor( + private logger: LoggerService, + private submitConvertibleCallback: (convertible: HgItem, items: HgItem[]) => void) { + super(); + this.logger = logger; + this.submitConvertibleCallback = submitConvertibleCallback; + } + + match(url: string): boolean { + if (!url.includes("mousehuntgame.com/managers/ajax/events/spooky_shuffle.php")) { + return false; + } + + return true; + } + + async execute(responseJSON: unknown): Promise { + if (!this.isSpookyShuffleResponse(responseJSON)) { + this.logger.warn("Unexpected spooky shuffle response.", responseJSON); + return; + } + + await this.recordBoard(responseJSON); + } + + /** + * Record complete Spooky Shuffle Board as convertible in MHCT + * @param responseJSON + */ + async recordBoard(responseJSON: SpookyShuffleResponse) { + const result = responseJSON.memory_game; + + if (!result.is_complete || !result.has_selected_testing_pair) { + this.logger.debug('Spooky Shuffle board is not complete yet.'); + return; + } + + + const convertibleContent: HgItem[] = []; + const processed = new Set(); + try { + // We don't know the item id's of the cards. Hit MH inventory API to get the item names and ids + // to convert the names to ids. + const itemMap = await this.fetchItemNameToIdMap(); + + result.cards.forEach(c => { + // All cards need to be revealed and also exist in name to id map. + // An error here typically means: + // 1. Didn't fetch the required item classification + // 2. The item isn't in the users inventory (unlikely unless the user used it between start and finish of the board). + if (!c.is_revealed || itemMap[c.name] == null) { + throw new Error(`Item '${c.name}' wasn't found in item map. Check its classification type`); + } + + // Two of each card means we don't want to double process the same item + if (processed.has(c.name)) { + return; + } + processed.add(c.name); + + convertibleContent.push({ + id: itemMap[c.name], + name: c.name, + quantity: c.quantity, + }); + }); + } catch (error) { + if (error instanceof Error) { + this.logger.warn(error.message); + return; + } + } + + const id = result.is_upgraded + ? SpookyShuffleAjaxHandler.UpgradedShuffleConvertibleIds[result.title_range] + : SpookyShuffleAjaxHandler.ShuffleConvertibleIds[result.title_range]; + const tierName = result.reward_tiers.find(r => r.type == result.title_range)?.name; + + // Example convertible names: + // Spooky Shuffle (Novice to Journyperson) + // Upgraded Spooky Shuffle (Grand Duke and up) + let convertibleName = `Spooky Shuffle (${tierName})`; + if (result.is_upgraded) { + convertibleName = `Upgraded ${convertibleName}`; + } + + const convertible: HgItem = { + id: id, + name: convertibleName, + quantity: 1, + }; + + this.logger.debug("Shuffle Board: ", {convertible, items: convertibleContent}); + this.submitConvertibleCallback(convertible, convertibleContent); + } + + async fetchItemNameToIdMap(): Promise> { + // async fetch of all items that are of the same classification of the rewards in spooky shuffle + const itemArray = await hgFuncs.getItemsByClass(['bait', 'stat', 'trinket', 'crafting_item'], true); + + const itemMap = itemArray.reduce((map: Record, item) => { + map[item.name] = parseHgInt(item.item_id); + return map; + }, {}); + + // Gold is never returned as an item so need to add manually + itemMap.Gold = 431; + + return itemMap; + } + + /** + * Validates that the given object is a JSON response from interacting with spooky shuffle board + * @param responseJSON + * @returns + */ + private isSpookyShuffleResponse(responseJSON: unknown): responseJSON is SpookyShuffleResponse { + const resultKey: keyof SpookyShuffleResponse = 'memory_game'; + return responseJSON != null && + typeof responseJSON === 'object' && + resultKey in responseJSON; + } + + static ShuffleConvertibleIds: Record = { + novice_journeyman: CustomConvertibleIds.HalloweenSpookyShuffleNovice, + master_lord: CustomConvertibleIds.HalloweenSpookyShuffleMaster, + baron_duke: CustomConvertibleIds.HalloweenSpookyShuffleBaron, + grand_duke_plus: CustomConvertibleIds.HalloweenSpookyShuffleGrandDuke, + }; + + static UpgradedShuffleConvertibleIds: Record = { + novice_journeyman: CustomConvertibleIds.HalloweenSpookyShuffleNoviceDusted, + master_lord: CustomConvertibleIds.HalloweenSpookyShuffleMasterDusted, + baron_duke: CustomConvertibleIds.HalloweenSpookyShuffleBaronDusted, + grand_duke_plus: CustomConvertibleIds.HalloweenSpookyShuffleGrandDukeDusted, + }; +} diff --git a/src/scripts/modules/ajax-handlers/spookyShuffle.types.ts b/src/scripts/modules/ajax-handlers/spookyShuffle.types.ts new file mode 100644 index 00000000..f89db0a0 --- /dev/null +++ b/src/scripts/modules/ajax-handlers/spookyShuffle.types.ts @@ -0,0 +1,47 @@ +import {HgResponse} from "@scripts/types/hg"; + +export interface SpookyShuffleResponse extends HgResponse { + memory_game: SpookyShuffleStatus; +} + +export interface SpookyShuffleStatus { + is_complete: true | null; + is_upgraded: true | null; + has_selected_testing_pair: boolean; // true on selection of the 2nd card + reward_tiers: RewardTier[]; + title_range: TitleRange; + cards: Card[]; +} + +interface RewardTier { + type: TitleRange; + name: string; // A readable/nice english string of the title range +} + +const TitleRanges = [ + 'novice_journeyman', + 'master_lord', + 'baron_duke', + 'grand_duke_plus', +] as const; + +export type TitleRange = typeof TitleRanges[number]; + +type Card = { + id: number; + quantity: number | null; + is_matched: true | null; + is_tested_pair: true | null; +} & (KnownCard | UnknownCard); + +interface KnownCard { + name: string; + is_revealed: true; + quantity: number; +} + +interface UnknownCard { + name: null; + is_revealed: false; + quantity: null; +} diff --git a/src/scripts/util/constants.ts b/src/scripts/util/constants.ts index bb0a38c6..86634208 100644 --- a/src/scripts/util/constants.ts +++ b/src/scripts/util/constants.ts @@ -1,7 +1,18 @@ export class CustomConvertibleIds { + // KGA public static readonly KingsMiniPrizePack: number = 130008; public static readonly KingsGiveawayVault: number = 130009; + + // Halloween + public static readonly HalloweenSpookyShuffleNovice = 130010; + public static readonly HalloweenSpookyShuffleNoviceDusted = 130011; + public static readonly HalloweenSpookyShuffleMaster = 130012; + public static readonly HalloweenSpookyShuffleMasterDusted = 130013; + public static readonly HalloweenSpookyShuffleBaron = 130014; + public static readonly HalloweenSpookyShuffleBaronDusted = 130015; + public static readonly HalloweenSpookyShuffleGrandDuke = 130016; + public static readonly HalloweenSpookyShuffleGrandDukeDusted = 130017; } /** diff --git a/src/scripts/util/hgFunctions.ts b/src/scripts/util/hgFunctions.ts new file mode 100644 index 00000000..4b33bb4f --- /dev/null +++ b/src/scripts/util/hgFunctions.ts @@ -0,0 +1,11 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ + +export async function getItemsByClass(itemClassifications: string[], forceRefresh = false): Promise<{ + name: string, + item_id: number +}[]> { + return await new Promise((resolve, reject) => { + // @ts-ignore + return hg.utils.UserInventory.getItemsByClass(itemClassifications, forceRefresh, resolve, reject); + }); +} diff --git a/tests/scripts/modules/ajax-handlers/spookyShuffle.spec.ts b/tests/scripts/modules/ajax-handlers/spookyShuffle.spec.ts new file mode 100644 index 00000000..301774ee --- /dev/null +++ b/tests/scripts/modules/ajax-handlers/spookyShuffle.spec.ts @@ -0,0 +1,229 @@ +import {SpookyShuffleAjaxHandler} from "@scripts/modules/ajax-handlers"; +import {SpookyShuffleStatus} from "@scripts/modules/ajax-handlers/spookyShuffle.types"; +import {HgResponse} from "@scripts/types/hg"; +import {HgItem} from "@scripts/types/mhct"; + +jest.mock('@scripts/util/logger'); +jest.mock('@scripts/util/hgFunctions'); + +import {ConsoleLogger} from '@scripts/util/logger'; +import {getItemsByClass} from "@scripts/util/hgFunctions"; +import {CustomConvertibleIds} from "@scripts/util/constants"; + +const logger = new ConsoleLogger(); +const submitConvertibleCallback = jest.fn() as jest.MockedFunction<(convertible: HgItem, items: HgItem[]) => void>; +const handler = new SpookyShuffleAjaxHandler(logger, submitConvertibleCallback); +const mockedGetItemsByClass = jest.mocked(getItemsByClass); + +const spookyShuffle_url = "mousehuntgame.com/managers/ajax/events/spooky_shuffle.php"; + +describe("SpookyShuffleAjaxHandler", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + }); + + describe("match", () => { + it('is false when url is ignored', () => { + expect(handler.match('mousehuntgame.com/managers/ajax/events/gwh.php')).toBe(false); + }); + + it('is true when url matches', () => { + expect(handler.match(spookyShuffle_url)).toBe(true); + }); + }); + + describe("execute", () => { + it('warns if response is unexpected', async () => { + // memory_game missing here, + const response = {user_id: 4} as unknown as HgResponse; + await handler.execute(response); + + expect(logger.warn).toBeCalledWith('Unexpected spooky shuffle response.', response); + expect(submitConvertibleCallback).toHaveBeenCalledTimes(0); + }); + + it('debug logs if response is an incomplete game', async () => { + const result: SpookyShuffleStatus = { + is_complete: null, + is_upgraded: null, + has_selected_testing_pair: false, + reward_tiers: [], + title_range: 'novice_journeyman', + cards: [], + }; + await handler.execute({memory_game: result} as unknown as HgResponse); + + expect(logger.debug).toBeCalledWith('Spooky Shuffle board is not complete yet.'); + expect(submitConvertibleCallback).toHaveBeenCalledTimes(0); + }); + + it('debug logs if response is an complete game but no testing pair', async () => { + const result: SpookyShuffleStatus = { + is_complete: true, + is_upgraded: null, + has_selected_testing_pair: false, + reward_tiers: [], + title_range: 'novice_journeyman', + cards: [], + }; + await handler.execute({memory_game: result} as unknown as HgResponse); + + expect(logger.debug).toBeCalledWith('Spooky Shuffle board is not complete yet.'); + expect(submitConvertibleCallback).toHaveBeenCalledTimes(0); + }); + + it('submits regular novice board', async () => { + mockedGetItemsByClass.mockReturnValue(Promise.resolve([ + { + name: 'Test Item', + item_id: 1234, + }, + ])); + + const result: SpookyShuffleStatus = { + is_complete: true, + is_upgraded: null, + has_selected_testing_pair: true, + reward_tiers: [ + { + name: 'Test Title Range', + type: 'novice_journeyman', + }, + ], + title_range: 'novice_journeyman', + cards: [ + { + id: 1, + name: 'Test Item', + is_matched: true, + is_revealed: true, + is_tested_pair: true, + quantity: 567, + }, + ], + }; + await handler.execute({memory_game: result} as unknown as HgResponse); + const expectedConvertible = { + id: CustomConvertibleIds.HalloweenSpookyShuffleNovice, + name: 'Spooky Shuffle (Test Title Range)', + quantity: 1, + }; + + const expectedItems = [ + { + id: 1234, + name: 'Test Item', + quantity: 567, + }, + ]; + + expect(submitConvertibleCallback).toBeCalledWith( + expectedConvertible, + expectedItems + ); + }); + + + it('submits upgraded duke board', async () => { + mockedGetItemsByClass.mockReturnValue(Promise.resolve([ + { + name: 'Test Item', + item_id: 1234, + }, + ])); + + const result: SpookyShuffleStatus = { + is_complete: true, + is_upgraded: true, + has_selected_testing_pair: true, + reward_tiers: [ + { + name: 'Grand Test Title and up', + type: 'grand_duke_plus', + }, + ], + title_range: 'grand_duke_plus', + cards: [ + { + id: 1, + name: 'Test Item', + is_matched: true, + is_revealed: true, + is_tested_pair: true, + quantity: 567, + }, + { + id: 1, + name: 'Gold', + is_matched: true, + is_revealed: true, + is_tested_pair: true, + quantity: 5000, + }, + ], + }; + await handler.execute({memory_game: result} as unknown as HgResponse); + const expectedConvertible = { + id: CustomConvertibleIds.HalloweenSpookyShuffleGrandDukeDusted, + name: 'Upgraded Spooky Shuffle (Grand Test Title and up)', + quantity: 1, + }; + + const expectedItems = [ + { + id: 1234, + name: 'Test Item', + quantity: 567, + }, + { + id: 431, + name: 'Gold', + quantity: 5000, + }, + ]; + + expect(submitConvertibleCallback).toBeCalledWith( + expectedConvertible, + expectedItems + ); + }); + + it('logs error when card name is not returned in getItemsByClass', async () => { + mockedGetItemsByClass.mockReturnValue(Promise.resolve([ + { + name: 'Fake Item', + item_id: 9876, + }, + ])); + + const result: SpookyShuffleStatus = { + is_complete: true, + is_upgraded: true, + has_selected_testing_pair: true, + reward_tiers: [ + { + name: 'Grand Test Title and up', + type: 'grand_duke_plus', + }, + ], + title_range: 'grand_duke_plus', + cards: [ + { + id: 1, + name: 'Test Item', + is_matched: true, + is_revealed: true, + is_tested_pair: true, + quantity: 567, + }, + ], + }; + await handler.execute({memory_game: result} as unknown as HgResponse); + + expect(logger.warn).toBeCalledWith(`Item 'Test Item' wasn't found in item map. Check its classification type`); + expect(submitConvertibleCallback).not.toBeCalled(); + }); + + }); +});