// ==UserScript==
// @name Steam Inventory Augmentor Modern
// @namespace https://github.com/ceeprus
// @version 3.28.1
// @description Steam inventory & trading enhancements with backpack.tf pricing: item value badges, sorting, duplicate grouping, trade tools.
// @author ceeprus
// @icon https://steamcommunity.com/favicon.ico
// @match *://steamcommunity.com/id/*/inventory*
// @match *://steamcommunity.com/profiles/*/inventory*
// @match *://steamcommunity.com/tradeoffer/*
// @run-at document-idle
// @grant unsafeWindow
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @connect backpack.tf
// @connect raw.githubusercontent.com
// @connect github.com
// @updateURL https://raw.githubusercontent.com/ceeprus/userscript/main/steam/steam-inventory-augmentor.user.js
// @downloadURL https://raw.githubusercontent.com/ceeprus/userscript/main/steam/steam-inventory-augmentor.user.js
// ==/UserScript==
(() => {
'use strict';
// page context (Steam globals, element expandos)
const W = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
// ------------------------------------------------------------------
// Config
// ------------------------------------------------------------------
const CONFIG = {
tf2Badges: true, // paint dots, KS tier, unusual star, spells, festivized, series, gift
markUncraftable: true, // dashed outline on non-craftable TF2 items
markUntradable: true, // red bottom bar on untradable items (any app)
duplicateFilter: true, // "Duplicate" tag in inventory filters
gemify: true, // one-click "Turn into Gems" (Steam Community items)
multiSell: true, // "Sell Multiple" link for commodity items
sortButton: true, // Sort dropdown (Default/Name/Type/Quality)
stacking: true, // "Stack dupes" toggle — collapse identical items into one box with ×N
metalCounter: true, // TF2 metal totals (ref/rec/scrap + total in ref, keys)
priceIndicator: true, // market price bottom-right on marketable items
priceCooldownMs: 2500, // delay between price requests (Steam rate-limits hard)
priceCacheMins: 1440, // how long a fetched price stays valid (24h)
tradeLockBadge: true, // "6D" countdown on trade-locked items
shrinkRenameWarning: true, // smaller red "!" on renamed items
backpackTfKey: '', // backpack.tf API key — set yours in the ⚙ settings panel
tradeMetalHelper: true, // "Add metal" amount box on trade offer pages
tradeEmptyButtons: true, // "Empty mine"/"Empty theirs" buttons on trade pages
shiftClickAdd: true, // shift+click an inventory item on trade pages to add it
panelLinks: true, // bp.tf + market icons on the selected-item panel
quicksellButtons: false, // ⚡ instant-sell/undercut listing buttons (own inventory)
showBuyOrders: false, // green badge shows highest buy order (instant-sell value)
// instead of the lowest listing — doubles market requests
tradeCompare: true, // live "Give ≈ X ref · Get ≈ Y ref" totals on trade pages
cheapPriceThreshold: 0.03, // at/below this, prices refresh on the slow cycle
cheapPriceCacheMins: 1440, // slow cycle: cheap items refresh once a day
afterFeesValue: false, // Value line also shows proceeds after Steam's ~15% cut
partnerValue: true, // show trade partner's visible backpack value
draftSaver: true, // restore your trade selection after the window closes
settingsPanel: true, // gear button opens a settings UI (no file editing)
updateURL: 'https://raw.githubusercontent.com/ceeprus/userscript/main/steam/steam-inventory-augmentor.user.js',
};
try { Object.assign(CONFIG, JSON.parse(localStorage.getItem('siaSettings')) || {}); } catch { /* defaults */ }
const METAL_REF = { 'Refined Metal': 1, 'Reclaimed Metal': 1 / 3, 'Scrap Metal': 1 / 9 };
const BPTF_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAC00lEQVR4AZRSXUhUQRg9M7u6GVlLRSFZWFj0kNEmCZIkG+b2o6alWUoaRpIPPlSSDz64LxVEUZI9BIKgkYSJP5jKBmomiaZutpU/tckKqShpboravTvTd9cUCyEa5tz5ud85c75vhksp/wcMfzVe29QRUttq/yfqmt/urW9v95fAHyK8ormjq+LFa/tKKG9otT+uttnLamz2J7bGrur6tso7OTmrl4twvY5LAvPR6xhjkknhYdpaCpUd2B3ICnIvseIbV1nozkCucoPZuWZL+b0rSasWRSh/QAiBdQaG4+EhCAnaDK0Z/fTIOncS641rYXv1BpPuGUBK9hOGowP+4WUPsrN9KY6RgMTMDzdiIsNwOjoSmzYYMTvtRvDWADDKts/pQnGlDYOjkxBSkoZg88w39oNxe8mjzEw9V1QViVHhOBS2z0tIiTuC4pvXkJUSD8459uzagbK7efDhZFrrgkSE5HPMJ7FzY3ARl3RKT78Trq+j5Ahw9DlRXt+MTkcfnSYxOj6Bp3WNUDwCHikgIBcgQPbFGKc1BoYn4Rwa9grYez/jedt7tL/rJQFgeGwcVS3dgM4XWq0WIKVOzLUEGLryuBCaL4KXTh9tqoGmS51sStojAyBIeNSRILcrJd9arniLSK8Rqurxxvut8sX0zDQFEoN2NKIg1jIoRnUyNb+wcITRvXDNANfp8fGLC5pQXFQErJfPUGbSuyYNGgEtjsog/ZSp/I1r9C0aWftHAoKCgdaeAZRW2fBpcAjjE1OY+O6GY1lxhRDSIGZrtvmz21arVWhkDVxRVDavKGRSL581dcnrBaXyflmD7HaOyFyaF1W/lEynE0KZdwXO9l/Mt1qlRlwEP2s5GJoeazalxZlNqSeiTBkJMab0OIvpfEy0KeNUjOlC/DFTmsW8PznWHHHrYcm3RetLAsmWCEeCOcyLZEuYYyUk0H7S4fCFe15k/h5/AQAA///sKIwVAAAABklEQVQDAIXGwCQOPvQKAAAAAElFTkSuQmCC';
const STEAM_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABv0lEQVR4AYSST0gUYRjGn/ebHXfdnSa2Vijz5rFLFARR0SEKNjoVHYuFwEOHsFtBnfNYFARJh0S8uSdRES+KHsSLIAge9qK4XhwPIir+mc/3fdcZdvVTB37zvPN9z/N8MzAGjutq152YsadxWNFSENy6FzP2yHrE4DSyx8TNRWlB7uZ9exh7xOASSLxJiRb4Nx7ERzbDJ7rZrc8hgbys+iQjJVpgyCdDGbjYX59FrvMJRiYaurM6feLzSQtynU8tkQ8XgC8e7NWn8PL5Q9V81zNV8UvWGA6fx159Uguab8Y0StOMl22Hl82fISwW01zY/Qp/B8YQdr/G9sqoapIxW7UqtmrD6Pv2HhkuS1hfHEoLxNPzrqy+67fftvhM4vpQeYHNpUGF2tqT5VRLd3sgEB9icnmklB71pqZkiBb6sX9wqI//qjMoPf4EFApOTDT7kzrKXyFo4uTW5md0+tw/DgoKTqKZH9T4hEJgEQToeNOH+eU11d4/o6qy7kQyfIQWbAx/MZZfUSh/r0J0cK6mKrMLyXAeWiBD9P8jIQwtgwu5Elr1SohJC3hG9KtiGEKxaHGN/4NmeE32ot+VlswxAAAA//+WslJ1AAAABklEQVQDAOsRwKnOCKgSAAAAAElFTkSuQmCC';
// Steam economy item images (hashes are long-term stable, CSP allows https:)
const CUR_IMG = (hash) => `https://community.fastly.steamstatic.com/economy/image/${hash}/28fx28f`;
const CUR_ICONS = {
ref: CUR_IMG('fWFc82js0fmoRAP-qOIPu5THSWqfSmTELLqcUywGkijVjZULUrsm1j-9xgEbZQsUYhTkhzJWhsO1Mv6NGucF1Ygzt8ZQijJukFMiMrbhYDEwI1yRVKNfD6xorQ3qW3Jr6546DNPuou9IOVK4p4kWJaA'),
rec: CUR_IMG('fWFc82js0fmoRAP-qOIPu5THSWqfSmTELLqcUywGkijVjZULUrsm1j-9xgEbZQsUYhTkhzJWhsO0Mv6NGucF1YJlscMEgDdvxVYsMLPkMmFjI1OSUvMHDPBp9lu0CnVluZQxA9Gwp-hIOVK4sMMNWF4'),
scrap: CUR_IMG('fWFc82js0fmoRAP-qOIPu5THSWqfSmTELLqcUywGkijVjZULUrsm1j-9xgEbZQsUYhTkhzJWhsPZAfOeD-VOn4phtsdQ32ZtxFYoN7PkYmVmIgeaUKNaX_Rjpwy8UHMz6pcxAIfnovUWJ1t9nYFqYw'),
key: CUR_IMG('fWFc82js0fmoRAP-qOIPu5THSWqfSmTELLqcUywGkijVjZULUrsm1j-9xgEAaR4uURrwvz0N252yVaDVWrRTno9m4ccG2GNqxlQoZrC2aG9hcVGUWflbX_drrVu5UGki5sAij6tOtQ'),
robot: CUR_IMG('fWFc82js0fmoRAP-qOIPu5THSWqfSmTELLqcUywGkijVjZULUrsm1j-9xgEGegouTxTgsSxQt5iwMv6NGucF1Y4wtsRXjmVuyVcuYrGxNGY-IwGVUqEGDKA-oA3uDSFq7JNnVYayrr5IOVK4CVyuuBM'),
};
const curIcon = (kind, title) =>
``;
const MARKET_ICON = 'data:image/svg+xml;base64,' + btoa(
'');
// ------------------------------------------------------------------
// Styles
// ------------------------------------------------------------------
const style = document.createElement('style');
style.textContent = `
.item { position: relative; }
.sia-b { position: absolute; inset: 1px; display: flex; flex-direction: column;
justify-content: space-between; pointer-events: none; z-index: 5;
font: bold 10px/1 "Motiva Sans", Arial, sans-serif; }
.sia-row { display: flex; justify-content: space-between; align-items: flex-start; }
.sia-row > span { display: flex; gap: 2px; align-items: center; }
.sia-tag { color: #fff; text-shadow: 0 0 2px #000, 0 0 2px #000; padding: 1px; }
.sia-star { color: #B07EDC; font-size: 12px; text-shadow: 0 0 3px #000; }
.sia-ks { color: #9FD3F6; background: rgba(42, 71, 94, .9); border-radius: 2px;
padding: 1px 3px; font-size: 9px; text-shadow: none; }
.sia-series { color: #C6C4C2; background: rgba(0, 0, 0, .55); border-radius: 2px;
padding: 1px 3px; font-size: 9px; text-shadow: none; }
.sia-uncraft { outline: 1px dashed rgba(255,255,255,.45); outline-offset: -3px; }
.sia-untrade::after { content: ""; position: absolute; left: 2px; right: 2px; bottom: 1px;
height: 2px; background: #A94847; z-index: 5; pointer-events: none; }
.sia-gems::before { display: block; position: absolute; content: "+" attr(data-gemcount) " GEMS";
color: #fff; text-align: center; font-size: 15px; width: 100%; z-index: 6;
font-family: "Motiva Sans Light", Arial, sans-serif; padding-top: 30px; }
.sia-gems > img { opacity: .2; }
#sia-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; row-gap: 5px;
margin: 8px 0; padding: 6px 8px; background: #1a1d21;
border: 1px solid #262a2e; border-radius: 3px; color: #b8b6b4;
font: 12px "Motiva Sans", Arial, sans-serif; }
#sia-bar button, #sia-bar select, #sia-bar input { height: 24px; box-sizing: border-box; }
#sia-bar select { max-width: 128px; }
#sia-bar button { padding: 3px 8px; }
#sia-bar select { background: #24282e; color: #dcdedf; border: 1px solid #000; padding: 2px 4px; }
#sia-bar input { background: #24282e; color: #dcdedf; border: 1px solid #000;
padding: 2px 4px; width: 60px; }
#sia-bar button { background: rgba(103, 112, 123, .2); color: #d2d8de; border: 0;
border-radius: 2px; padding: 3px 10px; cursor: pointer; font-size: 12px; }
#sia-bar button:hover { background: rgba(103, 112, 123, .45); color: #fff; }
#sia-bar .sia-stack-on { background: #67c1f5; color: #16202d; }
#sia-bar .sia-stack-on:hover { background: #8ed1f8; color: #16202d; }
#sia-bptf-toggle { padding: 3px 6px; }
#sia-bptf-toggle img { width: 16px; height: 16px; display: block; }
.sia-btn-ico { width: 16px; height: 16px; display: block; }
#sia-bptf-toggle.sia-bptf-off img { filter: grayscale(1); opacity: .35; }
.sia-cur { width: 16px; height: 16px; vertical-align: text-bottom; margin: 0 1px 0 4px; }
#tabcontent_inventory { position: relative; }
#sia-value-top { position: absolute; top: 36px; right: 10px; z-index: 50;
background: rgba(0, 0, 0, .4); border: 1px solid #262a2e;
border-radius: 3px; padding: 6px 10px; text-align: right;
color: #b8b6b4; font: 12px "Motiva Sans", Arial, sans-serif; }
#sia-value-top .sia-vc-metal { color: #d5d3d1; white-space: nowrap; }
#sia-value-top .sia-vc-money { color: #9CC83B; font-size: 16px; font-weight: bold;
margin-top: 2px; display: flex; align-items: center; justify-content: flex-end; gap: 3px; }
#sia-value-top .sia-vc-money img { width: 12px; height: 12px; }
#sia-value-top .sia-vc-sub { color: #7a8b99; font-size: 10px; font-weight: normal; }
#sia-value-top .sia-vc-trad { color: #E8A33D; font-size: 11px; margin-top: 2px;
display: flex; align-items: center; justify-content: flex-end; gap: 3px; }
#sia-value-top .sia-vc-trad img { width: 11px; height: 11px; }
#sia-value-top .sia-vc-note { color: #777; font-size: 10px; margin-top: 2px; }
.sia-progressbar { height: 3px; margin-top: 3px; border-radius: 2px;
background: rgba(255,255,255,.12); overflow: hidden; }
.sia-progressbar > div { height: 100%; background: #66c0f4; transition: width .5s; }
.sia-vc-note { position: relative; cursor: default; }
.sia-queue-tip { display: none; position: absolute; right: 0; top: 100%; z-index: 210;
background: #3d4450; border: 1px solid #262a2e; border-radius: 3px;
padding: 6px 10px; margin-top: 3px; text-align: left; white-space: nowrap;
color: #c7d5e0; font-size: 11px; line-height: 1.5; }
.sia-vc-note:hover .sia-queue-tip { display: block; }
.sia-queue-tip .sia-tip-cur { color: #66c0f4; font-weight: bold; }
.sia-stacked-holder { display: none !important; }
.sia-count { position: absolute; right: 2px; top: 2px; z-index: 6; color: #FFD700;
background: rgba(0, 0, 0, .55); border-radius: 2px; padding: 1px 3px;
font: bold 11px/1 "Motiva Sans", Arial, sans-serif; pointer-events: none; }
.sia-price { position: absolute; right: 2px; bottom: 1px; z-index: 6; color: #9CC83B;
font: bold 9px/1 "Motiva Sans", Arial, sans-serif; display: flex;
align-items: center; gap: 2px;
text-shadow: 0 0 2px #000, 0 0 3px #000; pointer-events: none; }
.sia-price img { width: 10px !important; height: 10px !important;
flex: 0 0 10px; object-fit: contain; }
.sia-bptf { position: absolute; right: 2px; bottom: 1px; z-index: 6; color: #E8A33D;
font: bold 9px/1 "Motiva Sans", Arial, sans-serif; display: flex;
align-items: center; gap: 2px;
text-shadow: 0 0 2px #000, 0 0 3px #000; pointer-events: none; }
.sia-bptf img { width: 10px !important; height: 10px !important;
flex: 0 0 10px; object-fit: contain; }
.sia-price.sia-pending { color: #777; }
.sia-hide-bptf .sia-bptf { display: none; }
.sia-panel-links .sia-qs { color: #9CC83B; font: bold 11px/16px Arial;
text-decoration: none; white-space: nowrap; }
.sia-panel-links { position: absolute; top: 10px; right: 44px; display: flex;
gap: 8px; z-index: 10; }
.sia-panel-links a img { width: 16px; height: 16px; opacity: .75; }
.sia-panel-links a:hover img { opacity: 1; }
.sia-panel-links .sia-addall { color: #9CC83B; font: bold 12px/16px Arial;
text-decoration: none; }
.sia-lock { position: absolute; left: 2px; top: 2px; z-index: 6; color: #fff;
background: #CD3B3B; border-radius: 2px; padding: 2px 3px;
font: bold 9px/1 "Motiva Sans", Arial, sans-serif; pointer-events: none; }
#sia-settings { position: absolute; z-index: 200; background: #3d4450;
border: 1px solid #262a2e; border-radius: 3px; padding: 10px 14px;
color: #dcdedf; font: 12px "Motiva Sans", Arial, sans-serif;
max-height: 420px; overflow-y: auto; box-shadow: 0 4px 16px rgba(0,0,0,.6); }
#sia-settings label { display: block; margin: 4px 0; padding: 1px 4px; cursor: pointer; border-radius: 2px; }
#sia-settings label:hover { background: #67c1f5; color: #16202d; }
#sia-settings input[type=number] { width: 70px; }
#sia-settings input[type=text] { width: 190px; }
.item:has(.sia-lock) .sia-b .sia-row:first-child > span:first-child { margin-left: 22px; }
${CONFIG.shrinkRenameWarning ? `.item .slot_app_fraudwarning {
width: 12px !important; height: 12px !important;
background-size: 12px 12px !important; }` : ''}
/* trade offer message box: two lines by default, user-resizable */
#trade_offer_note { resize: vertical; min-height: 34px; height: 34px; }
.sia-break { flex-basis: 100%; height: 0; }
/* game icon: bottom-right belongs to prices now; park it top-right */
.trade_slot .slot_applogo { top: 2px !important; right: 2px !important;
bottom: auto !important; left: auto !important; }
/* the per-slot dropdown arrow menu (Item Wiki/Inspect) is dead weight */
.slot_actionmenu_button { display: none !important; }
.sia-side-summary { display: flex; justify-content: space-between; gap: 10px;
align-items: center; min-height: 24px; padding: 4px 10px; margin: 3px 0;
background: rgba(0, 0, 0, .35); border: 1px solid #262a2e;
border-radius: 3px; color: #b8b6b4;
font: 11px "Motiva Sans", Arial, sans-serif; }
.sia-side-summary .sia-big { font-size: 15px; font-weight: bold; color: #fff; }
#sia-bar-note { margin-left: auto; }
#sia-bar-note .sia-vc-note { min-width: 120px; color: #777; font-size: 10px; }
`;
document.head.appendChild(style);
// ------------------------------------------------------------------
// Item helpers
// ------------------------------------------------------------------
// Inventory page assets look like {assetid, classid, description: {...}};
// trade offer page items are old-style with descriptions/tags at top level.
const descOf = (item) => (item && (item.description || item)) || null;
// Steam's pages replace Object.values with a for..in shim while also
// polluting Array.prototype with ~35 enumerable methods — calling it on an
// engine asset ARRAY returns library functions as "assets". Own props only.
function assetList(assets) {
const out = [];
if (!assets) return out;
for (const k in assets) {
if (Object.prototype.hasOwnProperty.call(assets, k) && k !== 'length') {
const v = assets[k];
if (v && typeof v === 'object') out.push(v);
}
}
return out;
}
// market-priceable: marketable now, or merely on a temporary trade/market hold
// (held CS2 items still have real market prices for their type)
const priceable = (d) => !!(d && d.market_hash_name && (d.marketable || lockDaysCached(d) > 0));
const descLines = (d) =>
(d.descriptions || []).map((x) => String(x.value || '').replace(/<[^>]*>/g, '').trim());
// "Tradable After: Monday, July 27, 2026 (7:00:00) GMT" -> days remaining
function lockDaysOf(d) {
if (d.cache_expiration) { // CS2 ships the hold end as an ISO timestamp
const days = Math.ceil((Date.parse(d.cache_expiration) - Date.now()) / 864e5);
if (days > 0) return days;
}
const all = [...(d.descriptions || []), ...(d.owner_descriptions || [])];
for (const x of all) {
const m = String(x.value || '').match(/Tradable(?:\s*[&\/]\s*Marketable)? After:?\s*(.+)/i);
if (!m) continue;
const ts = Date.parse(m[1].trim().replace(/\((.*?)\)/, '$1'));
if (!Number.isNaN(ts)) {
const days = Math.ceil((ts - Date.now()) / 864e5);
if (days > 0) return days;
}
}
return 0;
}
function tf2Info(d) {
const lines = descLines(d);
const info = {
effect: null, ks: null, spells: 0,
festivized: false, series: null,
uncraftable: false, untradable: d.tradable === 0 || d.tradable === false,
};
for (const line of lines) {
let m;
if ((m = line.match(/^★ Unusual Effect: (.+)$/))) info.effect = m[1];
else if (/^\(?Killstreaker: /.test(line)) info.ks = 'PK';
else if (/^\(?Sheen: /.test(line) && info.ks !== 'PK') info.ks = info.ks || 'SK';
else if (/^Killstreaks Active/.test(line)) info.ks = info.ks || 'K';
else if (/\(spell only active during event\)/.test(line)) info.spells++;
else if (line === 'Festivized') info.festivized = true;
else if ((m = line.match(/Crate Series #(\d+)/))) info.series = m[1];
else if (/Usable in Crafting/.test(line)) info.uncraftable = true;
if (/Not Tradable/.test(line)) info.untradable = true;
}
// Kit/fabricator names carry the tier even when desc lines are missing
const name = d.market_hash_name || d.name || '';
if (!info.ks) {
if (/^Professional Killstreak /.test(name)) info.ks = 'PK';
else if (/^Specialized Killstreak /.test(name)) info.ks = 'SK';
else if (/^Killstreak /.test(name)) info.ks = 'K';
}
return info;
}
function badge(cls, text, title) {
const el = document.createElement('span');
el.className = `sia-tag ${cls}`;
el.textContent = text;
if (title) el.title = title;
return el;
}
function augment(el) {
const item = el.rgItem;
if (!item) return; // element not wired up by Steam yet; retry on next scan
// assets can be wired before their description arrives; don't mark the
// element done on that placeholder or it would never get badges
const d = descOf(item);
if (!d || !(d.name || d.market_hash_name || d.descriptions)) return;
el.dataset.sia = '1';
// trade-locked = temporary; show countdown instead of the red untradable bar
const lockDays = CONFIG.tradeLockBadge ? lockDaysCached(d) : 0;
if (lockDays > 0 && !el.querySelector('.sia-lock')) {
const lock = document.createElement('span');
lock.className = 'sia-lock';
lock.textContent = `${lockDays}D`;
lock.title = `Tradable in ${lockDays} day${lockDays > 1 ? 's' : ''}`;
el.appendChild(lock);
}
if (lockDays <= 0 && CONFIG.markUntradable &&
(d.tradable === 0 || d.tradable === false)) {
el.classList.add('sia-untrade');
}
if (CONFIG.priceIndicator && priceable(d)) {
requestPrice(el, d);
}
if (String(d.appid) !== '440' || !CONFIG.tf2Badges) return;
const info = tf2Info(d);
if (CONFIG.markUncraftable && info.uncraftable) el.classList.add('sia-uncraft');
if (CONFIG.markUntradable && info.untradable && lockDays <= 0) el.classList.add('sia-untrade');
// bp.tf ref value: own badge for unmarketable items, tooltip data otherwise;
// permanently untradable items don't need a value — they can't move anyway
if (bptf && !el.dataset.siaBptf && !el.querySelector('.sia-bptf') &&
!(info.untradable && lockDays <= 0) &&
!/^(Refined|Reclaimed|Scrap) Metal$/.test(d.name || '')) {
const rv = bptfRefOf(d, info);
if (rv != null) {
el.dataset.siaBptf = String(rv);
if (!(d.marketable && d.market_hash_name)) {
const span = document.createElement('span');
span.className = 'sia-bptf';
const ico = document.createElement('img');
ico.src = BPTF_ICON;
// inline-lock the size: Steam CSS occasionally beats the stylesheet
ico.style.cssText = 'width:10px !important;height:10px !important;flex:0 0 10px;object-fit:contain';
span.append(ico, formatRef(rv));
span.title = `backpack.tf: ${rv} ref`;
el.appendChild(span);
} else {
// marketable: show bp.tf value while the market price loads
const pending = el.querySelector('.sia-price.sia-pending');
if (pending) {
pending.textContent = formatRef(rv);
pending.title = `backpack.tf: ${rv} ref · market price loading…`;
}
}
}
}
const hasBadge = info.effect || info.ks || info.spells || info.series;
if (!hasBadge || el.querySelector('.sia-b')) return;
const wrap = document.createElement('div');
wrap.className = 'sia-b';
const top = document.createElement('div');
const bottom = document.createElement('div');
top.className = bottom.className = 'sia-row';
const tl = document.createElement('span'), tr = document.createElement('span');
const bl = document.createElement('span'), br = document.createElement('span');
top.append(tl, tr);
bottom.append(bl, br);
wrap.append(top, bottom);
if (info.effect) tl.append(badge('sia-star', '★', `Unusual: ${info.effect}`));
if (info.spells) tr.append(badge('', info.spells > 1 ? `🎃${info.spells}` : '🎃', 'Spelled'));
if (info.series) bl.append(badge('sia-series', `#${info.series}`, `Crate Series #${info.series}`));
if (info.ks) bl.append(badge('sia-ks', info.ks === 'K' ? 'KS' : info.ks,
{ K: 'Killstreak', SK: 'Specialized Killstreak', PK: 'Professional Killstreak' }[info.ks]));
el.appendChild(wrap);
}
// ------------------------------------------------------------------
// backpack.tf pricing — daily dump reduced to name -> {quality+craft: refined}
// ------------------------------------------------------------------
const BPTF_LS_KEY = 'siaBptf';
let bptf = null;
try { bptf = JSON.parse(localStorage.getItem(BPTF_LS_KEY)); } catch { /* fresh */ }
const BPTF_QUALITY = {
Normal: 0, Genuine: 1, Vintage: 3, Unusual: 5, Unique: 6, Community: 7,
Valve: 8, 'Self-Made': 9, Strange: 11, Haunted: 13, "Collector's": 14,
'Decorated Weapon': 15,
};
// unusual effect name -> schema id (bp.tf keys unusual prices by this)
const EFFECT_IDS = {
'Green Confetti': 6, 'Purple Confetti': 7, 'Haze': 8, 'Green Energy': 9,
'Purple Energy': 10, 'Circling TF Logo': 11, 'Massed Flies': 12,
'Burning Flames': 13, 'Scorching Flames': 14, 'Searing Plasma': 15,
'Vivid Plasma': 16, 'Sunbeams': 17, 'Circling Peace Sign': 18,
'Circling Heart': 19, 'Stormy Storm': 29, 'Blizzardy Storm': 30,
'Nuts n\' Bolts': 31, 'Orbiting Planets': 32, 'Orbiting Fire': 33,
'Bubbling': 34, 'Smoking': 35, 'Steaming': 36, 'Flaming Lantern': 37,
'Cloudy Moon': 38, 'Cauldron Bubbles': 39, 'Eerie Orbiting Fire': 40,
'Knifestorm': 43, 'Misty Skull': 44, 'Harvest Moon': 45,
"It's A Secret To Everybody": 46, 'Stormy 13th Hour': 47,
'Kill-a-Watt': 56, 'Terror-Watt': 57, 'Cloud 9': 58, 'Aces High': 59,
'Dead Presidents': 60, 'Miami Nights': 61, 'Disco Beat Down': 62,
'Phosphorous': 63, 'Sulphurous': 64, 'Memory Leak': 65, 'Overclocked': 66,
'Electrostatic': 67, 'Power Surge': 68, 'Anti-Freeze': 69, 'Time Warp': 70,
'Green Black Hole': 71, 'Roboactive': 72, 'Arcana': 73, 'Spellbound': 74,
'Chiroptera Venenata': 75, 'Poisoned Shadows': 76,
'Something Burning This Way Comes': 77, 'Hellfire': 78, 'Darkblaze': 79,
'Demonflame': 80, 'Bonzo The All-Gnawing': 81, 'Amaranthine': 82,
'Stare From Beyond': 83, 'The Ooze': 84, 'Ghastly Ghosts Jr': 85,
'Haunted Phantasm Jr': 86, 'Frostbite': 87, 'Molten Mallard': 88,
'Morning Glory': 89, 'Death at Dusk': 90,
'Showstopper': 3001, 'Holy Grail': 3003, "'72": 3004,
'Fountain of Delight': 3005, 'Screaming Tiger': 3006,
'Skill Gotten Gains': 3007, 'Midnight Whirlwind': 3008,
'Silver Cyclone': 3009, 'Mega Strike': 3010, 'Haunted Phantasm': 3011,
'Ghastly Ghosts': 3012,
};
// Steam's CSP blocks page-context fetches to other hosts; go through the
// a privileged request channel when available
const gmFetchText = (url) => new Promise((resolve) => {
const gmReq = (typeof GM_xmlhttpRequest === 'function' && GM_xmlhttpRequest) ||
(typeof GM !== 'undefined' && GM.xmlHttpRequest);
if (gmReq) {
gmReq({
method: 'GET',
url,
onload: (r) => resolve(r.responseText),
onerror: () => resolve(null),
ontimeout: () => resolve(null),
});
} else {
fetch(url).then((r) => r.text()).then(resolve, () => resolve(null));
}
});
const bpFetchJson = async (url) => {
const txt = await gmFetchText(url);
try { return JSON.parse(txt); } catch { return null; }
};
async function refreshBptf() {
if (!CONFIG.backpackTfKey) return;
if (bptf && Date.now() - bptf.t < 24 * 3600e3) return;
try {
const [curJson, priceJson] = await Promise.all([
bpFetchJson(`https://backpack.tf/api/IGetCurrencies/v1?key=${CONFIG.backpackTfKey}`),
bpFetchJson(`https://backpack.tf/api/IGetPrices/v4?raw=1&key=${CONFIG.backpackTfKey}`),
]);
const cur = curJson?.response;
const prices = priceJson?.response;
if (!cur?.success || !prices?.success) return;
const items = {};
for (const [name, data] of Object.entries(prices.items || {})) {
const out = {};
for (const [q, byTradable] of Object.entries(data.prices || {})) {
const byCraft = byTradable.Tradable;
if (!byCraft) continue;
for (const [craft, entry] of Object.entries(byCraft)) {
const ck = craft === 'Craftable' ? 'c' : 'u';
if (Array.isArray(entry)) {
if (entry[0]?.value_raw != null) out[q + ck] = Math.round(entry[0].value_raw * 100) / 100;
} else if (entry && typeof entry === 'object') {
// keyed by priceindex: crate series / unusual effect id
const sub = {};
for (const [idx, e] of Object.entries(entry)) {
if (e?.value_raw != null) sub[idx] = Math.round(e.value_raw * 100) / 100;
}
if (Object.keys(sub).length) out[q + ck + 'x'] = sub;
}
}
}
if (Object.keys(out).length) items[name] = out;
}
bptf = {
t: Date.now(),
keyRefined: cur.currencies?.keys?.price?.value ?? null,
items,
};
try { localStorage.setItem(BPTF_LS_KEY, JSON.stringify(bptf)); } catch { /* quota */ }
// re-augment so already-scanned items pick up bp.tf values
document.querySelectorAll('.item[data-sia]').forEach((el) => {
if (!el.querySelector('.sia-bptf') && !el.dataset.siaBptf) delete el.dataset.sia;
});
valEpoch++;
queueScan();
resortIfNeeded();
} catch { /* bp.tf unreachable or bad key; retry next session */ }
}
// bp.tf keys items by schema base name: no "The", no quality/killstreak/
// festivized prefixes, no wear suffix — try progressively stripped candidates
function bptfRecOf(d) {
if (!bptf?.items) return null;
const seen = new Set();
const candidates = [];
const push = (n) => { if (n && !seen.has(n)) { seen.add(n); candidates.push(n); } };
for (const raw of [d.market_hash_name, d.name]) {
if (!raw) continue;
let n = String(raw);
push(n);
n = n.replace(/^(Strange|Vintage|Genuine|Haunted|Collector's|Normal|Unusual) /, '');
push(n);
n = n.replace(/^Festivized /, '')
.replace(/^(Professional Killstreak|Specialized Killstreak|Killstreak) /, '');
push(n);
push(n.replace(/^The /, ''));
push(n.replace(/ \((Factory New|Minimal Wear|Field-Tested|Well-Worn|Battle Scarred)\)$/, ''));
}
for (const n of candidates) {
if (bptf.items[n]) return bptf.items[n];
}
return null;
}
// refined value for a TF2 description, or null
function bptfRefOf(d, info) {
const rec = bptfRecOf(d);
if (!rec) return null;
const q = BPTF_QUALITY[tagName(tagOf(d, 'Quality'))] ?? 6;
const ck = info?.uncraftable ? 'u' : 'c';
// unusuals are priced per effect id
if (q === 5) {
const id = info?.effect != null ? EFFECT_IDS[info.effect] : null;
return (id != null ? rec['5cx']?.[id] : null) ?? null;
}
let v = rec[q + ck];
if (v == null && info?.series) v = rec[q + ck + 'x']?.[info.series];
if (v == null && ck === 'u') v = rec[q + 'c'];
return v ?? null;
}
const formatRef = (rv) => {
if (bptf?.keyRefined && rv >= bptf.keyRefined) {
return (rv / bptf.keyRefined).toFixed(1) + ' keys';
}
return (rv >= 100 ? Math.round(rv) : rv.toFixed(2)) + ' ref';
};
// ------------------------------------------------------------------
// Trade draft saver — your side survives a closed window
// ------------------------------------------------------------------
const tradePartnerKey = () =>
'siaDraft_' + ((new URLSearchParams(location.search)).get('partner') ||
String(location.pathname.match(/tradeoffer\/(\w+)/)?.[1] || 'x'));
function saveDraft() {
if (!CONFIG.draftSaver || !document.getElementById('trade_yours')) return;
// store app and context too — restoring must work even when a different
// game tab is active in the trade window
const ids = [...document.querySelectorAll('#your_slots .item')]
.map((e) => e.rgItem &&
`${e.rgItem.appid}_${e.rgItem.contextid}_${e.rgItem.id || e.rgItem.assetid}`)
.filter((s) => s && !s.includes('undefined'));
try {
if (ids.length) localStorage.setItem(tradePartnerKey(), JSON.stringify(ids));
} catch { /* quota */ }
}
// resolve a draft entry ("app_ctx_assetid", or a bare assetid from an old
// draft) to the item element, looking in the owning inventory
function draftElementOf(entry) {
const m = String(entry).match(/^(\d+)_(\d+)_(\d+)$/);
if (m) {
try {
const inv = W.UserYou?.getInventory?.(m[1], m[2]);
const a = inv && (inv.rgInventory || inv.m_rgAssets)?.[m[3]];
if (a && a.element) return a.element;
} catch { /* context not loaded yet */ }
}
const inv2 = W.g_ActiveInventory;
const a2 = inv2 && (inv2.rgInventory || inv2.m_rgAssets)?.[String(entry).split('_').pop()];
return (a2 && a2.element) || null;
}
function ensureDraftButton(bar) {
if (!CONFIG.draftSaver || !document.getElementById('trade_yours')) return;
if (document.getElementById('sia-draft') ||
document.querySelectorAll('#your_slots .item').length) return;
let ids = [];
try { ids = JSON.parse(localStorage.getItem(tradePartnerKey())) || []; } catch { /* none */ }
if (!ids.length) return;
const btn = document.createElement('button');
btn.id = 'sia-draft';
btn.type = 'button';
btn.textContent = `🕐${ids.length}`;
btn.title = `Restore draft: put your last ${ids.length} selected items back into the trade`;
btn.addEventListener('click', () => {
let moved = 0;
for (const id of ids) {
const el = draftElementOf(id);
if (el) {
moveToTradeSafe(el);
moved++;
}
}
afterTradeBatch();
// clicked before the inventory finished loading: keep the draft
// (and the button) instead of silently losing it
if (moved) btn.remove();
});
bar.appendChild(btn);
}
// ------------------------------------------------------------------
// Update checker — runs every page load (fetch throttled to 10 min);
// the last known remote version shows a banner instantly
// ------------------------------------------------------------------
async function checkForUpdate() {
if (!CONFIG.updateURL) return;
const local = (typeof GM_info !== 'undefined' && GM_info.script?.version) || '0';
const cmp = (a, b) => {
const A = a.split('.').map(Number), B = b.split('.').map(Number);
for (let i = 0; i < 3; i++) {
const d = (A[i] || 0) - (B[i] || 0);
if (d) return d;
}
return 0;
};
const showBanner = (remote) => {
// the toolbar is usually still being built when this runs —
// retry attaching for a while instead of dropping the banner
let tries = 30;
const attach = () => {
const bar = document.getElementById('sia-bar');
if (!bar) {
if (tries-- > 0) setTimeout(attach, 1000);
return;
}
let a = document.getElementById('sia-update');
if (!a) {
a = document.createElement('a');
a.id = 'sia-update';
a.href = CONFIG.updateURL;
a.target = '_blank';
a.title = 'Opens the install dialog with the new version';
a.style.color = '#FFD700';
bar.appendChild(a);
}
a.textContent = `Update ${remote} available`;
};
attach();
};
// version seen on a previous check: banner without waiting for the fetch
const known = localStorage.getItem('siaRemoteVer');
if (known && cmp(known, local) > 0) showBanner(known);
const last = +localStorage.getItem('siaUpdateChecked') || 0;
if (Date.now() - last < 600e3) return; // fresh enough — skip the fetch
const txt = await gmFetchText(CONFIG.updateURL);
const remote = txt?.match(/@version\s+([\d.]+)/)?.[1];
if (!remote) return; // fetch failed — no stamp, next load retries
localStorage.setItem('siaUpdateChecked', String(Date.now()));
localStorage.setItem('siaRemoteVer', remote);
if (cmp(remote, local) > 0) showBanner(remote);
}
// ------------------------------------------------------------------
// Price indicator — per-item queue with cooldown, cached in localStorage
// ------------------------------------------------------------------
const PRICE_LS_KEY = 'siaPriceCache';
let priceCache = {};
try { priceCache = JSON.parse(localStorage.getItem(PRICE_LS_KEY)) || {}; } catch { /* fresh */ }
const savePriceCache = () => {
try { localStorage.setItem(PRICE_LS_KEY, JSON.stringify(priceCache)); } catch { /* full */ }
};
// prune fossils on load so the cache doesn't grow without bound
{
const cutoff = Date.now() - 7 * 86400e3;
let dropped = 0;
for (const k of Object.keys(priceCache)) {
if (!priceCache[k] || !(priceCache[k].t > cutoff)) {
delete priceCache[k];
dropped++;
}
}
if (dropped) savePriceCache();
}
const priceQueue = [];
const priceQueued = new Set();
let pricePumpRunning = false;
let priceCurrentJob = null; // mhn being fetched right now, for the hover list
let priceBackoffUntil = 0; // 429 cool-off deadline, shown in the progress note
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// "$1.23" / "1,23₺" / "1.250,50 pуб." -> number (2-digit tail = decimals)
function parsePrice(s) {
const cleaned = String(s || '').replace(/[^\d.,]/g, '');
if (!cleaned) return NaN;
const dec = Math.max(cleaned.lastIndexOf('.'), cleaned.lastIndexOf(','));
if (dec === -1) return Number(cleaned);
const frac = cleaned.slice(dec + 1).replace(/\D/g, '');
const intDigits = cleaned.slice(0, dec).replace(/\D/g, '');
return frac.length === 2
? Number(intDigits) + Number(frac) / 100
: Number(intDigits + frac);
}
const cachedPriceOf = (d) => {
const rec = priceCache[`${d.appid}||${d.market_hash_name}`];
return rec ? parsePrice(rec.lp || rec.mp) : NaN;
};
// cheap items live on a slow refresh cycle; everything else on the fast one
const priceFresh = (rec) => {
if (!rec) return false;
const n = parsePrice(rec.lp || rec.mp);
const mins = (!Number.isNaN(n) && n <= CONFIG.cheapPriceThreshold)
? CONFIG.cheapPriceCacheMins
: CONFIG.priceCacheMins;
return (Date.now() - rec.t) < mins * 60000;
};
// after the main queue drains, quietly re-queue stale visible items —
// oldest first, cheapest last
let lastStaleSweep = 0;
function refreshStalePrices() {
if (pricePumpRunning || priceQueue.length) return;
if (Date.now() - lastStaleSweep < 5 * 60000) return;
lastStaleSweep = Date.now();
const jobs = [];
for (const el of document.querySelectorAll('[data-sia-pk]')) {
const key = el.dataset.siaPk;
const rec = priceCache[key];
if (!rec || priceFresh(rec) || priceQueued.has(key)) continue;
const n = parsePrice(rec.lp || rec.mp);
const cheap = !Number.isNaN(n) && n <= CONFIG.cheapPriceThreshold;
const [appid, mhn] = key.split('||');
priceQueued.add(key);
jobs.push({ key, appid, mhn, cheap, t: rec.t });
}
jobs.sort((a, b) => (a.cheap - b.cheap) || (a.t - b.t));
priceQueue.push(...jobs);
if (jobs.length) pumpPrices();
}
function renderPrice(el, rec) {
let text = rec.lp || rec.mp;
if (!text) return;
let span = el.querySelector('.sia-price');
if (!span) {
span = document.createElement('span');
span.className = 'sia-price';
el.appendChild(span);
}
span.classList.remove('sia-pending');
// optionally show the highest buy order — the instant-sell value
if (CONFIG.showBuyOrders && rec.bo) {
text = fmtMoney(rec.bo, rec.lp || rec.mp || '$');
}
span.innerHTML = '';
const ico = document.createElement('img');
ico.src = STEAM_ICON;
ico.style.cssText = 'width:10px !important;height:10px !important;flex:0 0 10px;object-fit:contain';
span.append(ico, text);
span.title = (rec.bo ? `Buy order: ${fmtMoney(rec.bo, rec.lp || rec.mp || '$')} · ` : '') +
`Lowest: ${rec.lp || '?'} · Median: ${rec.mp || '?'}` +
(el.dataset.siaBptf ? ` · bp.tf: ${el.dataset.siaBptf} ref` : '');
}
// Valve market fee math: buyer pays net + 5% (min 1¢) + 10% (min 1¢)
const marketFees = (net) => Math.max(1, Math.floor(net * 0.05)) + Math.max(1, Math.floor(net * 0.10));
function sellerNetCents(grossCents) {
let net = Math.floor(grossCents / 1.15);
while (net > 1 && net + marketFees(net) > grossCents) net--;
while (net + 1 + marketFees(net + 1) <= grossCents) net++;
return Math.max(1, net);
}
function rerenderAllPrices() {
document.querySelectorAll('[data-sia-pk]').forEach((el) => {
const rec = priceCache[el.dataset.siaPk];
if (rec) renderPrice(el, rec);
});
}
let keyPriceQueued = false;
function ensureKeyPrice() {
// the key's market price anchors currency->ref conversion for value sorts
if (!CONFIG.priceIndicator || keyPriceQueued) return;
const key = '440||Mann Co. Supply Crate Key';
if (priceFresh(priceCache[key]) || priceQueued.has(key)) { keyPriceQueued = true; return; }
keyPriceQueued = true;
priceQueued.add(key);
priceQueue.unshift({ key, appid: 440, mhn: 'Mann Co. Supply Crate Key' });
pumpPrices();
}
function requestPrice(el, d) {
const key = `${d.appid}||${d.market_hash_name}`;
el.dataset.siaPk = key;
const rec = priceCache[key];
if (priceFresh(rec)) {
renderPrice(el, rec);
// fresh price but buy order still missing: queue an orderbook backfill
if (!(CONFIG.showBuyOrders && rec.bo == null && (rec.lp || rec.mp))) return;
}
// grey placeholder so it's visible the price is still loading
if (!el.querySelector('.sia-price')) {
const span = document.createElement('span');
span.className = 'sia-price sia-pending';
span.textContent = '…';
span.title = 'Fetching market price…';
el.appendChild(span);
}
if (!priceQueued.has(key)) {
priceQueued.add(key);
priceQueue.push({ key, appid: d.appid, mhn: d.market_hash_name });
}
pumpPrices();
}
async function pumpPrices() {
if (pricePumpRunning) return;
pricePumpRunning = true;
let fetched = 0;
try {
while (priceQueue.length) {
if (document.hidden) { await sleep(5000); continue; } // don't burn quota in background
const job = priceQueue.shift();
priceCurrentJob = job.mhn;
try {
// backfill-only job: price is fresh, just the buy order is missing
const cachedRec = priceCache[job.key];
// cheap junk doesn't need a buy-order request — mark checked
if (CONFIG.showBuyOrders && cachedRec && cachedRec.bo == null) {
const n0 = parsePrice(cachedRec.lp || cachedRec.mp);
if (!Number.isNaN(n0) && n0 <= CONFIG.cheapPriceThreshold) cachedRec.bo = 0;
}
if (CONFIG.showBuyOrders && priceFresh(cachedRec) &&
cachedRec.bo == null && (cachedRec.lp || cachedRec.mp)) {
const ob = await fetchOrderbook(job.appid, job.mhn).catch(() => null);
cachedRec.bo = ob?.buyCents ? ob.buyCents / 100 : 0; // 0 = checked, none
savePriceCache();
document.querySelectorAll(`[data-sia-pk="${CSS.escape(job.key)}"]`)
.forEach((el2) => renderPrice(el2, cachedRec));
priceQueued.delete(job.key);
fetched++;
await sleep(CONFIG.priceCooldownMs);
continue;
}
const currency = W.g_rgWalletInfo?.wallet_currency || 1;
const res = await fetch(`/market/priceoverview/?appid=${job.appid}` +
`¤cy=${currency}&market_hash_name=${encodeURIComponent(job.mhn)}`,
{ credentials: 'same-origin' });
if (res.status === 429) {
priceQueue.unshift(job); // rate limited: retry after a minute
priceBackoffUntil = Date.now() + 60000;
// nothing mutates the page during the wait, so the countdown
// would freeze — tick a scan every few seconds to redraw it
for (let w = 0; w < 12; w++) {
await sleep(5000);
queueScan();
}
priceBackoffUntil = 0;
continue;
}
const j = res.ok ? await res.json() : null;
// cache misses too (success:false = not on market) so we don't refetch them
const rec = { lp: j?.lowest_price || null, mp: j?.median_price || null, t: Date.now() };
// buy-order mode: one extra order-book request per item, same pacing;
// cheap junk skips it — its buy order is a cent anyway
if (CONFIG.showBuyOrders && (rec.lp || rec.mp)) {
const n1 = parsePrice(rec.lp || rec.mp);
if (!Number.isNaN(n1) && n1 <= CONFIG.cheapPriceThreshold) {
rec.bo = 0;
} else {
await sleep(CONFIG.priceCooldownMs);
const ob = await fetchOrderbook(job.appid, job.mhn).catch(() => null);
rec.bo = ob?.buyCents ? ob.buyCents / 100 : 0; // 0 = checked, none
}
}
priceCache[job.key] = rec;
savePriceCache();
priceQueued.delete(job.key); // allow future stale refreshes
fetched++;
if (job.mhn === 'Mann Co. Supply Crate Key') {
valEpoch++; // the key rate anchors every currency->ref conversion
rerenderAllPrices();
} else {
document.querySelectorAll(`[data-sia-pk="${CSS.escape(job.key)}"]`)
.forEach((el) => renderPrice(el, rec));
}
} catch {
// network hiccup: release the key or the item could never
// re-enter the queue for the rest of the session
priceQueued.delete(job.key);
}
await sleep(CONFIG.priceCooldownMs);
}
} finally {
// whatever happens, the pump must be restartable — a stuck flag
// freezes the queue and the progress bar forever
pricePumpRunning = false;
priceCurrentJob = null;
priceBackoffUntil = 0;
}
// prices changed the ordering data: drop the per-item value caches
// (they only re-key on valEpoch) and keep an active price sort truthful
if (fetched) {
valEpoch++;
resortIfNeeded();
}
}
function resortIfNeeded() {
// only the price sort depends on fetched data; name/type/quality never
// change, and a full relayout of a big inventory is expensive
const key = document.getElementById('sia-sort')?.value;
if (key === 'price' && !document.hidden) runLayout();
}
// ------------------------------------------------------------------
// Scanner — covers inventory pages and both sides of trade offers
// ------------------------------------------------------------------
function iconLink(href, src, title) {
const a = document.createElement('a');
a.href = href;
a.target = '_blank';
a.rel = 'noopener';
a.title = title;
const img = document.createElement('img');
img.src = src;
a.appendChild(img);
return a;
}
// bp.tf + market icons on the selected-item info panel (React mount #iteminfoN)
function injectPanelLinks() {
if (!CONFIG.panelLinks) return;
const sel = W.g_ActiveInventory?.selectedItem;
if (!sel) return;
const d = descOf(sel);
if (!d) return;
for (const id of ['iteminfo0', 'iteminfo1']) {
const panel = document.getElementById(id);
if (!panel || panel.style.display === 'none' || !panel.firstElementChild?.firstElementChild) continue;
let box = panel.querySelector('.sia-panel-links');
if (!box) {
box = document.createElement('div');
box.className = 'sia-panel-links';
panel.appendChild(box);
}
const selId = String(sel.assetid || sel.id || d.market_hash_name || d.name);
if (box.dataset.for === selId) continue;
box.dataset.for = selId;
box.innerHTML = '';
// selected item jumps the price queue
if (d.market_hash_name) {
const qi = priceQueue.findIndex((j) => j.key === `${d.appid}||${d.market_hash_name}`);
if (qi > 0) priceQueue.unshift(priceQueue.splice(qi, 1)[0]);
}
if (String(d.appid) === '440') {
const base = (d.market_hash_name || d.name || '')
.replace(/^(Strange|Vintage|Genuine|Haunted|Collector's|Normal|Unusual) /, '');
const q = tagName(tagOf(d, 'Quality')) || 'Unique';
const info = tf2Info(d);
let stats = `https://backpack.tf/stats/${encodeURIComponent(q)}/` +
`${encodeURIComponent(base)}/Tradable/${info.uncraftable ? 'Non-Craftable' : 'Craftable'}`;
if (info.series) stats += `/${info.series}`;
box.appendChild(iconLink(stats, BPTF_ICON, 'backpack.tf price history'));
}
const mkt = d.marketable && d.market_hash_name
? `https://steamcommunity.com/market/listings/${d.appid}/${encodeURIComponent(d.market_hash_name)}`
: `https://steamcommunity.com/market/search?appid=${d.appid}&q=${encodeURIComponent(d.name || '')}`;
box.appendChild(iconLink(mkt, MARKET_ICON, 'Steam market'));
if (canQuicksell(d)) addQuicksellButton(box, sel, d);
// "Add all N" on trade pages when you own several copies
if (document.getElementById('trade_yours')) {
const copies = copiesOf(sel);
if (copies.length > 1) {
const btn = document.createElement('a');
btn.href = 'javascript:void(0)';
btn.className = 'sia-addall';
btn.textContent = `+${copies.length}`;
btn.title = `Add all ${copies.length} copies to the trade`;
btn.addEventListener('click', () => {
copiesOf(sel).forEach((a) => moveToTradeSafe(a.element));
afterTradeBatch();
});
box.appendChild(btn);
}
}
}
}
// ------------------------------------------------------------------
// Quicksell — list at instant-sell (highest buy order)
// or undercut (lowest listing − 0.01). Own inventory, logged in only.
// ------------------------------------------------------------------
const canQuicksell = (d) =>
CONFIG.quicksellButtons && W.g_sessionID && d.marketable && d.market_hash_name &&
!document.getElementById('trade_yours') &&
String(W.g_steamID || '') !== '' &&
String(W.g_ActiveInventory?.m_steamid || W.g_rgProfileData?.steamid || W.g_steamID) === String(W.g_steamID);
async function fetchOrderbook(appid, mhn) {
const params = new URLSearchParams();
params.set('q', 'Load');
params.set('qp', JSON.stringify([Number(appid), mhn]));
const res = await fetch('/market/orderbook?' + params.toString(), {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'SIAModern', 'X-Valve-Request-Type': 'queryAction' },
});
if (!res.ok) return null;
const j = await res.json().catch(() => null);
if (!j?.success || !j.data) return null;
const d = j.data;
// live shape (2026): amtMaxBuyOrder / amtMinSellOrder in cents,
// eCurrency matches the requester's wallet; older field names as fallback
const buy = Number(d.amtMaxBuyOrder) || Number(d.highest_buy_order) || 0;
const sell = Number(d.amtMinSellOrder) || Number(d.lowest_sell_order) || 0;
return { buyCents: buy, sellCents: sell };
}
async function listOnMarket(item, grossCents, label) {
const d = descOf(item);
const net = sellerNetCents(grossCents);
const ok = confirm(`List "${d.name}" at ${(grossCents / 100).toFixed(2)} (${label})?\n` +
`You receive ${(net / 100).toFixed(2)} after fees.`);
if (!ok) return;
const body = new URLSearchParams({
sessionid: W.g_sessionID,
appid: String(item.appid || d.appid),
contextid: String(item.contextid || '2'),
assetid: String(item.assetid || item.id),
amount: '1',
price: String(net),
});
try {
const res = await fetch('https://steamcommunity.com/market/sellitem/', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
const j = await res.json().catch(() => null);
if (j?.success) {
const extra = j.requires_confirmation || j.needs_mobile_confirmation || j.needs_email_confirmation
? ' Confirm the listing in your Steam app/email.' : '';
(W.ShowAlertDialog || ((t, m) => alert(m)))('Listed', `Listing created.${extra}`);
if (item.element) item.element.style.opacity = '.35';
} else {
(W.ShowAlertDialog || ((t, m) => alert(m)))('Listing failed',
j?.message || 'Steam rejected the listing (rate limit or trade restrictions).');
}
} catch {
(W.ShowAlertDialog || ((t, m) => alert(m)))('Listing failed', 'Network error.');
}
}
function addQuicksellButton(box, item, d) {
const trigger = document.createElement('a');
trigger.href = 'javascript:void(0)';
trigger.className = 'sia-qs';
trigger.textContent = '⚡';
trigger.title = 'Quicksell: fetch buy orders and list this item';
trigger.addEventListener('click', async () => {
trigger.textContent = '…';
const ob = await fetchOrderbook(d.appid, d.market_hash_name);
trigger.remove();
if (!ob || (!ob.buyCents && !ob.sellCents)) {
box.appendChild(Object.assign(document.createElement('span'),
{ className: 'sia-qs', textContent: 'no orders' }));
return;
}
if (ob.buyCents) {
const b = trigger.cloneNode();
b.textContent = `⚡${(ob.buyCents / 100).toFixed(2)}`;
b.title = 'Instant: list at the highest buy order';
b.addEventListener('click', () => listOnMarket(item, ob.buyCents, 'instant'));
box.appendChild(b);
}
if (ob.sellCents > 1) {
const u = trigger.cloneNode();
u.textContent = `↓${((ob.sellCents - 1) / 100).toFixed(2)}`;
u.title = 'Undercut: list 0.01 under the lowest listing';
u.addEventListener('click', () => listOnMarket(item, ob.sellCents - 1, 'undercut'));
box.appendChild(u);
}
});
box.appendChild(trigger);
}
// all copies (same stack key) of an item in the active inventory, not yet in trade
function copiesOf(item) {
const inv = W.g_ActiveInventory;
const assets = assetList(inv && (inv.m_rgAssets || inv.rgInventory));
const key = stackKeyOf(item);
const inTrade = new Set([...document.querySelectorAll('#your_slots .item, #their_slots .item')]
.map((e) => e.rgItem && (e.rgItem.id || e.rgItem.assetid)));
return assets.filter((a) => a && a.element && stackKeyOf(a) === key &&
!inTrade.has(a.id || a.assetid));
}
// when items move in/out of trade slots while stacking is on, the stacked
// layout goes stale (copies reappear unmerged) — re-apply it
let lastSlotSig = '';
function checkTradeSlots() {
if (!document.getElementById('trade_yours')) return;
const sig = [...document.querySelectorAll('#your_slots .item, #their_slots .item')]
.map((e) => e.rgItem && (e.rgItem.id || e.rgItem.assetid)).join(',');
if (sig !== lastSlotSig) {
lastSlotSig = sig;
if (stackOn) runLayout();
fixSlotImages(); // also covers items dragged in without our helpers
updateTradeCompare();
saveDraft();
}
}
// price the items the user is actually looking at first; re-rank on page change
let lastPageSig = '';
function prioritizePriceQueue() {
if (!priceQueue.length) return;
const invEl = getActiveInvEl();
if (!invEl) return;
const visible = new Set();
for (const p of invEl.querySelectorAll('.inventory_page')) {
if (p.style.display === 'none') continue;
for (const el of p.querySelectorAll('.item[data-sia-pk]')) visible.add(el.dataset.siaPk);
}
if (!visible.size) return;
const sig = [...visible].join(',');
if (sig === lastPageSig) return;
lastPageSig = sig;
priceQueue.sort((a, b) => (visible.has(b.key) ? 1 : 0) - (visible.has(a.key) ? 1 : 0));
}
// stacking artifacts (×N badges, hidden holders, stashed engine arrays) must
// never survive with the toggle off — tab switches on trade pages can race
// the normal cleanup, so self-heal on every scan
function healStackState() {
if (stackOn) return;
const leftovers = document.querySelector('.sia-count, .sia-stacked-holder');
const inv = W.g_ActiveInventory;
const stashed = inv && (v2Stashes.has(inv) ||
assetList(inv.m_rgChildInventories).some((k) => v2Stashes.has(k)));
if (!leftovers && !stashed) return;
clearStackBadges();
document.querySelectorAll('.sia-stacked-holder').forEach((h) => {
h.classList.remove('sia-stacked-holder');
});
document.querySelectorAll('[data-sia-stacked]').forEach((h) => {
delete h.dataset.siaStacked;
h.filtered = false;
h.style.display = '';
});
if (stashed) runLayout(); // restores the engine arrays + repaginates
else refilter(); // un-hidden holders must still respect an active filter
}
let scanQueued = false;
// price every sellable asset, not just the ones with rendered elements —
// off-page items may never touch the DOM on the responsive engine
function queueMissingPrices() {
if (!CONFIG.priceIndicator) return;
let added = 0;
for (const d of activeDescs()) {
if (!priceable(d)) continue;
const key = `${d.appid}||${d.market_hash_name}`;
if (priceQueued.has(key) || priceFresh(priceCache[key])) continue;
priceQueued.add(key);
priceQueue.push({ key, appid: d.appid, mhn: d.market_hash_name });
added++;
}
if (added) pumpPrices();
}
// totals need every asset, but Steam only loads inventory pages on demand;
// request the rest once per inventory (opening the advanced filters used
// to be the accidental way to trigger this load)
function ensureFullLoad() {
if (!CONFIG.priceIndicator && !CONFIG.metalCounter) return;
if (!document.getElementById('tabcontent_inventory')) return; // inventory pages only
const inv = W.g_ActiveInventory;
if (!inv || typeof inv.LoadCompleteInventory !== 'function') return;
const done = () => {
if (!inv._siaQueuedAll) {
inv._siaQueuedAll = 1;
queueMissingPrices();
}
lastBarUpdate = 0;
queueScan();
};
if (typeof inv.BIsFullyLoaded === 'function' && inv.BIsFullyLoaded()) {
if (!inv._siaQueuedAll) done();
return;
}
if (inv._siaLoadReq) return;
inv._siaLoadReq = 1;
const retry = () => setTimeout(() => { inv._siaLoadReq = 0; queueScan(); }, 5000);
try { Promise.resolve(inv.LoadCompleteInventory()).then(done, retry); }
catch { retry(); }
}
function scan() {
scanQueued = false;
document.querySelectorAll('.item:not([data-sia])').forEach(augment);
healStackState();
ensureFullLoad();
if (priceQueue.length && !pricePumpRunning) pumpPrices(); // self-restart
ensureKeyPrice();
injectPanelLinks();
checkTradeSlots();
prioritizePriceQueue();
refreshStalePrices();
updateBarThrottled();
}
function queueScan() {
if (scanQueued) return;
scanQueued = true;
setTimeout(scan, 250);
}
new MutationObserver(queueScan).observe(document.body,
{ childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
W.addEventListener('hashchange', queueScan);
queueScan();
refreshBptf();
checkForUpdate();
localStorage.removeItem('siaAltCache'); // alt-account feature removed
// trade-page click helpers: shift+click = instant add;
// click on a ×N stack (stacking on) = "how many?" then add that many
if (CONFIG.shiftClickAdd || CONFIG.stacking) {
document.addEventListener('click', (ev) => {
if (!document.getElementById('trade_yours')) return;
const item = ev.target.closest?.('.item');
if (!item || !item.rgItem || !item.closest('#inventories')) return;
if (ev.shiftKey && CONFIG.shiftClickAdd) {
ev.preventDefault();
ev.stopPropagation();
moveToTradeSafe(item);
afterTradeBatch();
} else if (stackOn && item.querySelector('.sia-count')) {
ev.preventDefault();
ev.stopPropagation();
const copies = copiesOf(item.rgItem);
const raw = prompt(`Add how many? (1-${copies.length})`, '1');
const n = Math.min(copies.length, Math.max(0, parseInt(raw, 10) || 0));
copies.slice(0, n).forEach((a) => moveToTradeSafe(a.element));
afterTradeBatch(); // also unsticks the hover the prompt froze
}
}, true);
}
// ------------------------------------------------------------------
// Toolbar: sort, stack dupes, metal counter
// ------------------------------------------------------------------
const QUALITY_ORDER = ['Unusual', "Collector's", 'Decorated Weapon', 'Haunted', 'Strange',
'Vintage', 'Genuine', 'Self-Made', 'Community', 'Valve', 'Unique', 'Normal'];
const tagOf = (d, category) =>
(d.tags || []).find((t) => (t.category || t.category_name) === category);
const tagName = (t) => (t && (t.localized_tag_name || t.name)) || '';
const getActiveInvEl = () => [...document.querySelectorAll('#inventories .inventory_ctn')]
.find((el) => el.style.display !== 'none');
const holdersOf = (invEl) => [...invEl.querySelectorAll('.inventory_page .itemHolder')];
const itemsOf = (invEl) => [...invEl.querySelectorAll('.inventory_page .itemHolder > .item')]
.filter((el) => el.rgItem);
// asset ids are numeric strings too large to trust as floats — compare by
// length first, then lexicographically
const idCmp = (x, y) => (x.length - y.length) || (x < y ? -1 : x > y ? 1 : 0);
const SORTS = {
name: (a, b) => a.name.localeCompare(b.name),
type: (a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name),
quality: (a, b) => a.quality - b.quality || a.name.localeCompare(b.name),
price: (a, b) => b.price - a.price || a.name.localeCompare(b.name),
// newer items get higher asset ids
newest: (a, b) => idCmp(b.aid, a.aid) || a.name.localeCompare(b.name),
};
// sortable value in refined: metal at face value, bp.tf value for TF2 items,
// market price converted through the key rate as fallback
function refValueFor(d) {
const mv = METAL_REF[d.name];
if (mv) return mv;
if (String(d.appid) === '440' && bptf) {
const rv = bptfRefOf(d, tf2Info(d));
if (rv != null) return rv;
}
if (priceable(d)) {
const n = cachedPriceOf(d);
if (!Number.isNaN(n)) {
const keyRec = priceCache['440||Mann Co. Supply Crate Key'];
const keyNum = keyRec ? parsePrice(keyRec.lp || keyRec.mp) : NaN;
return keyNum > 0 && bptf?.keyRefined ? n / (keyNum / bptf.keyRefined) : n;
}
}
return -1;
}
// per-description caches: these run over whole inventories every card tick,
// and the underlying regex/lookup work never changes until bp.tf or the key
// rate refreshes (valEpoch) — cache aggressively
let valEpoch = 0;
const refValCache = new WeakMap();
function refValueCached(d) {
const hit = refValCache.get(d);
if (hit && hit.e === valEpoch) return hit.v;
const v = refValueFor(d);
refValCache.set(d, { e: valEpoch, v });
return v;
}
const lockCache = new WeakMap();
function lockDaysCached(d) {
if (lockCache.has(d)) return lockCache.get(d);
const v = lockDaysOf(d);
lockCache.set(d, v);
return v;
}
function entryFor(el) {
const d = descOf(el.rgItem) || {};
const q = tagName(tagOf(d, 'Quality') || tagOf(d, 'Rarity'));
const qIdx = QUALITY_ORDER.indexOf(q);
return {
el,
name: String(d.name || '').toLowerCase(),
type: tagName(tagOf(d, 'Type')) || String(d.type || ''),
quality: qIdx === -1 ? 99 : qIdx,
price: refValueCached(d),
aid: String(el.rgItem.id || el.rgItem.assetid || 0),
};
}
const stackKeyOf = (item) => {
const d = descOf(item) || {};
return d.market_hash_name || d.name || String(item.classid || '?');
};
function addCountBadge(holderOrItem, count) {
const item = holderOrItem.classList?.contains('item')
? holderOrItem : holderOrItem.querySelector('.item');
if (!item) return;
const c = document.createElement('span');
c.className = 'sia-count';
c.textContent = `×${count}`;
item.appendChild(c);
}
const clearStackBadges = () =>
document.querySelectorAll('.sia-count').forEach((el) => el.remove());
const refilter = () => {
try { W.Filter?.ReApplyFilter?.(); } catch { /* filter UI not present */ }
};
// --- v2 engine (economy_v2.js): m_rgItemElements is the source of truth ---
// holders in m_rgItemElements are jQuery objects; be tolerant of raw elements too
const holderEl = (h) => (h && h[0] instanceof Element) ? h[0]
: (h instanceof Element ? h : null);
const v2Stashes = new WeakMap(); // inv -> {elements, mItems, mNext} while stacked
const v2StackCounts = new WeakMap(); // inv -> Map(stackKey -> count) while stacked
function restoreV2Stack(inv) {
const st = v2Stashes.get(inv);
if (!st) return;
inv.m_rgItemElements = st.elements;
inv.m_cItems = st.mItems;
inv.m_iNextEmptyItemElement = st.mNext;
v2Stashes.delete(inv);
v2StackCounts.delete(inv);
}
// repaginate + reload images for the current page; shared by plain and
// multi-context ("all" view) inventories
function v2Relayout(inv) {
const per = W.INVENTORY_PAGE_ITEMS || 25;
const total = (Array.isArray(inv.m_rgItemElements) && inv.m_rgItemElements.length) ||
(typeof inv.GetCountTotalItems === 'function' ? inv.GetCountTotalItems() : 0);
const cPages = Math.max(1, Math.ceil(total / per));
if (inv.m_iCurrentPage >= cPages) inv.m_iCurrentPage = 0;
inv.m_bNeedsRepagination = true;
inv.LayoutPages();
inv.ShowPageControlsIfNeeded?.();
// reordered-in items may never have had images loaded; the responsive
// single page is reused across layouts and keeps its images-loaded flag
const pg = inv.m_rgPages && inv.m_rgPages[inv.m_iCurrentPage];
if (pg) {
pg.m_bImagesLoaded = false;
try { pg.LoadPageImages?.(); } catch { /* not built yet */ }
}
refilter();
}
function applyV2(inv, sortKey, stack, deferLayout) {
restoreV2Stack(inv);
// re-capture if the inventory grew since we first saw it (late loads)
if (!inv._siaOrig || inv._siaOrig.length !== inv.m_rgItemElements.length) {
inv._siaOrig = inv.m_rgItemElements.slice();
}
// badge clearing is global — in the multi-context loop the caller clears
// once up front, or each child would wipe the previous child's badges
if (!deferLayout) clearStackBadges();
let arr;
if (sortKey === 'default') {
arr = inv._siaOrig.slice();
} else {
const withItem = [], rest = [];
for (const h of inv._siaOrig) {
const el = holderEl(h);
(el && el.rgItem ? withItem : rest).push(h);
}
const entries = withItem.map((h) => ({ h, ...entryFor(holderEl(h)) }));
entries.sort(SORTS[sortKey] || SORTS.name);
arr = entries.map((e) => e.h).concat(rest);
}
if (stack) {
const stash = {
elements: arr.slice(),
mItems: inv.m_cItems,
mNext: inv.m_iNextEmptyItemElement,
};
const groups = new Map();
const reps = [];
for (const h of arr) {
const el = holderEl(h);
if (!el || !el.rgItem) continue;
const key = stackKeyOf(el.rgItem);
const rec = groups.get(key);
if (rec) { rec.count++; continue; }
const r = { h, count: 1 };
groups.set(key, r);
reps.push(r);
}
for (const r of reps) if (r.count > 1) addCountBadge(holderEl(r.h), r.count);
inv.m_rgItemElements = reps.map((r) => r.h);
inv.m_cItems = reps.length;
inv.m_iNextEmptyItemElement = reps.length;
v2Stashes.set(inv, stash);
v2StackCounts.set(inv, new Map([...groups].map(([k, r]) => [k, r.count])));
} else {
inv.m_rgItemElements = arr;
}
if (!deferLayout) v2Relayout(inv);
}
// --- legacy engine (economy.js, trade offer pages): DOM pages in pageList ---
const legacyItemOf = (h) => {
const it = h.querySelector?.('.item');
return it && it.rgItem ? it : null;
};
function applyLegacy(inv, sortKey, stack) {
const per = W.INVENTORY_PAGE_ITEMS || 16;
const pages = inv.pageList;
if (!pages || !pages.length) return;
const holders = [];
for (const p of pages) {
for (const c of [...p.children]) {
if (c.classList.contains('itemHolder')) holders.push(c);
}
}
holders.forEach((h, i) => { if (!h.dataset.siaIdx) h.dataset.siaIdx = String(i + 1); });
clearStackBadges();
for (const h of holders) {
if (h.dataset.siaStacked) {
delete h.dataset.siaStacked;
h.filtered = false;
h.style.display = '';
}
}
const withItem = holders.filter(legacyItemOf);
const rest = holders.filter((h) => !legacyItemOf(h));
let ordered;
if (sortKey === 'default') {
ordered = withItem.slice().sort((a, b) => (+a.dataset.siaIdx) - (+b.dataset.siaIdx));
} else {
const entries = withItem.map((h) => ({ h, ...entryFor(legacyItemOf(h)) }));
entries.sort(SORTS[sortKey] || SORTS.name);
ordered = entries.map((e) => e.h);
}
if (stack) {
const groups = new Map();
const reps = [], extras = [];
for (const h of ordered) {
const key = stackKeyOf(legacyItemOf(h).rgItem);
const rec = groups.get(key);
if (rec) { rec.count++; extras.push(h); continue; }
const r = { h, count: 1 };
groups.set(key, r);
reps.push(r);
}
for (const r of reps) if (r.count > 1) addCountBadge(r.h, r.count);
for (const h of extras) {
h.dataset.siaStacked = '1';
h.filtered = true; // legacy filter counts it as filtered-out
h.style.display = 'none';
}
ordered = reps.map((r) => r.h).concat(extras);
}
ordered = ordered.concat(rest);
ordered.forEach((h, i) => {
pages[Math.min(Math.floor(i / per), pages.length - 1)].appendChild(h);
});
const visible = ordered.filter((h) => !h.filtered && h.style.display !== 'none').length;
inv.pageTotal = Math.max(1, Math.ceil(visible / per));
// only the current page may stay visible — a stray visible page full of
// hidden holders otherwise stretches the inventory box to double height
const cur = inv.pageCurrent < inv.pageTotal ? inv.pageCurrent : 0;
pages.forEach((p, i) => { p.style.display = i === cur ? '' : 'none'; });
if (inv.pageCurrent >= inv.pageTotal) inv.SetActivePage?.(0);
else inv.UpdatePageCounts?.();
// items pulled in from never-visited pages have no image yet, and Steam's
// per-page images_loaded flag would skip them — force a reload
pages.forEach((p) => { p.images_loaded = false; });
try { inv.LoadPageImages?.(pages[cur]); } catch { /* legacy only */ }
refilter();
}
// --- dom fallback (no engine detected): CSS-hide only, no page-count change ---
function applyDom(invEl, sortKey, stack) {
const holders = holdersOf(invEl);
const items = itemsOf(invEl);
items.forEach((el, i) => { if (!el.dataset.siaIdx) el.dataset.siaIdx = String(i + 1); });
holders.forEach((h) => h.classList.remove('sia-stacked-holder'));
clearStackBadges();
let entries = items.map(entryFor);
if (sortKey === 'default') {
entries.sort((a, b) => (+a.el.dataset.siaIdx) - (+b.el.dataset.siaIdx));
} else {
entries.sort(SORTS[sortKey] || SORTS.name);
}
let hiddenFrom = Infinity;
if (stack) {
const groups = new Map();
const reps = [], extras = [];
for (const e of entries) {
const key = stackKeyOf(e.el.rgItem);
const rec = groups.get(key);
if (rec) { rec.count++; extras.push(e); continue; }
const r = { e, count: 1 };
groups.set(key, r);
reps.push(r);
}
for (const r of reps) if (r.count > 1) addCountBadge(r.e.el, r.count);
entries = reps.map((r) => r.e).concat(extras);
hiddenFrom = reps.length;
}
entries.forEach((e, i) => {
const h = holders[i];
if (!h) return;
if (e.el.parentElement !== h) h.appendChild(e.el);
h.classList.toggle('sia-stacked-holder', i >= hiddenFrom);
});
}
function applyFor(inv, sortKey, stack) {
if (inv && Array.isArray(inv.m_rgItemElements) && typeof inv.LayoutPages === 'function') {
if (inv.m_contextid === (W.APPWIDE_CONTEXT ?? 0)) {
// multi-context "all" view: NEVER touch its engine arrays — its
// pages are rebuilt from child element lists under strict
// index/count invariants, and reordering them orphans the real
// item elements (blank inventory). Reorder the rendered DOM only.
const invEl = getActiveInvEl();
if (invEl) applyDom(invEl, sortKey, stack);
return;
}
applyV2(inv, sortKey, stack);
} else if (inv && Array.isArray(inv.pageList)) {
applyLegacy(inv, sortKey, stack);
} else {
const invEl = getActiveInvEl();
if (invEl) applyDom(invEl, sortKey, stack);
}
}
// keep Steam's own filter from resurrecting stacked-away holders (legacy engine)
function wrapMatchItem() {
const F = W.Filter;
if (!F || !F.MatchItem || F._siaWrapped) return;
const orig = F.MatchItem;
F.MatchItem = function (elItem, ...rest) {
const holder = elItem && elItem.closest ? elItem.closest('.itemHolder') : null;
if (holder && holder.dataset.siaStacked) return false;
return orig.call(this, elItem, ...rest);
};
F._siaWrapped = true;
}
wrapMatchItem();
// descriptions of everything in the active inventory: engine assets when
// loaded, child-context assets for the multi-context "all" view (its own
// asset map stays empty), rendered item elements as the last resort
function activeDescs() {
const inv = W.g_ActiveInventory;
const out = [];
const takeFrom = (i) => {
for (const a of assetList(i && (i.m_rgAssets || i.rgInventory))) {
const d = descOf(a);
if (d) out.push(d);
}
};
takeFrom(inv);
if (!out.length && inv?.m_rgChildInventories) {
for (const child of assetList(inv.m_rgChildInventories)) takeFrom(child);
}
if (!out.length) {
const invEl = getActiveInvEl();
if (invEl) for (const el of itemsOf(invEl)) {
const d = descOf(el.rgItem);
if (d) out.push(d);
}
}
return out;
}
function metalCounts() {
const c = { ref: 0, rec: 0, scrap: 0, keys: 0, is440: false };
for (const d of activeDescs()) {
if (!d) continue;
if (String(d.appid) === '440') c.is440 = true;
// permanently untradable metal can't be traded — don't count it
// (trade-locked with a countdown still counts)
if ((d.tradable === 0 || d.tradable === false) && lockDaysCached(d) <= 0) continue;
const n = d.market_hash_name || d.name;
if (n === 'Refined Metal') c.ref++;
else if (n === 'Reclaimed Metal') c.rec++;
else if (n === 'Scrap Metal') c.scrap++;
else if (n === 'Mann Co. Supply Crate Key') c.keys++;
}
return c;
}
let stackOn = false;
let lastInv = null;
let lastInvEl = null;
let bptfShow = localStorage.getItem('sia-bptf-show') !== '0';
document.documentElement.classList.toggle('sia-hide-bptf', !bptfShow);
function runLayout() {
const sortKey = document.getElementById('sia-sort')?.value || 'default';
const inv = W.g_ActiveInventory;
const go = () => applyFor(inv, sortKey, stackOn);
if (inv && typeof inv.BIsFullyLoaded === 'function' && inv.BIsFullyLoaded()) {
go(); // already complete: lay out synchronously, no promise round-trip
} else if (inv && typeof inv.LoadCompleteInventory === 'function') {
// make sure every asset is loaded before reordering, else we'd sort a partial page
Promise.resolve(inv.LoadCompleteInventory()).then(go, go);
} else {
go();
}
}
// gear-button settings UI — writes overrides to localStorage, reload applies
const SETTINGS_SCHEMA = [
['Item badges', null, 'header'],
['tf2Badges', 'TF2 badges (KS, unusual, spells…)'],
['tradeLockBadge', 'Trade-lock “6D” badge'],
['markUncraftable', 'Dashed outline on uncraftable'],
['markUntradable', 'Red bar on untradable'],
['shrinkRenameWarning', 'Small rename warning'],
['Pricing', null, 'header'],
['backpackTfKey', 'backpack.tf API key', 'text'],
['Get a free key: log into backpack.tf → Settings → API Access', null, 'help'],
['priceIndicator', 'Market price badges + value card'],
['showBuyOrders', 'Badge shows buy order (instant sell)'],
['metalCounter', 'Metal counter'],
['afterFeesValue', 'After-fees value line'],
['priceCooldownMs', 'Price request cooldown (ms)', 'number'],
['priceCacheMins', 'Price cache (min)', 'number'],
['Inventory tools', null, 'header'],
['sortButton', 'Sort dropdown'],
['stacking', 'Stack dupes toggle'],
['duplicateFilter', 'Duplicate filter tag'],
['panelLinks', 'bp.tf/market panel icons'],
['quicksellButtons', 'Quicksell buttons (⚡)'],
['Trading', null, 'header'],
['tradeCompare', 'Trade side summaries'],
['partnerValue', 'Partner backpack value'],
['tradeMetalHelper', 'Add metal box'],
['tradeEmptyButtons', 'Trade buttons (Take page…)'],
['shiftClickAdd', 'Shift+click quick add'],
['draftSaver', 'Trade draft saver'],
];
function toggleSettingsPanel(anchorBtn) {
let panel = document.getElementById('sia-settings');
if (panel) { panel.remove(); return; }
panel = document.createElement('div');
panel.id = 'sia-settings';
const saved = (() => {
try { return JSON.parse(localStorage.getItem('siaSettings')) || {}; } catch { return {}; }
})();
for (const [key, label, type] of SETTINGS_SCHEMA) {
if (type === 'help') {
const a = document.createElement('a');
a.href = 'https://backpack.tf/developer';
a.target = '_blank';
a.rel = 'noopener';
a.textContent = key;
a.style.cssText = 'display:block;font-size:10px;color:#67c1f5;margin:0 0 4px 4px';
panel.appendChild(a);
continue;
}
if (type === 'header') {
const h = document.createElement('div');
h.textContent = key;
h.style.cssText = 'font-weight:bold;color:#66c0f4;margin:8px 0 2px;' +
'border-bottom:1px solid #2a475e;padding-bottom:2px';
panel.appendChild(h);
continue;
}
const row = document.createElement('label');
const input = document.createElement('input');
input.dataset.key = key;
if (type === 'number' || type === 'text') {
input.type = type;
input.value = String(CONFIG[key] ?? '');
row.append(label + ' ', input);
} else {
input.type = 'checkbox';
input.checked = !!CONFIG[key];
row.append(input, ' ' + label);
}
if (key === 'backpackTfKey') {
// treat like a password: masked by default, eye to peek, verify to test
input.type = 'password';
const eye = document.createElement('button');
eye.type = 'button';
eye.textContent = '👁';
eye.title = 'Show/hide key';
eye.style.cssText = 'margin-left:4px;padding:1px 5px';
eye.addEventListener('click', (e) => {
e.preventDefault();
input.type = input.type === 'password' ? 'text' : 'password';
});
const check = document.createElement('button');
check.type = 'button';
check.textContent = 'Verify';
check.style.cssText = 'margin-left:4px;padding:1px 6px';
const status = document.createElement('span');
status.style.marginLeft = '6px';
check.addEventListener('click', async (e) => {
e.preventDefault();
status.textContent = '…';
status.style.color = '#b8b6b4';
const k = input.value.trim();
const j = k ? await bpFetchJson(
`https://backpack.tf/api/IGetCurrencies/v1?key=${encodeURIComponent(k)}`) : null;
const ok = j?.response?.success === 1;
status.textContent = ok ? '✓ valid' : '✗ invalid';
status.style.color = ok ? '#9CC83B' : '#D75A4A';
});
row.append(eye, check, status);
}
input.addEventListener('change', () => {
if (type === 'number') {
const n = Number(input.value);
// empty or garbage would save 0 (a zero cooldown hammers Steam) — keep the old value
if (input.value.trim() === '' || !Number.isFinite(n) || n <= 0) {
input.value = String(CONFIG[key] ?? '');
return;
}
saved[key] = n;
} else {
saved[key] = type === 'text' ? input.value.trim() : input.checked;
}
try { localStorage.setItem('siaSettings', JSON.stringify(saved)); } catch { /* quota */ }
document.getElementById('sia-settings-note').style.display = '';
});
panel.appendChild(row);
}
const note = document.createElement('div');
note.id = 'sia-settings-note';
note.style.cssText = 'display:none;margin-top:8px';
const reload = document.createElement('button');
reload.type = 'button';
reload.textContent = 'Reload to apply';
reload.addEventListener('click', () => location.reload());
note.appendChild(reload);
panel.appendChild(note);
const r = anchorBtn.getBoundingClientRect();
panel.style.left = `${Math.round(r.left + scrollX)}px`;
panel.style.top = `${Math.round(r.bottom + scrollY + 4)}px`;
document.body.appendChild(panel);
}
function ensureBar() {
wrapMatchItem(); // Filter may be defined after us; retry until wrapped
if (!CONFIG.sortButton && !CONFIG.stacking && !CONFIG.metalCounter) return;
const anchor = document.getElementById('inventories');
if (!anchor || document.getElementById('sia-bar')) return;
const bar = document.createElement('div');
bar.id = 'sia-bar';
bar.innerHTML =
(CONFIG.sortButton
? ``
: '') +
(CONFIG.stacking ? '' : '') +
(CONFIG.backpackTfKey
? ``
: '') +
(CONFIG.settingsPanel ? '' : '');
anchor.parentElement.insertBefore(bar, anchor);
const sel = bar.querySelector('#sia-sort');
if (sel) {
sel.value = localStorage.getItem('sia-sort') || 'default';
sel.addEventListener('change', () => {
localStorage.setItem('sia-sort', sel.value);
runLayout();
});
}
const stackBtn = bar.querySelector('#sia-stack');
if (stackBtn) {
stackBtn.addEventListener('click', () => {
stackOn = !stackOn;
stackBtn.classList.toggle('sia-stack-on', stackOn);
runLayout();
});
}
const bptfBtn = bar.querySelector('#sia-bptf-toggle');
if (bptfBtn) {
bptfBtn.classList.toggle('sia-bptf-off', !bptfShow);
bptfBtn.addEventListener('click', () => {
bptfShow = !bptfShow;
localStorage.setItem('sia-bptf-show', bptfShow ? '1' : '0');
bptfBtn.classList.toggle('sia-bptf-off', !bptfShow);
document.documentElement.classList.toggle('sia-hide-bptf', !bptfShow);
lastBarUpdate = 0;
updateBarThrottled();
});
}
const gear = bar.querySelector('#sia-gear');
gear?.addEventListener('click', () => toggleSettingsPanel(gear));
ensureTradeMetalUI(bar);
ensureDraftButton(bar);
}
// ------------------------------------------------------------------
// Trade helper: add an exact metal amount to your side ("5.33" -> ref+rec+scrap)
// ------------------------------------------------------------------
function parseRefAmount(s) {
const m = String(s).trim().match(/^(\d+)(?:[.,]([0-8])\2?)?$/);
if (!m) return NaN;
return Number(m[1]) * 9 + (m[2] ? Number(m[2]) : 0);
}
function addMetalToTrade() {
const input = document.getElementById('sia-metal-amount');
let need = parseRefAmount(input.value);
if (Number.isNaN(need) || need <= 0) {
input.style.borderColor = '#A94847';
return;
}
input.style.borderColor = '';
const inv = W.g_ActiveInventory;
const assets = assetList(inv && (inv.rgInventory || inv.m_rgAssets));
const inTrade = new Set([...document.querySelectorAll('#your_slots .item')]
.map((e) => e.rgItem && (e.rgItem.id || e.rgItem.assetid)));
const pool = { 'Refined Metal': [], 'Reclaimed Metal': [], 'Scrap Metal': [] };
for (const a of assets) {
const d = descOf(a);
const n = d && (d.market_hash_name || d.name);
if (pool[n] && a.element && !inTrade.has(a.id || a.assetid)) pool[n].push(a);
}
for (const [n, units] of [['Refined Metal', 9], ['Reclaimed Metal', 3], ['Scrap Metal', 1]]) {
for (const a of pool[n]) {
if (need < units) break;
moveToTradeSafe(a.element);
need -= units;
}
}
afterTradeBatch();
if (need > 0) {
const r = need % 9;
(W.ShowAlertDialog || ((t, m) => alert(m)))('Add metal',
`Short by ${Math.floor(need / 9)}.${r}${r} ref — not enough small metal to make the exact amount.`);
}
}
function emptyTradeSide(slotsId) {
document.querySelectorAll(`#${slotsId} .itemHolder .item`).forEach((el) => {
// currency stacks have their own remove flow — RemoveItemFromTrade
// doesn't cover them and would desync the offer
if (el.rgItem && !el.rgItem.is_currency) {
try { W.GTradeStateManager?.RemoveItemFromTrade?.(el.rgItem); } catch { /* read-only */ }
}
});
}
// live totals for both trade sides: refined (bp.tf/metal) + wallet money (market)
function tradeSideRef(slotsId) {
let total = 0, money = 0, moneySample = '', unpriced = 0, items = 0;
for (const el of document.querySelectorAll(`#${slotsId} .itemHolder .item`)) {
const it = el.rgItem;
if (!it || it.is_currency) continue; // currency stacks aren't items
items++;
const d = descOf(it) || {};
const v = refValueCached(d);
if (v > 0) total += v;
else unpriced++;
if (priceable(d)) {
const rec = priceCache[`${d.appid}||${d.market_hash_name}`];
const n = rec ? parsePrice(rec.lp || rec.mp) : NaN;
if (!Number.isNaN(n)) {
money += n;
if (!moneySample) moneySample = rec.lp || rec.mp;
}
}
}
return { total: Math.round(total * 100) / 100, money, moneySample, unpriced, items };
}
// partner's whole visible backpack value, computed once their inventory loads
const partnerValues = new WeakMap();
function partnerBackpackRef() {
if (!CONFIG.partnerValue || !W.UserThem) return null;
const inv = W.g_ActiveInventory;
if (!inv || W.g_ActiveUser !== W.UserThem) {
return partnerValues.get(W.UserThem) ?? null;
}
if (partnerValues.has(W.UserThem)) return partnerValues.get(W.UserThem);
const assets = assetList(inv.rgInventory || inv.m_rgAssets);
if (!assets.length) return null;
let total = 0;
for (const a of assets) {
const v = refValueCached(descOf(a) || {});
if (v > 0) total += v;
}
total = Math.round(total);
// only freeze the number once their inventory is fully loaded — caching a
// half-loaded backpack would understate them for the whole trade
if (typeof inv.BIsFullyLoaded !== 'function' || inv.BIsFullyLoaded()) {
partnerValues.set(W.UserThem, total);
}
return total;
}
function fmtMoney(n, sample) {
const prefix = (sample.match(/^[^\d]+/) || [''])[0].trim();
const suffix = (sample.match(/[^\d]+$/) || [''])[0].trim();
const decSep = /,\d\d\s*[^\d]*$/.test(sample) ? ',' : '.';
return `${prefix}${n.toFixed(2).replace('.', decSep)}${suffix}`;
}
// engine-aware lazy-image load: legacy wants the raw element,
// v2 wants a jQuery-wrapped one
function loadItemImageSafe(el) {
const inv = W.g_ActiveInventory;
if (!inv || typeof inv.LoadItemImage !== 'function') return;
try {
if (Array.isArray(inv.m_rgItemElements) && W.$J) inv.LoadItemImage(W.$J(el));
else inv.LoadItemImage(el);
} catch { /* best effort */ }
}
// moving hidden/stacked copies needs their lazy image loaded first, and a
// full slot must not abort the batch
function moveToTradeSafe(el) {
loadItemImageSafe(el);
try { W.MoveItemToTrade?.(el); } catch { /* no free slot */ }
}
// repair slot images Steam leaves broken after rapid moves
function fixSlotImages() {
// a grouped representative carries its ×N badge along when moved into a
// slot — one offered item must not read "×5"
for (const b of document.querySelectorAll('#your_slots .sia-count, #their_slots .sia-count')) {
b.remove();
}
for (const slot of document.querySelectorAll('#your_slots .itemHolder, #their_slots .itemHolder')) {
const itemEl = slot.querySelector('.item');
if (!itemEl || !itemEl.rgItem) continue;
const img = itemEl.querySelector('img');
if (!img || !img.getAttribute('src') || img.src.includes('trans.gif')) {
loadItemImageSafe(itemEl);
}
const logo = slot.querySelector('.slot_applogo_img');
if (logo && (!logo.getAttribute('src') || logo.getAttribute('src') === 'undefined' ||
(logo.complete && logo.naturalWidth === 0))) {
const icon = W.g_rgAppContextData?.[itemEl.rgItem.appid]?.icon;
if (icon) logo.src = icon;
else logo.closest('.slot_applogo')?.style.setProperty('display', 'none');
}
}
}
function afterTradeBatch() {
try { W.HideHover?.(); } catch { /* no hover system */ }
fixSlotImages();
queueScan();
}
// per-side value summaries injected into the trade boxes
function sideSummaryEl(boxId, id) {
const box = document.getElementById(boxId);
if (!box) return null;
let el = document.getElementById(id);
if (!el) {
el = document.createElement('div');
el.id = id;
el.className = 'sia-side-summary';
el.style.display = 'none';
box.insertBefore(el, box.firstChild);
}
return el;
}
// whole-side money estimate: the ref total (already covers bp.tf + market
// items) converted through the key's market price
function refToMoney(ref) {
const keyRec = priceCache['440||Mann Co. Supply Crate Key'];
const keyNum = keyRec ? parsePrice(keyRec.lp || keyRec.mp) : NaN;
if (!(keyNum > 0) || !bptf?.keyRefined || !(ref > 0)) return null;
return { v: ref * (keyNum / bptf.keyRefined), sample: keyRec.lp || keyRec.mp };
}
function updateTradeCompare() {
if (!CONFIG.tradeCompare || !document.getElementById('trade_yours')) return;
const give = tradeSideRef('your_slots');
const get = tradeSideRef('their_slots');
const sideMoney = (s) => refToMoney(s.total) ||
(s.money ? { v: s.money, sample: s.moneySample } : null);
const sidePart = (label, s, color) => {
const span = document.createElement('span');
const money = sideMoney(s);
span.append(`${label} ≈ `);
if (money) {
const big = document.createElement('span');
big.className = 'sia-big';
big.textContent = fmtMoney(money.v, money.sample);
big.style.color = color;
span.append(big, ' · ');
}
span.append(`${s.total} ref` +
(s.unpriced ? ` · +${s.unpriced} unpriced` : '') +
` · ${s.items} item${s.items === 1 ? '' : 's'}`);
return span;
};
// build off-DOM and only write on change — a rebuild every tick flickers
// and feeds the mutation observer its own updates
const setContent = (el, tmp) => {
const h = tmp.innerHTML;
if (el.dataset.last !== h) {
el.dataset.last = h;
el.innerHTML = h;
}
};
const gm = sideMoney(give), tm = sideMoney(get);
const giveEl = sideSummaryEl('trade_yours', 'sia-give-summary');
const getEl = sideSummaryEl('trade_theirs', 'sia-get-summary');
if (giveEl) {
const empty = !give.total && !give.money && !give.unpriced;
giveEl.style.display = empty ? 'none' : '';
const tmp = document.createElement('div');
tmp.appendChild(sidePart('You give', give, '#D75A4A')); // money out: red
const right = document.createElement('span');
let profitVal, profitBig, profitSmall;
if (gm || tm) {
profitVal = (tm?.v || 0) - (gm?.v || 0);
const sample = (tm || gm).sample;
profitBig = `${profitVal > 0 ? '+' : profitVal < 0 ? '-' : ''}${fmtMoney(Math.abs(profitVal), sample)}`;
const refDelta = Math.round((get.total - give.total) * 100) / 100;
profitSmall = ` (${refDelta > 0 ? '+' : ''}${refDelta} ref)`;
} else {
profitVal = Math.round((get.total - give.total) * 100) / 100;
profitBig = `${profitVal > 0 ? '+' : ''}${profitVal} ref`;
profitSmall = '';
}
right.append('Profit: ');
const big = document.createElement('span');
big.className = 'sia-big';
big.textContent = profitBig;
big.style.color = profitVal > 0 ? '#9CC83B' : (profitVal < 0 ? '#D75A4A' : '#fff');
right.append(big, profitSmall);
right.style.color = profitVal > 0 ? '#9CC83B' : (profitVal < 0 ? '#D75A4A' : '#b8b6b4');
tmp.appendChild(right);
setContent(giveEl, tmp);
}
if (getEl) {
const bpRef = partnerBackpackRef();
const empty = !get.total && !get.money && !get.unpriced && bpRef == null;
getEl.style.display = empty ? 'none' : '';
const tmp = document.createElement('div');
if (get.items) tmp.appendChild(sidePart('You get', get, '#9CC83B')); // money in: green
if (bpRef != null) {
const bp = document.createElement('span');
bp.textContent = `Their backpack ≈ ${bpRef} ref`;
tmp.appendChild(bp);
}
setContent(getEl, tmp);
}
}
// add every item on the currently visible inventory page
function addVisiblePage() {
const invEl = getActiveInvEl();
if (!invEl) return;
const pages = [...invEl.querySelectorAll('.inventory_page')]
.filter((p) => p.style.display !== 'none');
for (const p of pages) {
for (const el of p.querySelectorAll('.itemHolder .item')) {
// stackable items (gem-style) open an amount dialog per move —
// a batch add would fire a dialog storm; leave them to manual adds
if (el.rgItem && !el.rgItem.is_stackable && el.offsetParent !== null) {
moveToTradeSafe(el);
}
}
}
afterTradeBatch();
}
// the trade screen only loads tradable items; an untradable copy at home
// still counts as "the one you keep" — learn which items have one
let untradableKeys = null;
async function loadUntradableKeys() {
if (untradableKeys || !W.g_steamID) return;
try {
const cached = JSON.parse(localStorage.getItem('siaUntradKeys'));
if (cached && Date.now() - cached.t < 30 * 60000 && cached.sid === String(W.g_steamID)) {
untradableKeys = new Set(cached.keys);
return;
}
} catch { /* refetch */ }
try {
const r = await fetch(
`https://steamcommunity.com/inventory/${W.g_steamID}/440/2?l=english&count=2000`,
{ credentials: 'same-origin' });
const j = r.ok ? await r.json() : null;
untradableKeys = new Set();
if (!j?.descriptions) return;
const byKey = {};
for (const d of j.descriptions) byKey[d.classid + '_' + (d.instanceid || '0')] = d;
for (const a of j.assets || []) {
const d = byKey[a.classid + '_' + (a.instanceid || '0')];
if (d && !d.tradable) untradableKeys.add(d.market_hash_name || d.name);
}
try {
localStorage.setItem('siaUntradKeys', JSON.stringify(
{ t: Date.now(), sid: String(W.g_steamID), keys: [...untradableKeys] }));
} catch { /* quota */ }
} catch { untradableKeys = new Set(); }
}
// keep one of each, trade away the rest
async function giveAllDupes() {
const inv = W.g_ActiveInventory;
if (!inv) return;
await loadUntradableKeys();
const assets = assetList(inv.rgInventory || inv.m_rgAssets);
const inTrade = new Set([...document.querySelectorAll('#your_slots .item, #their_slots .item')]
.map((e) => e.rgItem && (e.rgItem.id || e.rgItem.assetid)));
const groups = new Map();
for (const a of assets) {
if (!a || !a.element || a.is_stackable || inTrade.has(a.id || a.assetid)) continue;
const d = descOf(a) || {};
if (METAL_REF[d.name]) continue; // metal is currency — use "Add metal" instead
const k = stackKeyOf(a);
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(a);
}
for (const [key, list] of groups) {
// untradable copy exists at home: every tradable copy is a dupe
const keep = untradableKeys?.has(key) ? 0 : 1;
for (const a of list.slice(keep)) moveToTradeSafe(a.element);
}
afterTradeBatch();
}
// add every robot part (MvM crafting drops) from the active inventory
function giveAllRobotParts() {
const inv = W.g_ActiveInventory;
if (!inv) return;
const assets = assetList(inv.rgInventory || inv.m_rgAssets);
const inTrade = new Set([...document.querySelectorAll('#your_slots .item, #their_slots .item')]
.map((e) => e.rgItem && (e.rgItem.id || e.rgItem.assetid)));
for (const a of assets) {
if (!a || !a.element || inTrade.has(a.id || a.assetid)) continue;
const d = descOf(a) || {};
if (/^(Battle-Worn|Reinforced|Pristine) Robot /.test(d.name || '')) {
moveToTradeSafe(a.element);
}
}
afterTradeBatch();
}
// "Remove all" empties whichever side's inventory tab is open
function removeAllActiveSide() {
const theirs = !!(W.g_ActiveUser && W.UserThem && W.g_ActiveUser === W.UserThem);
emptyTradeSide(theirs ? 'their_slots' : 'your_slots');
afterTradeBatch();
}
function ensureTradeMetalUI(bar) {
if (!document.getElementById('trade_yours') || document.getElementById('sia-trade-metal')) return;
const wrap = document.createElement('span');
wrap.id = 'sia-trade-metal';
wrap.style.display = 'contents'; // children join the bar's flex flow
wrap.innerHTML =
// row 1, right of Sort: Take all | Remove all
(CONFIG.tradeEmptyButtons
? `` +
``
: '') +
'' +
// row 2: Add metal | Give dupes
(CONFIG.tradeMetalHelper
? `` +
``
: '') +
(CONFIG.tradeEmptyButtons
? `` +
``
: '');
if (!wrap.innerHTML) return;
bar.appendChild(wrap);
wrap.querySelector('#sia-metal-add')?.addEventListener('click', addMetalToTrade);
wrap.querySelector('#sia-add-page')?.addEventListener('click', addVisiblePage);
wrap.querySelector('#sia-give-dupes')?.addEventListener('click', giveAllDupes);
wrap.querySelector('#sia-give-robot')?.addEventListener('click', giveAllRobotParts);
wrap.querySelector('#sia-remove-all')?.addEventListener('click', removeAllActiveSide);
// gear rides at the end of row 2 on trade pages
const gearBtn = document.getElementById('sia-gear');
if (gearBtn) bar.appendChild(gearBtn);
// trade offer note: restore last size the user chose
const note = document.getElementById('trade_offer_note');
if (note && !note.dataset.siaResize) {
note.dataset.siaResize = '1';
const saved = localStorage.getItem('sia-note-h');
if (saved) note.style.height = saved;
new ResizeObserver(() => {
if (note.style.height) localStorage.setItem('sia-note-h', note.style.height);
}).observe(note);
}
}
let priceBatchTotal = 0; // high-water mark of the current pricing batch
// pricing progress note with live hover queue — shared by the value card
// (inventory) and the toolbar (trade pages)
function buildPricingNote() {
if (!priceQueue.length && !priceCurrentJob) {
priceBatchTotal = 0;
return '';
}
priceBatchTotal = Math.max(priceBatchTotal, priceQueue.length);
const pct = priceBatchTotal
? Math.round((1 - priceQueue.length / priceBatchTotal) * 100) : 0;
const esc = (s) => String(s).replace(/&/g, '&').replace(/ `· ${esc(j.mhn)}`);
if (priceQueue.length > 12) upNext.push('…');
const tip = `