From 64ab76032e1643207c6db7ef1f159044a5661092 Mon Sep 17 00:00:00 2001 From: Timotej Zatko Date: Mon, 29 Jun 2026 00:06:16 +0200 Subject: [PATCH 1/3] fix: make chest handler patch resilient to missing item_used anchor The chest tracker rewrites ItemUse.doIt by string-inserting a trackChest call before the item_used signal. The anchor lookup used a single-quote-only indexOf and had no guard for "not found": when indexOf returned -1, the substr math collapsed the body to `TW_Calc.trackChest(itemId,res);}` and eval threw a misleading `Unexpected token '}'`. Use a quote-agnostic regex (some minified builds emit the signal with double quotes) and fail loudly with a clear, trackable error instead of evaling broken code from a -1 position. Co-Authored-By: Claude Opus 4.8 --- src/components/chests/chests.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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; From 7fd6e5a48dccca38a9135a78769ae5cec310923b Mon Sep 17 00:00:00 2001 From: Timotej Zatko Date: Mon, 29 Jun 2026 00:06:37 +0200 Subject: [PATCH 2/3] fix: retry battle calc core load and reload it on demand BattleCalc.init fired a single fire-and-forget $.getScript with no error handling, so a transient/failed fetch left the calc unavailable for the whole session with only "refresh the game" as recovery. - Retry the load up to 3 times with a backoff and log each failure via Logger. - Guard against concurrent/duplicate loads with a loading flag. - When a view finds the calc unavailable, kick off a background reload so reopening the window recovers it without a full game refresh. Co-Authored-By: Claude Opus 4.8 --- .../battle-calc/battle-calc-view.ts | 4 +- src/components/battle-calc/battle-calc.ts | 37 ++++++++++++++++++- src/components/battle-calc/character-view.ts | 4 +- 3 files changed, 42 insertions(+), 3 deletions(-) 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.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 = $('
'); From e64a41dc70387cf98908167b2c95bec700c9f145 Mon Sep 17 00:00:00 2001 From: Timotej Zatko Date: Mon, 29 Jun 2026 00:14:15 +0200 Subject: [PATCH 3/3] test: cover chest anchor patching and battle calc load retry - Chests: patches with single- and double-quoted item_used anchors, and tracks an error (leaving the handler untouched) when the anchor is missing. - BattleCalc: loads on init, retries up to 3 times with logging on failure, is a no-op while loading or already available, and getInstance throws/returns per availability. Co-Authored-By: Claude Opus 4.8 --- .../battle-calc/battle-calc.spec.ts | 116 ++++++++++++++++++ src/components/chests/chests.spec.ts | 74 +++++++++++ 2 files changed, 190 insertions(+) create mode 100644 src/components/battle-calc/battle-calc.spec.ts create mode 100644 src/components/chests/chests.spec.ts 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/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(); + }); +});