diff --git a/src/environment.dev.ts b/src/environment.dev.ts index a0677260..d715f4ec 100644 --- a/src/environment.dev.ts +++ b/src/environment.dev.ts @@ -6,4 +6,5 @@ export const environment = { loggingLevel: 'Error', }, reverse_watch_base_api_url: 'http://localhost:3434/api', + floatdb_gateway_url: 'https://gateway.floatdb.com', }; diff --git a/src/environment.staging.ts b/src/environment.staging.ts index adf33364..a35422eb 100644 --- a/src/environment.staging.ts +++ b/src/environment.staging.ts @@ -6,4 +6,5 @@ export const environment = { loggingLevel: 'Error', }, reverse_watch_base_api_url: 'https://reverse.watch/api', + floatdb_gateway_url: 'https://gateway.floatdb.com', }; diff --git a/src/environment.ts b/src/environment.ts index 02f83b4e..a81c07ec 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -6,4 +6,5 @@ export const environment = { loggingLevel: 'Warn', }, reverse_watch_base_api_url: 'https://reverse.watch/api', + floatdb_gateway_url: 'https://gateway.floatdb.com', }; diff --git a/src/lib/bridge/handlers/fetch_inspect_info.ts b/src/lib/bridge/handlers/fetch_inspect_info.ts index 214e7ff7..748298a8 100644 --- a/src/lib/bridge/handlers/fetch_inspect_info.ts +++ b/src/lib/bridge/handlers/fetch_inspect_info.ts @@ -2,6 +2,8 @@ import {decodeLink, CEconItemPreviewDataBlock} from '@csfloat/cs2-inspect-serial import {SimpleHandler} from './main'; import {RequestType} from './types'; import {gSchemaFetcher} from '../../services/schema_fetcher'; +import {gThresholdFetcher} from '../../services/threshold_fetcher'; +import {gRankBatcher} from '../../services/rank_batcher'; import type {ItemSchema} from '../../types/schema'; interface Sticker { @@ -42,7 +44,7 @@ export interface ItemInfo { export interface FetchInspectInfoRequest { link: string; - listPrice?: number; + asset_id: string; } export interface FetchInspectInfoResponse { @@ -50,87 +52,136 @@ export interface FetchInspectInfoResponse { error?: string; } +async function processInspectItem(req: FetchInspectInfoRequest, schema: ItemSchema.Response): Promise { + let decoded: CEconItemPreviewDataBlock; + try { + decoded = decodeLink(req.link); + } catch (error) { + throw new Error('Failed to decode inspect link'); + } + + const defindex = decoded.defindex ?? 0; + const paintindex = decoded.paintindex ?? 0; + const floatvalue = decoded.paintwear ?? 0; + + let min = 0; + let max = 1; + let weaponType: string | undefined; + let itemName: string | undefined; + let rarityName: string | undefined; + let stickers: Sticker[] = []; + let keychains: Keychain[] = []; + + try { + const weapon = schema.weapons[defindex]; + const paint = getSchemaPaint(weapon, paintindex); + + weaponType = weapon?.name; + rarityName = schema.rarities.find((rarity) => rarity.value === (paint?.rarity ?? decoded.rarity))?.name; + + if (paint) { + itemName = paint.name; + min = paint.min; + max = paint.max; + } + + stickers = decoded.stickers.map((sticker) => { + const schemaSticker = schema.stickers[sticker.stickerId?.toString() ?? '']; + return { + slot: sticker.slot ?? 0, + stickerId: sticker.stickerId ?? 0, + wear: sticker.wear, + name: schemaSticker?.market_hash_name, + }; + }); + + keychains = decoded.keychains.map((keychain) => { + const schemaKeychain = schema.keychains[keychain.stickerId?.toString() ?? '']; + return { + slot: keychain.slot ?? 0, + stickerId: keychain.stickerId ?? 0, + wear: keychain.wear, + pattern: keychain.pattern ?? 0, + name: schemaKeychain?.market_hash_name, + }; + }); + } catch (error) { + console.error('Failed to fetch schema item metadata:', error); + } + + const iteminfo: ItemInfo = { + stickers, + keychains, + itemid: decoded.itemid?.toString() ?? '', + defindex, + paintindex, + rarity: decoded.rarity ?? 0, + quality: decoded.quality ?? 0, + paintseed: decoded.paintseed ?? 0, + inventory: decoded.inventory ?? 0, + origin: decoded.origin ?? 0, + floatvalue, + min, + max, + weapon_type: weaponType, + item_name: itemName, + rarity_name: rarityName, + wear_name: getWearName(floatvalue), + }; + + try { + if (decoded.itemid != null && decoded.paintwear != null) { + const stattrak = decoded.killeaterscoretype !== undefined; + const souvenir = decoded.quality === 12; + + if (await gThresholdFetcher.qualifiesForRankCheck(defindex, paintindex, stattrak, souvenir, floatvalue)) { + const rankResult = await gRankBatcher.check(req.link, decoded.itemid.toString()); + if (rankResult) { + iteminfo.low_rank = rankResult.low_rank; + iteminfo.high_rank = rankResult.high_rank; + } + } + } + } catch (e) { + console.error('Failed to check rank:', e); + } + + return iteminfo; +} + export const FetchInspectInfo = new SimpleHandler( RequestType.FETCH_INSPECT_INFO, async (req) => { - let decoded: CEconItemPreviewDataBlock; - try { - decoded = decodeLink(req.link); - } catch (error) { - throw new Error('Failed to decode inspect link'); - } + const schema = await gSchemaFetcher.getSchema(); + return {iteminfo: await processInspectItem(req, schema)}; + } +); - const defindex = decoded.defindex ?? 0; - const paintindex = decoded.paintindex ?? 0; - const floatvalue = decoded.paintwear ?? 0; - - let min = 0; - let max = 1; - let weaponType: string | undefined; - let itemName: string | undefined; - let rarityName: string | undefined; - let stickers: Sticker[] = []; - let keychains: Keychain[] = []; - - try { - const schema = await gSchemaFetcher.getSchema(); - const weapon = schema.weapons[defindex]; - const paint = getSchemaPaint(weapon, paintindex); - - weaponType = weapon?.name; - rarityName = schema.rarities.find((rarity) => rarity.value === (paint?.rarity ?? decoded.rarity))?.name; - - if (paint) { - itemName = paint.name; - min = paint.min; - max = paint.max; - } +export interface FetchInspectInfoBatchRequest { + requests: FetchInspectInfoRequest[]; +} - stickers = decoded.stickers.map((sticker) => { - const schemaSticker = schema.stickers[sticker.stickerId?.toString() ?? '']; - return { - slot: sticker.slot ?? 0, - stickerId: sticker.stickerId ?? 0, - wear: sticker.wear, - name: schemaSticker?.market_hash_name, - }; - }); - - keychains = decoded.keychains.map((keychain) => { - const schemaKeychain = schema.keychains[keychain.stickerId?.toString() ?? '']; - return { - slot: keychain.slot ?? 0, - stickerId: keychain.stickerId ?? 0, - wear: keychain.wear, - pattern: keychain.pattern ?? 0, - name: schemaKeychain?.market_hash_name, - }; - }); - } catch (error) { - console.error('Failed to fetch schema item metadata:', error); - } +export interface FetchInspectInfoBatchResponse { + results: Record; +} - return { - iteminfo: { - stickers, - keychains, - itemid: decoded.itemid?.toString() ?? '', - defindex, - paintindex, - rarity: decoded.rarity ?? 0, - quality: decoded.quality ?? 0, - paintseed: decoded.paintseed ?? 0, - inventory: decoded.inventory ?? 0, - origin: decoded.origin ?? 0, - floatvalue, - min, - max, - weapon_type: weaponType, - item_name: itemName, - rarity_name: rarityName, - wear_name: getWearName(floatvalue), - }, - }; +export const FetchInspectInfoBatch = new SimpleHandler( + RequestType.FETCH_INSPECT_INFO_BATCH, + async (req) => { + const schema = await gSchemaFetcher.getSchema(); + + const results: Record = {}; + await Promise.all( + req.requests.map(async (itemReq) => { + try { + results[itemReq.asset_id] = {iteminfo: await processInspectItem(itemReq, schema)}; + } catch (e) { + results[itemReq.asset_id] = {iteminfo: {} as ItemInfo, error: (e as any).toString()}; + } + }) + ); + + return {results}; } ); diff --git a/src/lib/bridge/handlers/handlers.ts b/src/lib/bridge/handlers/handlers.ts index 1b679ac0..e868a0c6 100644 --- a/src/lib/bridge/handlers/handlers.ts +++ b/src/lib/bridge/handlers/handlers.ts @@ -1,6 +1,6 @@ import {ExecuteScriptOnPage} from './execute_script'; import {FetchStall} from './fetch_stall'; -import {FetchInspectInfo} from './fetch_inspect_info'; +import {FetchInspectInfo, FetchInspectInfoBatch} from './fetch_inspect_info'; import {ExecuteCssOnPage} from './execute_css'; import {StorageGet} from './storage_get'; import {StorageSet} from './storage_set'; @@ -78,4 +78,5 @@ export const HANDLERS_MAP: {[key in RequestType]: RequestHandler} = { [RequestType.FETCH_NOTARY_TOKEN]: FetchNotaryToken, [RequestType.FETCH_STEAM_POWERED_INVENTORY]: FetchSteamPoweredInventory, [RequestType.FETCH_REVERSAL_STATUS]: FetchReversalStatus, + [RequestType.FETCH_INSPECT_INFO_BATCH]: FetchInspectInfoBatch, }; diff --git a/src/lib/bridge/handlers/types.ts b/src/lib/bridge/handlers/types.ts index 6c9285b2..22e62bee 100644 --- a/src/lib/bridge/handlers/types.ts +++ b/src/lib/bridge/handlers/types.ts @@ -37,4 +37,5 @@ export enum RequestType { FETCH_NOTARY_TOKEN = 35, FETCH_STEAM_POWERED_INVENTORY = 36, FETCH_REVERSAL_STATUS = 37, + FETCH_INSPECT_INFO_BATCH = 38, } diff --git a/src/lib/components/common/item_holder_metadata.ts b/src/lib/components/common/item_holder_metadata.ts index 261ac510..d75f79d7 100644 --- a/src/lib/components/common/item_holder_metadata.ts +++ b/src/lib/components/common/item_holder_metadata.ts @@ -173,11 +173,12 @@ export abstract class ItemHolderMetadata extends FloatElement { if (!isSkin(this.asset) && !isCharm(this.asset)) return; // Commodities won't have inspect links - if (!this.inspectLink) return; + if (!this.inspectLink || !this.assetId) return; try { this.itemInfo = await gFloatFetcher.fetch({ link: this.inspectLink, + asset_id: this.assetId, }); } catch (e: any) { console.error(`Failed to fetch float for ${this.assetId}: ${e.toString()}`); diff --git a/src/lib/components/inventory/inventory_item_holder_metadata.ts b/src/lib/components/inventory/inventory_item_holder_metadata.ts index a9d672a4..1b57406d 100644 --- a/src/lib/components/inventory/inventory_item_holder_metadata.ts +++ b/src/lib/components/inventory/inventory_item_holder_metadata.ts @@ -5,10 +5,7 @@ import {ContextId} from '../../types/steam_constants'; import {isCAppwideInventory} from '../../utils/checkers'; @CustomElement() -@InjectAppend( - '#active_inventory_page div.inventory_page:not([style*="display: none"]) .itemHolder div.app730', - InjectionMode.CONTINUOUS -) +@InjectAppend('#active_inventory_page div.inventory_page .itemHolder div.app730', InjectionMode.CONTINUOUS) export class InventoryItemHolderMetadata extends ItemHolderMetadata { get asset(): rgAsset | undefined { if (!this.assetId) return; diff --git a/src/lib/components/inventory/selected_item_info.ts b/src/lib/components/inventory/selected_item_info.ts index 35ece6f8..a76bf0c6 100644 --- a/src/lib/components/inventory/selected_item_info.ts +++ b/src/lib/components/inventory/selected_item_info.ts @@ -262,6 +262,7 @@ export class SelectedItemInfo extends FloatElement { ) { try { this.itemInfo = await gFloatFetcher.fetch({ + asset_id: this.asset.assetid, link: this.inspectLink, }); } catch (e: any) { diff --git a/src/lib/components/market/item_row_wrapper.ts b/src/lib/components/market/item_row_wrapper.ts index bc94cb19..da8f6bef 100644 --- a/src/lib/components/market/item_row_wrapper.ts +++ b/src/lib/components/market/item_row_wrapper.ts @@ -83,9 +83,13 @@ export class ItemRowWrapper extends FloatElement { } async fetchFloat(): Promise { + if (!this.listingInfo?.asset.id) { + throw new Error('Missing asset ID'); + } + return gFloatFetcher.fetch({ link: this.inspectLink!, - listPrice: this.usdPrice, + asset_id: this.listingInfo?.asset.id, }); } @@ -111,14 +115,6 @@ export class ItemRowWrapper extends FloatElement { return (this.listingInfo.converted_price + this.listingInfo.converted_fee) / 100; } - get usdPrice(): number | undefined { - if (this.listingInfo?.currencyid === Currency.USD) { - return this.listingInfo.price + this.listingInfo.fee; - } else if (this.listingInfo?.converted_currencyid === Currency.USD) { - return this.listingInfo.converted_price! + this.listingInfo.converted_fee!; - } - } - @state() private itemInfo: ItemInfo | undefined; @state() diff --git a/src/lib/components/market/sort_listings.ts b/src/lib/components/market/sort_listings.ts index d063481f..dce0a0b2 100644 --- a/src/lib/components/market/sort_listings.ts +++ b/src/lib/components/market/sort_listings.ts @@ -122,9 +122,9 @@ export class SortListings extends FloatElement { // Catch error to prevent one failure from stopping the Promise.all() later try { const link = getMarketInspectLink(listingId); - const info = await gFloatFetcher.fetch({link: link!}); const listingInfo = g_rgListingInfo[listingId]; const asset = g_rgAssets[AppId.CSGO][ContextId.PRIMARY][listingInfo.asset.id]; + const info = await gFloatFetcher.fetch({link: link!, asset_id: listingInfo.asset.id}); return { failed: false, info, diff --git a/src/lib/page_scripts/inventory.ts b/src/lib/page_scripts/inventory.ts index 0809ad07..6cf37013 100644 --- a/src/lib/page_scripts/inventory.ts +++ b/src/lib/page_scripts/inventory.ts @@ -1,7 +1,69 @@ import {init} from './utils'; import '../components/inventory/inventory_item_holder_metadata'; import '../components/inventory/selected_item_info'; +import {Observe} from '../utils/observers'; +import {gFloatFetcher} from '../services/float_fetcher'; +import {rgAssetProperty} from '../types/steam'; +import {isCAppwideInventory} from '../utils/checkers'; +import {ContextId} from '../types/steam_constants'; init('src/lib/page_scripts/inventory.js', main); -async function main() {} +async function main() { + let initialRun = false; + + /** + * We want to limit the number of rank checks we make to the FloatDB gateway. If we wait until each item is + * rendered in an inventory (requires them to click on each page), then it causes a separate server-side fetch + * for each page. + * + * Instead, we eagerly fetch the ranks for all items that have been loaded. + */ + Observe( + () => { + const count = Object.keys(getAllCS2AssetProperties()).length; + if (count > 0 && !initialRun) { + initialRun = true; + return true; + } + return count; + }, + () => { + if (typeof g_ActiveInventory === 'undefined') return; + + for (const [asset_id, props] of Object.entries(getAllCS2AssetProperties())) { + // No float value, skip + if (!props.some((e) => e.propertyid === 2)) continue; + + const inspectLink = props.find((e) => e.propertyid === 6)?.string_value; + if (!inspectLink) continue; + + gFloatFetcher + .fetch({ + asset_id, + link: `steam://run/730//+csgo_econ_action_preview%20${inspectLink}`, + }) + .catch((e) => { + console.error(`Failed to eagerly fetch ${inspectLink}: ${e}`); + }); + } + } + ); +} + +function getAllCS2AssetProperties(): {[assetId: string]: rgAssetProperty[]} { + if (typeof g_ActiveInventory === 'undefined') return {}; + + const allProperties = Object.assign({}, g_ActiveInventory.m_rgAssetProperties || {}); + + if (isCAppwideInventory(g_ActiveInventory)) { + for (const contextId of [ContextId.PRIMARY, ContextId.PROTECTED]) { + const props = g_ActiveInventory.m_rgChildInventories[contextId]?.m_rgAssetProperties; + if (props && Object.keys(props).length > 0) { + Object.assign(allProperties, props); + } + } + } + + return allProperties; +} diff --git a/src/lib/services/float_fetcher.ts b/src/lib/services/float_fetcher.ts index 740b2e87..33ec1fea 100644 --- a/src/lib/services/float_fetcher.ts +++ b/src/lib/services/float_fetcher.ts @@ -1,26 +1,86 @@ -import {Job, SimpleCachedQueue} from '../utils/queue'; import {ClientSend} from '../bridge/client'; -import {FetchInspectInfo, FetchInspectInfoRequest, ItemInfo} from '../bridge/handlers/fetch_inspect_info'; +import {FetchInspectInfoBatch, FetchInspectInfoRequest, ItemInfo} from '../bridge/handlers/fetch_inspect_info'; +import {Cache} from '../utils/cache'; +import {DeferredPromise} from '../utils/deferred_promise'; -class InspectJob extends Job { - hashCode(): string { - return this.data.link; - } -} - -class FloatFetcher extends SimpleCachedQueue { - constructor() { - /** allow up to 10 simultaneous float fetch reqs */ - super(10); - } +/** + * All fetch() calls within a single microtask are collected and sent to the service worker as one bridge message. + * + * Cached requests and in-flight requests are de-duped. + * + * Why? Because sending hundreds of messages has transport overhead of hundreds of milliseconds and causes sub-optimal + * rank check batching. + */ +class FloatFetcher { + private cache = new Cache(); + private pending = new Map}>(); + private inFlight = new Map>(); + private flushScheduled = false; fetch(req: FetchInspectInfoRequest): Promise { - return this.add(new InspectJob(req)); + const key = req.asset_id; + + if (this.cache.has(key)) { + return Promise.resolve(this.cache.getOrThrow(key)); + } + + const inflight = this.inFlight.get(key); + if (inflight) { + return inflight.promise(); + } + + const existing = this.pending.get(key); + if (existing) { + return existing.deferred.promise(); + } + + const deferred = new DeferredPromise(); + this.pending.set(key, {req, deferred}); + + if (!this.flushScheduled) { + this.flushScheduled = true; + queueMicrotask(() => this.flush()); + } + + return deferred.promise(); } - protected async process(req: FetchInspectInfoRequest): Promise { - const resp = await ClientSend(FetchInspectInfo, req); - return resp.iteminfo; + private async flush() { + this.flushScheduled = false; + + const batch = new Map(this.pending); + this.pending.clear(); + + if (batch.size === 0) return; + + for (const [assetId, {deferred}] of batch) { + this.inFlight.set(assetId, deferred); + } + + try { + const requests = Array.from(batch.values()).map((e) => e.req); + const resp = await ClientSend(FetchInspectInfoBatch, {requests}); + + for (const [assetId, {deferred}] of batch) { + const result = resp.results[assetId]; + if (result?.error) { + deferred.reject(result.error); + } else if (result?.iteminfo) { + this.cache.set(assetId, result.iteminfo); + deferred.resolve(result.iteminfo); + } else { + deferred.reject('No result for ' + assetId); + } + } + } catch (e) { + for (const [, {deferred}] of batch) { + deferred.reject((e as any).toString()); + } + } finally { + for (const [assetId] of batch) { + this.inFlight.delete(assetId); + } + } } } diff --git a/src/lib/services/rank_batcher.ts b/src/lib/services/rank_batcher.ts new file mode 100644 index 00000000..f10fc9ad --- /dev/null +++ b/src/lib/services/rank_batcher.ts @@ -0,0 +1,82 @@ +import {environment} from '../../environment'; +import {DeferredPromise} from '../utils/deferred_promise'; + +const RANKS_CHECK_URL = `${environment.floatdb_gateway_url}/v1/ranks/check`; +const DEBOUNCE_MS = 100; + +export interface RankResult { + low_rank?: number; + high_rank?: number; +} + +interface PendingItem { + link: string; + deferred: DeferredPromise; +} + +/** + * Batches concurrent rank check requests into a single POST to /v1/ranks/check. + * Callers receive individual promises; a short debounce window collects + * qualifying items so that bulk page loads produce one network request instead of many. + */ +class RankBatcher { + private pending = new Map(); + private debounceTimer: ReturnType | null = null; + + check(link: string, assetId: string): Promise { + const existing = this.pending.get(assetId); + if (existing) { + return existing.deferred.promise(); + } + + const deferred = new DeferredPromise(); + this.pending.set(assetId, {link, deferred}); + + if (this.debounceTimer !== null) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = setTimeout(() => this.flush(), DEBOUNCE_MS); + + return deferred.promise(); + } + + private async flush(): Promise { + if (this.debounceTimer !== null) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + + const batch = new Map(this.pending); + this.pending.clear(); + + if (batch.size === 0) return; + + const links = Array.from(batch.values()).map((item) => item.link); + + try { + const resp = await fetch(RANKS_CHECK_URL, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({links}), + }); + + if (!resp.ok) { + throw new Error(`Ranks check failed with status ${resp.status}`); + } + + const data: {ranks: Record} = await resp.json(); + + for (const [assetId, item] of batch) { + const rank = data.ranks[assetId] ?? null; + item.deferred.resolve(rank); + } + } catch (error) { + console.error('Failed to batch check ranks:', error); + for (const [, item] of batch) { + item.deferred.resolve(null); + } + } + } +} + +export const gRankBatcher = new RankBatcher(); diff --git a/src/lib/services/threshold_fetcher.ts b/src/lib/services/threshold_fetcher.ts new file mode 100644 index 00000000..faab65bc --- /dev/null +++ b/src/lib/services/threshold_fetcher.ts @@ -0,0 +1,146 @@ +import {environment} from '../../environment'; +import {gStore} from '../storage/store'; +import {THRESHOLD_CACHE} from '../storage/keys'; +import type {ThresholdEntry} from '../types/floatdb'; + +const THRESHOLDS_URL = `${environment.floatdb_gateway_url}/v1/ranks/thresholds/bin`; +const CACHE_DURATION_MS = 60 * 60 * 1000; // 1 hour +const RETRY_AFTER_FAILURE_MS = 15 * 60 * 1000; // 15 minutes + +interface ThresholdCache { + lastUpdated: number; + thresholds: Record; +} + +function makeKey(defindex: number, paintindex: number, stattrak: boolean, souvenir: boolean): string { + return `${defindex}:${paintindex}:${stattrak ? 1 : 0}:${souvenir ? 1 : 0}`; +} + +/** + * Binary wire format (little-endian): + * Header (4 bytes): version(u8), entry_size(u8), count(u16) + * Per entry (13 bytes): defindex(u16), paintindex(u16), flags(u8), low(f32), high(f32) + */ +function parseThresholdsBinary(buffer: ArrayBuffer): Map { + const view = new DataView(buffer); + const entrySize = view.getUint8(1); + const count = view.getUint16(2, true); + const headerSize = 4; + + const map = new Map(); + + for (let i = 0; i < count; i++) { + const off = headerSize + i * entrySize; + const defindex = view.getUint16(off, true); + const paintindex = view.getUint16(off + 2, true); + const flags = view.getUint8(off + 4); + const stattrak = (flags & 1) !== 0; + const souvenir = (flags & 2) !== 0; + const low = view.getFloat32(off + 5, true); + const high = view.getFloat32(off + 9, true); + + map.set(makeKey(defindex, paintindex, stattrak, souvenir), {low, high}); + } + + return map; +} + +class ThresholdFetcher { + private thresholds: Map | null = null; + private lastFetched = 0; + private lastFailedAt = 0; + private pendingFetch?: Promise | null>; + + async qualifiesForRankCheck( + defindex: number, + paintindex: number, + stattrak: boolean, + souvenir: boolean, + floatvalue: number + ): Promise { + const thresholds = await this.getThresholds(); + if (!thresholds) return false; + + const entry = thresholds.get(makeKey(defindex, paintindex, stattrak, souvenir)); + if (!entry) return false; + return floatvalue <= entry.low || floatvalue >= entry.high; + } + + private async getThresholds(): Promise | null> { + if (this.thresholds && Date.now() - this.lastFetched < CACHE_DURATION_MS) { + return this.thresholds; + } + + if (this.pendingFetch) { + return this.pendingFetch; + } + + if (this.lastFailedAt && Date.now() - this.lastFailedAt < RETRY_AFTER_FAILURE_MS) { + return this.thresholds; + } + + const fetchPromise = this.resolveThresholds(); + this.pendingFetch = fetchPromise; + + try { + return await fetchPromise; + } finally { + if (this.pendingFetch === fetchPromise) { + this.pendingFetch = undefined; + } + } + } + + private async resolveThresholds(): Promise | null> { + const storedCache = await gStore.getWithStorage(chrome.storage.local, THRESHOLD_CACHE.key); + + if (storedCache?.thresholds && Date.now() - storedCache.lastUpdated < CACHE_DURATION_MS) { + this.thresholds = new Map(Object.entries(storedCache.thresholds)); + this.lastFetched = storedCache.lastUpdated; + return this.thresholds; + } + + return this.fetchThresholds(storedCache); + } + + private async fetchThresholds(storedCache: ThresholdCache | null): Promise | null> { + try { + const resp = await fetch(THRESHOLDS_URL); + if (!resp.ok) { + throw new Error(`Thresholds request failed with status ${resp.status}`); + } + + const buffer = await resp.arrayBuffer(); + const parsed = parseThresholdsBinary(buffer); + + this.thresholds = parsed; + this.lastFetched = Date.now(); + this.lastFailedAt = 0; + + const serializable = Object.fromEntries(parsed); + await gStore.setWithStorage(chrome.storage.local, THRESHOLD_CACHE.key, { + lastUpdated: this.lastFetched, + thresholds: serializable, + }); + + return parsed; + } catch (error) { + console.error('Error fetching thresholds:', error); + this.lastFailedAt = Date.now(); + + // Fallback to last successful threshold fetch + if (this.thresholds) return this.thresholds; + + // Fallback to last successful stored fetch + if (storedCache?.thresholds) { + this.thresholds = new Map(Object.entries(storedCache.thresholds)); + this.lastFetched = storedCache.lastUpdated; + return this.thresholds; + } + + return null; + } + } +} + +export const gThresholdFetcher = new ThresholdFetcher(); diff --git a/src/lib/storage/keys.ts b/src/lib/storage/keys.ts index 5035e44a..28caa248 100644 --- a/src/lib/storage/keys.ts +++ b/src/lib/storage/keys.ts @@ -3,6 +3,7 @@ */ import {SerializedFilter} from '../filter/types'; import type {ItemSchema} from '../types/schema'; +import type {ThresholdEntry} from '../types/floatdb'; export enum StorageKey { // Backwards compatible with <3.0.0 @@ -14,6 +15,7 @@ export enum StorageKey { LAST_TRADE_BLOCKED_PING_ATTEMPT = 'last_trade_blocked_ping_attempt', PRICE_CACHE = 'price_cache', // Stores market hash name -> price mapping (~0.86MB) SCHEMA_CACHE = 'schema_cache', // Stores the full CSFloat schema payload + THRESHOLD_CACHE = 'threshold_cache', // Stores FloatDB rank thresholds } export type DynamicStorageKey = string; @@ -59,3 +61,7 @@ export const SCHEMA_CACHE = newRow<{ lastUpdated: number; schema: ItemSchema.Response; }>(StorageKey.SCHEMA_CACHE); +export const THRESHOLD_CACHE = newRow<{ + lastUpdated: number; + thresholds: Record; +}>(StorageKey.THRESHOLD_CACHE); diff --git a/src/lib/types/floatdb.ts b/src/lib/types/floatdb.ts new file mode 100644 index 00000000..c29daf8d --- /dev/null +++ b/src/lib/types/floatdb.ts @@ -0,0 +1,4 @@ +export interface ThresholdEntry { + low: number; + high: number; +} diff --git a/src/lib/types/steam.d.ts b/src/lib/types/steam.d.ts index 1aa7defc..09457c2a 100644 --- a/src/lib/types/steam.d.ts +++ b/src/lib/types/steam.d.ts @@ -84,7 +84,7 @@ interface rgAssetPropertyBase { } // Only one of int_value, float_value, or string_value can be present -type rgAssetProperty = RequireOnlyOne; +export type rgAssetProperty = RequireOnlyOne; // g_rgAssets export interface rgAsset extends rgDescription {