diff --git a/src/components/battle-calc/battle-calc-view.ts b/src/components/battle-calc/battle-calc-view.ts index 8e50da1..3311b43 100644 --- a/src/components/battle-calc/battle-calc-view.ts +++ b/src/components/battle-calc/battle-calc-view.ts @@ -146,7 +146,9 @@ export class BattleCalcView implements TW2WindowView, Compone getMainDiv(): JQuery { if (!this.battleCalc.isAvailable()) { - return $('
Battle calc core script is not loaded, try refreshing the game.
'); + // kick off a (re)load in the background so reopening this window recovers it + this.battleCalc.load(); + return $('
Battle calc core script is not loaded yet, reopen this window in a few seconds.
'); } const { west } = this.window; diff --git a/src/components/battle-calc/battle-calc.spec.ts b/src/components/battle-calc/battle-calc.spec.ts new file mode 100644 index 0000000..35dc26c --- /dev/null +++ b/src/components/battle-calc/battle-calc.spec.ts @@ -0,0 +1,116 @@ +import { BattleCalc } from './battle-calc'; +import { BattleCalcWindow, BattleCoreCalc } from './battle-calc.types'; +import { Config } from '../config/config'; +import { ErrorTracker } from '../error-tracker/error-tracker'; +import { Logger } from '../logger/logger'; +import { Mock } from 'ts-mocks'; + +type Behavior = 'done' | 'fail' | 'pending'; + +/** + * Minimal stand-in for the jqXHR returned by `$.getScript`, with controllable + * resolution so we can drive the success / failure / in-flight branches. + */ +function fakeJqXHR(behavior: Behavior): unknown { + const xhr = { + done(cb: () => void) { + if (behavior === 'done') { + cb(); + } + return xhr; + }, + fail(cb: () => void) { + if (behavior === 'fail') { + cb(); + } + return xhr; + }, + }; + return xhr; +} + +describe('BattleCalc', () => { + const website = 'https://tw-calc.net'; + const coreScriptUrl = website + '/js/battle-calculator-core.js'; + + let battleCalc: BattleCalc; + let loggerMock: Mock; + let getScript: jasmine.Spy; + let setTimeoutSpy: jasmine.Spy; + let win: BattleCalcWindow & { BattleCalc?: BattleCoreCalc }; + + function build(behavior: Behavior): void { + getScript = jasmine.createSpy('getScript').and.callFake(() => fakeJqXHR(behavior)); + // run scheduled retries synchronously so the whole chain resolves in the test + setTimeoutSpy = jasmine.createSpy('setTimeout').and.callFake((cb: () => void) => { + cb(); + return 0; + }); + loggerMock = new Mock({ error: Mock.ANY_FUNC }); + + win = { + $: { getScript } as unknown as BattleCalcWindow['$'], + setTimeout: setTimeoutSpy as unknown as Window['setTimeout'], + } as BattleCalcWindow; + + battleCalc = new BattleCalc( + win, + new Mock({ website }).Object, + loggerMock.Object, + new Mock({ track: Mock.ANY_FUNC }).Object, + ); + } + + it('loads the battle calc core script on init', () => { + build('done'); + + battleCalc.init(); + + expect(getScript).toHaveBeenCalledTimes(1); + expect(getScript).toHaveBeenCalledWith(coreScriptUrl); + }); + + it('retries up to 3 times and logs each failure', () => { + build('fail'); + + battleCalc.init(); + + expect(getScript).toHaveBeenCalledTimes(3); + expect(loggerMock.Object.error).toHaveBeenCalledTimes(3); + // backoff is only scheduled between attempts, not after the final one + expect(setTimeoutSpy).toHaveBeenCalledTimes(2); + }); + + it('does not start a second load while one is already in progress', () => { + build('pending'); + + battleCalc.load(); + battleCalc.load(); + + expect(getScript).toHaveBeenCalledTimes(1); + }); + + it('does not load when the core is already available', () => { + build('done'); + win.BattleCalc = { coreCalc: () => ({}) } as unknown as BattleCoreCalc; + + battleCalc.load(); + + expect(getScript).not.toHaveBeenCalled(); + }); + + it('getInstance throws when the core is not loaded', () => { + build('pending'); + + expect(() => battleCalc.getInstance()).toThrowError('Battle calc core is not loaded yet!'); + }); + + it('getInstance returns the core once available', () => { + build('done'); + const core = { coreCalc: () => ({}) } as unknown as BattleCoreCalc; + win.BattleCalc = core; + + expect(battleCalc.isAvailable()).toBe(true); + expect(battleCalc.getInstance()).toBe(core); + }); +}); diff --git a/src/components/battle-calc/battle-calc.ts b/src/components/battle-calc/battle-calc.ts index fcc072b..9ccab6b 100644 --- a/src/components/battle-calc/battle-calc.ts +++ b/src/components/battle-calc/battle-calc.ts @@ -4,19 +4,39 @@ import { Component } from '../component.types'; import { Config } from '../config/config'; import { ErrorTracker } from '../error-tracker/error-tracker'; import { inject, singleton } from 'tsyringe'; +import { Logger } from '../logger/logger'; import { MarkRequired } from 'ts-essentials'; +const MAX_LOAD_ATTEMPTS = 3; + @singleton() export class BattleCalc implements Component { + private loading = false; + constructor( @inject('window') private window: BattleCalcWindow, private config: Config, + private readonly logger: Logger, public readonly errorTracker: ErrorTracker, ) {} @CatchErrors('BattleCalc.init') init(): void { - this.window.$.getScript(this.config.website + '/js/battle-calculator-core.js'); + this.load(); + } + + /** + * Loads the battle calc core script from the server. A single failed or cancelled + * request used to leave the calc unavailable for the whole session, so failures are + * retried with a backoff. Safe to call again (e.g. when a view finds it unavailable): + * it is a no-op while already loaded or loading. + */ + load(): void { + if (this.isAvailable() || this.loading) { + return; + } + this.loading = true; + this.attemptLoad(1); } isAvailable(): boolean { @@ -30,6 +50,21 @@ export class BattleCalc implements Component { } return window['BattleCalc']; } + + private attemptLoad(attempt: number): void { + this.window.$.getScript(this.config.website + '/js/battle-calculator-core.js') + .done(() => { + this.loading = false; + }) + .fail(() => { + this.logger.error(`failed to load battle calc core (attempt ${attempt}/${MAX_LOAD_ATTEMPTS})`); + if (attempt < MAX_LOAD_ATTEMPTS) { + this.window.setTimeout(() => this.attemptLoad(attempt + 1), attempt * 2000); + } else { + this.loading = false; + } + }); + } } function isAvailable(window: BattleCalcWindow): window is MarkRequired { diff --git a/src/components/battle-calc/character-view.ts b/src/components/battle-calc/character-view.ts index ad0e6be..4eaa261 100644 --- a/src/components/battle-calc/character-view.ts +++ b/src/components/battle-calc/character-view.ts @@ -68,7 +68,9 @@ export class CharacterView implements TW2WindowView { getMainDiv(): JQuery { if (!this.battleCalc.isAvailable()) { - return $('
Battle calc core script is not loaded, try refreshing the game.
'); + // kick off a (re)load in the background so reopening this window recovers it + this.battleCalc.load(); + return $('
Battle calc core script is not loaded yet, reopen this window in a few seconds.
'); } const html = $('
'); diff --git a/src/components/chests/chests.spec.ts b/src/components/chests/chests.spec.ts new file mode 100644 index 0000000..3582724 --- /dev/null +++ b/src/components/chests/chests.spec.ts @@ -0,0 +1,74 @@ +import { Chests } from './chests'; +import { Config } from '../config/config'; +import { ErrorTracker } from '../error-tracker/error-tracker'; +import { Logger } from '../logger/logger'; +import { Mock } from 'ts-mocks'; +import { TheWestWindow } from '../../@types/the-west'; + +/** + * Builds a stand-in for the game's `ItemUse.doIt` whose `toString()` returns a + * controlled source, so we can exercise the string-rewriting patch with both + * quote styles without depending on how the test bundler emits a real function. + */ +function doItWithSource(source: string): () => void { + const fn = function () {} as unknown as { toString: () => string }; + fn.toString = () => source; + return fn as unknown as () => void; +} + +describe('Chests', () => { + let chests: Chests; + let loggerMock: Mock; + let errorTrackerMock: Mock; + let itemUse: { doIt: () => void; doItOrigin?: () => void }; + + function setup(source: string): void { + itemUse = { doIt: doItWithSource(source) }; + // eval() inside init() resolves the bare `ItemUse` identifier from the + // global scope, so the injected window and the global must share the object. + (window as unknown as { ItemUse: typeof itemUse }).ItemUse = itemUse; + + loggerMock = new Mock({ log: Mock.ANY_FUNC, error: Mock.ANY_FUNC }); + errorTrackerMock = new Mock({ track: Mock.ANY_FUNC }); + + const windowStub = { ItemUse: itemUse } as unknown as TheWestWindow; + chests = new Chests(windowStub, loggerMock.Object, new Mock().Object, errorTrackerMock.Object); + } + + afterEach(() => { + delete (window as unknown as { ItemUse?: unknown }).ItemUse; + }); + + it('patches the chest handler when the item_used anchor uses single quotes', () => { + setup("function (itemId, itemCount) { EventHandler.signal('item_used', [itemId]); }"); + + chests.init(); + + const patched = itemUse.doIt.toString(); + expect(patched).toContain('TW_Calc.trackChest(itemId,res);'); + expect(patched.indexOf('TW_Calc.trackChest')).toBeLessThan(patched.indexOf('EventHandler.signal')); + expect(errorTrackerMock.Object.track).not.toHaveBeenCalled(); + }); + + it('patches the chest handler when the item_used anchor uses double quotes', () => { + setup('function (itemId, itemCount) { EventHandler.signal("item_used", [itemId]); }'); + + chests.init(); + + const patched = itemUse.doIt.toString(); + expect(patched).toContain('TW_Calc.trackChest(itemId,res);'); + expect(errorTrackerMock.Object.track).not.toHaveBeenCalled(); + }); + + it('tracks an error and leaves the handler untouched when the anchor is missing', () => { + setup("function (itemId, itemCount) { EventHandler.signal('something_else', [itemId]); }"); + const original = itemUse.doIt; + + chests.init(); + + // no broken eval, no rewrite — the original handler is preserved + expect(itemUse.doIt).toBe(original); + expect(itemUse.doIt.toString()).not.toContain('TW_Calc.trackChest'); + expect(errorTrackerMock.Object.track).toHaveBeenCalled(); + }); +}); diff --git a/src/components/chests/chests.ts b/src/components/chests/chests.ts index fb9d3e6..366561e 100644 --- a/src/components/chests/chests.ts +++ b/src/components/chests/chests.ts @@ -26,7 +26,13 @@ export class Chests implements Component { let newStr: string | undefined; try { const str = originalFn.toString(); - const pos = str.indexOf("EventHandler.signal('item_used'"); + // quote-agnostic anchor: some minified builds emit the signal with double quotes + const match = str.match(/EventHandler\.signal\(['"]item_used['"]/); + if (!match || match.index === undefined) { + // fail loudly & harmlessly instead of building broken code from a -1 position + throw new Error('item_used anchor not found in ItemUse.doIt'); + } + const pos = match.index; const body = str.substr(0, pos) + 'TW_Calc.trackChest(itemId,res);' + str.substr(pos); this.logger.log('patching the chest handler...', body); newStr = 'ItemUse.doIt = ' + body;