diff --git a/__tests__/schema.test.ts b/__tests__/schema.test.ts index 34fb440..bd1d516 100644 --- a/__tests__/schema.test.ts +++ b/__tests__/schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, mock } from "bun:test"; -import { ItemSchema, ItemSlot, getItemIndexByName, getTradableStatusByDefindex, getTradableStatusByName, prepareSchema } from '../src/content/schemaService' +import { ItemSchema, ItemSlot, getItemIndexByName, getTradableStatusByDefindex, getTradableStatusByName, linkBotkillerVariants, linkFestiveVariants, prepareSchema } from '../src/content/schemaService' // Mock the storage and log functions mock.module('../src/content/storage', () => ({ @@ -19,35 +19,72 @@ const mockSchema: ItemSchema = { slot: ItemSlot.Primary, tradable: false, hasAustraliumVariant: false, - canKillstreakify: true + canKillstreakify: true, + festiveVariant: null, + botkillerVariants: null }, '208': { name: 'Flame Thrower', slot: ItemSlot.Primary, tradable: true, hasAustraliumVariant: true, - canKillstreakify: true + canKillstreakify: true, + festiveVariant: 659, + botkillerVariants: [798, 807] + }, + '659': { + name: 'Festive Flame Thrower', + slot: ItemSlot.Primary, + tradable: true, + hasAustraliumVariant: false, + canKillstreakify: true, + festiveVariant: null, + botkillerVariants: null + }, + '798': { + name: 'Silver Botkiller Flame Thrower Mk.I', + slot: ItemSlot.Primary, + tradable: true, + hasAustraliumVariant: false, + canKillstreakify: true, + festiveVariant: null, + botkillerVariants: null + }, + '807': { + name: 'Gold Botkiller Flame Thrower Mk.I', + slot: ItemSlot.Primary, + tradable: true, + hasAustraliumVariant: false, + canKillstreakify: true, + festiveVariant: null, + botkillerVariants: null }, '5021': { name: 'Mann Co. Supply Crate Key', slot: ItemSlot.Tool, tradable: true, hasAustraliumVariant: false, - canKillstreakify: false + canKillstreakify: false, + festiveVariant: null, + botkillerVariants: null }, '15141': { name: 'Flame Thrower', slot: ItemSlot.Primary, tradable: true, hasAustraliumVariant: false, - canKillstreakify: true + canKillstreakify: true, + festiveVariant: null, + botkillerVariants: null }, '69420': { name: 'Non-Tradable Item', slot: ItemSlot.Misc, tradable: false, hasAustraliumVariant: false, - canKillstreakify: false + canKillstreakify: false, + festiveVariant: null, + botkillerVariants: null } } @@ -117,4 +154,187 @@ describe('Schema Service', () => { } }); }); + + describe('linkFestiveVariants', () => { + test('should link festive variants to their original counterparts', () => { + const testSchema = { + '21': { // Stock (should be ignored) + name: 'Flame Thrower', + slot: ItemSlot.Primary, + tradable: false, + hasAustraliumVariant: false, + canKillstreakify: false, + festiveVariant: null, + botkillerVariants: null + }, + '208': { // Original Flame Thrower + name: 'Flame Thrower', + slot: ItemSlot.Primary, + tradable: true, + hasAustraliumVariant: true, + canKillstreakify: true, + festiveVariant: null, + botkillerVariants: null + }, + '659': { // Festive Flame Thrower (should be detected) + name: 'Festive Flame Thrower', + slot: ItemSlot.Primary, + tradable: true, + hasAustraliumVariant: false, + canKillstreakify: true, + festiveVariant: null, + botkillerVariants: null + }, + '15141': { // Decorated (should be ignored) + name: 'Flame Thrower', + slot: ItemSlot.Primary, + tradable: true, + hasAustraliumVariant: false, + canKillstreakify: true, + festiveVariant: null, + botkillerVariants: null + } + }; + const mockResponseItems = [ + { item_class: 'tf_weapon_flamethrower', defindex: 21, item_name: 'Flame Thrower' }, // Incorrect; stock + { item_class: 'tf_weapon_flamethrower', defindex: 208, item_name: 'Flame Thrower' }, // Original + { item_class: 'tf_weapon_flamethrower', defindex: 659, item_name: 'Festive Flame Thrower' }, // Festive + { item_class: 'tf_weapon_flamethrower', defindex: 15141, item_name: 'Flame Thrower' }, // Incorrect; decorated + ]; + linkFestiveVariants(mockResponseItems, testSchema) + expect(testSchema['21'].festiveVariant).toBeNull() + expect(testSchema['208'].festiveVariant).toBe(659) + expect(testSchema['659'].festiveVariant).toBeNull(); + expect(testSchema['15141'].festiveVariant).toBeNull() + }) + + test('should not link if no festive variant exists', () => { + const testSchema = { + '163': { + name: 'Crit-a-Cola', + slot: ItemSlot.Secondary, + tradable: true, + hasAustraliumVariant: false, + canKillstreakify: false, + festiveVariant: null, + botkillerVariants: null + } + }; + + const mockResponseItems = [ + { item_class: 'tf_weapon_lunchbox_drink', defindex: 163, item_name: 'Crit-a-Cola' } + ]; + + linkFestiveVariants(mockResponseItems, testSchema); + + expect(testSchema['163'].festiveVariant).toBeNull(); + }); + }); + + describe('linkBotkillerVariants', () => { + test('should link botkiller variants to their original counterparts', () => { + const testSchema = { + '21': { // Original Rocket Launcher + name: 'Flame Thrower', + slot: ItemSlot.Primary, + tradable: false, + hasAustraliumVariant: false, + canKillstreakify: false, + festiveVariant: null, + botkillerVariants: null + }, + '208': { // Original Flame Thrower + name: 'Flame Thrower', + slot: ItemSlot.Primary, + tradable: true, + hasAustraliumVariant: true, + canKillstreakify: true, + festiveVariant: null, + botkillerVariants: null + }, + '798': { + name: 'Silver Botkiller Flame Thrower Mk. I', + slot: ItemSlot.Primary, + tradable: true, + hasAustraliumVariant: false, + canKillstreakify: true, + festiveVariant: null, + botkillerVariants: null + }, + '807': { + name: 'Gold Botkiller Flame Thrower Mk. I', + slot: ItemSlot.Primary, + tradable: true, + hasAustraliumVariant: false, + canKillstreakify: true, + festiveVariant: null, + botkillerVariants: null + }, + '15141': { // Decorated (should be ignored) + name: 'Flame Thrower', + slot: ItemSlot.Primary, + tradable: true, + hasAustraliumVariant: false, + canKillstreakify: true, + festiveVariant: null, + botkillerVariants: null + } + }; + const mockResponseItems = [ + { item_class: 'tf_weapon_flamethrower', defindex: 21, item_name: 'Flame Thrower', item_type_name: 'Flame Thrower' + }, // Incorrect; stock + { + item_class: 'tf_weapon_flamethrower', + defindex: 208, + item_name: 'Flame Thrower', + item_type_name: 'Flame Thrower' + }, + { + item_class: 'tf_weapon_flamethrower', + defindex: 798, + item_name: 'Silver Botkiller Flame Thrower Mk.I', + item_type_name: 'Flame Thrower' + }, + { + item_class: 'tf_weapon_flamethrower', + defindex: 807, + item_name: 'Gold Botkiller Flame Thrower Mk.I', + item_type_name: 'Flame Thrower' + }, + { item_class: 'tf_weapon_flamethrower', defindex: 15141, item_name: 'Flame Thrower', item_type_name: 'Flame Thrower' }, // Incorrect; decorated + ]; + linkBotkillerVariants(mockResponseItems, testSchema); + expect(testSchema['21'].botkillerVariants).toBeNull() + expect(testSchema['208'].botkillerVariants).toEqual([798, 807]); + expect(testSchema['798'].botkillerVariants).toBeNull(); + expect(testSchema['807'].botkillerVariants).toBeNull(); + expect(testSchema['15141'].botkillerVariants).toBeNull() + }); + + test('should not link if no botkiller variants exist', () => { + const testSchema = { + '163': { // Crit-a-Cola + name: 'Crit-a-Cola', + slot: ItemSlot.Secondary, + tradable: true, + hasAustraliumVariant: false, + festiveVariant: null, + canKillstreakify: false, + botkillerVariants: null, + } + }; + + const mockResponseItems = [ + { + item_class: 'tf_weapon_lunchbox_drink', + defindex: 163, + item_name: 'Crit-a-Cola', + item_type_name: 'Lunch Box', + } + ]; + + linkBotkillerVariants(mockResponseItems, testSchema); + expect(testSchema['163'].botkillerVariants).toBeNull(); + }); + }) }) \ No newline at end of file diff --git a/package.json b/package.json index 04fa4d1..c1c602a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tf2wikipricing", - "version": "0.4.1", + "version": "0.5.0", "description": "Adds item pricing to the Team Fortress 2 wiki", "devDependencies": { "@happy-dom/global-registrator": "^17.4.4", diff --git a/src/content/content.ts b/src/content/content.ts index c41d706..d0efdc7 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -187,9 +187,16 @@ async function inject() { var updateTime: Date | null = null; + enum PriceRowCategory { + None, + Festive, + Botkiller + } + interface PriceRow { - quality: number + order: number row: HTMLTableRowElement + category: PriceRowCategory } var priceRows: PriceRow[]= []; @@ -214,7 +221,7 @@ async function inject() { const qualityName = itemQualities[quality as unknown as keyof typeof itemQualities].toString() const priceRow = createPriceRow(qualityName, data, keyPrice, locale) - priceRows.push({quality: quality, row: priceRow}) + priceRows.push({order: quality == 6 ? -1 : quality, row: priceRow, category: PriceRowCategory.None}) }) // Check item schema for Australium variant of current defindex @@ -231,24 +238,128 @@ async function inject() { const priceRow = createPriceRow($T("Australium"), data, keyPrice, locale, "https://wiki.teamfortress.com/wiki/Australium_weapons") - priceRows.push({quality: 99, row: priceRow}) + priceRows.push({order: 99, row: priceRow, category: PriceRowCategory.None}) resolve() return })) } + var festiveHeadingRow: HTMLTableRowElement | null + // Check item schema for Festive variant of current defindex + if(itemSchema[itemIndex].festiveVariant != null) { + /// Create subheading + festiveHeadingRow = document.createElement("tr") + const festiveHeading = document.createElement("th") + festiveHeading.className = "infobox-subheader" + festiveHeading.colSpan = 2 + festiveHeading.innerText = $T("Festive") + festiveHeading.style.fontSize = '1em'; + festiveHeading.style.backgroundColor = '#F5C087'; + festiveHeadingRow.appendChild(festiveHeading); + + promises.push(new Promise(async (resolve) => { + logDebug(`Fetching price for Festive ${itemName}`) + var data: ItemPriceData | null + try { + data = await fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};6`, currentTime); + updateTime = new Date(data.update) + } catch { + log(`Festive ${itemName} is unpriced or unavailable, skipping...`) + } + + const priceRow = createPriceRow($T("Unique"), data, keyPrice, locale) + + priceRows.push({order: -1, row: priceRow, category: PriceRowCategory.Festive}) + resolve() + return + })) + promises.push(new Promise(async (resolve) => { + logDebug(`Fetching price for Strange Festive ${itemName}`) + var data: ItemPriceData | null + try { + data = await fetchPrice(token, `${itemSchema[itemIndex].festiveVariant};11`, currentTime); + updateTime = new Date(data.update) + } catch { + log(`Strange Festive ${itemName} is unpriced or unavailable, skipping...`) + } + + const priceRow = createPriceRow($T("Strange"), data, keyPrice, locale) + + priceRows.push({order: 11, row: priceRow, category: PriceRowCategory.Festive}) + resolve() + return + })) + } + + // Silver Mk.I, Gold Mk.II, Rust, Blood, Carbonado, Diamond, Silver Mk.II, Gold Mk.II + const botkillerOrder = [ + "Silver", + "Gold", + "Rust", + "Blood", + "Carbonado", + "Diamond", + "Silver Mk.II", + "Gold Mk.II", + ] + var botKillerHeadingRow: HTMLTableRowElement | null + if(itemSchema[itemIndex].botkillerVariants != null && itemSchema[itemIndex].botkillerVariants.length > 0) { + /// Create subheading + botKillerHeadingRow = document.createElement("tr") + const festiveHeading = document.createElement("th") + festiveHeading.className = "infobox-subheader" + festiveHeading.colSpan = 2 + festiveHeading.innerText = $T("Botkiller") + festiveHeading.style.fontSize = '1em'; + festiveHeading.style.backgroundColor = '#F5C087'; + botKillerHeadingRow.appendChild(festiveHeading); + + itemSchema[itemIndex].botkillerVariants.map((variantIndex) => { + const itemName = itemSchema[variantIndex].name + // FIXME: variantName should match wiki display name + const variantName = itemName.includes('Mk.II') ? itemName.split(' ')[0] + ' Mk.II' : itemName.split(' ')[0] + promises.push(new Promise(async (resolve) => { + logDebug(`Fetching price for ${itemName}`) + var data: ItemPriceData | null + try { + data = await fetchPrice(token, `${variantIndex};11`, currentTime); + updateTime = new Date(data.update) + } catch { + log(`${itemName} is unpriced or unavailable, skipping...`) + } + + const priceRow = createPriceRow($T(variantName), data, keyPrice, locale, "https://wiki.teamfortress.com/wiki/Botkiller_weapons") + + // FIXME: order should be by release + // Silver Mk.I, Gold Mk.II, Rust, Blood, Carbonado, Diamond, Silver Mk.II, Gold Mk.II + priceRows.push({order: botkillerOrder.indexOf(variantName), row: priceRow, category: PriceRowCategory.Botkiller}) + resolve() + return + })) + }) + } + + if(botKillerHeadingRow) priceInfoboxHeadingRow.insertAdjacentElement('afterend', botKillerHeadingRow); + if(festiveHeadingRow) priceInfoboxHeadingRow.insertAdjacentElement('afterend', festiveHeadingRow); + Promise.all(promises).then(() => { priceRows.sort((a, b) => { - // Sort 6 first always, then numerically - if (a.quality === 6) { - return -1; - } else if (b.quality === 6) { - return 1; - } else { - return a.quality == b.quality ? a.quality < b.quality ? -1 : 1 : 0; + if (a.category != b.category) { + return a.category - b.category; } + return a.order - b.order; }).reverse().forEach((element) => { - priceInfoboxHeadingRow.insertAdjacentElement('afterend', element.row); + switch(element.category) { + case PriceRowCategory.None: + priceInfoboxHeadingRow.insertAdjacentElement('afterend', element.row); + break; + case PriceRowCategory.Festive: + festiveHeadingRow.insertAdjacentElement('afterend', element.row); + break; + case PriceRowCategory.Botkiller: + botKillerHeadingRow.insertAdjacentElement('afterend', element.row); + break; + } }) if(!updateTime || !(updateTime instanceof Date) || isNaN(+updateTime)) updateTime = new Date() diff --git a/src/content/pricing/pricestf.ts b/src/content/pricing/pricestf.ts index bf56fa0..1d6989d 100644 --- a/src/content/pricing/pricestf.ts +++ b/src/content/pricing/pricestf.ts @@ -1,5 +1,6 @@ declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise import '../GM_fetch' +import { logDebug } from '../utils/log' async function getPricesToken(): Promise { return new Promise((resolve, reject) => { @@ -37,7 +38,7 @@ class PricesResponse { * @returns {Promise} Object containing 'keys' and 'metal' prices * @throws When authentication fails or API returns non-200 status code */ -async function priceUsingPricesTF(token: string, sku: string): Promise { +async function priceUsingPricesTF(token: string, sku: string, retries: number = 3): Promise { // prices.tf // https://api2.prices.tf/prices/${sku} // Authorization: Bearer ${token} @@ -65,11 +66,37 @@ async function priceUsingPricesTF(token: string, sku: string): Promise 0) { + logDebug(`Cloudflare rate limit exceeded, trying again after 2 seconds, ${retries} retries left`) + await new Promise(resolve => setTimeout(resolve, 2000)); + try { + const retryResult = await priceUsingPricesTF(token, sku, retries - 1); + resolve(retryResult); + } catch (err) { + reject(err); + } + } else { + reject("Cloudflare rate limit exceeded, stopping") + } + break; + default: + // Something went wrong + logDebug(`Received ${response.status} error while pricing ${sku}`) + logDebug(`${JSON.stringify(response.headers)}`) + reject("Unknown error") + break; } }) } diff --git a/src/content/schemaService.ts b/src/content/schemaService.ts index e86a2f0..0a95ca8 100644 --- a/src/content/schemaService.ts +++ b/src/content/schemaService.ts @@ -38,6 +38,8 @@ export class ItemSchema { slot: ItemSlot, tradable: Boolean, hasAustraliumVariant: Boolean, + festiveVariant: number | null + botkillerVariants: Array | null canKillstreakify: Boolean }; } @@ -70,6 +72,45 @@ export function getTradableStatusByName(schema: ItemSchema, name: string, exclud return true } +export function linkFestiveVariants(items: Array<{item_class: string, defindex: number, item_name: string}>, schema: ItemSchema): void { + if(!schema) return + items.filter(item => + item.item_class != null && + item.item_class.startsWith('tf_weapon') && + item.item_name.startsWith('Festive ') + ).forEach(festive => { + const originalName = festive.item_name.slice(8); // "Festive " is 8 chars + const original = items.find(item => item.item_name === originalName && item.defindex > 30 && (item.defindex < 15000 || item.defindex >= 16000)); + + if (original) { + if(schema[original.defindex]) { + schema[original.defindex].festiveVariant = festive.defindex; + } + } + }); +} + +export function linkBotkillerVariants(items: Array<{item_class: string, defindex: number, item_name: string, item_type_name: string}>, schema: ItemSchema): void { + if(!schema) return + items.filter(item => + item.item_class != null && + item.item_class.startsWith('tf_weapon') && + item.item_name.includes('Botkiller') + ).forEach(botkiller => { + const originalName = botkiller.item_type_name + const original = items.find(item => item.item_name === originalName && item.defindex > 30 && (item.defindex < 15000 || item.defindex >= 16000)); + if (original) { + if(schema[original.defindex]) { + if(schema[original.defindex].botkillerVariants == null) { + // init array + schema[original.defindex].botkillerVariants = new Array() + } + schema[original.defindex].botkillerVariants.push(botkiller.defindex) + } + } + }); +} + export async function wipeSchema(): Promise { await setStorageValue(storage_version, __VERSION__) await setStorageValue(storage_schema, null) @@ -149,6 +190,9 @@ export async function prepareSchema(): Promise { } }); + linkFestiveVariants(responseItems, cacheItems) + linkBotkillerVariants(responseItems, cacheItems) + await setStorageValue(storage_schema, (cacheItems)); itemSchema = cacheItems await setStorageValue(storage_version, __VERSION__); diff --git a/src/strings/en.js b/src/strings/en.js index d281096..30c8860 100644 --- a/src/strings/en.js +++ b/src/strings/en.js @@ -22,5 +22,17 @@ module.exports = { "Strange": "Strange", "Collector's": "Collector's", "Haunted": "Haunted", - "Australium": "Australium" + "Australium": "Australium", + "Festive": "Festive", + + // Botkiller names, all sourced from TF2 wiki + "Botkiller": "Botkiller", + "Silver": "Silver", + "Gold": "Gold", + "Rust": "Rust", + "Blood": "Blood", + "Carbonado": "Carbonado", + "Diamond": "Diamond", + "Silver Mk.II": "Silver Mk.II", + "Gold Mk.II": "Gold Mk.II", } \ No newline at end of file diff --git a/src/strings/es.js b/src/strings/es.js index 2f464ba..62f5a54 100644 --- a/src/strings/es.js +++ b/src/strings/es.js @@ -22,5 +22,17 @@ module.exports = { "Strange": "de Calidad Rara", "Collector's": "de Coleccionista", "Haunted": "de Calidad Embrujada", - "Australium": "de Australium" + "Australium": "de Australium", + "Festive": "Festiva", + + // Botkiller names, all sourced from TF2 wiki + "Botkiller": "Matabots", + "Silver": "Plata", + "Gold": "Oro", + "Rust": "Oxidado", + "Blood": "Sangriento", + "Carbonado": "Carbonado", + "Diamond": "Diamante", + "Silver Mk.II": "Plata Mk.II", + "Gold Mk.II": "Oro Mk.II", } \ No newline at end of file