diff --git a/__tests__/priceService.test.ts b/__tests__/priceService.test.ts index b9c421d..468641f 100644 --- a/__tests__/priceService.test.ts +++ b/__tests__/priceService.test.ts @@ -49,7 +49,7 @@ describe('Price Service', () => { test('fetchPrice returns cached data if available and not expired', async () => { (getStorageValue as jest.Mock).mockResolvedValue(mockCachedData) - const result = await fetchPrice(mockToken, mockDefIndex, mockQuality) + const result = await fetchPrice(mockToken, mockDefIndex + ";" + mockQuality) expect(getStorageValue).toHaveBeenCalledWith(expect.stringContaining(mockSku), null) expect(result).toEqual(mockCachedData) @@ -60,16 +60,16 @@ describe('Price Service', () => { (getStorageValue as jest.Mock).mockResolvedValue(expiredCache); (priceUsingPricesTF as jest.Mock).mockResolvedValue(mockPriceResponse) - const result = await fetchPrice(mockToken, mockDefIndex, mockQuality) + const result = await fetchPrice(mockToken, mockDefIndex + ";" + mockQuality) - expect(priceUsingPricesTF).toHaveBeenCalledWith(mockToken, mockDefIndex, mockQuality) + expect(priceUsingPricesTF).toHaveBeenCalledWith(mockToken, `${mockDefIndex};${mockQuality}`) expect(setStorageValue).toHaveBeenCalled() expect(result.metal).not.toBe(mockCachedData.metal) expect(result.metal).toBe(mockPriceResponse.metal) }) test('fetchPrice rejects with 401 when no token provided', async () => { - await expect(fetchPrice('', mockDefIndex, mockQuality)).rejects.toBe(401) + await expect(fetchPrice('', mockDefIndex + ";" + mockQuality)).rejects.toBe(401) }) test('fetchPrice handles pricing API errors', async () => { @@ -77,7 +77,7 @@ describe('Price Service', () => { (priceUsingPricesTF as jest.Mock).mockRejectedValue(testError); (getStorageValue as jest.Mock).mockResolvedValue(null) - await expect(fetchPrice(mockToken, mockDefIndex, mockQuality)).rejects.toBe(testError) + await expect(fetchPrice(mockToken, mockDefIndex + ";" + mockQuality)).rejects.toBe(testError) }) test('fetchKeyPrice uses correct parameters', async () => { @@ -86,7 +86,7 @@ describe('Price Service', () => { const result = await fetchKeyPrice(mockToken) - expect(priceUsingPricesTF).toHaveBeenCalledWith(mockToken, defindex_key, 6) + expect(priceUsingPricesTF).toHaveBeenCalledWith(mockToken, `${defindex_key};6`) expect(result.keys).toBe(0) // A key cannot cost a key :P expect(result.metal).toBe(mockKeyPriceResponse.metal) }) diff --git a/__tests__/schema.test.ts b/__tests__/schema.test.ts index 2f34c1d..34fb440 100644 --- a/__tests__/schema.test.ts +++ b/__tests__/schema.test.ts @@ -1,16 +1,57 @@ -import { describe, expect, test } from "bun:test"; -import { ItemSchema, getItemIndexByName, getTradableStatusByDefindex, getTradableStatusByName} from '../src/content/schemaService' +import { describe, expect, test, mock } from "bun:test"; +import { ItemSchema, ItemSlot, getItemIndexByName, getTradableStatusByDefindex, getTradableStatusByName, prepareSchema } from '../src/content/schemaService' + +// Mock the storage and log functions +mock.module('../src/content/storage', () => ({ + setStorageValue: mock(async (key: string, value: any) => {}), + getStorageValue: mock(async (key: string, defaultValue: any) => defaultValue) +})); + +mock.module('../src/content/utils/log', () => ({ + logDebug: mock(() => {}), + log: mock(() => {}), + logError: mock(() => {}) +})); const mockSchema: ItemSchema = { - '21': { name: 'Flame Thrower', tradable: false }, - '208': { name: 'Flame Thrower', tradable: true }, - '5021': { name: 'Mann Co. Supply Crate Key', tradable: true }, - '15141': { name: 'Flame Thrower', tradable: true }, - '69420': { name: 'Non-Tradable Item', tradable: false } + '21': { + name: 'Flame Thrower', + slot: ItemSlot.Primary, + tradable: false, + hasAustraliumVariant: false, + canKillstreakify: true + }, + '208': { + name: 'Flame Thrower', + slot: ItemSlot.Primary, + tradable: true, + hasAustraliumVariant: true, + canKillstreakify: true + }, + '5021': { + name: 'Mann Co. Supply Crate Key', + slot: ItemSlot.Tool, + tradable: true, + hasAustraliumVariant: false, + canKillstreakify: false + }, + '15141': { + name: 'Flame Thrower', + slot: ItemSlot.Primary, + tradable: true, + hasAustraliumVariant: false, + canKillstreakify: true + }, + '69420': { + name: 'Non-Tradable Item', + slot: ItemSlot.Misc, + tradable: false, + hasAustraliumVariant: false, + canKillstreakify: false + } } describe('Schema Service', () => { - test('getItemIndexByName returns correct defindex', () => { expect(getItemIndexByName(mockSchema, 'Flame Thrower')).toBe(208) expect(getItemIndexByName(mockSchema, 'Mann Co. Supply Crate Key')).toBe(5021) @@ -30,4 +71,50 @@ describe('Schema Service', () => { expect(getTradableStatusByName(mockSchema, 'Non-Tradable Item')).toBe(false) expect(getTradableStatusByName(mockSchema, 'Non-Existent Item')).toBe(true) }) + + test('prepareSchema fetches and processes schema correctly', async () => { + // Mock GM_fetch response + const mockResponse = { + ok: true, + json: async () => [ + { + defindex: 1, + item_name: 'Test Item', + item_slot: 'misc', + attributes: [ + { "name": "cannot trade", "class": "cannot_trade", "value": 1 } + ], + capabilities: {} + }, + { + defindex: 208, + item_name: 'Flame Thrower', + item_slot: 'primary', + capabilities: { can_killstreakify: true } + } + ] + }; + + // Mock GM_fetch + globalThis.GM_fetch = mock(async () => mockResponse); + + const result = await prepareSchema(); + + expect(result).toEqual({ + '1': { + name: 'Test Item', + slot: ItemSlot.Misc, + tradable: false, + canKillstreakify: false, + hasAustraliumVariant: false + }, + '208': { + name: 'Flame Thrower', + slot: ItemSlot.Primary, + tradable: true, + canKillstreakify: true, + hasAustraliumVariant: true + } + }); + }); }) \ No newline at end of file diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 0000000..d005f5b --- /dev/null +++ b/global.d.ts @@ -0,0 +1,7 @@ +export {}; + +declare global { + interface Window { + GM_fetch: typeof fetch; // or a more specific custom type + } +} \ No newline at end of file diff --git a/package.json b/package.json index 9d59646..a582708 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tf2wikipricing", - "version": "0.3.1", + "version": "0.4.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 066b717..052cf9e 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -206,7 +206,7 @@ async function inject() { var data: ItemPriceData | null try { - data = await fetchPrice(token, itemIndex, quality, currentTime); + data = await fetchPrice(token, itemIndex + ";" + quality, currentTime); updateTime = new Date(data.update) } catch { log(`${qualifiedName} is unpriced or unavailable, skipping...`) @@ -217,6 +217,27 @@ async function inject() { priceRows.push({quality: quality, row: priceRow}) }) + + // Check item schema for Australium variant of current defindex + if(itemSchema[itemIndex].hasAustraliumVariant) { + promises.push(new Promise(async (resolve) => { + logDebug(`Fetching price for Australium ${itemName}`) + var data: ItemPriceData | null + try { + data = await fetchPrice(token, `${itemIndex};11;australium`, currentTime); + updateTime = new Date(data.update) + } catch { + log(`Australium ${itemName} is unpriced or unavailable, skipping...`) + } + + const priceRow = createPriceRow($T("Australium"), data, keyPrice, locale, "https://wiki.teamfortress.com/wiki/Australium_weapons") + + priceRows.push({quality: 99, row: priceRow}) + resolve() + return + })) + } + Promise.all(promises).then(() => { priceRows.sort((a, b) => { // Sort 6 first always, then numerically diff --git a/src/content/priceService.ts b/src/content/priceService.ts index fd40e0d..0fe7eaa 100644 --- a/src/content/priceService.ts +++ b/src/content/priceService.ts @@ -30,7 +30,7 @@ export class ItemPriceData { export async function fetchKeyPrice(token: string) { - return fetchPrice(token, defindex_key, 6, new Date(), 86400000) + return fetchPrice(token, `${defindex_key};6`, new Date(), 86400000) } /** @@ -39,9 +39,8 @@ export async function fetchKeyPrice(token: string) { * @param update Date retrieved. * @param ttl Time to cache results in milliseconds. 30 minutes by default. */ -export async function fetchPrice(token: string, defIndex: number, quality: number, update: Date = new Date(), ttl: number = 30 * 60 * 1000): Promise { +export async function fetchPrice(token: string, sku: string, update: Date = new Date(), ttl: number = 30 * 60 * 1000): Promise { return new Promise(async (resolve, reject) => { - const sku = defIndex.toString() + ";" + quality.toString(); var data: ItemPriceData | null const cached = await getStorageValue(storage_priceprefix + sku, null) @@ -60,7 +59,7 @@ export async function fetchPrice(token: string, defIndex: number, quality: numbe data.ttl = ttl try { - const response = await priceUsingPricesTF(token, defIndex, quality) + const response = await priceUsingPricesTF(token, sku) if (response) { data.keys = response.keys data.metal = response.metal diff --git a/src/content/pricing/pricestf.ts b/src/content/pricing/pricestf.ts index cdb273b..bf56fa0 100644 --- a/src/content/pricing/pricestf.ts +++ b/src/content/pricing/pricestf.ts @@ -31,13 +31,13 @@ class PricesResponse { * and uses it to fetch the latest pricing data for the given item in keys and metal. * * @example - * const price = await priceUsingPricesTF(token, 105, 11); + * const price = await priceUsingPricesTF(token, '105;11'); * console.log("Strange Brigade Helm price: ${price.keys} keys ${price.metal} metal") * * @returns {Promise} Object containing 'keys' and 'metal' prices * @throws When authentication fails or API returns non-200 status code */ -async function priceUsingPricesTF(token: string, defIndex: number, quality: number): Promise { +async function priceUsingPricesTF(token: string, sku: string): Promise { // prices.tf // https://api2.prices.tf/prices/${sku} // Authorization: Bearer ${token} @@ -45,7 +45,6 @@ async function priceUsingPricesTF(token: string, defIndex: number, quality: numb if (!token) { reject(401) } - const sku = defIndex + ";" + quality; var response = await GM_fetch(`https://api2.prices.tf/prices/${encodeURIComponent(sku)}`, { method: 'get', headers: new Headers({ @@ -53,15 +52,18 @@ async function priceUsingPricesTF(token: string, defIndex: number, quality: numb 'Authorization': `Bearer ${token}` }) }) - if (response.status === 404 && quality === 6) { - // Try uncraftable variant - response = await GM_fetch(`https://api2.prices.tf/prices/${encodeURIComponent(sku + ';uncraftable')}`, { - method: 'get', - headers: new Headers({ - 'Accept': 'application/json', - 'Authorization': `Bearer ${token}` + if (response.status === 404 && sku.includes(';')) { + const quality: number = parseInt(sku.split(';')[1], 10); + if(quality === 6) { + // Try uncraftable variant if unique weapon + response = await GM_fetch(`https://api2.prices.tf/prices/${encodeURIComponent(sku + ';uncraftable')}`, { + method: 'get', + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': `Bearer ${token}` + }) }) - }) + } } if (response.status === 200) { const json = await response.json() diff --git a/src/content/schemaService.ts b/src/content/schemaService.ts index 1c6e847..e86a2f0 100644 --- a/src/content/schemaService.ts +++ b/src/content/schemaService.ts @@ -4,8 +4,13 @@ import './config' declare function GM_fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise import './GM_fetch' import { storage_version, storage_schema, storage_lastUpdateTime } from './config' +import Australiums from '../resources/australiums.json' const semver = require('semver') +export function checkAustraliumVariant(defindex: number): boolean { + return Object.prototype.hasOwnProperty.call(Australiums, defindex.toString()); +} + export declare const __VERSION__: string; function isDateAfterOneDay(date1: Date, date2: Date): boolean { @@ -14,7 +19,28 @@ function isDateAfterOneDay(date1: Date, date2: Date): boolean { return diffDays > 1; } -export class ItemSchema { [key: string]: {name: string, tradable: Boolean}; } +export enum ItemSlot { + Primary = "primary", + Secondary = "secondary", + Melee = "melee", + PDA = "pda", + PDA2 = "pda2", + Building = "building", + Misc = "misc", + Special = "special", + Taunt = "taunt", + Tool = "tool", +} + +export class ItemSchema { + [key: string]: { + name: string, + slot: ItemSlot, + tradable: Boolean, + hasAustraliumVariant: Boolean, + canKillstreakify: Boolean + }; +} export function getItemIndexByName(schema: ItemSchema, name: string, excludeStock: Boolean = true, excludeDecorated: Boolean = true) { for (const [defindex, value] of Object.entries(schema)) { @@ -63,6 +89,7 @@ export async function prepareSchema(): Promise { log(`Cache is from a previous version (${storedVersion}) of the extension. Updating for version ${__VERSION__}`); needsUpdate = true } else { + log(`Cache is from current version (${storedVersion}) of the extension.`); itemSchema = await getStorageValue(storage_schema, null); } @@ -87,8 +114,8 @@ export async function prepareSchema(): Promise { var responseItems: any[] = await response.json() // We want to keep the keys `defindex`, `item_name`, and `attributes` responseItems.forEach((item: any) => { - const defindex = item['defindex'] - const name = item['item_name'] + const defindex: number = item['defindex'] + var tradable: Boolean = true try { if(item['attributes'] != null) { @@ -100,7 +127,26 @@ export async function prepareSchema(): Promise { logError(error) log(item) } - (cacheItems as any)[defindex.toString()] = { "name": name, "tradable": tradable } + + var canKillstreakify: Boolean = false + try { + if(item['capabilities'] != null) { + if(item['capabilities']['can_killstreakify'] != null && item['capabilities']['can_killstreakify'] == true) { + canKillstreakify = true + } + } + } catch(error) { + logError(error) + log(item) + } + + (cacheItems as any)[defindex.toString()] = { + "name": item['item_name'], + "slot": item['item_slot'], + "tradable": tradable, + "canKillstreakify": canKillstreakify, + "hasAustraliumVariant": checkAustraliumVariant(defindex) + } }); await setStorageValue(storage_schema, (cacheItems)); diff --git a/src/content/uiRenderer.ts b/src/content/uiRenderer.ts index 38c30ce..3d65acf 100644 --- a/src/content/uiRenderer.ts +++ b/src/content/uiRenderer.ts @@ -2,13 +2,16 @@ import { ItemPriceData } from "./priceService"; import { formatPrice } from "./utils/formatting"; import { $T } from "./utils/localization"; -export function createPriceRow(qualityName: string, data: ItemPriceData, keyPrice: ItemPriceData, locale: string): HTMLTableRowElement { +export function createPriceRow(qualityName: string, data: ItemPriceData, keyPrice: ItemPriceData, locale: string, wikiPage: string = null): HTMLTableRowElement { const priceRow = document.createElement("tr"); const priceLabel = document.createElement("td"); priceLabel.className = "infobox-label"; const priceLabelLink = document.createElement("a"); - priceLabelLink.href = locale === 'en' ? `https://wiki.teamfortress.com/wiki/${qualityName}` : `https://wiki.teamfortress.com/wiki/${qualityName}/${locale}` + if (wikiPage == null) { + wikiPage = `https://wiki.teamfortress.com/wiki/${qualityName}` + } + priceLabelLink.href = locale === 'en' ? wikiPage : `${wikiPage}/${locale}` priceLabelLink.innerText = $T(qualityName) priceLabel.appendChild(priceLabelLink); priceLabel.innerHTML += ':' diff --git a/src/resources/australiums.json b/src/resources/australiums.json new file mode 100644 index 0000000..414db43 --- /dev/null +++ b/src/resources/australiums.json @@ -0,0 +1,21 @@ +{ + "36": "Blutsauger", + "38": "Axtinguisher", + "45": "Force-A-Nature", + "61": "Ambassador", + "132": "Eyelander", + "141": "Frontier Justice", + "194": "Knife", + "197": "Wrench", + "200": "Scattergun", + "201": "Sniper Rifle", + "202": "Minigun", + "203": "SMG", + "205": "Rocket Launcher", + "206": "Grenade Launcher", + "207": "Stickybomb Launcher", + "208": "Flame Thrower", + "211": "Medi Gun", + "228": "Black Box", + "424": "Tomislav" +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index d459c51..8282a68 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,5 @@ { + "include": ["src/**/*", "declarations.d.ts", "global.d.ts"], "compilerOptions": { "outDir": "./dist/", "noImplicitAny": true,