Crowd-sourced price intelligence for Torn City, inside Torn PDA. Pushes anonymised observations to a shared pool and surfaces deals across six pages: Travel (margin overlays + YATA destination preview), Item Market (watchlist matches, lowest bazaar, TornExchange flash deals), Bazaar (deals below market/points value), Items (best trader buy-offers for your inventory), Museum (artifact prices), Points Market. Companion app: https://valigia.girovagabondo.com
// ==UserScript==
// @name Valigia
// @namespace https://valigia.girovagabondo.com/
// @version 0.20.6
// @description Crowd-sourced price intelligence for Torn City, inside Torn PDA. Pushes anonymised observations to a shared pool and surfaces deals across six pages: Travel (margin overlays + YATA destination preview), Item Market (watchlist matches, lowest bazaar, TornExchange flash deals), Bazaar (deals below market/points value), Items (best trader buy-offers for your inventory), Museum (artifact prices), Points Market. Companion app: https://valigia.girovagabondo.com
// @author drumorgan
// @match https://www.torn.com/page.php?sid=travel*
// @match https://www.torn.com/page.php?sid=ItemMarket*
// @match https://www.torn.com/bazaar.php*
// @match https://www.torn.com/item.php*
// @match https://www.torn.com/museum.php*
// @match https://www.torn.com/pmarket.php*
// @run-at document-end
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @connect vtslzplzlxdptpvxtanz.supabase.co
// @connect api.torn.com
// @connect yata.yt
// ==/UserScript==
(function () {
'use strict';
// -- Config --------------------------------------------------------------
// PDA substitutes ###PDA-APIKEY### with the user's Torn key at runtime.
// Outside PDA the placeholder stays literal, and the script aborts cleanly.
const TORN_API_KEY = '###PDA-APIKEY###';
// Mirror of the @version header above. Not shown in toasts (they should
// stay short), but kept here so anything needing the version at runtime
// — future diagnostic panels, log() traces, edge-function telemetry —
// has a single source to read from. Bump alongside @version.
const SCRIPT_VERSION = '0.20.4';
const INGEST_URL =
'https://vtslzplzlxdptpvxtanz.supabase.co/functions/v1/ingest-travel-shop';
const INGEST_SELL_URL =
'https://vtslzplzlxdptpvxtanz.supabase.co/functions/v1/ingest-sell-prices';
const INGEST_BAZAAR_URL =
'https://vtslzplzlxdptpvxtanz.supabase.co/functions/v1/ingest-bazaar-prices';
const ACTIVITY_URL =
'https://vtslzplzlxdptpvxtanz.supabase.co/functions/v1/record-pda-activity';
const SUPABASE_ANON_KEY =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ0c2x6cGx6bHhkcHRwdnh0YW56Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzU4MzQyNTMsImV4cCI6MjA5MTQxMDI1M30.Ddzoq8bCmWc875gbdQKhqnR5M7TraWWj4TYS4RRKkMY';
// Flip to true to draw an always-on debug panel on the Torn page showing
// exactly what the parser found. Useful on iPad where DevTools is absent.
const DEBUG = false;
// -- Overlay design note -------------------------------------------------
// The overlay shows PER-ITEM numbers only: Market Price (net of the 5%
// item-market fee), absolute margin, and margin percent. The value is
// explicitly labelled "Market Price" so it's unambiguous against Torn's
// existing "Cost" / "Buy" columns on the shop page — those are what you
// pay abroad; Market Price is what you'll get listing back home.
// It deliberately does NOT multiply by slot count. Doing
// so would require either hardcoding a default (wrong for many players)
// or syncing the web app's slot preference through Supabase (extra
// plumbing for a value the player already knows in their head). Ranking
// within a single shop is identical under a constant flight time whether
// we sort by margin-per-item or profit/hr, so the BEST badge is still
// accurate without any slot-count input.
// Public Supabase PostgREST base. The sell_prices cache is read by the
// travel-page overlay (anon SELECT, see migration 002_sell_prices.sql) and
// written by the Item Market / Bazaar ingest runners (anon INSERT+UPDATE,
// same migration + 004_bazaar_prices.sql). Going direct to PostgREST keeps
// those two new runners edge-function-free: no Torn API key-validation
// round-trip on every scrape, matching how the web app already writes to
// these community-data tables.
const SUPABASE_REST_URL = 'https://vtslzplzlxdptpvxtanz.supabase.co/rest/v1';
const SELL_PRICES_URL = SUPABASE_REST_URL + '/sell_prices';
const BAZAAR_PRICES_URL = SUPABASE_REST_URL + '/bazaar_prices';
const RESTOCK_EVENTS_URL = SUPABASE_REST_URL + '/restock_events';
const POINTS_RATE_URL = SUPABASE_REST_URL + '/points_market_rate';
const ABROAD_PRICES_URL = SUPABASE_REST_URL + '/abroad_prices';
// Known Torn travel shop category names. Used as section anchors: the
// parser looks for these in visible text to group items by shop.
const SHOP_CATEGORIES = [
'General Store',
'Pharmacy',
'Arms Dealer',
'Black Market',
'Cantina',
'Tourist Center',
'Jewelry Store',
'Souvenir Shop',
'Drug Store',
'Food Stand',
'Farmers Market',
'Plushie Shop',
'Flower Shop',
'Estate Agent',
'Barber Shop',
];
// -- Utilities -----------------------------------------------------------
function log(...args) {
if (DEBUG) {
try { console.log('[valigia]', ...args); } catch { /* ignore */ }
}
}
function toast(message, kind) {
const bg = kind === 'error' ? '#b33'
: kind === 'success' ? '#2a7'
: kind === 'warning' ? '#e8824a'
: '#333';
const el = document.createElement('div');
el.textContent = 'Valigia: ' + message;
Object.assign(el.style, {
position: 'fixed',
top: '10px',
left: '50%',
transform: 'translateX(-50%)',
background: bg,
color: '#fff',
padding: '10px 16px',
borderRadius: '8px',
zIndex: '999999',
font: '600 14px/1.3 sans-serif',
maxWidth: '90vw',
boxShadow: '0 4px 16px rgba(0,0,0,.5)',
});
document.body.appendChild(el);
setTimeout(function () { el.remove(); }, 6000);
}
function debugPanel(lines) {
if (!DEBUG) return;
const existing = document.getElementById('valigia-debug-panel');
if (existing) existing.remove();
const el = document.createElement('pre');
el.id = 'valigia-debug-panel';
el.textContent = lines.join('\n');
Object.assign(el.style, {
position: 'fixed',
top: '60px',
right: '10px',
maxWidth: '45vw',
maxHeight: '70vh',
overflow: 'auto',
background: 'rgba(0,0,0,.85)',
color: '#9f9',
padding: '8px',
borderRadius: '6px',
zIndex: '999998',
font: '11px/1.3 ui-monospace, monospace',
whiteSpace: 'pre-wrap',
});
document.body.appendChild(el);
}
function parseMoney(text) {
// "$1,234" / "$1.234" / "1234" -> 1234
const digits = String(text).replace(/[^\d]/g, '');
return digits ? Number(digits) : NaN;
}
function parseInt10(text) {
const digits = String(text).replace(/[^\d]/g, '');
return digits ? Number(digits) : NaN;
}
// -- Destination detection -----------------------------------------------
// Torn's travel page reads: "You are in {Country} and have..."
function detectDestination() {
const body = document.body && document.body.innerText || '';
const m = body.match(/You are in ([A-Z][A-Za-z ]+?) and have/);
return m ? m[1].trim() : null;
}
// City names Torn shows in the flight banner, mapped back to our
// canonical destination keys (matching YATA's COUNTRY_MAP values in
// src/log-sync.js). Source: https://wiki.torn.com/wiki/Travel — the
// wiki's destination table lists the country name and the city Torn
// uses inside the banner. We map both forms because confirmed live
// observations show Torn writing "Torn to Tokyo" and "Torn to Dubai"
// (city), so the city is the form actually rendered; the country
// entries are kept as harmless fallbacks in case Torn ever changes.
// Note "Ciudad Juárez" — 'á' is escaped to keep the source pure
// ASCII for the FTP deploy.
const CITY_TO_DESTINATION = {
'Ciudad Ju\u00E1rez': 'Mexico',
'Mexico': 'Mexico',
'George Town': 'Caymans',
'Cayman Islands': 'Caymans',
'Toronto': 'Canada',
'Canada': 'Canada',
'Honolulu': 'Hawaii',
'Hawaii': 'Hawaii',
'London': 'UK',
'United Kingdom': 'UK',
'Buenos Aires': 'Argentina',
'Argentina': 'Argentina',
'Zurich': 'Switzerland',
'Switzerland': 'Switzerland',
'Tokyo': 'Japan',
'Japan': 'Japan',
'Beijing': 'China',
'China': 'China',
'Dubai': 'UAE',
'United Arab Emirates': 'UAE',
'UAE': 'UAE',
'Johannesburg': 'South Africa',
'South Africa': 'South Africa',
};
// In-flight banner reads: "Torn to {City}. Remaining Flight Time - HH:MM:SS"
// (or the inverse "{City} to Torn..." when returning home — we only show
// the strip on the outbound leg, since flying back the player can't shop
// at the origin anymore). The city group matches anything except a period
// so accented chars (Ciudad Juárez) and multi-word names (Buenos Aires)
// both work. Returns { destination, returning, remainingMins } or null.
function detectInFlight() {
const body = document.body && document.body.innerText || '';
if (!/Remaining Flight Time/i.test(body)) return null;
// Pull HH:MM:SS off the timer if present. Banner format is
// "Remaining Flight Time - 02:37:06" — convert to minutes (fractional).
// null when missing, so callers can fall back to the destination's
// standard flight time.
let remainingMins = null;
const tm = body.match(/Remaining Flight Time\s*-\s*(\d{1,2}):(\d{2}):(\d{2})/);
if (tm) {
const h = Number(tm[1]); const mi = Number(tm[2]); const se = Number(tm[3]);
if (Number.isFinite(h) && Number.isFinite(mi) && Number.isFinite(se)) {
remainingMins = h * 60 + mi + se / 60;
}
}
let m = body.match(/Torn to ([^.]+?)\.\s*Remaining Flight Time/);
if (m) {
const city = m[1].trim();
const dest = CITY_TO_DESTINATION[city] || city;
return { destination: dest, returning: false, remainingMins: remainingMins };
}
m = body.match(/([^.]+?) to Torn\.\s*Remaining Flight Time/);
if (m) {
const city = m[1].trim();
const dest = CITY_TO_DESTINATION[city] || city;
return { destination: dest, returning: true, remainingMins: remainingMins };
}
return null;
}
// -- Shop scraping -------------------------------------------------------
// Strategy: every shop item row on the travel page contains an <img> whose
// src matches /images/items/{id}/. We locate each such image, walk up to
// the nearest row-like ancestor, extract name/stock/price from that row's
// text, and attribute the row to the nearest preceding heading that looks
// like a shop category name. This stays robust if Torn shuffles CSS classes.
function nearestShopCategoryFor(node) {
// Walk backwards through the DOM to find text that matches a known shop
// category name. We scan the ancestors' preceding siblings first, then
// fall back to a full-document text scan.
let cursor = node;
while (cursor && cursor !== document.body) {
let sib = cursor.previousElementSibling;
while (sib) {
const txt = (sib.innerText || '').trim();
for (const cat of SHOP_CATEGORIES) {
if (txt.includes(cat)) return cat;
}
sib = sib.previousElementSibling;
}
cursor = cursor.parentElement;
}
return 'Unknown';
}
function rowContainer(img) {
// Find the closest ancestor that behaves like a row. We try a few shapes
// Torn has used: tr, li, div with sibling cells.
return (
img.closest('tr') ||
img.closest('li') ||
img.closest('[class*="row"]') ||
img.closest('[class*="Row"]') ||
(img.parentElement && img.parentElement.parentElement) ||
img.parentElement
);
}
// -- Parse-mismatch capture (TEMPORARY DIAGNOSTIC) -----------------------
// The travel-shop overlay badged Flail in UK as BEST when the real run
// is a multi-million-dollar loss — the scraper read $8,000,000 as ~$800
// because some other smaller `$N` token in the row was matched first.
// This block collects rows where the first-`$` parse and the largest-`$`
// parse disagree, and renderParseMismatchPanel() draws an unconditional
// amber panel with the raw outerHTML so we can lock the parser onto the
// right element. Remove the buffer, captureParseMismatch(), the
// renderParseMismatchPanel() call in runTravel(), and the capture block
// inside parseItemRow once the parser is hardened.
const parseMismatches = [];
const MAX_MISMATCH_CAPTURES = 5;
function captureParseMismatch(entry) {
if (parseMismatches.length >= MAX_MISMATCH_CAPTURES) return;
for (const m of parseMismatches) {
if (m.item_id === entry.item_id && m.firstDollar === entry.firstDollar) return;
}
parseMismatches.push(entry);
}
function renderParseMismatchPanel() {
if (parseMismatches.length === 0) return;
const existing = document.getElementById('valigia-parse-mismatch-panel');
if (existing) existing.remove();
const lines = [
'VALIGIA PARSER MISMATCH \u2014 please screenshot',
'v' + SCRIPT_VERSION + ' \u00B7 ' + parseMismatches.length + ' row(s)',
'',
];
for (const m of parseMismatches) {
lines.push('\u2014 ' + m.name + ' (id=' + m.item_id + ')');
lines.push(' first-$ = ' + m.firstDollar);
lines.push(' largest-$ = ' + m.largestDollar);
lines.push(' HTML: ' + m.htmlSnippet);
lines.push('');
}
const el = document.createElement('pre');
el.id = 'valigia-parse-mismatch-panel';
el.textContent = lines.join('\n');
Object.assign(el.style, {
position: 'fixed',
top: '60px',
left: '10px',
maxWidth: '45vw',
maxHeight: '70vh',
overflow: 'auto',
background: 'rgba(20,12,0,.92)',
color: '#ffd27a',
border: '2px solid #e8824a',
padding: '10px',
borderRadius: '6px',
zIndex: '999998',
font: '11px/1.35 ui-monospace, monospace',
whiteSpace: 'pre-wrap',
margin: '0',
});
document.body.appendChild(el);
}
function parseItemRow(img) {
const src = img.getAttribute('src') || '';
const idMatch = src.match(/\/images\/items\/(\d+)\//);
if (!idMatch) return null;
const item_id = Number(idMatch[1]);
const row = rowContainer(img);
if (!row) return null;
// The name is usually the alt text on the item image, or the first bit
// of text in the row. Prefer alt: it's the most stable.
const altName = (img.getAttribute('alt') || '').trim();
const rowText = (row.innerText || '').trim();
let name = altName;
if (!name) {
const firstLine = rowText.split('\n')[0] || '';
name = firstLine.trim();
}
// Stock: the row typically shows the stock count as a bare integer, and
// the price as a $-prefixed number. We grep both from the row text.
//
// Example row text: "Hammer\n1,000\n$25" or variations with tabs/spaces.
const priceMatch = rowText.match(/\$\s*([\d,\.]+)/);
const buy_price = priceMatch ? parseMoney(priceMatch[1]) : NaN;
// TEMP DIAGNOSTIC (see parseMismatches comment above): if the largest
// `$N` token in the row disagrees with the first one we picked, capture
// the row's HTML so we can fix the parser from real data.
const allDollarTokens = rowText.match(/\$\s*[\d,\.]+/g) || [];
let largestDollar = NaN;
for (const tok of allDollarTokens) {
const n = parseMoney(tok);
if (Number.isFinite(n) && (Number.isNaN(largestDollar) || n > largestDollar)) {
largestDollar = n;
}
}
if (
Number.isFinite(buy_price) &&
Number.isFinite(largestDollar) &&
buy_price !== largestDollar
) {
const html = (row.outerHTML || '').replace(/\s+/g, ' ').trim();
captureParseMismatch({
name: altName || (rowText.split('\n')[0] || '').trim() || 'unknown',
item_id: item_id,
firstDollar: buy_price,
largestDollar: largestDollar,
htmlSnippet: html.length > 600 ? html.slice(0, 600) + '\u2026' : html,
});
}
// For stock, strip out the price portion first so its digits don't leak.
const textWithoutPrice = rowText.replace(/\$\s*[\d,\.]+/g, ' ');
// Find the largest bare-integer token: stock is almost always >= 1.
const intTokens = textWithoutPrice.match(/(?<![\w.])\d[\d,]*(?![\w.])/g) || [];
let stock = NaN;
for (const tok of intTokens) {
const n = parseInt10(tok);
if (Number.isFinite(n) && (Number.isNaN(stock) || n > stock)) stock = n;
}
return { item_id: item_id, name: name, stock: stock, buy_price: buy_price };
}
function scrapeShops() {
const imgs = Array.from(document.querySelectorAll('img[src*="/images/items/"]'));
const shops = new Map();
for (const img of imgs) {
const row = rowContainer(img);
if (!row) continue;
const category = nearestShopCategoryFor(row);
const parsed = parseItemRow(img);
if (!parsed) continue;
if (!Number.isFinite(parsed.buy_price) || parsed.buy_price <= 0) continue;
if (!Number.isFinite(parsed.stock) || parsed.stock < 0) continue;
if (!parsed.name) continue;
if (!shops.has(category)) shops.set(category, []);
shops.get(category).push(parsed);
}
return Array.from(shops.entries()).map(function (entry) {
return { category: entry[0], items: entry[1] };
});
}
// -- Network -------------------------------------------------------------
// Tries three transports in order:
// 1. GM_xmlhttpRequest (classic Tampermonkey / older PDA builds)
// 2. GM.xmlHttpRequest (newer Greasemonkey-style)
// 3. PDA_httpGet / PDA_httpPost (Torn PDA's native cross-origin helpers,
// promise-returning, sidestep the webview's CORS the same way GM_*
// does). Some PDA builds don't expose GM_xmlhttpRequest at all, so
// this fallback is what keeps the script alive there.
function gmRequest(opts) {
return new Promise(function (resolve, reject) {
const base = {
method: opts.method || 'POST',
url: opts.url,
headers: opts.headers || {},
data: opts.data,
timeout: 15000,
onload: function (res) { resolve(res); },
onerror: function (err) { reject(err); },
ontimeout: function () { reject(new Error('timeout')); },
};
if (typeof GM_xmlhttpRequest === 'function') {
GM_xmlhttpRequest(base);
return;
}
if (typeof GM !== 'undefined' && GM.xmlHttpRequest) {
GM.xmlHttpRequest(base);
return;
}
const method = (opts.method || 'POST').toUpperCase();
let pdaCall = null;
try {
if (method === 'GET' && typeof PDA_httpGet === 'function') {
// Newer PDA accepts (url, headers); older accepts (url) only.
// Pass headers when present and let PDA ignore the extra arg
// on builds that don't read it.
pdaCall = PDA_httpGet(opts.url, opts.headers || {});
} else if (method === 'POST' && typeof PDA_httpPost === 'function') {
pdaCall = PDA_httpPost(opts.url, opts.headers || {}, opts.data || '');
}
} catch (err) {
reject(err);
return;
}
if (pdaCall && typeof pdaCall.then === 'function') {
const timer = setTimeout(function () {
reject(new Error('timeout'));
}, 15000);
pdaCall.then(function (res) {
clearTimeout(timer);
// PDA_httpGet/Post resolve with { status, responseText } on
// current builds. Some older builds resolved with a raw string —
// normalise both shapes so callers can read .status/.responseText.
let status = 200;
let body = '';
if (res && typeof res === 'object') {
if (res.status != null) status = res.status;
if (res.responseText != null) body = res.responseText;
else if (typeof res.body === 'string') body = res.body;
else if (typeof res.data === 'string') body = res.data;
} else if (typeof res === 'string') {
body = res;
}
resolve({ status: status, responseText: body });
}).catch(function (err) {
clearTimeout(timer);
reject(err);
});
return;
}
reject(new Error('No GM_xmlhttpRequest available - install as userscript in PDA'));
});
}
// Travel-shop ingest POST. Same retry policy as postIngestRows (below):
// transient failures (network/timeout, HTTP 5xx, 429 rate-limit) get up
// to two retries with 500ms → 1500ms backoff; permanent 4xx failures
// (bad key, validation) return immediately so the friendly-error toast
// fires without delay. Returns the same { ok, count, error, raw } shape
// that friendlyIngestError() consumes.
async function postIngest(payload) {
let lastError = 'unknown';
let lastRaw = null;
for (let attempt = 0; attempt < INGEST_MAX_ATTEMPTS; attempt++) {
let transient = false;
try {
const res = await gmRequest({
method: 'POST',
url: INGEST_URL,
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_ANON_KEY,
'Authorization': 'Bearer ' + SUPABASE_ANON_KEY,
},
data: JSON.stringify(payload),
});
let body = null;
try { body = JSON.parse(res.responseText); } catch { /* ignore */ }
if (res.status >= 200 && res.status < 300 && body && body.ok) {
return { ok: true, count: body.stored ?? 0, body };
}
lastError = (body && body.error) || ('HTTP ' + res.status);
lastRaw = res.responseText;
if (!isTransientStatus(res.status)) {
return { ok: false, error: lastError, raw: lastRaw };
}
transient = true;
} catch (err) {
lastError = (err && err.message) || String(err);
transient = true;
}
if (transient && attempt < INGEST_MAX_ATTEMPTS - 1) {
log('travel ingest transient failure, retrying', { attempt, error: lastError });
await sleep(INGEST_BACKOFF_MS[attempt]);
}
}
return { ok: false, error: lastError, raw: lastRaw, retried: true };
}
// -- Activity ping -------------------------------------------------------
// Fires a key-validated heartbeat to record-pda-activity so the Item
// Market and Bazaar scrapes show up in the scout count alongside travel
// contributions. Travel's ping is fanned out server-side from
// ingest-travel-shop, so we don't ping from runTravel().
//
// Throttled per page_type via localStorage: each page_type pings at most
// once per ACTIVITY_PING_WINDOW_MS. The cap keeps the extra Torn API
// calls (one user/basic validation per ping) off the user's rate budget
// during long SPA browsing sessions, while still marking them "active"
// on a rolling 24h window.
//
// Fire-and-forget: any failure here is invisible to the user. The scout
// count is a vanity metric; it must never interrupt the scrape flow.
const ACTIVITY_PING_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
const ACTIVITY_PING_STORAGE_KEY = 'valigia_last_activity_ping';
function loadActivityPingMap() {
try {
const raw = localStorage.getItem(ACTIVITY_PING_STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return (parsed && typeof parsed === 'object') ? parsed : {};
} catch (e) { return {}; }
}
function saveActivityPingMap(map) {
try { localStorage.setItem(ACTIVITY_PING_STORAGE_KEY, JSON.stringify(map)); }
catch (e) { /* quota or private mode - ignore */ }
}
async function pingActivity(pageType) {
const now = Date.now();
const map = loadActivityPingMap();
const last = Number(map[pageType]) || 0;
if (now - last < ACTIVITY_PING_WINDOW_MS) {
log('activity ping throttled (' + pageType + ')');
return;
}
// Record the attempt BEFORE firing so a stuck request can't cause
// every subsequent scrape to re-ping.
map[pageType] = now;
saveActivityPingMap(map);
try {
await gmRequest({
method: 'POST',
url: ACTIVITY_URL,
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_ANON_KEY,
'Authorization': 'Bearer ' + SUPABASE_ANON_KEY,
},
data: JSON.stringify({ api_key: TORN_API_KEY, page_type: pageType }),
});
} catch (e) {
log('activity ping failed (' + pageType + '):', e);
}
}
// -- Sell prices from Supabase -------------------------------------------
// Single GET against PostgREST with an in.(...) filter pulls every item
// we see on the shop page in one round trip. sell_prices is anon-readable
// by design (shared community cache).
async function fetchSellPrices(itemIds) {
if (!Array.isArray(itemIds) || itemIds.length === 0) return new Map();
const idList = itemIds.join(',');
const url = SELL_PRICES_URL +
'?select=item_id,price,updated_at' +
'&item_id=in.(' + idList + ')';
try {
const res = await gmRequest({
method: 'GET',
url: url,
headers: {
'apikey': SUPABASE_ANON_KEY,
'Authorization': 'Bearer ' + SUPABASE_ANON_KEY,
'Accept': 'application/json',
},
});
if (res.status < 200 || res.status >= 300) return new Map();
const rows = JSON.parse(res.responseText || '[]');
const map = new Map();
for (const r of rows) {
if (r && typeof r.item_id === 'number' && typeof r.price === 'number') {
map.set(r.item_id, { price: r.price, updatedAt: r.updated_at || null });
}
}
return map;
} catch (e) {
return new Map();
}
}
// -- Restock ETA fetch + estimator --------------------------------------
// Slim port of stock-forecast.js's estimateNextRestock for the travel
// overlay: only "expected refill" mins, only for shelves that are
// currently empty. Falls back silently on any failure — refill ETA is a
// nice-to-have, never blocks the BEST/margin overlay.
//
// Anon SELECT on restock_events is allowed (migration 018). 30-day
// window matches the web app's RESTOCK_HISTORY_WINDOW_MINS so we
// estimate from the same data the dashboard uses; 2000-row cap keeps
// payloads bounded on shelves with very high cadence.
async function fetchRestockEvents(itemIds, destination) {
if (!Array.isArray(itemIds) || itemIds.length === 0) return new Map();
if (!destination) return new Map();
const cutoffIso = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
const idList = itemIds.join(',');
// post_qty is the typical-shelf-after-restock count — needed by the
// in-flight strip's during-flight refill override (estimateRestockPlan
// below). The landed-overlay caller only uses .at and ignores postQty,
// so the change is backwards-compatible.
const url = RESTOCK_EVENTS_URL +
'?select=item_id,restocked_at,post_qty' +
'&item_id=in.(' + idList + ')' +
'&destination=eq.' + encodeURIComponent(destination) +
'&restocked_at=gte.' + encodeURIComponent(cutoffIso) +
'&order=restocked_at.desc' +
'&limit=2000';
try {
const res = await gmRequest({
method: 'GET',
url: url,
headers: {
'apikey': SUPABASE_ANON_KEY,
'Authorization': 'Bearer ' + SUPABASE_ANON_KEY,
'Accept': 'application/json',
},
});
if (res.status < 200 || res.status >= 300) return new Map();
const rows = JSON.parse(res.responseText || '[]');
const byItem = new Map();
for (const r of rows) {
if (!r) continue;
const itemId = Number(r.item_id);
if (!Number.isFinite(itemId)) continue;
const t = new Date(r.restocked_at).getTime();
if (!Number.isFinite(t)) continue;
const postQty = Number(r.post_qty);
let arr = byItem.get(itemId);
if (!arr) { arr = []; byItem.set(itemId, arr); }
arr.push({ at: t, postQty: Number.isFinite(postQty) ? postQty : null });
}
return byItem;
} catch (e) {
return new Map();
}
}
// Median observed interval minus time-since-last-restock. Mirrors the
// central calculation in stock-forecast.js (estimateNextRestock) without
// the confidence/MAD/MAE machinery — the overlay just needs one number.
// Needs ≥2 events (one interval sample); returns null otherwise.
function estimateRefillMins(events, nowMs) {
if (!Array.isArray(events) || events.length < 2) return null;
const sorted = events.map(function (e) { return e.at; })
.filter(Number.isFinite)
.sort(function (a, b) { return a - b; });
if (sorted.length < 2) return null;
const gaps = [];
for (let i = 1; i < sorted.length; i++) {
gaps.push((sorted[i] - sorted[i - 1]) / 60000);
}
const sortedGaps = gaps.slice().sort(function (a, b) { return a - b; });
const median = sortedGaps[Math.floor(sortedGaps.length / 2)];
if (!(median > 0)) return null;
const lastAt = sorted[sorted.length - 1];
const sinceLastMins = (nowMs - lastAt) / 60000;
return Math.max(0, Math.round(median - sinceLastMins));
}
// Slim port of stock-forecast.js's estimateNextRestock — returns
// { timeToNextMins, typicalPostQty } so the in-flight strip can apply
// the same restock-during-flight override the web app does:
//
// if depletion forecast hits 0 AND a restock is expected before
// landing, replace 0 with typicalPostQty.
//
// Skips the confidence/MAD/MAE/uncertainty machinery — the strip only
// needs the median cadence + median post-restock qty.
function estimateRestockPlan(events, nowMs) {
if (!Array.isArray(events) || events.length < 2) return null;
const atTimes = events.map(function (e) { return e.at; })
.filter(Number.isFinite)
.sort(function (a, b) { return a - b; });
if (atTimes.length < 2) return null;
const gaps = [];
for (let i = 1; i < atTimes.length; i++) {
gaps.push((atTimes[i] - atTimes[i - 1]) / 60000);
}
const sortedGaps = gaps.slice().sort(function (a, b) { return a - b; });
const medianInterval = sortedGaps[Math.floor(sortedGaps.length / 2)];
if (!(medianInterval > 0)) return null;
const postQtys = events.map(function (e) { return e.postQty; })
.filter(Number.isFinite)
.sort(function (a, b) { return a - b; });
if (postQtys.length === 0) return null;
const typicalPostQty = postQtys[Math.floor(postQtys.length / 2)];
const lastRestockAt = atTimes[atTimes.length - 1];
const sinceLastMins = (nowMs - lastRestockAt) / 60000;
const timeToNextMins = Math.max(0, medianInterval - sinceLastMins);
return {
timeToNextMins: timeToNextMins,
typicalPostQty: typicalPostQty,
medianIntervalMins: medianInterval,
};
}
function formatRefillEta(mins) {
if (mins == null) return null;
if (mins < 1) return 'refill imminent';
if (mins < 90) return 'refill ~' + Math.round(mins) + 'm';
const h = Math.floor(mins / 60);
const m = Math.round(mins % 60);
return m > 0 ? 'refill ~' + h + 'h ' + m + 'm' : 'refill ~' + h + 'h';
}
// -- Ingest edge-function post ------------------------------------------
// Layer 2 security hardening: writes to sell_prices / bazaar_prices flow
// through ingest-sell-prices / ingest-bazaar-prices. Each validates
// TORN_API_KEY via user/?selections=basic and stamps observer_player_id
// onto every row before a service-role upsert. Same pattern as
// ingest-travel-shop — one extra Torn API round-trip per scrape, paid
// out of the player's own 100/min budget.
//
// Retry policy: iPad cellular drops one request every few minutes. A
// single flake used to lose the entire scrape. We now retry up to 2
// times on transient failures — network/timeout errors, HTTP 5xx, and
// 429 rate limits — with 500ms → 1500ms backoff. 4xx responses (bad
// key, payload too big, validation error) are NOT retried since they
// are permanent; retrying would just delay the error toast.
const INGEST_MAX_ATTEMPTS = 3;
const INGEST_BACKOFF_MS = [500, 1500];
function sleep(ms) {
return new Promise(function (r) { setTimeout(r, ms); });
}
function isTransientStatus(status) {
return status === 429 || (status >= 500 && status < 600);
}
async function postIngestRows(ingestUrl, rows) {
if (!Array.isArray(rows) || rows.length === 0) {
return { ok: true, count: 0 };
}
let lastError = 'unknown';
let lastRaw = null;
for (let attempt = 0; attempt < INGEST_MAX_ATTEMPTS; attempt++) {
let transient = false;
try {
const res = await gmRequest({
method: 'POST',
url: ingestUrl,
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_ANON_KEY,
'Authorization': 'Bearer ' + SUPABASE_ANON_KEY,
},
data: JSON.stringify({ api_key: TORN_API_KEY, rows: rows }),
});
let body = null;
try { body = JSON.parse(res.responseText); } catch { /* ignore */ }
if (res.status >= 200 && res.status < 300 && body && body.ok) {
return { ok: true, count: body.stored ?? rows.length };
}
lastError = (body && body.error) || ('HTTP ' + res.status);
lastRaw = res.responseText;
if (!isTransientStatus(res.status)) {
return { ok: false, error: lastError, raw: lastRaw };
}
transient = true;
} catch (err) {
// Network error, timeout, DNS — all transient from our POV.
lastError = (err && err.message) || String(err);
transient = true;
}
if (transient && attempt < INGEST_MAX_ATTEMPTS - 1) {
log('ingest transient failure, retrying', { attempt, error: lastError });
await sleep(INGEST_BACKOFF_MS[attempt]);
}
}
return { ok: false, error: lastError, raw: lastRaw, retried: true };
}
// -- Friendly ingest-error text -----------------------------------------
// The edge functions echo Torn's literal error wording on a rejected
// key (e.g. "Torn API rejected key: Incorrect key" for code 2). That's
// technically accurate but un-actionable on iPad where PDA is the only
// place the key lives — the user doesn't always realise "the key"
// means the one they pasted into PDA's Script Manager, not the one
// stored on Valigia's server. Translate the common failures into a
// one-line instruction the user can act on without leaving the toast.
function friendlyIngestError(label, rowCount, result) {
const raw = (result && result.error) || 'unknown';
const lower = String(raw).toLowerCase();
if (lower.indexOf('incorrect key') !== -1 || lower.indexOf('invalid key') !== -1) {
return label + ': Torn rejected your API key. Update it in PDA Settings \u2192 Script Manager \u2192 Valigia.';
}
if (lower.indexOf('access level') !== -1 || lower.indexOf('key access') !== -1) {
return label + ': API key is missing a permission. Re-create a Custom Key from the Valigia login screen.';
}
if (lower.indexOf('rate_limited') !== -1 || lower.indexOf('rate limit') !== -1) {
return label + ': rate-limited by Valigia \u2014 scrape again in a few seconds.';
}
return label + ' (' + rowCount + '): ' + raw;
}
// Failures the user can't act on — rate-limit gating and transient
// network/5xx errors that exhausted retries — shouldn't paint a red
// toast. The next scrape will succeed on its own (the iPad is the only
// surface and there's no DevTools to read the message anyway). Real
// key/permission errors still toast: those need user action in PDA's
// Script Manager.
function isSilentIngestError(result) {
if (!result || result.ok) return false;
if (result.retried) return true;
const lower = String(result.error || '').toLowerCase();
return lower.indexOf('rate_limited') !== -1 || lower.indexOf('rate limit') !== -1;
}
// -- Per-item profit math ------------------------------------------------
// The overlay only displays per-item values, so we only compute them.
// Net sell is after Torn's 5% item-market fee. Returns null when inputs
// are missing or non-positive so the renderer can show "no sell data".
function computeProfit(opts) {
const buyPrice = opts.buyPrice;
const sellPrice = opts.sellPrice;
if (!(sellPrice > 0) || !(buyPrice > 0)) return null;
const netSell = sellPrice * 0.95; // 5% item-market fee
const marginPerItem = netSell - buyPrice;
const marginPct = (marginPerItem / buyPrice) * 100;
return {
netSell: netSell,
marginPerItem: marginPerItem,
marginPct: marginPct,
};
}
// -- Formatters ----------------------------------------------------------
function formatMoney(n) {
if (n == null || !Number.isFinite(n)) return '-';
const sign = n < 0 ? '-' : '';
const abs = Math.abs(Math.round(n));
return sign + '$' + abs.toLocaleString('en-US');
}
function formatPct(n) {
if (n == null || !Number.isFinite(n)) return '-';
return (n >= 0 ? '+' : '') + n.toFixed(0) + '%';
}
// -- Style injection -----------------------------------------------------
// Single <style> tag per page load. Kept minimal in v1; polish later.
let stylesInjected = false;
function injectStyles() {
if (stylesInjected) return;
stylesInjected = true;
const css = [
'.valigia-cell {',
' padding: 4px 8px;',
' font: 600 11px/1.3 Arial, Helvetica, sans-serif;',
' color: #c8cdd8;',
' background: rgba(22,26,34,0.55);',
' border-left: 2px solid #252a35;',
' white-space: nowrap;',
' vertical-align: middle;',
'}',
// When the host row isn't a <tr>, we inject a block-level <div> below
// the row instead. Give it a touch of top margin so it reads as an
// annotation of the row above rather than a row of its own.
'div.valigia-cell {',
' display: block;',
' margin: 2px 0 6px 44px;',
' border-left: 3px solid #252a35;',
' border-radius: 2px;',
'}',
'.valigia-cell .v-label {',
' color: #5a6070;',
' font-weight: 500;',
' text-transform: uppercase;',
' letter-spacing: 0.04em;',
' font-size: 10px;',
'}',
'.valigia-cell .v-sell { color: #c8cdd8; }',
'.valigia-cell .v-margin-pos { color: #4ae8a0; }',
'.valigia-cell .v-margin-neg { color: #b33; }',
'.valigia-cell .v-muted { color: #5a6070; font-weight: 400; }',
'.valigia-cell .v-sep { color: #3a4050; margin: 0 4px; }',
'.valigia-best .valigia-cell,',
'.valigia-best > .valigia-cell,',
'.valigia-best + div.valigia-cell {',
' background: rgba(74,232,160,0.14);',
' border-left: 3px solid #4ae8a0;',
'}',
'.valigia-best-badge {',
' display: inline-block;',
' background: #4ae8a0;',
' color: #0d0f14;',
' font-weight: 800;',
' letter-spacing: 0.05em;',
' padding: 1px 5px;',
' border-radius: 3px;',
' margin-right: 6px;',
' font-size: 10px;',
'}',
].join('\n');
const style = document.createElement('style');
style.id = 'valigia-overlay-styles';
style.textContent = css;
document.head.appendChild(style);
}
// -- Overlay render ------------------------------------------------------
// For each row we scraped, compute profit and inject a cell at the end of
// the row showing margin + profit/hr. Mark the top profit/hr row with a
// BEST badge and a subtle green highlight.
function renderOverlay(shops, sellPriceMap, refillEtaMap) {
if (!(refillEtaMap instanceof Map)) refillEtaMap = new Map();
injectStyles();
// Flatten every scraped item into a row descriptor with a reference to
// its DOM row container so we can inject directly. We re-walk the same
// images we scraped from to find the row — Torn has migrated many pages
// away from <table>/<tr>, so we use the same flexible rowContainer()
// helper the scraper uses.
const allRows = [];
const imgs = Array.from(document.querySelectorAll('img[src*="/images/items/"]'));
for (const img of imgs) {
const src = img.getAttribute('src') || '';
const idMatch = src.match(/\/images\/items\/(\d+)\//);
if (!idMatch) continue;
const item_id = Number(idMatch[1]);
const row = rowContainer(img);
if (!row) continue;
// Skip rows we've already decorated (in case the script fires twice
// from tab switches inside the same page).
if (row.classList && row.classList.contains('valigia-decorated')) continue;
// Find the scraped record for this item_id so we don't re-parse the
// row ourselves (already done by scrapeShops).
let buyPrice = null;
let stock = null;
for (const sh of shops) {
for (const it of sh.items) {
if (it.item_id === item_id) {
buyPrice = it.buy_price;
stock = it.stock;
break;
}
}
if (buyPrice != null) break;
}
if (buyPrice == null) continue;
const sp = sellPriceMap.get(item_id);
const sellPrice = sp ? sp.price : null;
const metrics = sellPrice != null
? computeProfit({
buyPrice: buyPrice,
sellPrice: sellPrice,
})
: null;
allRows.push({
row: row,
item_id: item_id,
buyPrice: buyPrice,
stock: stock,
sellPrice: sellPrice,
metrics: metrics,
});
}
// Rank by per-item margin - within a single shop page the flight time
// and slot count are constants, so ranking by margin-per-item is
// equivalent to ranking by profit/hr. No slot count needed here.
// Only rows with positive margin and non-zero stock are eligible.
let best = null;
for (const r of allRows) {
if (!r.metrics) continue;
if (r.metrics.marginPerItem <= 0) continue;
if (r.stock != null && r.stock <= 0) continue;
// Sanity cap: catches parser blow-ups like the historical Flail UK
// bug (+831,249% when $8M was misread as ~$800) without excluding
// legitimate UK collectibles, plushies, and flowers, which routinely
// run past +3000% (Heather +602%, Inkwell +3429%, etc.). A 100,000%
// ceiling is well above any real Torn travel margin and still flags
// gross parse errors. The numbers still render in the overlay either
// way so the player can see when something's off.
if (r.metrics.marginPct > 100000) continue;
if (!best || r.metrics.marginPerItem > best.metrics.marginPerItem) best = r;
}
// Inject the cell into each row. We show per-item values only:
// net sell price, absolute margin, margin %. The player does the
// "times my actual slot count" math in their head, which avoids us
// needing to know (or sync) their slot preference.
//
// If the row is a <tr> we append a matching <td>; otherwise (Torn's
// newer div-based shop grid) we insert a block <div> right after the
// row so it reads as an annotation immediately beneath.
for (const r of allRows) {
const isTr = r.row.tagName === 'TR';
const cell = document.createElement(isTr ? 'td' : 'div');
cell.className = 'valigia-cell';
if (!r.metrics) {
if (r.sellPrice == null) {
cell.innerHTML = '<span class="v-muted">no market price data</span>';
} else {
cell.innerHTML = '<span class="v-muted">-</span>';
}
} else {
const m = r.metrics;
const isBest = (r === best);
const marginClass = m.marginPerItem >= 0 ? 'v-margin-pos' : 'v-margin-neg';
const outOfStock = (r.stock != null && r.stock <= 0);
let html = '';
if (isBest) html += '<span class="valigia-best-badge">BEST</span>';
if (outOfStock) {
const etaMins = refillEtaMap.get(r.item_id);
const etaText = formatRefillEta(etaMins != null ? etaMins : null);
if (etaText) {
html += '<span class="v-muted">stock 0 · ' + etaText + '</span>';
} else {
html += '<span class="v-muted">stock 0 · skip</span>';
}
} else {
// Label the number "Market Price" so it's clearly distinct from
// Torn's existing "Cost" / "Buy" columns on the shop page. The
// value shown is the net per-unit Item Market sell price (after
// the 5% market fee) — what the player actually realises per unit.
html += '<span class="v-label">Market Price</span> ';
html += '<span class="v-sell">' + formatMoney(m.netSell) + '</span>';
html += '<span class="v-sep">·</span>';
html += '<span class="' + marginClass + '">' + formatMoney(m.marginPerItem) + '</span>';
html += '<span class="v-sep">·</span>';
html += '<span class="' + marginClass + '">' + formatPct(m.marginPct) + '</span>';
}
cell.innerHTML = html;
if (isBest) r.row.classList.add('valigia-best');
}
if (isTr) {
r.row.appendChild(cell);
} else {
// For non-<tr> rows, insert directly after the row so it appears
// immediately beneath. If the row has no parent (detached), fall
// back to appending into the row itself.
if (r.row.parentNode) {
r.row.parentNode.insertBefore(cell, r.row.nextSibling);
} else {
r.row.appendChild(cell);
}
}
if (r.row.classList) r.row.classList.add('valigia-decorated');
}
return { total: allRows.length, withMetrics: allRows.filter(r => r.metrics).length, best: best };
}
// -- Item Market scraper -------------------------------------------------
// Every listing card on the modern Item Market has an <img> with the item
// id in its src and a $-prefixed price somewhere in the same row/card. We
// group all rows by item_id, pick the lowest price as the floor, count the
// rows as the listing depth, and upsert that straight into sell_prices.
//
// One catch: the same item image appears in navigation / sidebar / search
// chrome. We skip any row that doesn't contain a $-price so chrome rows
// don't pollute the listing count.
// Listings above this qty are almost certainly category-card contamination
// (the item-browse card shows "$price (circulation)" where circulation is
// a five/six-figure total-in-game count — NOT a listing size). Popular
// drugs sit at 60k-240k circulation, so a 10k cap cleanly separates real
// listings (largest observed ~5k amount) from that noise.
const MAX_LISTING_QTY = 10000;
function scrapeItemMarket() {
const imgs = Array.from(document.querySelectorAll('img[src*="/images/items/"]'));
const byItem = new Map(); // item_id -> { name, listings: [{price, qty}] }
const seenRows = new Set();
for (const img of imgs) {
const src = img.getAttribute('src') || '';
const idMatch = src.match(/\/images\/items\/(\d+)\//);
if (!idMatch) continue;
const item_id = Number(idMatch[1]);
const row = rowContainer(img);
if (!row || seenRows.has(row)) continue;
seenRows.add(row);
const text = (row.innerText || '').trim();
const priceMatch = text.match(/\$\s*([\d,\.]+)/);
if (!priceMatch) continue; // no price in this row => chrome, skip
const price = parseMoney(priceMatch[1]);
if (!Number.isFinite(price) || price <= 0) continue;
// Quantity: first bare integer after removing the price token AND
// parenthesized numbers. Category-card pages show "$price (circulation)"
// where the parenthesized number is total items in-game, not a listing
// quantity. Stripping "(51,422)" etc. lets the qty default to 1 so we
// still capture the price signal without skewing floor_qty.
const withoutPrice = text.replace(/\$\s*[\d,\.]+/g, ' ');
const withoutCirculation = withoutPrice.replace(/\(\s*[\d,]+\s*\)/g, ' ');
const intTokens = withoutCirculation.match(/(?<![\w.])\d[\d,]*(?![\w.])/g) || [];
let qty = 1;
for (const tok of intTokens) {
const n = parseInt10(tok);
if (Number.isFinite(n) && n > 0) { qty = n; break; }
}
// Safety net: reject listings claiming absurd quantities (likely a
// scraping artefact we didn't anticipate).
if (qty > MAX_LISTING_QTY) continue;
// Item name: prefer the image alt (stable across Torn's UI shuffles);
// fall back to the first line of the row text.
const altName = (img.getAttribute('alt') || '').trim();
const firstLine = (text.split('\n')[0] || '').trim();
const name = altName || firstLine || '';
if (!byItem.has(item_id)) byItem.set(item_id, { name: name, listings: [] });
const entry = byItem.get(item_id);
if (!entry.name && name) entry.name = name;
entry.listings.push({ price: price, qty: qty });
}
const now = new Date().toISOString();
const rows = [];
for (const [item_id, entry] of byItem) {
const listings = entry.listings;
if (listings.length === 0) continue;
listings.sort(function (a, b) { return a.price - b.price; });
// min_price = absolute floor (cheapest listing, any qty). Feeds
// the Watchlist matcher: a single-unit $219k listing under a
// $250k alert is a real buying opportunity even if the next stack
// sits above the threshold. Travel profit math ignores this and
// keeps using the qty-filtered effective floor below.
const minPrice = listings[0].price;
// Effective floor = first listing with qty >= 2. A single-unit listing
// at a much lower price is almost always a loss-leader or misclick
// (see Cannabis case: 1 unit at $10,500 sitting atop 19-unit stacks at
// $12,900+). Storing the $10,500 as THE sell price overstates profit
// for any realistic multi-unit travel run. Fall back to the absolute
// floor when every listing is single-unit (rare items / collector bins).
let floor = null;
for (const l of listings) {
if (l.qty >= 2) { floor = l; break; }
}
if (!floor) floor = listings[0];
rows.push({
item_id: item_id,
price: floor.price,
min_price: minPrice,
floor_qty: floor.qty,
listing_count: listings.length,
updated_at: now,
// Keep name out of the upsert payload (sell_prices doesn't have a
// name column), but carry it on the row for the toast to read.
_name: entry.name,
});
}
return rows;
}
async function runItemMarket() {
// Kick the watchlist-matches banner in parallel with the scrape.
// Fire-and-forget: any failure is silent so the primary scraper
// flow isn't blocked on a (potentially slow) Torn key validation.
injectWatchlistBar();
// When the player has filtered down to a single item (hash carries
// itemID=N), surface the cheapest fresh bazaar listing for that
// item from the shared pool. Silent no-op on the catalog landing
// view or when the pool has no fresh hit.
injectLowestPriceBar();
// Pool-wide flip surface: cross-references fresh sell_prices floors
// against the highest fresh te_buy_prices offer per item. Scopes to
// a single item when itemID=N is in the hash.
injectFlashDealsBar();
// Poll briefly for listings to hydrate - the Item Market page is SPA-ish.
const start = Date.now();
let rows = [];
while (Date.now() - start < 8000) {
rows = scrapeItemMarket();
if (rows.length > 0) break;
await new Promise(function (r) { setTimeout(r, 500); });
}
if (DEBUG) {
const lines = ['page=itemmarket', 'items=' + rows.length];
for (const r of rows.slice(0, 10)) {
lines.push(' id=' + r.item_id + ' (' + (r._name || '?') + ') $' + r.price +
' x' + r.floor_qty + ' (' + r.listing_count + ' listings)');
}
debugPanel(lines);
}
if (rows.length === 0) {
log('Item Market: no listings found, skipping upsert.');
return;
}
const upsertRows = rows.map(function (r) {
// Drop both the carry-through _name and updated_at — the edge function
// stamps updated_at server-side, and there's no name column on
// sell_prices.
const { _name, updated_at, ...rest } = r;
return rest;
});
const result = await postIngestRows(INGEST_SELL_URL, upsertRows);
if (result.ok) {
toast('Item Market: ' + result.count + ' prices', 'success');
pingActivity('item_market');
} else if (isSilentIngestError(result)) {
log('Item Market ingest skipped (silent):', result.error);
} else {
toast(friendlyIngestError('Market', upsertRows.length, result), 'error');
}
}
// -- Bazaar scraper ------------------------------------------------------
// Bazaar URLs look like bazaar.php?userId=123 (legacy) or with step= query
// strings in the modern layout. We pull the owner id from either the
// query string or the hash; if neither carries one, we're looking at the
// player's own bazaar - nothing useful to push to the shared pool there,
// so bail.
function detectBazaarOwnerId() {
try {
const url = new URL(location.href);
const qs = url.searchParams;
const fromQuery = qs.get('userID') || qs.get('userId') || qs.get('user_id');
if (fromQuery && /^\d+$/.test(fromQuery)) return Number(fromQuery);
} catch (e) { /* ignore */ }
// Hash-routed forms: "#/p=bazaar&userId=123" or similar.
const hash = location.hash || '';
const hashMatch = hash.match(/user(?:ID|Id|_id)=(\d+)/i);
if (hashMatch) return Number(hashMatch[1]);
return null;
}
/**
* Is the tile's item image a padlock glyph?
*
* Torn overlays a padlock on bazaar tiles the owner has parked as $1
* placeholders. Earlier versions tried to sniff class names, aria
* attributes, and inner text for "lock"/"locked" — but every CSS
* selector we tried (substring `[class*="lock"]`, word-boundary
* regex, etc.) either missed tiles or false-positived on common
* tokens like `block`, `clock`, and `unlocked` that Torn uses
* liberally. The result was real listings vanishing from the Deals
* bar AND the bazaar pool.
*
* This minimal version only checks the item image's own src for a
* padlock filename. The $1 price gate at the caller handles every
* other locked tile — a real bazaar never lists a buyable item at
* $1, so dropping $1 rows at scrape time is both accurate and
* DOM-independent.
*/
function isLockedListing(img) {
try {
const imgSrc = img.getAttribute('src') || '';
return /\/padlock|\/lock[._-]/i.test(imgSrc);
} catch (_) { return false; }
}
function scrapeBazaarItems() {
const imgs = Array.from(document.querySelectorAll('img[src*="/images/items/"]'));
const byItem = new Map(); // item_id -> {price, qty} (cheapest only)
const seenRows = new Set();
for (const img of imgs) {
const src = img.getAttribute('src') || '';
const idMatch = src.match(/\/images\/items\/(\d+)\//);
if (!idMatch) continue;
const item_id = Number(idMatch[1]);
const row = rowContainer(img);
if (!row || seenRows.has(row)) continue;
seenRows.add(row);
// Skip tiles whose image is a padlock glyph. The real work is done
// by the $1 price gate below — a real bazaar never lists a buyable
// item at $1, so dropping $1 rows is both DOM-independent and the
// canonical "this is locked" signal.
if (isLockedListing(img)) continue;
const text = (row.innerText || '').trim();
const priceMatch = text.match(/\$\s*([\d,\.]+)/);
if (!priceMatch) continue;
const price = parseMoney(priceMatch[1]);
if (!Number.isFinite(price) || price <= 1) continue;
const withoutPrice = text.replace(/\$\s*[\d,\.]+/g, ' ');
const intTokens = withoutPrice.match(/(?<![\w.])\d[\d,]*(?![\w.])/g) || [];
let qty = 1;
for (const tok of intTokens) {
const n = parseInt10(tok);
if (Number.isFinite(n) && n > 0) { qty = n; break; }
}
const existing = byItem.get(item_id);
if (!existing || price < existing.price) {
byItem.set(item_id, { price: price, qty: qty });
}
}
return Array.from(byItem.entries()).map(function (entry) {
return { item_id: entry[0], price: entry[1].price, quantity: entry[1].qty };
});
}
async function runBazaar() {
// Watchlist banner kicks off in parallel. Even on an own-bazaar visit
// (no ownerId, early return below) we still want to surface matches
// — so this runs before the ownerId guard.
injectWatchlistBar();
const ownerId = detectBazaarOwnerId();
if (!ownerId) {
log('Bazaar: no userId in URL (own bazaar?) - skipping.');
return;
}
// Same hydration poll used on travel + item market.
const start = Date.now();
let items = [];
while (Date.now() - start < 8000) {
items = scrapeBazaarItems();
if (items.length > 0) break;
await new Promise(function (r) { setTimeout(r, 500); });
}
if (DEBUG) {
const lines = ['page=bazaar', 'owner=' + ownerId, 'items=' + items.length];
for (const it of items.slice(0, 10)) {
lines.push(' id=' + it.item_id + ' $' + it.price + ' x' + it.quantity);
}
debugPanel(lines);
}
if (items.length === 0) {
log('Bazaar: no items visible, skipping upsert.');
return;
}
// Scraping a bazaar page is a definitive hit: we see the listing right
// now, so miss_count resets to 0. Items that USED to be in this bazaar
// but aren't in our scrape are left alone - the web-app scanner's
// miss-count logic catches those on its next live check. checked_at is
// stamped server-side by the edge function.
const rows = items.map(function (it) {
return {
item_id: it.item_id,
bazaar_owner_id: ownerId,
price: it.price,
quantity: it.quantity,
miss_count: 0,
};
});
// Surface any flippable listings in a top-of-page bar (mirrors the
// Watchlist Matches bar's UX). Fire-and-forget: any failure is
// silent so the primary ingest path is never blocked.
injectBazaarDealsBar(items, ownerId).catch(function (e) { log('deals bar error', e); });
const result = await postIngestRows(INGEST_BAZAAR_URL, rows);
if (result.ok) {
toast('Bazaar: ' + result.count + ' prices', 'success');
pingActivity('bazaar');
} else if (isSilentIngestError(result)) {
log('Bazaar ingest skipped (silent):', result.error);
} else {
toast(friendlyIngestError('Bazaar', rows.length, result), 'error');
}
}
// -- Watchlist matches banner --------------------------------------------
// A collapsed green bar injected at the top of the Item Market and
// Bazaar pages that surfaces this player's active Watchlist matches.
// Tapping the triangle expands it into a Valigia-styled list with
// direct deep-links back into Torn. Hidden entirely on zero matches.
//
// Trust/data path:
// 1. Resolve player_id once (cached per api_key hash in localStorage)
// via a single Torn /user/?selections=basic call.
// 2. Fetch watchlist_alerts + sell_prices + bazaar_prices via anon
// SELECT — all three are public-read, same surface the web app
// and existing scrapers already use.
// 3. Compute matches client-side, mirroring src/watchlist.js. Abroad
// venue is skipped here (it'd require a per-page YATA fetch); the
// web app remains the surface for abroad matches.
//
// Scope: runs only on Market + Bazaar. Travel is intentionally excluded
// — the travel page already shows abroad prices inline, so a banner
// would duplicate information the user is actively looking at.
const WATCHLIST_BAR_ID = 'valigia-watchlist-bar';
const WATCHLIST_ALERTS_URL = SUPABASE_REST_URL + '/watchlist_alerts';
const PLAYER_ID_CACHE_KEY = 'valigia_pda_player_id_v1';
// Torn items catalog cache. The web app maintains its own copy on
// valigia.girovagabondo.com, but userscript localStorage is scoped to
// torn.com — we can't share. Cost is one Torn /torn/?selections=items
// call per player per catalog-TTL, answered by a static dataset.
// Bumped to v2 when we extended the cache shape from name-only to
// { name, type }. v1 readers got a one-time refetch on first load.
const ITEM_CATALOG_CACHE_KEY = 'valigia_item_catalog_v2';
// Torn rarely changes item names. A 30-day TTL keeps the cache small in
// terms of refresh pressure; any unknown id at lookup time still falls
// back to "Item #N" so a stale cache doesn't break the banner.
const ITEM_CATALOG_TTL_MS = 30 * 24 * 60 * 60 * 1000;
// Bazaar rows older than this are dropped — matches the web app's
// 10-minute threshold so the bar doesn't claim a stale deal.
const WATCHLIST_BAZAAR_MAX_AGE_MS = 10 * 60 * 1000;
// Item Market rows older than this are dropped. Mirrors the web
// app's 1-hour MARKET_MAX_AGE_MS so a stale floor (e.g. someone
// scraped Gold Noble Coin an hour ago at $1.4M and the listing has
// long since been bought, leaving a $2.3M real floor) can't
// masquerade as a current match. The web app force-refreshes every
// watchlisted item on a 10-minute staleness window on each dashboard
// load, so a price that still holds will quickly re-appear here.
const WATCHLIST_MARKET_MAX_AGE_MS = 60 * 60 * 1000;
// Tiny non-crypto hash of the api_key so we can key the player_id cache
// by it — lets the cache invalidate automatically when the user swaps
// to a different Torn API key without leaking the key itself.
function hashApiKey(key) {
let h = 0;
for (let i = 0; i < key.length; i++) {
h = (h * 31 + key.charCodeAt(i)) | 0;
}
return String(h);
}
async function resolvePlayerId() {
if (!TORN_API_KEY || TORN_API_KEY.indexOf('PDA-APIKEY') !== -1) return null;
const keyHash = hashApiKey(TORN_API_KEY);
try {
const raw = localStorage.getItem(PLAYER_ID_CACHE_KEY);
if (raw) {
const cached = JSON.parse(raw);
if (cached && cached.hash === keyHash && cached.player_id) {
return cached.player_id;
}
}
} catch (_) { /* ignore corrupt cache */ }
// Use GM_xmlhttpRequest (not plain fetch) so PDA's webview CORS
// behaviour doesn't block the call. api.torn.com is in @connect.
try {
const res = await gmRequest({
method: 'GET',
url: 'https://api.torn.com/user/?selections=basic&key=' +
encodeURIComponent(TORN_API_KEY),
headers: { 'Accept': 'application/json' },
});
let data = null;
try { data = JSON.parse(res.responseText); } catch (_) { /* ignore */ }
if (data && data.player_id) {
try {
localStorage.setItem(
PLAYER_ID_CACHE_KEY,
JSON.stringify({ hash: keyHash, player_id: data.player_id })
);
} catch (_) { /* ignore quota / disabled storage */ }
return data.player_id;
}
log('resolvePlayerId: unexpected response', res.status, res.responseText);
} catch (err) {
log('resolvePlayerId: request failed', err);
}
return null;
}
async function fetchJSON(url) {
try {
const res = await gmRequest({
method: 'GET',
url: url,
headers: {
'apikey': SUPABASE_ANON_KEY,
'Authorization': 'Bearer ' + SUPABASE_ANON_KEY,
'Accept': 'application/json',
},
});
if (res.status < 200 || res.status >= 300) {
log('fetchJSON non-2xx', res.status, url);
return null;
}
try { return JSON.parse(res.responseText); } catch (_) { return null; }
} catch (err) {
log('fetchJSON failed', err, url);
return null;
}
}
/**
* Read alerts for this player, then look up live market/bazaar prices
* for the alerted items and compute the match list. Returns [] on any
/**
* Fetch + shape this player's watchlist matches. Returns [] on any
* failure so callers can treat "no matches" and "fetch failed"
* identically — a silent no-op is the right failure mode for a banner.
*
* Memoised for WATCHLIST_CACHE_TTL_MS (30 s). Item Market's SPA nav
* fires dispatch() — and therefore injectWatchlistBar() — on every
* item tap, so a user flicking through 10 items in 30 s used to burn
* 30 PostgREST reads on data that can't have changed. Per-player key
* isolates cache across key rotation (player_id changes), and cached
* reads of an empty result are just as valid as non-empty, so zero
* matches are memoised too.
*/
const WATCHLIST_CACHE_TTL_MS = 30_000;
let watchlistMatchesCache = null; // { playerId, expiresAt, matches } | null
async function fetchWatchlistMatches(playerId) {
if (!playerId) return [];
const now = Date.now();
if (watchlistMatchesCache
&& watchlistMatchesCache.playerId === playerId
&& watchlistMatchesCache.expiresAt > now) {
return watchlistMatchesCache.matches;
}
const alerts = await fetchJSON(
WATCHLIST_ALERTS_URL +
'?player_id=eq.' + encodeURIComponent(playerId) +
'&select=item_id,max_price,venues'
);
if (!Array.isArray(alerts) || alerts.length === 0) return [];
const idList = alerts.map(function (a) { return a.item_id; }).join(',');
if (!idList) return [];
// Parallel reads — same pattern as src/watchlist.js. Abroad skipped.
const inClause = 'in.(' + idList + ')';
const [sellRows, bazaarRows] = await Promise.all([
fetchJSON(
SELL_PRICES_URL +
'?item_id=' + inClause +
'&select=item_id,price,min_price,updated_at'
),
fetchJSON(
BAZAAR_PRICES_URL +
'?item_id=' + inClause +
'&select=item_id,price,quantity,bazaar_owner_id,checked_at'
),
]);
const sellByItem = new Map();
if (Array.isArray(sellRows)) {
for (const r of sellRows) sellByItem.set(r.item_id, r);
}
const bazaarByItem = new Map();
if (Array.isArray(bazaarRows)) {
for (const r of bazaarRows) {
const existing = bazaarByItem.get(r.item_id);
if (!existing || r.price < existing.price) {
const observedAt = r.checked_at ? new Date(r.checked_at).getTime() : 0;
if (Date.now() - observedAt <= WATCHLIST_BAZAAR_MAX_AGE_MS) {
bazaarByItem.set(r.item_id, r);
}
}
}
}
const matches = [];
for (const a of alerts) {
const venues = new Set(a.venues || ['market', 'bazaar']);
const maxPrice = Number(a.max_price);
if (venues.has('market')) {
const s = sellByItem.get(a.item_id);
// Match against min_price (absolute floor) — see src/watchlist.js
// for the rationale. Falls back to price for rows that haven't
// been refreshed since migration 024.
const floorPrice = s && s.min_price != null
? Number(s.min_price)
: (s && s.price != null ? Number(s.price) : null);
if (s && floorPrice != null && floorPrice <= maxPrice) {
const observedAt = s.updated_at ? new Date(s.updated_at).getTime() : 0;
const fresh = observedAt > 0 && Date.now() - observedAt <= WATCHLIST_MARKET_MAX_AGE_MS;
if (fresh) {
const limited = s.price != null
&& s.min_price != null
&& Number(s.min_price) < Number(s.price);
matches.push({
item_id: a.item_id,
venue: 'market',
venue_label: 'Item Market',
price: floorPrice,
max_price: maxPrice,
savings: maxPrice - floorPrice,
savings_pct: ((maxPrice - floorPrice) / maxPrice) * 100,
observed_at: observedAt,
link: 'https://www.torn.com/page.php?sid=ItemMarket#/market/view=search&itemID=' + a.item_id,
extra: { limited: limited },
});
}
}
}
if (venues.has('bazaar')) {
const b = bazaarByItem.get(a.item_id);
// Drop $1 locked placeholders. bazaar_prices still carries
// rows written before the scraper's $1 filter landed, and
// surfacing them as a "bazaar match" means the user clicks
// through to a bazaar only to find the listing is unbuyable.
if (b && Number(b.price) > 1 && Number(b.price) <= maxPrice) {
const price = Number(b.price);
matches.push({
item_id: a.item_id,
venue: 'bazaar',
venue_label: 'Bazaar',
price: price,
max_price: maxPrice,
savings: maxPrice - price,
savings_pct: ((maxPrice - price) / maxPrice) * 100,
observed_at: b.checked_at ? new Date(b.checked_at).getTime() : 0,
link: 'https://www.torn.com/bazaar.php?userId=' + b.bazaar_owner_id,
extra: { owner_id: b.bazaar_owner_id, quantity: b.quantity },
});
}
}
}
matches.sort(function (a, b) { return b.savings_pct - a.savings_pct; });
watchlistMatchesCache = {
playerId: playerId,
expiresAt: now + WATCHLIST_CACHE_TTL_MS,
matches: matches,
};
return matches;
}
// -- Banner styles + DOM -------------------------------------------------
function injectWatchlistStyles() {
if (document.getElementById('valigia-watchlist-styles')) return;
const css = [
'#' + WATCHLIST_BAR_ID + ' {',
' all: initial;',
' display: block;',
' margin: 8px auto 12px;',
' max-width: 1100px;',
' font-family: Arial, Helvetica, sans-serif;',
' color: #c8cdd8;',
' background: #161a22;',
' border: 1px solid #252a35;',
' border-left: 3px solid #4ae8a0;',
' border-radius: 4px;',
' box-sizing: border-box;',
' overflow: hidden;',
'}',
'#' + WATCHLIST_BAR_ID + ' .vgl-wl-head {',
' display: flex;',
' align-items: center;',
' gap: 8px;',
' padding: 8px 12px;',
' cursor: pointer;',
' user-select: none;',
'}',
'#' + WATCHLIST_BAR_ID + ' .vgl-wl-title {',
' color: #4ae8a0;',
' font-weight: 700;',
' font-size: 12px;',
' letter-spacing: 0.12em;',
' text-transform: uppercase;',
'}',
'#' + WATCHLIST_BAR_ID + ' .vgl-wl-count {',
' background: #4ae8a0;',
' color: #0d0f14;',
' font-weight: 700;',
' font-size: 11px;',
' padding: 1px 7px;',
' border-radius: 999px;',
'}',
'#' + WATCHLIST_BAR_ID + ' .vgl-wl-caret {',
' margin-left: auto;',
' color: #4ae8a0;',
' font-size: 11px;',
' transition: transform 150ms;',
'}',
'#' + WATCHLIST_BAR_ID + '.vgl-wl-open .vgl-wl-caret {',
' transform: rotate(180deg);',
'}',
'#' + WATCHLIST_BAR_ID + ' .vgl-wl-body {',
' display: none;',
' padding: 4px 10px 10px;',
' gap: 4px;',
' flex-direction: column;',
'}',
'#' + WATCHLIST_BAR_ID + '.vgl-wl-open .vgl-wl-body {',
' display: flex;',
'}',
'#' + WATCHLIST_BAR_ID + ' .vgl-wl-row {',
' display: grid;',
' grid-template-columns: minmax(0,1.4fr) auto auto minmax(0,1fr) auto auto;',
' align-items: center;',
' gap: 8px;',
' padding: 6px 8px;',
' border: 1px solid #252a35;',
' border-radius: 3px;',
' background: rgba(74,232,160,0.04);',
' color: #c8cdd8;',
' text-decoration: none;',
' font-size: 12px;',
'}',
'#' + WATCHLIST_BAR_ID + ' .vgl-wl-row:active {',
' background: rgba(74,232,160,0.12);',
'}',
'#' + WATCHLIST_BAR_ID + ' .vgl-wl-item { font-weight: 700; color: #c8cdd8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
'#' + WATCHLIST_BAR_ID + ' .vgl-wl-venue { font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; padding: 2px 6px; border-radius: 2px; white-space: nowrap; }',
'#' + WATCHLIST_BAR_ID + ' .vgl-wl-venue--market { background: rgba(232,200,74,0.18); color: #e8c84a; }',
'#' + WATCHLIST_BAR_ID + ' .vgl-wl-venue--bazaar { background: rgba(74,232,160,0.18); color: #4ae8a0; }',
'#' + WATCHLIST_BAR_ID + ' .vgl-wl-price { color: #e8c84a; font-weight: 700; white-space: nowrap; }',
'#' + WATCHLIST_BAR_ID + ' .vgl-wl-save { color: #8a8fa0; white-space: nowrap; }',
'#' + WATCHLIST_BAR_ID + ' .vgl-wl-save strong { color: #4ae8a0; font-weight: 700; }',
'#' + WATCHLIST_BAR_ID + ' .vgl-wl-age { color: #8a8fa0; font-size: 10px; white-space: nowrap; }',
'#' + WATCHLIST_BAR_ID + ' .vgl-wl-arrow { color: #e8c84a; font-weight: 700; }',
].join('\n');
const style = document.createElement('style');
style.id = 'valigia-watchlist-styles';
style.textContent = css;
document.head.appendChild(style);
}
function formatMoney(n) {
if (n == null) return '\u2014';
const sign = n < 0 ? '-' : '';
return sign + '$' + Math.abs(Math.round(n)).toLocaleString('en-US');
}
/**
* Compact money formatter used inside the bazaar row overlay where
* horizontal space is tight. < $10k: full digits ($9,876). $10k–$1M:
* "$12.3k" / "$123k". >= $1M: "$1.2M" / "$120M". Negative numbers get
* a leading "-".
*/
function formatMoneyCompact(n) {
if (n == null || !Number.isFinite(n)) return '\u2014';
const sign = n < 0 ? '-' : '';
const abs = Math.abs(n);
if (abs < 10000) return sign + '$' + Math.round(abs).toLocaleString('en-US');
if (abs < 1_000_000) {
const k = abs / 1000;
return sign + '$' + (k >= 100 ? Math.round(k) : k.toFixed(1)) + 'k';
}
if (abs < 1_000_000_000) {
const m = abs / 1_000_000;
return sign + '$' + (m >= 100 ? Math.round(m) : m.toFixed(1)) + 'M';
}
const b = abs / 1_000_000_000;
return sign + '$' + (b >= 100 ? Math.round(b) : b.toFixed(1)) + 'B';
}
function formatAge(ms) {
if (!ms) return '';
const diff = Date.now() - ms;
if (diff < 60000) return 'just now';
const mins = Math.floor(diff / 60000);
if (mins < 60) return mins + 'm ago';
const hrs = Math.floor(mins / 60);
if (hrs < 24) return hrs + 'h ago';
return Math.floor(hrs / 24) + 'd ago';
}
// In-memory view of the Torn items catalog. Populated lazily by
// ensureItemCatalog() from localStorage or a Torn API call. Shape:
// Map<itemId:number, { name:string, type:string|null }>.
// The cache used to hold names only; v2 added type so the in-flight
// strip can filter to the canonical arbitrage categories
// (Drug/Flower/Plushie/Artifact).
let itemMetaCache = null;
/**
* Load the id→name map, hydrating the in-memory cache from localStorage
* or fetching from Torn if we're cold. Safe to call repeatedly — only
* hits the network once per TTL window. Silent-fail on any error so
* the banner never blocks on name resolution.
*/
async function ensureItemCatalog() {
if (itemMetaCache && itemMetaCache.size > 0) return itemMetaCache;
// Try localStorage first. If the cached blob is present and fresh,
// hydrate the in-memory cache and skip the fetch entirely.
try {
const raw = localStorage.getItem(ITEM_CATALOG_CACHE_KEY);
if (raw) {
const cached = JSON.parse(raw);
if (
cached &&
cached.fetchedAt &&
Date.now() - cached.fetchedAt < ITEM_CATALOG_TTL_MS &&
cached.byId && typeof cached.byId === 'object'
) {
itemMetaCache = new Map();
for (const idStr in cached.byId) {
const meta = cached.byId[idStr];
if (meta && typeof meta === 'object') {
itemMetaCache.set(Number(idStr), meta);
}
}
if (itemMetaCache.size > 0) return itemMetaCache;
}
}
} catch (_) { /* corrupt cache — fall through to refetch */ }
if (!TORN_API_KEY || TORN_API_KEY.indexOf('PDA-APIKEY') !== -1) {
return itemMetaCache || new Map();
}
try {
const res = await gmRequest({
method: 'GET',
url: 'https://api.torn.com/torn/?selections=items&key=' +
encodeURIComponent(TORN_API_KEY),
headers: { 'Accept': 'application/json' },
});
let data = null;
try { data = JSON.parse(res.responseText); } catch (_) { /* ignore */ }
if (data && data.items) {
const byId = {};
const map = new Map();
for (const idStr in data.items) {
const entry = data.items[idStr];
if (entry && entry.name) {
const meta = { name: entry.name, type: entry.type || null };
byId[idStr] = meta;
map.set(Number(idStr), meta);
}
}
try {
localStorage.setItem(
ITEM_CATALOG_CACHE_KEY,
JSON.stringify({ byId, fetchedAt: Date.now() })
);
} catch (_) { /* storage full / disabled — non-fatal */ }
itemMetaCache = map;
return itemMetaCache;
}
} catch (err) {
log('item catalog fetch failed', err);
}
return itemMetaCache || new Map();
}
/** Synchronous lookup used once the catalog is warm. "Item #N" fallback. */
function itemNameFor(itemId) {
const meta = itemMetaCache && itemMetaCache.get(Number(itemId));
if (meta && meta.name) return meta.name;
return 'Item #' + itemId;
}
// Returns Torn's item-category string ('Drug' / 'Flower' / 'Plushie' /
// 'Artifact' / 'Energy Drink' / etc.) or null when the catalog hasn't
// been warmed yet for this id. Callers must treat null as "unknown" —
// the in-flight strip drops unknown rows when filtering by type.
function itemTypeFor(itemId) {
const meta = itemMetaCache && itemMetaCache.get(Number(itemId));
return meta && meta.type ? meta.type : null;
}
function buildWatchlistBar(matches) {
const bar = document.createElement('div');
bar.id = WATCHLIST_BAR_ID;
const head = document.createElement('div');
head.className = 'vgl-wl-head';
const title = document.createElement('span');
title.className = 'vgl-wl-title';
title.textContent = 'Watchlist Matches';
const count = document.createElement('span');
count.className = 'vgl-wl-count';
count.textContent = String(matches.length);
const caret = document.createElement('span');
caret.className = 'vgl-wl-caret';
caret.textContent = '\u25BE';
head.appendChild(title);
head.appendChild(count);
head.appendChild(caret);
const body = document.createElement('div');
body.className = 'vgl-wl-body';
for (const m of matches) {
const row = document.createElement('a');
row.className = 'vgl-wl-row';
row.href = m.link;
row.target = '_top';
row.rel = 'noopener';
const name = document.createElement('span');
name.className = 'vgl-wl-item';
name.textContent = itemNameFor(m.item_id);
const venue = document.createElement('span');
venue.className = 'vgl-wl-venue vgl-wl-venue--' + m.venue;
venue.textContent = m.venue_label;
const price = document.createElement('span');
price.className = 'vgl-wl-price';
price.textContent = formatMoney(m.price);
const save = document.createElement('span');
save.className = 'vgl-wl-save';
const saveStrong = document.createElement('strong');
saveStrong.textContent = formatMoney(m.savings);
save.appendChild(document.createTextNode('saves '));
save.appendChild(saveStrong);
save.appendChild(document.createTextNode(
Number.isFinite(m.savings_pct) ? ' (' + Math.round(m.savings_pct) + '%)' : ''
));
// Loss-leader heads-up — see src/watchlist.js for rationale.
if (m.venue === 'market' && m.extra && m.extra.limited) {
save.appendChild(document.createTextNode(' \u00B7 single unit'));
}
const age = document.createElement('span');
age.className = 'vgl-wl-age';
age.textContent = formatAge(m.observed_at);
const arrow = document.createElement('span');
arrow.className = 'vgl-wl-arrow';
arrow.textContent = '\u2192';
row.appendChild(name);
row.appendChild(venue);
row.appendChild(price);
row.appendChild(save);
row.appendChild(age);
row.appendChild(arrow);
body.appendChild(row);
}
head.addEventListener('click', function () {
bar.classList.toggle('vgl-wl-open');
});
bar.appendChild(head);
bar.appendChild(body);
return bar;
}
/**
* Top-level entry point. Safe to call on every page load — it no-ops
* silently when there are no matches, and removes any prior bar before
* injecting a fresh one so SPA navs don't stack duplicates.
*/
async function injectWatchlistBar() {
// Idempotent: tear down any previous instance before fetching.
const existing = document.getElementById(WATCHLIST_BAR_ID);
if (existing) existing.remove();
const playerId = await resolvePlayerId();
if (!playerId) return;
// Warm the items catalog in parallel with the match fetch — by the
// time we go to render row labels we'll have real item names instead
// of the "Item #N" fallback.
const [matches] = await Promise.all([
fetchWatchlistMatches(playerId),
ensureItemCatalog(),
]);
if (matches.length === 0) return;
injectWatchlistStyles();
const bar = buildWatchlistBar(matches);
// Torn's content layout varies across pages and PDA skins. Try a
// couple of well-known containers, fall back to body so we never
// disappear silently.
const host =
document.querySelector('#mainContainer .content-wrapper') ||
document.querySelector('.content-wrapper') ||
document.querySelector('#mainContainer') ||
document.body;
host.insertBefore(bar, host.firstChild);
}
// -- Bazaar Deals bar ----------------------------------------------------
// A top-of-page collapsed bar (visual twin of the Watchlist Matches
// bar) that surfaces every bazaar listing priced below its Item
// Market floor. We used to inject a per-row overlay into each tile,
// but Torn's bazaar DOM varies so much across layouts that the
// overlay ended up truncated, squeezed between flex items, or
// stacked into the wrong row. A single bar at the top sidesteps all
// of that — one known-good injection point, one clean list.
//
// Hidden entirely when there are zero profitable listings. Every
// row is a deep-link into the Item Market for that item so the
// player can list their flip in one tap.
const BAZAAR_DEALS_BAR_ID = 'valigia-bazaar-deals-bar';
// Torn takes a 5% fee on item market sales — a flip is only real
// when net-sell (market * 0.95) exceeds the bazaar buy price.
const MARKET_FEE_RATE = 0.05;
function injectBazaarDealsStyles() {
if (document.getElementById('valigia-bazaar-deals-styles')) return;
const css = [
'#' + BAZAAR_DEALS_BAR_ID + ' {',
' all: initial;',
' display: block;',
' margin: 8px auto 12px;',
' max-width: 1100px;',
' font-family: Arial, Helvetica, sans-serif;',
' color: #c8cdd8;',
' background: #161a22;',
' border: 1px solid #252a35;',
' border-left: 3px solid #4ae8a0;',
' border-radius: 4px;',
' box-sizing: border-box;',
' overflow: hidden;',
'}',
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-head {',
' display: flex;',
' align-items: center;',
' gap: 8px;',
' padding: 8px 12px;',
' cursor: pointer;',
' user-select: none;',
'}',
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-title {',
' color: #4ae8a0;',
' font-weight: 700;',
' font-size: 12px;',
' letter-spacing: 0.12em;',
' text-transform: uppercase;',
'}',
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-count {',
' background: #4ae8a0;',
' color: #0d0f14;',
' font-weight: 700;',
' font-size: 11px;',
' padding: 1px 7px;',
' border-radius: 999px;',
'}',
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-caret {',
' margin-left: auto;',
' color: #4ae8a0;',
' font-size: 11px;',
' transition: transform 150ms;',
'}',
'#' + BAZAAR_DEALS_BAR_ID + '.vgl-bd-open .vgl-bd-caret {',
' transform: rotate(180deg);',
'}',
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-body {',
' display: none;',
' padding: 4px 10px 10px;',
' gap: 4px;',
' flex-direction: column;',
'}',
'#' + BAZAAR_DEALS_BAR_ID + '.vgl-bd-open .vgl-bd-body {',
' display: flex;',
'}',
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-row {',
' display: grid;',
' grid-template-columns: minmax(0,1.6fr) auto auto auto auto;',
' align-items: center;',
' gap: 10px;',
' padding: 6px 8px;',
' border: 1px solid #252a35;',
' border-radius: 3px;',
' background: rgba(74,232,160,0.04);',
' color: #c8cdd8;',
' text-decoration: none;',
' font-size: 12px;',
'}',
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-row:active {',
' background: rgba(74,232,160,0.12);',
'}',
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-item { font-weight: 700; color: #c8cdd8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-baz { color: #c8cdd8; white-space: nowrap; }',
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-arrow { color: #8a8fa0; }',
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-mkt { color: #e8c84a; font-weight: 700; white-space: nowrap; }',
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-gain { color: #4ae8a0; font-weight: 700; white-space: nowrap; }',
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-label { color: #8a8fa0; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; margin-right: 3px; }',
// Points-arb route: gold badge + tinted left edge so a quick scan
// distinguishes "flip on Item Market" rows from "complete a museum
// set" rows. Same row layout so the eye doesnt have to retrain.
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-row--points { background: rgba(232,200,74,0.04); border-left: 2px solid rgba(232,200,74,0.45); padding-left: 6px; }',
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-mkt--points { color: #e8c84a; }',
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-route { font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 2px; letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; }',
'#' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-route--points { background: rgba(232,200,74,0.18); color: #e8c84a; }',
// Narrow viewports: stack so nothing clips.
'@media (max-width: 560px) {',
' #' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-row {',
' grid-template-columns: 1fr auto;',
' row-gap: 2px;',
' }',
' #' + BAZAAR_DEALS_BAR_ID + ' .vgl-bd-item { grid-column: 1 / -1; }',
'}',
].join('\n');
const el = document.createElement('style');
el.id = 'valigia-bazaar-deals-styles';
el.textContent = css;
document.head.appendChild(el);
}
function buildBazaarDealsBar(deals) {
const bar = document.createElement('div');
bar.id = BAZAAR_DEALS_BAR_ID;
const head = document.createElement('div');
head.className = 'vgl-bd-head';
const title = document.createElement('span');
title.className = 'vgl-bd-title';
title.textContent = 'Bazaar Deals';
const count = document.createElement('span');
count.className = 'vgl-bd-count';
count.textContent = String(deals.length);
const caret = document.createElement('span');
caret.className = 'vgl-bd-caret';
caret.textContent = '\u25BE';
head.appendChild(title);
head.appendChild(count);
head.appendChild(caret);
const body = document.createElement('div');
body.className = 'vgl-bd-body';
for (const d of deals) {
const isPoints = d.route === 'points';
const row = document.createElement('a');
row.className = 'vgl-bd-row' + (isPoints ? ' vgl-bd-row--points' : '');
// Market-flip rows deep-link to the Item Market search (where the
// player will resell). Points rows deep-link back to the listings
// bazaar so the player can buy it directly \u2014 theres nothing to
// search for, the bazaar IS the action.
row.href = isPoints
? 'https://www.torn.com/bazaar.php?userId=' + d.bazaar_owner_id
: 'https://www.torn.com/page.php?sid=ItemMarket#/market/view=search&itemID=' + d.item_id;
row.target = '_top';
row.rel = 'noopener';
const name = document.createElement('span');
name.className = 'vgl-bd-item';
name.textContent = d.name;
const baz = document.createElement('span');
baz.className = 'vgl-bd-baz';
const bazLabel = document.createElement('span');
bazLabel.className = 'vgl-bd-label';
bazLabel.textContent = 'Bazaar';
baz.appendChild(bazLabel);
baz.appendChild(document.createTextNode(formatMoneyCompact(d.bazaarPrice)));
const arrow = document.createElement('span');
arrow.className = 'vgl-bd-arrow';
arrow.textContent = '\u2192';
// Right-side cell flips between two routes:
// - market: gross Item Market price (the 5% fee is already baked
// into d.profit, so the gain column tells the truthful net
// story).
// - points: cash-equivalent of this items share of its museum
// set, computed at evaluation time as
// setPoints * itemMarketShare * pointsRate.
const dest = document.createElement('span');
dest.className = 'vgl-bd-mkt' + (isPoints ? ' vgl-bd-mkt--points' : '');
const destLabel = document.createElement('span');
destLabel.className = 'vgl-bd-label';
destLabel.textContent = isPoints ? 'Points' : 'Market';
dest.appendChild(destLabel);
const destValue = isPoints ? d.pointsCash : d.marketPrice;
dest.appendChild(document.createTextNode(formatMoneyCompact(destValue)));
const gain = document.createElement('span');
gain.className = 'vgl-bd-gain';
gain.textContent = '+' + formatMoneyCompact(d.profit) +
' (' + (d.profitPct >= 100 ? Math.round(d.profitPct) : d.profitPct.toFixed(d.profitPct >= 10 ? 0 : 1)) + '%)';
row.appendChild(name);
row.appendChild(baz);
row.appendChild(arrow);
row.appendChild(dest);
row.appendChild(gain);
body.appendChild(row);
}
head.addEventListener('click', function () {
bar.classList.toggle('vgl-bd-open');
});
bar.appendChild(head);
bar.appendChild(body);
return bar;
}
/**
* Top-level entry. Reads sell_prices for the scraped bazaar items,
* filters for flippable ones (bazaar < net-sell), and injects the
* bar at the top of the page. Silent no-op on zero flips or any
* failure along the way so the ingest path is never blocked.
*/
async function injectBazaarDealsBar(scrapedItems, ownerId) {
// Remove any prior instance so SPA nav doesn't stack duplicates.
const existing = document.getElementById(BAZAAR_DEALS_BAR_ID);
if (existing) existing.remove();
if (!Array.isArray(scrapedItems) || scrapedItems.length === 0) return;
const ids = [...new Set(scrapedItems.map(function (r) { return r.item_id; }))];
if (ids.length === 0) return;
// Warm the items catalog and the shared Points Market rate in
// parallel with the sell-prices read so the bar has real names for
// every row, the museum-set resolver can map item names → ids,
// and the points-arb route can fire even for players who've never
// visited pmarket.php (rate falls through to the community pool).
const [sellRows] = await Promise.all([
fetchJSON(
SELL_PRICES_URL +
'?item_id=in.(' + ids.join(',') + ')' +
'&select=item_id,price'
),
ensureItemCatalog(),
ensurePointsRate(),
]);
const marketByItem = new Map();
if (Array.isArray(sellRows)) {
for (const r of sellRows) {
if (r.price != null) marketByItem.set(Number(r.item_id), Number(r.price));
}
}
// Points-arb prework: if the bazaar contains any item thats a
// member of a museum set AND we have a fresh Points Market rate,
// also fetch market prices for the OTHER set members so we can
// compute proportional per-item points value (set is worth N points,
// each members share is its proportion of total set market value).
const pointsRate = getPointsRate();
if (pointsRate) {
const extraIds = new Set();
for (const it of scrapedItems) {
const set = setForItemId(Number(it.item_id));
if (!set) continue;
for (const member of set.items) {
const memberId = itemIdForName(member.name);
if (memberId && !marketByItem.has(memberId)) extraIds.add(memberId);
}
}
if (extraIds.size > 0) {
const extraRows = await fetchJSON(
SELL_PRICES_URL +
'?item_id=in.(' + [...extraIds].join(',') + ')' +
'&select=item_id,price'
);
if (Array.isArray(extraRows)) {
for (const r of extraRows) {
if (r.price != null) marketByItem.set(Number(r.item_id), Number(r.price));
}
}
}
}
const deals = [];
for (const it of scrapedItems) {
const itemId = Number(it.item_id);
const bazaarPrice = Number(it.price);
if (!Number.isFinite(bazaarPrice) || bazaarPrice <= 0) continue;
// Route 1: market flip (existing behavior). Only valid when we
// have a market floor AND the bazaar price beats net-sell.
let marketDeal = null;
const marketPrice = marketByItem.get(itemId);
if (Number.isFinite(marketPrice)) {
const netSell = marketPrice * (1 - MARKET_FEE_RATE);
const profit = netSell - bazaarPrice;
if (profit > 0) {
marketDeal = {
route: 'market',
marketPrice: marketPrice,
netSell: netSell,
profit: profit,
profitPct: (profit / bazaarPrice) * 100,
};
}
}
// Route 2: museum-points exchange. Buy bazaar → complete set →
// exchange at museum for N points → sell points at current
// pmarket rate. Only fires if the listing is at least
// POINTS_BUY_DISCOUNT under the points-equivalent cash value, so
// we dont flag rows that are just barely-below — a 1% under
// bazaar isnt worth the friction of completing a set.
let pointsDeal = null;
if (pointsRate) {
const set = setForItemId(itemId);
if (set) {
const ptsForItem = computePointsForItem(itemId, set, marketByItem);
if (Number.isFinite(ptsForItem) && ptsForItem > 0) {
const pointsCash = ptsForItem * pointsRate;
const profit = pointsCash - bazaarPrice;
if (profit > pointsCash * POINTS_BUY_DISCOUNT) {
pointsDeal = {
route: 'points',
pointsPerItem: ptsForItem,
pointsCash: pointsCash,
setName: set.name,
profit: profit,
profitPct: (profit / bazaarPrice) * 100,
};
}
}
}
}
// Pick the better route for this item — bigger absolute profit
// wins. We surface only one row per item to keep the bar tight;
// both routes triggering on the same item is rare and the loser
// is always strictly less profitable, so dropping it costs the
// user nothing actionable.
const winner = (marketDeal && pointsDeal)
? (pointsDeal.profit > marketDeal.profit ? pointsDeal : marketDeal)
: (marketDeal || pointsDeal);
if (!winner) continue;
deals.push(Object.assign({
item_id: itemId,
name: itemNameFor(itemId),
bazaarPrice: bazaarPrice,
bazaar_owner_id: ownerId,
}, winner));
}
if (deals.length === 0) return;
// Best margins first — most actionable deal at the top of the list.
deals.sort(function (a, b) { return b.profitPct - a.profitPct; });
injectBazaarDealsStyles();
const bar = buildBazaarDealsBar(deals);
const host =
document.querySelector('#mainContainer .content-wrapper') ||
document.querySelector('.content-wrapper') ||
document.querySelector('#mainContainer') ||
document.body;
host.insertBefore(bar, host.firstChild);
}
// -- Lowest Price Found bar ---------------------------------------------
// On the Item Market when the user has filtered down to a single item
// (the hash carries `itemID=N`), look up the cheapest fresh bazaar
// listing for that item from the shared `bazaar_prices` pool and
// inject a single-row card. Stacks directly below the Watchlist
// Matches bar when both are present, otherwise sits at the top of
// the page on its own. Hidden entirely when the pool has no fresh
// hit for the active item.
const LOWEST_PRICE_BAR_ID = 'valigia-lowest-price-bar';
// Aligned with DRIP_BAZAAR_FRESH_WINDOW_MS (the drip-scrape's "skip if
// fresh" gate) — using a tighter window here would create a dead zone
// where the bar hides data the drip refuses to refresh, leaving no
// bazaar info visible for an item even though the pool has it. Bazaar
// listings typically stay up for hours-to-days; 30 min is a reasonable
// "actionable" window for buying decisions, and the row stamps the
// freshness ("3m ago") so the player can judge for themselves.
const LOWEST_PRICE_BAZAAR_MAX_AGE_MS = 30 * 60 * 1000;
// Anything priced under 10% of the Item Market floor is almost
// certainly a locked / troll listing — same threshold the web app
// uses before claiming the Best Run card. Filter these so we never
// deep-link a player to a bazaar where the listing isn't actually
// buyable.
const LOWEST_PRICE_TOO_GOOD_THRESHOLD = 0.10;
/**
* Pull the active item id out of the Item Market hash. Torn uses
* patterns like "#/market/view=search&itemID=12345" and
* "#/market/view=category&itemID=12345&...". Returns null on the
* landing view (no itemID) so the bar doesn't fire across the
* whole catalog.
*/
function detectItemMarketSingleItemId() {
const hash = location.hash || '';
const m = hash.match(/itemID=(\d+)/i);
return m ? Number(m[1]) : null;
}
/**
* Read the cheapest fresh bazaar entry for `itemId` from the shared
* pool. Cross-references `sell_prices` so we can both filter
* locked-listing scams (price < 10% of market floor) and surface
* the savings vs. the Item Market the player is currently looking
* at. Returns null when there's no eligible row.
*/
async function fetchLowestBazaarForItem(itemId) {
if (!itemId) return null;
const [bazaarRows, sellRows] = await Promise.all([
fetchJSON(
BAZAAR_PRICES_URL +
'?item_id=eq.' + encodeURIComponent(itemId) +
'&price=gt.1' +
'&select=item_id,price,quantity,bazaar_owner_id,checked_at' +
'&order=price.asc' +
'&limit=20'
),
fetchJSON(
SELL_PRICES_URL +
'?item_id=eq.' + encodeURIComponent(itemId) +
'&select=price,min_price'
),
]);
if (!Array.isArray(bazaarRows) || bazaarRows.length === 0) return null;
const sellRow = Array.isArray(sellRows) && sellRows.length > 0
? sellRows[0] : null;
const marketPrice = sellRow && sellRow.price != null
? Number(sellRow.price) : null;
const marketFloor = sellRow && sellRow.min_price != null
? Number(sellRow.min_price)
: marketPrice;
const cutoff = Date.now() - LOWEST_PRICE_BAZAAR_MAX_AGE_MS;
for (const r of bazaarRows) {
const observedAt = r.checked_at ? new Date(r.checked_at).getTime() : 0;
if (observedAt < cutoff) continue;
const price = Number(r.price);
if (!Number.isFinite(price) || price <= 1) continue;
if (Number.isFinite(marketFloor) && marketFloor > 0 &&
price < marketFloor * LOWEST_PRICE_TOO_GOOD_THRESHOLD) {
continue;
}
return {
item_id: itemId,
price: price,
quantity: Number(r.quantity) || 1,
bazaar_owner_id: r.bazaar_owner_id,
observed_at: observedAt,
market_price: Number.isFinite(marketPrice) ? marketPrice : null,
};
}
return null;
}
function injectLowestPriceStyles() {
if (document.getElementById('valigia-lowest-price-styles')) return;
const css = [
'#' + LOWEST_PRICE_BAR_ID + ' {',
' all: initial;',
' display: block;',
' margin: 8px auto 12px;',
' max-width: 1100px;',
' font-family: Arial, Helvetica, sans-serif;',
' color: #c8cdd8;',
' background: #161a22;',
' border: 1px solid #252a35;',
' border-left: 3px solid #4ae8a0;',
' border-radius: 4px;',
' box-sizing: border-box;',
' overflow: hidden;',
'}',
'#' + LOWEST_PRICE_BAR_ID + ' .vgl-lp-row {',
' display: flex;',
' align-items: center;',
' flex-wrap: wrap;',
' gap: 6px 10px;',
' padding: 10px 12px;',
' color: #c8cdd8;',
' text-decoration: none;',
' font-size: 12px;',
'}',
'#' + LOWEST_PRICE_BAR_ID + ' .vgl-lp-row:active {',
' background: rgba(74,232,160,0.08);',
'}',
'#' + LOWEST_PRICE_BAR_ID + ' .vgl-lp-title {',
' color: #4ae8a0;',
' font-weight: 700;',
' font-size: 11px;',
' letter-spacing: 0.12em;',
' text-transform: uppercase;',
' white-space: nowrap;',
'}',
'#' + LOWEST_PRICE_BAR_ID + ' .vgl-lp-item {',
' font-weight: 700;',
' color: #c8cdd8;',
' white-space: nowrap;',
'}',
'#' + LOWEST_PRICE_BAR_ID + ' .vgl-lp-price {',
' color: #4ae8a0;',
' font-weight: 700;',
' white-space: nowrap;',
'}',
'#' + LOWEST_PRICE_BAR_ID + ' .vgl-lp-qty {',
' color: #8a8fa0;',
' white-space: nowrap;',
'}',
'#' + LOWEST_PRICE_BAR_ID + ' .vgl-lp-vs {',
' color: #8a8fa0;',
' white-space: nowrap;',
'}',
'#' + LOWEST_PRICE_BAR_ID + ' .vgl-lp-vs strong { color: #4ae8a0; font-weight: 700; }',
'#' + LOWEST_PRICE_BAR_ID + ' .vgl-lp-vs.vgl-lp-vs--worse strong { color: #e8824a; }',
'#' + LOWEST_PRICE_BAR_ID + ' .vgl-lp-arrow {',
' color: #4ae8a0;',
' font-weight: 700;',
' margin-left: auto;',
'}',
].join('\n');
const style = document.createElement('style');
style.id = 'valigia-lowest-price-styles';
style.textContent = css;
document.head.appendChild(style);
}
function buildLowestPriceBar(deal) {
const bar = document.createElement('div');
bar.id = LOWEST_PRICE_BAR_ID;
const row = document.createElement('a');
row.className = 'vgl-lp-row';
row.href = 'https://www.torn.com/bazaar.php?userId=' + deal.bazaar_owner_id;
row.target = '_top';
row.rel = 'noopener';
const title = document.createElement('span');
title.className = 'vgl-lp-title';
title.textContent = 'Lowest Price Found';
const name = document.createElement('span');
name.className = 'vgl-lp-item';
name.textContent = itemNameFor(deal.item_id);
const price = document.createElement('span');
price.className = 'vgl-lp-price';
price.textContent = formatMoney(deal.price);
const qty = document.createElement('span');
qty.className = 'vgl-lp-qty';
qty.textContent = 'qty ' + deal.quantity;
const vs = document.createElement('span');
vs.className = 'vgl-lp-vs';
if (deal.market_price != null && deal.market_price > 0) {
const diff = deal.market_price - deal.price;
const pct = (diff / deal.market_price) * 100;
const strong = document.createElement('strong');
if (diff > 0) {
strong.textContent = formatMoney(diff);
vs.appendChild(document.createTextNode('saves '));
vs.appendChild(strong);
vs.appendChild(document.createTextNode(
' (' + Math.round(pct) + '%) vs market'
));
} else {
vs.classList.add('vgl-lp-vs--worse');
strong.textContent = formatMoney(-diff);
vs.appendChild(document.createTextNode('+'));
vs.appendChild(strong);
vs.appendChild(document.createTextNode(' over market'));
}
} else {
vs.appendChild(document.createTextNode('no market reference'));
}
// Append the freshness inside the same cell with a mid-dot separator.
// Cleaner than a separate grid cell whose 10px gap renders too tight
// at typical PDA viewport sizes — "vs market" and "just now" looked
// smushed together (e.g. "marketnow") in earlier versions.
const ageText = formatAge(deal.observed_at);
if (ageText) {
vs.appendChild(document.createTextNode(' \u00B7 ' + ageText));
}
const arrow = document.createElement('span');
arrow.className = 'vgl-lp-arrow';
// Use a Unicode escape rather than the literal arrow glyph: cPanel
// serves .user.js as Latin-1 by default, so the UTF-8 bytes for the
// arrow would mis-decode to "a-circumflex" + control chars in PDA's
// webview. The escape keeps the source ASCII and lets the JS engine
// produce the correct codepoint at runtime regardless of file charset.
arrow.textContent = '\u2192';
row.appendChild(title);
row.appendChild(name);
row.appendChild(price);
row.appendChild(qty);
row.appendChild(vs);
row.appendChild(arrow);
bar.appendChild(row);
return bar;
}
/**
* Top-level entry. Safe to call on every Item Market dispatch — it
* tears down any prior instance and silently no-ops when the user
* isn't filtered to a single item or the pool has no fresh hit.
* Stacks below the Watchlist Matches bar when present (race-safe:
* whichever bar arrives later sits in the right slot regardless of
* order).
*/
async function injectLowestPriceBar() {
const existing = document.getElementById(LOWEST_PRICE_BAR_ID);
if (existing) existing.remove();
const itemId = detectItemMarketSingleItemId();
if (!itemId) return;
const [deal] = await Promise.all([
fetchLowestBazaarForItem(itemId),
ensureItemCatalog(),
]);
if (!deal) return;
injectLowestPriceStyles();
const bar = buildLowestPriceBar(deal);
const host =
document.querySelector('#mainContainer .content-wrapper') ||
document.querySelector('.content-wrapper') ||
document.querySelector('#mainContainer') ||
document.body;
// Slot directly after the Watchlist Matches bar when it's already
// present. If the watchlist injects after us, its insertBefore at
// host.firstChild will push this bar down naturally — so the
// stacking order ends up Watchlist → Lowest Price either way.
const watchlistBar = document.getElementById(WATCHLIST_BAR_ID);
if (watchlistBar && watchlistBar.parentNode === host) {
host.insertBefore(bar, watchlistBar.nextSibling);
} else {
host.insertBefore(bar, host.firstChild);
}
}
// -- Flash Deals bar -----------------------------------------------------
// Pool-wide discovery surface on the Item Market page: cross-references
// every fresh sell_prices floor against the highest fresh te_buy_prices
// offer per item_id. When a market listing is priced below what the
// best TornExchange trader will pay, the bar surfaces it as a one-tap
// flip opportunity. Hidden when there are zero opportunities.
//
// When the player has filtered to a single item (hash carries
// itemID=N), the bar scopes to that item only — same data path,
// tighter query.
const FLASH_DEALS_BAR_ID = 'valigia-flash-deals-bar';
// Mirror the watchlist Item Market freshness window — a "fresh" market
// floor must have been observed within the last 30 minutes for us to
// claim it as a current opportunity. Stricter than the watchlist (1h)
// because flash deals get acted on immediately, not tracked over time.
const FLASH_DEAL_MARKET_MAX_AGE_MS = 30 * 60 * 1000;
// Trader offers stay live for days; 24h matches the Sell-tab matcher.
const FLASH_DEAL_TRADER_MAX_AGE_MS = 24 * 60 * 60 * 1000;
// Below this absolute profit per unit, skip — the click cost on iPad
// outweighs the gain. Empirical pick; tune if it filters real deals.
const FLASH_DEAL_MIN_PROFIT = 10_000;
// Cap rows in pool-wide mode. Bar is collapsed by default so a long
// list has no UI cost, but iPad players generally only chase the top
// handful and we want to keep the DOM bounded.
const FLASH_DEAL_MAX_ROWS = 15;
const FLASH_DEAL_CACHE_TTL_MS = 30_000;
// Keyed by 'all' or `item:N` so a single-item drill doesn't poison
// the pool-wide cache and vice-versa.
const flashDealCache = new Map();
// Generation guard: SPA hash navigation can spawn multiple
// injectFlashDealsBar() calls in flight before any one has finished
// its fetch + insert, and each call's "remove existing" check happens
// before the await — so multiple calls all see "no existing" and stack
// their bars at the end. Bumping this on every call and bailing if our
// generation isn't current makes only the latest call paint.
let flashDealsGeneration = 0;
async function fetchFlashDeals(itemIdFilter) {
const cacheKey = itemIdFilter ? 'item:' + itemIdFilter : 'all';
const now = Date.now();
const cached = flashDealCache.get(cacheKey);
if (cached && cached.expiresAt > now) return cached.deals;
const sellSinceIso = new Date(now - FLASH_DEAL_MARKET_MAX_AGE_MS).toISOString();
// Bazaar listings stay actionable for the same window we use on the
// Lowest Price Found bar — anything older than 30 min and the listing
// is likely sold or pulled.
const bazaarSinceIso = new Date(now - LOWEST_PRICE_BAZAAR_MAX_AGE_MS).toISOString();
const tradeSinceIso = new Date(now - FLASH_DEAL_TRADER_MAX_AGE_MS).toISOString();
let sellRows;
let bazaarRows;
let teRows;
if (itemIdFilter) {
[sellRows, bazaarRows, teRows] = await Promise.all([
fetchJSON(
SELL_PRICES_URL +
'?item_id=eq.' + encodeURIComponent(itemIdFilter) +
'&select=item_id,price,min_price,updated_at'
),
fetchJSON(
BAZAAR_PRICES_URL +
'?item_id=eq.' + encodeURIComponent(itemIdFilter) +
'&checked_at=gte.' + encodeURIComponent(bazaarSinceIso) +
'&price=gt.1' +
'&select=item_id,price,quantity,bazaar_owner_id,checked_at' +
'&order=price.asc' +
'&limit=20'
),
fetchJSON(
TE_BUY_PRICES_URL +
'?item_id=eq.' + encodeURIComponent(itemIdFilter) +
'&updated_at=gte.' + encodeURIComponent(tradeSinceIso) +
'&select=item_id,handle,buy_price,updated_at' +
'&order=buy_price.desc' +
'&limit=5'
),
]);
} else {
// Pool-wide: anchor on freshly-scraped market and bazaar rows so the
// trader IN-clause stays bounded. Bazaar items can be entirely absent
// from sell_prices (obscure stuff with no recent market activity but
// a cheap bazaar listing), so the trader query unions both id sets.
[sellRows, bazaarRows] = await Promise.all([
fetchJSON(
SELL_PRICES_URL +
'?updated_at=gte.' + encodeURIComponent(sellSinceIso) +
'&min_price=gt.0' +
'&select=item_id,price,min_price,updated_at' +
'&order=updated_at.desc' +
'&limit=400'
),
fetchJSON(
BAZAAR_PRICES_URL +
'?checked_at=gte.' + encodeURIComponent(bazaarSinceIso) +
'&price=gt.1' +
'&select=item_id,price,quantity,bazaar_owner_id,checked_at' +
'&order=price.asc' +
'&limit=400'
),
]);
const idSet = new Set();
if (Array.isArray(sellRows)) {
for (const r of sellRows) {
if (r && Number.isFinite(r.item_id)) idSet.add(r.item_id);
}
}
if (Array.isArray(bazaarRows)) {
for (const r of bazaarRows) {
if (r && Number.isFinite(r.item_id)) idSet.add(r.item_id);
}
}
if (idSet.size === 0) {
flashDealCache.set(cacheKey, { expiresAt: now + FLASH_DEAL_CACHE_TTL_MS, deals: [] });
return [];
}
teRows = await fetchJSON(
TE_BUY_PRICES_URL +
'?item_id=in.(' + Array.from(idSet).join(',') + ')' +
'&updated_at=gte.' + encodeURIComponent(tradeSinceIso) +
'&select=item_id,handle,buy_price,updated_at'
);
}
if (!Array.isArray(teRows) || teRows.length === 0) {
flashDealCache.set(cacheKey, { expiresAt: now + FLASH_DEAL_CACHE_TTL_MS, deals: [] });
return [];
}
// Highest buy_price per item_id; updated_at breaks ties so a
// newly-scraped trader wins over a stale one at the same price.
const bestTrader = new Map();
for (const r of teRows) {
if (!r || typeof r.item_id !== 'number' || typeof r.buy_price !== 'number') continue;
const existing = bestTrader.get(r.item_id);
if (
!existing
|| r.buy_price > existing.buy_price
|| (r.buy_price === existing.buy_price && (r.updated_at || '') > (existing.updated_at || ''))
) {
bestTrader.set(r.item_id, r);
}
}
// Cheapest fresh bazaar listing per item_id. Pool already excludes
// $1 placeholders and stale rows at the query layer; we just pick the
// floor here. Multiple bazaars per item end up represented by the
// single best deal — a player can drill into Item Market for that
// item to see the rest via Lowest Price Found.
const bestBazaar = new Map();
if (Array.isArray(bazaarRows)) {
for (const r of bazaarRows) {
if (!r || typeof r.item_id !== 'number') continue;
const price = Number(r.price);
if (!Number.isFinite(price) || price <= 1) continue;
const observedAt = r.checked_at ? new Date(r.checked_at).getTime() : 0;
if (!observedAt || now - observedAt > LOWEST_PRICE_BAZAAR_MAX_AGE_MS) continue;
const existing = bestBazaar.get(r.item_id);
if (!existing || price < existing.price) {
bestBazaar.set(r.item_id, { price, bazaar_owner_id: r.bazaar_owner_id, observed_at: observedAt });
}
}
}
const deals = [];
// Source: market floor → trader. Selling to a trader is a direct
// cash trade (no Item Market 5% fee), so the gross trader_price is
// also the net you keep.
if (Array.isArray(sellRows)) {
for (const s of sellRows) {
if (!s || typeof s.item_id !== 'number') continue;
const floorRaw = s.min_price != null ? Number(s.min_price)
: (s.price != null ? Number(s.price) : NaN);
if (!Number.isFinite(floorRaw) || floorRaw <= 0) continue;
const observedAt = s.updated_at ? new Date(s.updated_at).getTime() : 0;
if (!observedAt || now - observedAt > FLASH_DEAL_MARKET_MAX_AGE_MS) continue;
const trader = bestTrader.get(s.item_id);
if (!trader) continue;
const traderPrice = Number(trader.buy_price);
if (!Number.isFinite(traderPrice) || traderPrice <= 0) continue;
const profit = traderPrice - floorRaw;
if (profit < FLASH_DEAL_MIN_PROFIT) continue;
deals.push({
source: 'market',
item_id: s.item_id,
buy_price: floorRaw,
buy_label: 'Item Market',
buy_link: 'https://www.torn.com/page.php?sid=ItemMarket#/market/view=search&itemID=' + s.item_id,
sell_price: traderPrice,
sell_label: 'Trader',
trader_handle: trader.handle,
profit: profit,
profit_pct: (profit / floorRaw) * 100,
buy_observed_at: observedAt,
sell_observed_at: trader.updated_at ? new Date(trader.updated_at).getTime() : 0,
});
}
}
// Source: bazaar listing → trader. Same fee rationale (no Item Market
// fee on a trader sell). Click target is the specific bazaar.
for (const [itemId, b] of bestBazaar) {
const trader = bestTrader.get(itemId);
if (!trader) continue;
const traderPrice = Number(trader.buy_price);
if (!Number.isFinite(traderPrice) || traderPrice <= 0) continue;
const profit = traderPrice - b.price;
if (profit < FLASH_DEAL_MIN_PROFIT) continue;
deals.push({
source: 'bazaar',
item_id: itemId,
buy_price: b.price,
buy_label: 'Bazaar',
buy_link: 'https://www.torn.com/bazaar.php?userId=' + b.bazaar_owner_id,
sell_price: traderPrice,
sell_label: 'Trader',
trader_handle: trader.handle,
profit: profit,
profit_pct: (profit / b.price) * 100,
buy_observed_at: b.observed_at,
sell_observed_at: trader.updated_at ? new Date(trader.updated_at).getTime() : 0,
});
}
deals.sort(function (a, b) { return b.profit - a.profit; });
// Single-item mode lets both source variants through (one market +
// one bazaar at most) so the player can compare; pool-wide caps at
// FLASH_DEAL_MAX_ROWS.
const trimmed = itemIdFilter ? deals.slice(0, 5) : deals.slice(0, FLASH_DEAL_MAX_ROWS);
flashDealCache.set(cacheKey, { expiresAt: now + FLASH_DEAL_CACHE_TTL_MS, deals: trimmed });
return trimmed;
}
function injectFlashDealsStyles() {
if (document.getElementById('valigia-flash-deals-styles')) return;
const css = [
'#' + FLASH_DEALS_BAR_ID + ' {',
' all: initial;',
' display: block;',
' margin: 8px auto 12px;',
' max-width: 1100px;',
' font-family: Arial, Helvetica, sans-serif;',
' color: #c8cdd8;',
' background: #161a22;',
' border: 1px solid #252a35;',
' border-left: 3px solid #e8c84a;',
' border-radius: 4px;',
' box-sizing: border-box;',
' overflow: hidden;',
'}',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-head {',
' display: flex;',
' align-items: center;',
' gap: 8px;',
' padding: 8px 12px;',
' cursor: pointer;',
' user-select: none;',
'}',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-title {',
' color: #e8c84a;',
' font-weight: 700;',
' font-size: 12px;',
' letter-spacing: 0.12em;',
' text-transform: uppercase;',
'}',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-count {',
' background: #e8c84a;',
' color: #0d0f14;',
' font-weight: 700;',
' font-size: 11px;',
' padding: 1px 7px;',
' border-radius: 999px;',
'}',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-caret {',
' margin-left: auto;',
' color: #e8c84a;',
' font-size: 11px;',
' transition: transform 150ms;',
'}',
'#' + FLASH_DEALS_BAR_ID + '.vgl-fd-open .vgl-fd-caret {',
' transform: rotate(180deg);',
'}',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-body {',
' display: none;',
' padding: 4px 10px 10px;',
' gap: 4px;',
' flex-direction: column;',
'}',
'#' + FLASH_DEALS_BAR_ID + '.vgl-fd-open .vgl-fd-body {',
' display: flex;',
'}',
// Two-row layout: item name + profit on the header line, then
// buy → sell on a wrap-friendly second line. Avoids the prior
// single-line grid where 9-digit dollar amounts on a phone-width
// screen forced the item-name column to 0px and clipped the
// profit off the right edge under overflow:hidden.
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-row {',
' display: flex;',
' flex-direction: column;',
' gap: 4px;',
' padding: 6px 8px;',
' border: 1px solid #252a35;',
' border-radius: 3px;',
' background: rgba(232,200,74,0.04);',
' color: #c8cdd8;',
' font-size: 12px;',
'}',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-head-line {',
' display: flex;',
' align-items: baseline;',
' gap: 8px;',
'}',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-trade-line {',
' display: flex;',
' align-items: center;',
' flex-wrap: wrap;',
' gap: 4px 8px;',
'}',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-item {',
' font-weight: 700;',
' color: #c8cdd8;',
' flex: 1 1 auto;',
' min-width: 0;',
' overflow: hidden;',
' text-overflow: ellipsis;',
' white-space: nowrap;',
'}',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-buy, #' + FLASH_DEALS_BAR_ID + ' .vgl-fd-sell {',
' color: #c8cdd8;',
' text-decoration: none;',
' display: inline-flex;',
' align-items: baseline;',
' flex-wrap: wrap;',
' gap: 4px;',
' padding: 2px 4px;',
' border-radius: 2px;',
'}',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-buy:active { background: rgba(232,200,74,0.18); }',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-sell:active { background: rgba(74,232,160,0.18); }',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-buy strong { color: #e8c84a; font-weight: 700; }',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-sell strong { color: #4ae8a0; font-weight: 700; }',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-handle { color: #8a8fa0; font-size: 11px; }',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-profit { color: #4ae8a0; font-weight: 700; white-space: nowrap; flex: 0 0 auto; }',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-arrow { color: #e8c84a; font-weight: 700; }',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-prefix { color: #8a8fa0; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }',
'#' + FLASH_DEALS_BAR_ID + ' .vgl-fd-label { color: #8a8fa0; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }',
].join('\n');
const style = document.createElement('style');
style.id = 'valigia-flash-deals-styles';
style.textContent = css;
document.head.appendChild(style);
}
function buildFlashDealsBar(deals) {
const bar = document.createElement('div');
bar.id = FLASH_DEALS_BAR_ID;
const head = document.createElement('div');
head.className = 'vgl-fd-head';
const title = document.createElement('span');
title.className = 'vgl-fd-title';
title.textContent = 'Flash Deals';
const count = document.createElement('span');
count.className = 'vgl-fd-count';
count.textContent = String(deals.length);
const caret = document.createElement('span');
caret.className = 'vgl-fd-caret';
caret.textContent = '\u25BE';
head.appendChild(title);
head.appendChild(count);
head.appendChild(caret);
const body = document.createElement('div');
body.className = 'vgl-fd-body';
for (const d of deals) {
// Two independent click targets per row \u2014 buy side opens the
// venue (Item Market search or specific bazaar), sell side opens
// the trader's TornExchange profile. Nested <a> isn't valid, so
// the row container is a <div>.
const row = document.createElement('div');
row.className = 'vgl-fd-row';
// Header line: item name (truncating) + profit pinned right.
const headLine = document.createElement('div');
headLine.className = 'vgl-fd-head-line';
const name = document.createElement('span');
name.className = 'vgl-fd-item';
name.textContent = itemNameFor(d.item_id);
const profit = document.createElement('span');
profit.className = 'vgl-fd-profit';
profit.textContent = '+' + formatMoney(d.profit);
headLine.appendChild(name);
headLine.appendChild(profit);
// Trade line: buy \u2192 sell, wrapping if it doesn't fit.
const tradeLine = document.createElement('div');
tradeLine.className = 'vgl-fd-trade-line';
const buy = document.createElement('a');
buy.className = 'vgl-fd-buy';
buy.href = d.buy_link;
buy.target = '_top';
buy.rel = 'noopener';
const buyPrefix = document.createElement('span');
buyPrefix.className = 'vgl-fd-prefix';
buyPrefix.textContent = 'Buy';
const buyStrong = document.createElement('strong');
buyStrong.textContent = formatMoney(d.buy_price);
const buyLabel = document.createElement('span');
buyLabel.className = 'vgl-fd-label';
buyLabel.textContent = d.buy_label;
buy.appendChild(buyPrefix);
buy.appendChild(buyStrong);
buy.appendChild(buyLabel);
const arrow = document.createElement('span');
arrow.className = 'vgl-fd-arrow';
arrow.textContent = '\u2192';
const sell = document.createElement('a');
sell.className = 'vgl-fd-sell';
sell.href = 'https://tornexchange.com/prices/' + encodeURIComponent(d.trader_handle) + '/';
sell.target = '_top';
sell.rel = 'noopener';
const sellPrefix = document.createElement('span');
sellPrefix.className = 'vgl-fd-prefix';
sellPrefix.textContent = 'Sell';
const sellStrong = document.createElement('strong');
sellStrong.textContent = formatMoney(d.sell_price);
const sellLabel = document.createElement('span');
sellLabel.className = 'vgl-fd-label';
sellLabel.textContent = d.sell_label;
const handle = document.createElement('span');
handle.className = 'vgl-fd-handle';
handle.textContent = '@' + d.trader_handle;
sell.appendChild(sellPrefix);
sell.appendChild(sellStrong);
sell.appendChild(sellLabel);
sell.appendChild(handle);
tradeLine.appendChild(buy);
tradeLine.appendChild(arrow);
tradeLine.appendChild(sell);
row.appendChild(headLine);
row.appendChild(tradeLine);
body.appendChild(row);
}
head.addEventListener('click', function () {
bar.classList.toggle('vgl-fd-open');
});
bar.appendChild(head);
bar.appendChild(body);
return bar;
}
/**
* Top-level entry. Safe to call on every Item Market dispatch — it
* tears down any prior instance and silently no-ops when there are
* no opportunities. When the user has drilled into a single item
* (hash itemID=N) the bar scopes to that item; otherwise it surfaces
* the top FLASH_DEAL_MAX_ROWS pool-wide opportunities.
*
* Stacks below the Watchlist Matches and Lowest Price Found bars
* when present. Race-safe: each bar fights for its own slot, so the
* final order ends up Watchlist → Lowest → Flash regardless of
* fetch ordering.
*/
async function injectFlashDealsBar() {
const myGeneration = ++flashDealsGeneration;
// Sweep any already-rendered bars before fetching too — keeps the
// page tidy if a prior generation already painted.
document.querySelectorAll('#' + FLASH_DEALS_BAR_ID).forEach(function (n) { n.remove(); });
const itemIdFilter = detectItemMarketSingleItemId();
const [deals] = await Promise.all([
fetchFlashDeals(itemIdFilter),
ensureItemCatalog(),
]);
if (myGeneration !== flashDealsGeneration) return;
if (!deals || deals.length === 0) return;
injectFlashDealsStyles();
const bar = buildFlashDealsBar(deals);
const host =
document.querySelector('#mainContainer .content-wrapper') ||
document.querySelector('.content-wrapper') ||
document.querySelector('#mainContainer') ||
document.body;
// Final sweep right before insert: a parallel call from an earlier
// generation could have completed its fetch and inserted between our
// generation check and now. querySelectorAll catches every duplicate
// (getElementById would only return the first).
document.querySelectorAll('#' + FLASH_DEALS_BAR_ID).forEach(function (n) { n.remove(); });
const lowestBar = document.getElementById(LOWEST_PRICE_BAR_ID);
const watchlistBar = document.getElementById(WATCHLIST_BAR_ID);
const anchor = (lowestBar && lowestBar.parentNode === host) ? lowestBar
: (watchlistBar && watchlistBar.parentNode === host) ? watchlistBar
: null;
if (anchor) {
host.insertBefore(bar, anchor.nextSibling);
} else {
host.insertBefore(bar, host.firstChild);
}
}
// -- Stakeout mode -------------------------------------------------------
// Tier 3 of the restock-data-quality plan. When the user is abroad and
// this toggle is ON, re-run runTravel() every STAKEOUT_INTERVAL_MS so
// each upsert to abroad_prices gives the restock trigger a chance to
// fire on a stock-up delta. Every tick is an independently-observed
// data point with tight confidence (≤5 min since prior observation),
// so stakers meaningfully improve the community's cadence metric.
//
// The 5 min cadence is well above the edge function's 5 s per-player
// rate limit, so there's no backend pressure. It's also above the
// realistic post-cleanup median cadence (20–45 min observed), which
// means most real refills during a long stakeout get caught on the
// *next* tick after they occur.
//
// User-facing UI is a small pill fixed to the top-right of the travel
// page: [STAKEOUT: OFF] tap-to-enable, [STAKEOUT: ON · next 4:32]
// tap-to-disable. Setting is persisted in localStorage so it survives
// page reloads and re-landings.
const STAKEOUT_INTERVAL_MS = 5 * 60 * 1000;
const STAKEOUT_STORAGE_KEY = 'valigia_stakeout_enabled';
const stakeout = {
intervalId: null,
tickIntervalId: null,
badge: null,
nextTickAt: 0,
};
function stakeoutEnabled() {
try { return localStorage.getItem(STAKEOUT_STORAGE_KEY) === '1'; }
catch (e) { return false; }
}
function setStakeoutEnabled(val) {
try { localStorage.setItem(STAKEOUT_STORAGE_KEY, val ? '1' : '0'); }
catch (e) { /* quota / private mode — ignore */ }
}
function formatCountdown(ms) {
const s = Math.max(0, Math.round(ms / 1000));
const m = Math.floor(s / 60);
const r = s % 60;
return m + ':' + String(r).padStart(2, '0');
}
function updateStakeoutBadge() {
if (!stakeout.badge) return;
const on = stakeoutEnabled();
let text = 'STAKEOUT: ' + (on ? 'ON' : 'OFF');
if (on && stakeout.nextTickAt > 0) {
text += ' \u00B7 next ' + formatCountdown(stakeout.nextTickAt - Date.now());
}
stakeout.badge.textContent = text;
stakeout.badge.style.color = on ? '#4ae8a0' : '#999';
stakeout.badge.style.borderColor = on ? '#4ae8a0' : '#444';
}
function onStakeoutBadgeClick() {
if (stakeoutEnabled()) {
setStakeoutEnabled(false);
stopStakeoutInterval();
toast('Stakeout disabled', 'success');
} else {
setStakeoutEnabled(true);
startStakeoutInterval();
toast('Stakeout enabled \u2014 next scrape in 5 min', 'success');
}
updateStakeoutBadge();
}
function mountStakeoutBadge() {
if (stakeout.badge) return;
const badge = document.createElement('div');
badge.id = 'valigia-stakeout-badge';
Object.assign(badge.style, {
position: 'fixed',
top: '10px',
right: '10px',
zIndex: '99998',
padding: '6px 10px',
background: '#161a22',
border: '1px solid #444',
borderRadius: '6px',
font: "600 11px/1 Arial, Helvetica, sans-serif",
letterSpacing: '0.04em',
color: '#999',
cursor: 'pointer',
userSelect: 'none',
boxShadow: '0 2px 8px rgba(0,0,0,.4)',
});
badge.addEventListener('click', onStakeoutBadgeClick);
document.body.appendChild(badge);
stakeout.badge = badge;
}
function unmountStakeoutBadge() {
if (stakeout.badge) {
stakeout.badge.remove();
stakeout.badge = null;
}
}
async function stakeoutTick() {
log('stakeout tick');
stakeout.nextTickAt = Date.now() + STAKEOUT_INTERVAL_MS;
updateStakeoutBadge();
try { await runTravel(); } catch (e) { log('stakeout tick error:', e); }
}
function startStakeoutInterval() {
if (stakeout.intervalId) return; // already running, don't stack
stakeout.nextTickAt = Date.now() + STAKEOUT_INTERVAL_MS;
stakeout.intervalId = setInterval(stakeoutTick, STAKEOUT_INTERVAL_MS);
stakeout.tickIntervalId = setInterval(updateStakeoutBadge, 1000);
}
function stopStakeoutInterval() {
if (stakeout.intervalId) { clearInterval(stakeout.intervalId); stakeout.intervalId = null; }
if (stakeout.tickIntervalId) { clearInterval(stakeout.tickIntervalId); stakeout.tickIntervalId = null; }
stakeout.nextTickAt = 0;
}
// Called from runTravel() once a destination is confirmed. Mounts the
// badge (shows OFF or ON state from localStorage) and starts the
// auto-scrape interval if the user previously enabled it.
function initStakeoutUI() {
mountStakeoutBadge();
if (stakeoutEnabled() && !stakeout.intervalId) {
startStakeoutInterval();
}
updateStakeoutBadge();
}
// Called from dispatch() when navigating to a non-travel page. Clears
// timers + removes the badge but does NOT flip the user's preference.
function tearDownStakeout() {
stopStakeoutInterval();
unmountStakeoutBadge();
}
// -- In-flight destination strip ----------------------------------------
// While the player is mid-flight Torn shows a static cloud-image banner
// and a "Remaining Flight Time" countdown — no shop list to scrape, no
// overlay to paint, just dead time. The strip injects a single static
// (non-scrolling) row at the top of the page summarising what's actually
// buyable at the destination right now: item · stock · buy → net sell ·
// margin %. Sorted by margin desc, in-stock + positive-margin only,
// filtered to a sane row count so the iPad layout stays scannable.
//
// Data source: YATA's public abroad-prices feed (yata.yt/api/v1/travel/
// export/), same source the web app uses. Sell prices come from the
// shared sell_prices cache via the existing fetchSellPrices() helper, so
// the strip pieces together the same buy-vs-net-sell math as the
// landed overlay. Both fetches run via gmRequest so PDA's webview CORS
// doesn't block them.
const INFLIGHT_BAR_ID = 'valigia-inflight-strip';
// The four canonical Torn arbitrage categories. Filtering the strip
// to these surfaces the items players actually fly to buy/sell, and
// drops noise like alcohol/booster/melee/etc. Torn's items endpoint
// returns these strings verbatim in the `type` field (capitalized,
// singular). Match exactly.
const INFLIGHT_ALLOWED_TYPES = new Set(['Drug', 'Flower', 'Plushie', 'Artifact']);
// Generous upper bound rather than a hard top-N: every Drug/Flower/
// Plushie/Artifact at the destination should fit comfortably under
// this. Keeps a safety net against runaway DOM cost if Torn ever
// dramatically expands the catalog.
const INFLIGHT_MAX_ROWS = 50;
// Slots to multiply the buy price by for the run-cost column. The web
// app stores its slot count in localStorage under 'valigia_slots' on the
// valigia.girovagabondo.com origin, which the userscript can't read
// (cross-origin). Use a separate key the player can override:
// localStorage.setItem('valigia_pda_slots', '32')
// Default 29 matches the web app and is correct for most players.
const SLOTS_STORAGE_KEY = 'valigia_pda_slots';
function getSlotCount() {
try {
const raw = localStorage.getItem(SLOTS_STORAGE_KEY);
const n = Number(raw);
if (Number.isFinite(n) && n >= 5 && n <= 44) return n;
} catch { /* ignore */ }
return 29;
}
const YATA_EXPORT_URL = 'https://yata.yt/api/v1/travel/export/';
// Mirrors src/log-sync.js — YATA keys destinations by lowercase 3-letter
// codes, which we map back to the same canonical names the rest of this
// userscript and the web app use.
const YATA_COUNTRY_MAP = {
mex: 'Mexico',
cay: 'Caymans',
can: 'Canada',
haw: 'Hawaii',
uni: 'UK',
arg: 'Argentina',
swi: 'Switzerland',
jap: 'Japan',
chi: 'China',
uae: 'UAE',
sou: 'South Africa',
};
async function fetchYataForDestination(destination) {
try {
const res = await gmRequest({
method: 'GET',
url: YATA_EXPORT_URL,
headers: { 'Accept': 'application/json' },
});
if (res.status < 200 || res.status >= 300) return [];
const data = JSON.parse(res.responseText || '{}');
const countries = data.stocks || data;
const out = [];
for (const code of Object.keys(countries)) {
const dest = YATA_COUNTRY_MAP[code];
if (dest !== destination) continue;
const stocks = (countries[code] && countries[code].stocks) || [];
for (const s of stocks) {
if (!s || !s.id || !s.cost) continue;
out.push({
item_id: Number(s.id),
name: s.name || ('Item ' + s.id),
buy_price: Number(s.cost),
stock: Number.isFinite(Number(s.quantity)) ? Number(s.quantity) : null,
});
}
}
return out;
} catch (e) {
log('yata fetch failed', e);
return [];
}
}
// -- First-party scout scrapes (abroad_prices) -------------------------
// Mirrors the merge policy in src/log-sync.js: a Valigia Scout (any
// userscript user who's landed at the destination recently) writes
// freshly-scraped buy_price/stock into abroad_prices via the
// ingest-travel-shop edge function. Anything we observed within
// FIRST_PARTY_FRESH_MS overrides YATA — we trust our own scrape over a
// crowd-sourced reading that may be 10-30 min stale. Long-term goal:
// weaning the in-flight strip off YATA entirely once scout coverage is
// wide enough that every destination has a fresh first-party reading on
// every flight. This is the first step.
const FIRST_PARTY_FRESH_MS = 10 * 60 * 1000;
// Pad a couple minutes for clock skew when filtering server-side.
const FIRST_PARTY_QUERY_WINDOW_MS = 12 * 60 * 1000;
async function fetchAbroadScrapes(destination) {
if (!destination) return new Map();
const sinceIso = new Date(Date.now() - FIRST_PARTY_QUERY_WINDOW_MS).toISOString();
const url = ABROAD_PRICES_URL +
'?select=item_id,item_name,buy_price,stock,observed_at' +
'&destination=eq.' + encodeURIComponent(destination) +
'&observed_at=gte.' + encodeURIComponent(sinceIso) +
'&order=observed_at.desc';
try {
const res = await gmRequest({
method: 'GET',
url: url,
headers: {
'apikey': SUPABASE_ANON_KEY,
'Authorization': 'Bearer ' + SUPABASE_ANON_KEY,
'Accept': 'application/json',
},
});
if (res.status < 200 || res.status >= 300) return new Map();
const rows = JSON.parse(res.responseText || '[]');
// Multiple scouts may land in the same window — keep the freshest
// observation per item_id. Rows arrived ordered desc by observed_at,
// so the first hit per item is already the freshest.
const freshest = new Map();
const cutoff = Date.now() - FIRST_PARTY_FRESH_MS;
for (const r of rows) {
if (!r) continue;
const itemId = Number(r.item_id);
if (!Number.isFinite(itemId)) continue;
if (freshest.has(itemId)) continue;
const t = new Date(r.observed_at).getTime();
if (!Number.isFinite(t) || t < cutoff) continue;
freshest.set(itemId, {
item_id: itemId,
name: r.item_name || ('Item ' + itemId),
buy_price: Number(r.buy_price),
stock: Number.isFinite(Number(r.stock)) ? Number(r.stock) : null,
observedAt: t,
});
}
return freshest;
} catch (e) {
return new Map();
}
}
// -- Depletion-slope fitter (slim port of stock-forecast.js) ------------
// For each (item_id, destination) we want a per-minute steady-state
// depletion rate so the strip can answer "how much will be left when I
// land?". The web app's stock-forecast.js does this with restock cadence,
// confidence tiers, and a 48h history window — overkill for a quick
// arrival estimate. We use the last 2 hours of yata_snapshots, segment
// by restock boundaries (positive deltas), least-squares fit a slope per
// segment, and weighted-median pool. Same algorithm as the web app's
// pooledDepletionSlope(), just compressed.
// Match the web app's HISTORY_WINDOW_MINS exactly (src/stock-forecast.js).
// Initial guess of 2h was wrong: yata_snapshots is dedup-on-write (only
// inserts when stock or buy_price changes), so a stable shelf can have
// < 2 samples over a 2-hour window even when it has dozens over 48
// hours. The web app's pooledDepletionSlope explicitly handles 48h of
// data — splits on restock boundaries and weighted-medians the
// per-segment slopes — so widening here doesn't dilute the fit, it
// just gives the fitter material to work with.
const SNAPSHOTS_HISTORY_MINS = 48 * 60;
const YATA_SNAPSHOTS_URL = SUPABASE_REST_URL + '/yata_snapshots';
async function fetchYataSnapshots(itemIds, destination) {
if (!Array.isArray(itemIds) || itemIds.length === 0) return new Map();
if (!destination) return new Map();
const cutoffIso = new Date(Date.now() - SNAPSHOTS_HISTORY_MINS * 60_000).toISOString();
const idList = itemIds.join(',');
const url = YATA_SNAPSHOTS_URL +
'?select=item_id,quantity,snapped_at' +
'&item_id=in.(' + idList + ')' +
'&destination=eq.' + encodeURIComponent(destination) +
'&snapped_at=gte.' + encodeURIComponent(cutoffIso) +
'&order=snapped_at.asc' +
'&limit=20000';
try {
const res = await gmRequest({
method: 'GET',
url: url,
headers: {
'apikey': SUPABASE_ANON_KEY,
'Authorization': 'Bearer ' + SUPABASE_ANON_KEY,
'Accept': 'application/json',
},
});
if (res.status < 200 || res.status >= 300) return new Map();
const rows = JSON.parse(res.responseText || '[]');
const byItem = new Map();
// Coerce numeric columns explicitly: yata_snapshots.quantity is
// bigint (migration 010), and raw PostgREST may return bigint
// values as strings. The web app's Supabase JS SDK auto-coerces;
// we don't get that for free here, so the strict typeof === 'number'
// check we used previously silently dropped every snapshot row,
// which is why the strip's "on arrival" column went all-dashes
// overnight despite the data being there.
for (const r of rows) {
if (!r) continue;
const itemId = Number(r.item_id);
const qty = Number(r.quantity);
if (!Number.isFinite(itemId) || !Number.isFinite(qty)) continue;
const t = new Date(r.snapped_at).getTime();
if (!Number.isFinite(t)) continue;
let arr = byItem.get(itemId);
if (!arr) { arr = []; byItem.set(itemId, arr); }
arr.push({ q: qty, t: t });
}
return byItem;
} catch (e) {
return new Map();
}
}
// Walk a chronologically-sorted sample series and split into runs of
// non-increasing quantity; a strictly-positive delta is a restock and
// breaks the run. Same shape as stock-forecast.js's allDepletionSegments.
function splitDepletionSegments(samples) {
if (!samples || samples.length < 2) return [];
const segs = [];
let cur = [samples[0]];
for (let i = 1; i < samples.length; i++) {
if (samples[i].q > cur[cur.length - 1].q) {
if (cur.length >= 2) segs.push(cur);
cur = [samples[i]];
} else {
cur.push(samples[i]);
}
}
if (cur.length >= 2) segs.push(cur);
return segs;
}
// Least-squares slope of quantity vs minutes-since-segment-start.
// Returns null on degenerate segments (single sample, all same time).
function fitSlope(seg) {
if (!seg || seg.length < 2) return null;
const t0 = seg[0].t;
let n = 0, sx = 0, sy = 0, sxy = 0, sxx = 0;
for (const s of seg) {
const x = (s.t - t0) / 60_000;
const y = s.q;
sx += x; sy += y; sxy += x * y; sxx += x * x; n++;
}
const denom = n * sxx - sx * sx;
if (denom === 0) return null;
return (n * sxy - sx * sy) / denom;
}
// Weighted-median pool of per-segment slopes, weighted by segment length.
// Drops positive slopes (numerical noise on flat segments); keeps zeros.
// Returns units/min (≤ 0) or null when no usable slope.
function poolSlope(segments) {
const weighted = [];
for (const seg of segments) {
const s = fitSlope(seg);
if (s == null || s > 0) continue;
weighted.push({ s: s, w: seg.length });
}
if (weighted.length === 0) return null;
weighted.sort(function (a, b) { return a.s - b.s; });
const total = weighted.reduce(function (acc, x) { return acc + x.w; }, 0);
let acc = 0;
let picked = weighted[weighted.length - 1].s;
for (const w of weighted) {
acc += w.w;
if (acc >= total / 2) { picked = w.s; break; }
}
return picked;
}
// Top-level: turns a samples array into a per-minute depletion rate.
// null when we can't fit one (no history, all flat, single restock cycle
// shorter than 2 samples, etc.) — caller falls back to "stock now".
function depletionRatePerMin(samples) {
return poolSlope(splitDepletionSegments(samples || []));
}
function injectInFlightStyles() {
if (document.getElementById('valigia-inflight-styles')) return;
const css = [
'#' + INFLIGHT_BAR_ID + ' {',
' all: initial;',
' display: block;',
' margin: 8px auto 12px;',
' max-width: 1100px;',
' font-family: Arial, Helvetica, sans-serif;',
' color: #c8cdd8;',
' background: #161a22;',
' border: 1px solid #252a35;',
' border-left: 3px solid #e8c84a;',
' border-radius: 4px;',
' box-sizing: border-box;',
' overflow: hidden;',
'}',
'#' + INFLIGHT_BAR_ID + ' .vgl-if-head {',
' display: flex; align-items: center; gap: 8px;',
' padding: 8px 12px;',
' cursor: pointer; user-select: none;',
'}',
'#' + INFLIGHT_BAR_ID + ' .vgl-if-title {',
' color: #e8c84a; font-weight: 700; font-size: 12px;',
' letter-spacing: 0.12em; text-transform: uppercase;',
'}',
'#' + INFLIGHT_BAR_ID + ' .vgl-if-dest {',
' color: #c8cdd8; font-size: 11px; opacity: 0.8;',
'}',
'#' + INFLIGHT_BAR_ID + ' .vgl-if-count {',
' margin-left: auto; background: #e8c84a; color: #0d0f14;',
' font-weight: 700; font-size: 11px; padding: 1px 7px;',
' border-radius: 999px;',
'}',
'#' + INFLIGHT_BAR_ID + ' .vgl-if-caret {',
' color: #e8c84a; font-size: 11px;',
' transition: transform 150ms;',
'}',
'#' + INFLIGHT_BAR_ID + '.vgl-if-open .vgl-if-caret {',
' transform: rotate(180deg);',
'}',
'#' + INFLIGHT_BAR_ID + ' .vgl-if-body {',
' display: none;',
' flex-direction: column; gap: 3px;',
' padding: 4px 10px 10px;',
'}',
'#' + INFLIGHT_BAR_ID + '.vgl-if-open .vgl-if-body {',
' display: flex;',
'}',
// Five columns, all on a single line. Name flexes; the four numeric
// cells size to content with right alignment so they read like a
// table. min-width:0 on name lets ellipsis kick in cleanly when an
// item name is unusually long instead of pushing the row to wrap.
'#' + INFLIGHT_BAR_ID + ' .vgl-if-row {',
' display: grid;',
' grid-template-columns: minmax(0,1fr) auto auto auto auto;',
' align-items: baseline; gap: 12px;',
' padding: 5px 8px;',
' border: 1px solid #252a35; border-radius: 3px;',
' background: rgba(232,200,74,0.04);',
' font-size: 12px;',
'}',
'#' + INFLIGHT_BAR_ID + ' .vgl-if-name { font-weight: 700; color: #c8cdd8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }',
'#' + INFLIGHT_BAR_ID + ' .vgl-if-buy { color: #c8cdd8; white-space: nowrap; text-align: right; }',
'#' + INFLIGHT_BAR_ID + ' .vgl-if-runcost { color: #e8c84a; font-weight: 700; white-space: nowrap; text-align: right; }',
'#' + INFLIGHT_BAR_ID + ' .vgl-if-stock { color: #8a8fa0; font-size: 11px; white-space: nowrap; text-align: right; }',
'#' + INFLIGHT_BAR_ID + ' .vgl-if-arrival { color: #4ae8a0; font-weight: 700; white-space: nowrap; text-align: right; }',
'#' + INFLIGHT_BAR_ID + ' .vgl-if-arrival--empty { color: #e8824a; }',
'#' + INFLIGHT_BAR_ID + ' .vgl-if-arrival--unknown { color: #5a6070; font-weight: 400; }',
'#' + INFLIGHT_BAR_ID + ' .vgl-if-empty {',
' display: none;',
' padding: 4px 12px 10px; font-size: 11px; color: #8a8fa0;',
'}',
'#' + INFLIGHT_BAR_ID + '.vgl-if-open .vgl-if-empty {',
' display: block;',
'}',
].join('\n');
const style = document.createElement('style');
style.id = 'valigia-inflight-styles';
style.textContent = css;
document.head.appendChild(style);
}
function buildInFlightStrip(destination, rows) {
const bar = document.createElement('div');
bar.id = INFLIGHT_BAR_ID;
const head = document.createElement('div');
head.className = 'vgl-if-head';
const title = document.createElement('span');
title.className = 'vgl-if-title';
title.textContent = 'Arriving Soon';
const dest = document.createElement('span');
dest.className = 'vgl-if-dest';
// Middle dot (U+00B7) and the row arrow (U+2192) below are escaped
// rather than written as literal multi-byte UTF-8: the FTP deploy
// pipeline mangles unescaped non-ASCII into latin-1, which renders
// as garbled mojibake on iPad. Match the watchlist bar's convention.
dest.textContent = '\u00B7 ' + destination;
head.appendChild(title);
head.appendChild(dest);
const count = document.createElement('span');
count.className = 'vgl-if-count';
count.textContent = String(rows.length);
head.appendChild(count);
const caret = document.createElement('span');
caret.className = 'vgl-if-caret';
caret.textContent = '\u25BE';
head.appendChild(caret);
bar.appendChild(head);
if (rows.length === 0) {
const empty = document.createElement('div');
empty.className = 'vgl-if-empty';
empty.textContent = 'No profitable in-stock items right now.';
bar.appendChild(empty);
} else {
const body = document.createElement('div');
body.className = 'vgl-if-body';
for (const r of rows) {
const row = document.createElement('div');
row.className = 'vgl-if-row';
// Five cells, in order: name | unit price | run cost
// (price * slots) | current stock | predicted on arrival.
const name = document.createElement('span');
name.className = 'vgl-if-name';
name.textContent = r.name;
const buy = document.createElement('span');
buy.className = 'vgl-if-buy';
buy.textContent = formatMoneyCompact(r.buy_price);
const runcost = document.createElement('span');
runcost.className = 'vgl-if-runcost';
runcost.textContent = formatMoneyCompact(r.runCost);
const stock = document.createElement('span');
stock.className = 'vgl-if-stock';
stock.textContent = (r.stock != null ? r.stock.toLocaleString('en-US') : '?') + ' stock';
// Predicted-arrival cell: green number when we have a slope and
// Arrival cell: green when we have a real depletion fit, muted
// when we fell back to slope=0 (no observed depletion in the
// 2h window so the prediction equals current stock). Amber
// "may sell out" wins regardless of source when predicted hits 0.
const arrival = document.createElement('span');
arrival.className = 'vgl-if-arrival';
if (r.predictedStock != null) {
if (r.predictedStock <= 0) {
arrival.textContent = 'may sell out';
arrival.classList.add('vgl-if-arrival--empty');
} else {
// "post-refill" suffix when the prediction depends on a
// restock event landing during the flight \u2014 the player
// should know they're betting on the cadence holding.
const suffix = r.postRefill ? ' post-refill' : ' arr.';
arrival.textContent = '\u2248 ' + r.predictedStock.toLocaleString('en-US') + suffix;
if (!r.predictedFromSlope) {
arrival.classList.add('vgl-if-arrival--unknown');
}
}
} else {
arrival.textContent = '\u2014 arr.';
arrival.classList.add('vgl-if-arrival--unknown');
}
row.appendChild(name);
row.appendChild(buy);
row.appendChild(runcost);
row.appendChild(stock);
row.appendChild(arrival);
body.appendChild(row);
}
bar.appendChild(body);
}
head.addEventListener('click', function () {
bar.classList.toggle('vgl-if-open');
});
return bar;
}
// Top-level injection. Idempotent: tears down any previous strip before
// rendering, so a hashchange-driven re-dispatch on the travel page never
// stacks duplicates. Silent on any data failure — the player still has
// the cloud image, they just don't get a preview.
async function injectInFlightStrip(destination, remainingMins) {
const existing = document.getElementById(INFLIGHT_BAR_ID);
if (existing) existing.remove();
// Fire YATA + first-party scrapes + item catalog warm in parallel.
// The catalog is needed to filter the strip by Torn's type field
// (Drug / Flower / Plushie / Artifact); without it we can't tell
// a Cherry Blossom from a Bottle of Sake. Cache hit is free; cold
// first run pays one Torn API call.
const [yataRows, scoutMap] = await Promise.all([
fetchYataForDestination(destination),
fetchAbroadScrapes(destination),
ensureItemCatalog(),
]);
// Build the merged row list. Every YATA row is kept (so the strip
// covers items even when no scout has visited recently); when a fresh
// scout reading exists for the same item_id, its stock + buy_price
// override YATA's. We also surface scout-only items (e.g. a brand-new
// shelf YATA hasn't picked up yet) by appending them after the YATA pass.
const merged = [];
const seen = new Set();
for (const y of yataRows) {
const s = scoutMap.get(y.item_id);
if (s) {
merged.push({
item_id: y.item_id,
name: y.name || s.name,
buy_price: Number.isFinite(s.buy_price) ? s.buy_price : y.buy_price,
stock: s.stock != null ? s.stock : y.stock,
source: 'scout',
});
} else {
merged.push({
item_id: y.item_id,
name: y.name,
buy_price: y.buy_price,
stock: y.stock,
source: 'yata',
});
}
seen.add(y.item_id);
}
for (const [itemId, s] of scoutMap) {
if (seen.has(itemId)) continue;
if (!Number.isFinite(s.buy_price) || s.buy_price <= 0) continue;
merged.push({
item_id: itemId,
name: s.name,
buy_price: s.buy_price,
stock: s.stock,
source: 'scout',
});
}
if (merged.length === 0) {
log('inflight: no rows for ' + destination + ' (yata=0, scout=0)');
return;
}
const itemIds = merged.map(function (r) { return r.item_id; });
// Three parallel reads: sell prices for margin math, snapshots for
// depletion slope, restock events for during-flight refill modeling.
const [sellMap, snapshotsMap, restockMap] = await Promise.all([
fetchSellPrices(itemIds),
fetchYataSnapshots(itemIds, destination),
fetchRestockEvents(itemIds, destination),
]);
const slots = getSlotCount();
const ranked = [];
let slopeHits = 0;
let slopeMisses = 0;
let restockOverrides = 0;
let typeFiltered = 0;
const nowMs = Date.now();
for (const r of merged) {
// Drop anything that isn't one of the four canonical arbitrage
// categories. Items the catalog hasn't resolved yet (type=null)
// are dropped too — better to wait one dispatch for the warm
// than to flash non-arbitrage items and remove them on rerender.
const itype = itemTypeFor(r.item_id);
if (!itype || !INFLIGHT_ALLOWED_TYPES.has(itype)) {
typeFiltered++;
continue;
}
const sell = sellMap.get(r.item_id);
if (!sell || !Number.isFinite(sell.price)) continue;
const netSell = sell.price * 0.95;
const margin = netSell - r.buy_price;
if (margin <= 0) continue;
// Don't early-drop stock=0 rows: they're exactly the case where
// the restock-during-flight model wins. Drop happens later if the
// predictor can't produce a positive arrival estimate.
const stockNow = r.stock;
// Predicted stock at arrival. Three branches:
// A. stock > 0 with depletion slope: stock + slope * remainingMins.
// If slope drives it to 0 AND a restock is due during flight,
// apply the post-restock branch below to replace 0.
// B. stock == 0: rely entirely on the restock-during-flight
// branch — typicalPostQty + slope * (remainingMins -
// timeToNext). When neither slope nor restock apply, drop.
// C. stock > 0 with no slope data: assume slope=0 (predicted =
// stock now). Muted in the UI to flag the guess.
let predicted = null;
let predictedFromSlope = false;
let postRefill = false;
if (Number.isFinite(remainingMins) && remainingMins > 0) {
const slope = depletionRatePerMin(snapshotsMap.get(r.item_id));
const plan = estimateRestockPlan(restockMap.get(r.item_id), nowMs);
const restockDuringFlight = !!(plan &&
plan.timeToNextMins <= remainingMins &&
Number.isFinite(plan.typicalPostQty));
if (stockNow != null && stockNow > 0) {
if (slope != null) {
predicted = Math.max(0, Math.round(stockNow + slope * remainingMins));
predictedFromSlope = true;
slopeHits++;
} else {
predicted = stockNow;
slopeMisses++;
}
// Empty-shelf override: if depletion bottoms out at 0 and a
// restock is due, project post-restock depletion. Same math as
// branch B below.
if (predicted === 0 && restockDuringFlight) {
const slopeForProj = slope != null ? slope : 0;
const timeAfterRestock = remainingMins - plan.timeToNextMins;
predicted = Math.max(0, Math.round(
plan.typicalPostQty + slopeForProj * timeAfterRestock
));
predictedFromSlope = true;
postRefill = true;
restockOverrides++;
}
} else if (stockNow === 0 && restockDuringFlight) {
// Branch B — currently empty, refilling during flight. Apply
// post-restock depletion using the steady-state slope (or 0
// when we have no slope data).
const slopeForProj = slope != null ? slope : 0;
const timeAfterRestock = remainingMins - plan.timeToNextMins;
predicted = Math.max(0, Math.round(
plan.typicalPostQty + slopeForProj * timeAfterRestock
));
predictedFromSlope = true;
postRefill = true;
restockOverrides++;
}
}
// Drop rows with no actionable arrival count: stock=0 now AND no
// restock predicted during flight. Nothing to buy.
if (predicted == null || predicted <= 0) continue;
// Run cost: unit price × min(predicted, slots). Use predicted
// rather than current stock so a refilling shelf shows real
// expected spend, not $0 because it's empty right now.
const effectiveSlots = predicted < slots ? predicted : slots;
const runCost = r.buy_price * effectiveSlots;
ranked.push({
item_id: r.item_id,
name: r.name,
stock: r.stock,
predictedStock: predicted,
predictedFromSlope: predictedFromSlope,
postRefill: postRefill,
buy_price: r.buy_price,
runCost: runCost,
netSell: netSell,
margin: margin,
marginPct: (margin / r.buy_price) * 100,
});
}
ranked.sort(function (a, b) { return b.marginPct - a.marginPct; });
const top = ranked.slice(0, INFLIGHT_MAX_ROWS);
if (DEBUG) {
// Counts the userscript-side breakdown of where the strip's
// numbers come from. Visible on iPad as a fixed black panel
// (debugPanel renders to the page; PDA has no console).
let snapshotItems = 0;
let snapshotSamples = 0;
for (const arr of snapshotsMap.values()) {
snapshotItems++;
snapshotSamples += arr.length;
}
let scoutCount = 0;
for (const r of merged) if (r.source === 'scout') scoutCount++;
debugPanel([
'inflight ' + destination,
'remaining=' + (remainingMins != null ? Math.round(remainingMins) + 'm' : '?'),
'merged=' + merged.length + ' ranked=' + ranked.length,
'sources: scout=' + scoutCount + ' yata=' + (merged.length - scoutCount),
'snapshots: items=' + snapshotItems + ' samples=' + snapshotSamples,
'filter: typeFiltered=' + typeFiltered + ' (kept Drug/Flower/Plushie/Artifact)',
'predict: slopeHits=' + slopeHits + ' slopeMisses=' + slopeMisses + ' restockOverrides=' + restockOverrides,
]);
}
injectInFlightStyles();
const bar = buildInFlightStrip(destination, top);
const host =
document.querySelector('#mainContainer .content-wrapper') ||
document.querySelector('.content-wrapper') ||
document.querySelector('#mainContainer') ||
document.body;
host.insertBefore(bar, host.firstChild);
}
// -- Main ----------------------------------------------------------------
async function runTravel() {
// TEMP DIAGNOSTIC: clear any prior parse-mismatch captures so a fresh
// scrape replaces a stale panel.
parseMismatches.length = 0;
// In-flight branch: no shop DOM to scrape, but we can preview what's
// available at the destination so the flight isn't dead time. Outbound
// legs only; on the return leg the player can't shop anymore.
const flight = detectInFlight();
if (flight && !flight.returning) {
try { await injectInFlightStrip(flight.destination, flight.remainingMins); } catch (e) { log('inflight error', e); }
return;
}
const destination = detectDestination();
if (!destination) {
log('No "You are in X" marker - probably not landed yet.');
return;
}
// Mount the stakeout toggle as soon as we know the user is abroad.
// Idempotent: re-running this on a stakeout tick is a no-op. Must run
// BEFORE the 8-s shop-hydration poll so the toggle appears even if
// scraping fails for DOM-selector reasons.
initStakeoutUI();
// Torn's travel page hydrates its shop lists after initial DOM render.
// Poll briefly for item images to show up before scraping.
const start = Date.now();
let shops = [];
while (Date.now() - start < 8000) {
shops = scrapeShops();
const total = shops.reduce(function (s, sh) { return s + sh.items.length; }, 0);
if (total > 0) break;
await new Promise(function (r) { setTimeout(r, 500); });
}
const totalItems = shops.reduce(function (s, sh) { return s + sh.items.length; }, 0);
if (totalItems === 0) {
log('No shop items found after 8s - aborting.');
if (DEBUG) debugPanel(['destination=' + destination, 'No items found.']);
return;
}
// TEMP DIAGNOSTIC: render any captured parse mismatches as soon as
// we have rows to show. Fires unconditionally (no DEBUG flag needed)
// so the user can screenshot it and we can fix the parser from real
// DOM. Remove this call once parser is hardened.
renderParseMismatchPanel();
if (DEBUG) {
const lines = ['destination=' + destination, 'shops=' + shops.length, 'items=' + totalItems, ''];
for (const sh of shops) {
lines.push(' [' + sh.category + '] ' + sh.items.length + ' items');
for (const it of sh.items.slice(0, 3)) {
lines.push(' - ' + it.name + ' (id=' + it.item_id + ') stock=' + it.stock + ' $' + it.buy_price);
}
if (sh.items.length > 3) lines.push(' ... +' + (sh.items.length - 3) + ' more');
}
debugPanel(lines);
}
// Collect the item_ids we need sell prices for (single Supabase GET).
const itemIds = [];
const seen = new Set();
for (const sh of shops) {
for (const it of sh.items) {
if (!seen.has(it.item_id)) {
seen.add(it.item_id);
itemIds.push(it.item_id);
}
}
}
// Surface silent selector drift: if nearestShopCategoryFor couldn't
// match any heading in an item's ancestry, rows get tagged 'Unknown'.
// On iPad with no DevTools that's indistinguishable from a real
// scrape — the user just thinks it worked. Add a visible count to
// the success toast so a Torn DOM change shows up as "UAE: 48 prices
// (3 unknown)" instead of a silent degradation.
const unknownCount = shops.reduce(function (s, sh) {
return s + (sh.category === 'Unknown' ? sh.items.length : 0);
}, 0);
// Fire ingest (POST) and sell-price fetch (GET) in parallel. Overlay
// render waits only on the sell-price fetch; ingest toast fires
// independently when its POST resolves.
const ingestPromise = (async function () {
const result = await postIngest({
api_key: TORN_API_KEY,
destination: destination,
shops: shops,
});
if (result.ok) {
const suffix = unknownCount > 0
? ' (' + unknownCount + ' unknown)'
: '';
const toneForUnknown = unknownCount > 0 ? 'warning' : 'success';
toast(destination + ': ' + result.count + ' prices' + suffix, toneForUnknown);
} else if (isSilentIngestError(result)) {
log('Travel ' + destination + ' ingest skipped (silent):', result.error);
} else {
toast(friendlyIngestError('Travel ' + destination, totalItems, result), 'error');
}
})();
// Identify items currently at stock 0 — those are the only rows where
// a refill ETA is useful to show. Skipping the fetch entirely when none
// are zero-stock keeps the common case (fully stocked shelves) free of
// an extra round-trip.
const zeroStockIds = [];
const seenZero = new Set();
for (const sh of shops) {
for (const it of sh.items) {
if (it.stock === 0 && !seenZero.has(it.item_id)) {
seenZero.add(it.item_id);
zeroStockIds.push(it.item_id);
}
}
}
const overlayPromise = (async function () {
try {
const [sellPriceMap, restockEventsMap] = await Promise.all([
fetchSellPrices(itemIds),
zeroStockIds.length > 0
? fetchRestockEvents(zeroStockIds, destination)
: Promise.resolve(new Map()),
]);
const refillEtaMap = new Map();
const nowMs = Date.now();
for (const entry of restockEventsMap) {
const itemId = entry[0];
const events = entry[1];
const etaMins = estimateRefillMins(events, nowMs);
if (etaMins != null) refillEtaMap.set(itemId, etaMins);
}
const stats = renderOverlay(shops, sellPriceMap, refillEtaMap);
if (DEBUG) {
const bestLine = stats.best
? ('best=' + stats.best.item_id + ' margin=' + Math.round(stats.best.metrics.marginPerItem))
: 'best=(none eligible)';
debugPanel([
'destination=' + destination,
'overlay rows=' + stats.total + ' with-metrics=' + stats.withMetrics,
bestLine,
]);
}
} catch (err) {
log('overlay error:', err);
}
})();
await Promise.all([ingestPromise, overlayPromise]);
}
// -- Item page (item.php) ------------------------------------------------
// Torn's own Items page (item.php) lists every item the player owns,
// broken up into category tabs (Flowers / Plushies / Drugs / ...). The
// Torn API's v1 `user/?selections=inventory` path has been deprecated
// ("The inventory selection is no longer available") and the v2
// replacement has had flaky rollout on PDA — but the page itself is
// right there in the browser, so we scrape it directly.
//
// What this runner does:
// 1. Scrape the currently-visible category tab for { item_id, name, qty }.
// 2. Query te_buy_prices for those item_ids and find the single highest
// buy-offer per item (anon SELECT — public data).
// 3. Inject a green "Best Sell Opportunities" bar at the top of the
// page, summarising the rows whose best offer × qty is largest.
//
// Earlier versions (≤0.8.4) merged every scrape into a 24-hour
// localStorage cache so all tabs visited in a session accumulated in the
// bar. That made just-sold items linger and mixed categories — when the
// player is on "Plushies" they only want to see plushies, not the
// flowers they scrolled past ten minutes ago. Dropping the cache gives
// an always-current view scoped to the visible tab; the MutationObserver
// already rescrapes on every tab switch, so the bar tracks the DOM.
const ITEM_PAGE_BAR_ID = 'valigia-sell-opportunities-bar';
const TE_BUY_PRICES_URL = SUPABASE_REST_URL + '/te_buy_prices';
// Walk up from el to body; return true if any ancestor (or el itself) is
// actually rendered. Torn's items page keeps every previously-rendered
// category tab alive in the DOM and hides the inactive ones — some with
// display:none, some with zero-height collapses. innerText *should* skip
// display:none text but in practice on PDA's webview it sometimes still
// returns the text for those nodes, so an explicit geometry check is the
// reliable filter.
function isRowVisible(el) {
if (!el) return false;
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
return true;
}
// Scrape the currently-visible category's item rows. Uses the same
// /images/items/{id}/ selector other runners rely on, plus the shared
// rowContainer() heuristic to tolerate Torn's <tr>/<div> drift, then
// filters to only rows that are actually on screen so hidden category
// tabs don't leak into the bar.
function scrapeItemPageRows() {
const imgs = Array.from(document.querySelectorAll('img[src*="/images/items/"]'));
const rows = new Map();
const seenRows = new Set();
for (const img of imgs) {
const src = img.getAttribute('src') || '';
const idMatch = src.match(/\/images\/items\/(\d+)\//);
if (!idMatch) continue;
const item_id = Number(idMatch[1]);
const row = rowContainer(img);
if (!row || seenRows.has(row)) continue;
seenRows.add(row);
// Filter hidden category tabs still present in the DOM.
if (!isRowVisible(row)) continue;
// Skip the left sidebar's item-icon row (category tabs, equipped
// preview) — those have images but no "x{N}" count next to them.
const text = (row.innerText || '').trim();
const qtyMatch = text.match(/\bx\s*([\d,]+)\b/i);
if (!qtyMatch) continue;
const quantity = Number(qtyMatch[1].replace(/,/g, ''));
if (!Number.isInteger(quantity) || quantity <= 0) continue;
const altName = (img.getAttribute('alt') || '').trim();
// Fall back to the text before the "xN" if alt is empty. Keep it
// short — a row can have follow-on text like "Send", "Destroy",
// prices, etc., and we only want the name.
const nameFromText = text.split(/\bx\s*[\d,]+\b/i)[0]
.replace(/\s+/g, ' ')
.trim();
const name = altName || nameFromText.slice(0, 60);
if (!name) continue;
rows.set(item_id, { item_id, name, quantity, observed_at: Date.now() });
}
return rows;
}
async function fetchTeBuyPricesFor(itemIds) {
if (!Array.isArray(itemIds) || itemIds.length === 0) return new Map();
const url = TE_BUY_PRICES_URL +
'?select=handle,item_id,item_name,buy_price,updated_at' +
'&item_id=in.(' + itemIds.join(',') + ')';
try {
const res = await gmRequest({
method: 'GET',
url: url,
headers: {
'apikey': SUPABASE_ANON_KEY,
'Authorization': 'Bearer ' + SUPABASE_ANON_KEY,
'Accept': 'application/json',
},
});
if (res.status < 200 || res.status >= 300) return new Map();
const rows = JSON.parse(res.responseText || '[]');
// Collapse to best (highest) buy_price per item_id. Freshness is a
// tiebreak so a newly-scraped trader wins ties over an old one.
const best = new Map();
for (const r of rows) {
if (!r || typeof r.item_id !== 'number' || typeof r.buy_price !== 'number') continue;
const existing = best.get(r.item_id);
if (
!existing
|| r.buy_price > existing.buy_price
|| (r.buy_price === existing.buy_price && (r.updated_at || '') > (existing.updated_at || ''))
) {
best.set(r.item_id, r);
}
}
return best;
} catch (_) {
return new Map();
}
}
function injectItemPageStyles() {
if (document.getElementById('valigia-itempage-styles')) return;
const st = document.createElement('style');
st.id = 'valigia-itempage-styles';
st.textContent = `
#${ITEM_PAGE_BAR_ID} {
font-family: Arial, Helvetica, sans-serif;
background: #0d0f14;
color: #c8cdd8;
border: 1px solid #252a35;
border-left: 3px solid #4ae8a0;
border-radius: 4px;
margin: 8px 0;
overflow: hidden;
}
#${ITEM_PAGE_BAR_ID} summary {
list-style: none;
cursor: pointer;
padding: 10px 12px;
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
#${ITEM_PAGE_BAR_ID} summary::-webkit-details-marker { display: none; }
#${ITEM_PAGE_BAR_ID} summary::before {
content: '\\25B8'; /* \u25B8 right-pointing triangle — escaped so a miscoded file doesn't mojibake the glyph */
color: #4ae8a0;
transition: transform 120ms ease;
}
#${ITEM_PAGE_BAR_ID}[open] summary::before { transform: rotate(90deg); }
#${ITEM_PAGE_BAR_ID} .v-ip-title {
font-weight: 700;
color: #4ae8a0;
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 11px;
}
#${ITEM_PAGE_BAR_ID} .v-ip-count {
font-weight: 400;
color: #5a6070;
margin-left: auto;
font-size: 11px;
}
#${ITEM_PAGE_BAR_ID} .v-ip-total {
color: #4ae8a0;
font-weight: 700;
}
#${ITEM_PAGE_BAR_ID} .v-ip-rows {
border-top: 1px solid #252a35;
max-height: 60vh;
overflow-y: auto;
}
#${ITEM_PAGE_BAR_ID} .v-ip-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px 10px;
padding: 8px 12px;
border-bottom: 1px solid #1a1e27;
text-decoration: none;
color: #c8cdd8;
font-size: 12px;
}
#${ITEM_PAGE_BAR_ID} .v-ip-row:last-child { border-bottom: 0; }
#${ITEM_PAGE_BAR_ID} .v-ip-row:hover { background: rgba(74, 232, 160, 0.05); }
#${ITEM_PAGE_BAR_ID} .v-ip-qty { color: #5a6070; font-variant-numeric: tabular-nums; }
#${ITEM_PAGE_BAR_ID} .v-ip-item { font-weight: 700; color: #c8cdd8; }
#${ITEM_PAGE_BAR_ID} .v-ip-trader { color: #e8c84a; font-size: 11px; }
#${ITEM_PAGE_BAR_ID} .v-ip-price { color: #4ae8a0; white-space: nowrap; font-variant-numeric: tabular-nums; }
#${ITEM_PAGE_BAR_ID} .v-ip-unit { color: #5a6070; font-size: 10px; }
#${ITEM_PAGE_BAR_ID} .v-ip-stack { color: #c8cdd8; white-space: nowrap; font-variant-numeric: tabular-nums; font-size: 11px; }
`;
document.head.appendChild(st);
}
function fmtMoney(n) {
if (!Number.isFinite(n)) return '\u2014';
return '$' + Math.round(n).toLocaleString('en-US');
}
function buildItemPageBar(matches) {
injectItemPageStyles();
const totalPay = matches.reduce((s, m) => s + m.total, 0);
const details = document.createElement('details');
details.id = ITEM_PAGE_BAR_ID;
const summary = document.createElement('summary');
summary.innerHTML = `
<span class="v-ip-title">Best sell opportunities</span>
<span class="v-ip-total">${fmtMoney(totalPay)}</span>
<span class="v-ip-count">${matches.length} item${matches.length === 1 ? '' : 's'}</span>
`;
details.appendChild(summary);
const rowsEl = document.createElement('div');
rowsEl.className = 'v-ip-rows';
for (const m of matches) {
const a = document.createElement('a');
a.className = 'v-ip-row';
a.href = 'https://tornexchange.com/prices/' + encodeURIComponent(m.offer.handle) + '/';
a.target = '_blank';
a.rel = 'noopener';
// \u-escape the multiplication sign and arrow. InMotion's FTP deploy
// serves the userscript without an explicit UTF-8 charset header and
// PDA's webview has been seen to render raw bytes as Latin-1, turning
// "x" (U+00D7) into "Ã" + garbage. Escaped forms are safe regardless.
a.innerHTML = `
<span class="v-ip-qty">${m.item.quantity.toLocaleString('en-US')}\u00d7</span>
<span class="v-ip-item">${escapeHtml(m.offer.item_name || m.item.name)} <span class="v-ip-trader">\u2192 ${escapeHtml(m.offer.handle)}</span></span>
<span class="v-ip-price">${fmtMoney(m.offer.buy_price)}<span class="v-ip-unit">/ea</span></span>
<span class="v-ip-stack">${fmtMoney(m.total)}</span>
`;
rowsEl.appendChild(a);
}
details.appendChild(rowsEl);
return details;
}
// Lightweight HTML escape — other runners use a shared one but it's not
// exported in this script, so keep a local copy to avoid reshuffling.
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
});
}
async function renderItemPageBar(inventory) {
const prev = document.getElementById(ITEM_PAGE_BAR_ID);
if (!inventory || inventory.size === 0) {
if (prev) prev.remove();
return;
}
const ids = Array.from(inventory.keys());
const offers = await fetchTeBuyPricesFor(ids);
const matches = [];
for (const [id, row] of inventory) {
const offer = offers.get(id);
if (!offer) continue;
matches.push({ item: row, offer, total: offer.buy_price * row.quantity });
}
matches.sort((a, b) => b.total - a.total);
const after = document.getElementById(ITEM_PAGE_BAR_ID);
if (matches.length === 0) {
if (after) after.remove();
return;
}
const bar = buildItemPageBar(matches);
if (after) after.replaceWith(bar);
else {
const host =
document.querySelector('#mainContainer .content-wrapper') ||
document.querySelector('.content-wrapper') ||
document.querySelector('#mainContainer') ||
document.body;
host.insertBefore(bar, host.firstChild);
}
}
// Debounced scrape + render. Torn switches category tabs in two
// different ways depending on whether the tab has been visited this
// session: sometimes a full DOM rebuild (new item images added —
// observable via childList), sometimes a pure CSS toggle on a cached
// tree (no nodes added, only class/style attributes change). We
// schedule a scan on ANY mutation and let a hash guard collapse
// no-op re-renders, so both cases land on the same code path.
//
// The debounce resets on every mutation (so a burst of 20 mutations
// in 5ms collapses into one scan), but a max-wait guarantees the
// scan still fires ~1.2s after the first mutation even if mutations
// keep arriving — otherwise a constantly-ticking UI element could
// starve the scan forever.
let itemPageScheduled = null;
let itemPageFirstMutation = 0;
let lastRenderedHash = null;
function scheduleItemPageScan(reason) {
const now = Date.now();
if (!itemPageFirstMutation) itemPageFirstMutation = now;
if (itemPageScheduled) clearTimeout(itemPageScheduled);
const DEBOUNCE_MS = 300;
const MAX_WAIT_MS = 1200;
const elapsed = now - itemPageFirstMutation;
const wait = elapsed >= MAX_WAIT_MS ? 0 : Math.min(DEBOUNCE_MS, MAX_WAIT_MS - elapsed);
itemPageScheduled = setTimeout(async function () {
itemPageScheduled = null;
itemPageFirstMutation = 0;
try {
const fresh = scrapeItemPageRows();
// Hash the visible set (ids + quantities, sorted) so we can
// skip re-renders when nothing changed. Crucial: the bar's own
// DOM insertion feeds the observer, and without this guard
// that would loop forever.
const ids = Array.from(fresh.keys()).sort(function (a, b) { return a - b; });
const hash = ids.length === 0
? ''
: ids.map(function (id) { return id + ':' + fresh.get(id).quantity; }).join(',');
if (hash === lastRenderedHash) return;
lastRenderedHash = hash;
log('item.php: scrape reason=' + reason + ' size=' + fresh.size);
await renderItemPageBar(fresh);
} catch (e) {
log('item.php scan error:', e);
}
}, wait);
}
async function runItemPage() {
// First pass on whatever is currently rendered.
scheduleItemPageScan('initial');
// Watch for tab-switch triggers. We observe the whole body with
// childList + attributes so both full-DOM-swap and class-toggle
// forms of category switching are caught. The callback is
// unconditional — the debounce + hash guard handle noise.
const observer = new MutationObserver(function () {
scheduleItemPageScan('mutation');
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'aria-hidden'],
});
// Tear down if the PDA dispatcher fires again for a different URL
// (e.g. user navigates to the Item Market). The existing
// scheduleDispatch() flow doesn't explicitly cancel observers, but
// hashchange is its main trigger and a hashchange → different page
// → rowContainer wouldn't find item images anyway.
window.addEventListener('hashchange', function once() {
observer.disconnect();
window.removeEventListener('hashchange', once);
});
// Not pinging the scout counter here — record-pda-activity's
// ALLOWED_PAGE_TYPES is currently {travel, item_market, bazaar} and
// expanding it belongs in its own change. This runner's value to
// the player is immediate (the bar scoped to the visible tab); the
// scouts counter is vanity.
}
// -- Museum sets + points-arb data --------------------------------------
// Source of truth for "this item participates in a museum set worth N
// points." Used by the bazaar Deals bar to flag listings priced under
// their museum-points-equivalent value (the player buys cheap, completes
// a set, and exchanges at the museum for points worth more cash via the
// current Points Market rate).
//
// Per-item points value is computed dynamically at evaluation time:
// ptsForItem = setPoints * marketPrice(item) / sum(marketPrice(member) * qty)
// ...so a set with one expensive piece and many cheap pieces (Senet:
// 1 board + 10 pawns, where pawns dominate market value 17:1) auto-
// rebalances without us having to ship userscript updates every time
// the floor moves. For singletons this collapses to setPoints; for
// uniform sets (Coins, Arrowheads) it collapses to setPoints / N.
//
// Item names must match Torn's catalog exactly. The resolver silently
// skips any name that doesn't resolve, so a typo just means that
// member won't contribute to set valuation — the rest of the set still
// works. Verify against itemMetaCache when adding new sets.
const MUSEUM_SETS = [
{ name: 'Arrowhead Set', points: 25, items: [
{ name: 'Chert Point', qty: 1 },
{ name: 'Quartzite Point', qty: 1 },
{ name: 'Basalt Point', qty: 1 },
{ name: 'Obsidian Point', qty: 1 },
{ name: 'Quartz Point', qty: 1 },
{ name: 'Chalcedony Point', qty: 1 },
]},
{ name: 'Medieval Coin Set', points: 100, items: [
{ name: 'Leopard Coin', qty: 1 },
{ name: 'Florin Coin', qty: 1 },
{ name: 'Gold Noble Coin', qty: 1 },
]},
{ name: 'Patagonian Fossil', points: 20, items: [{ name: 'Patagonian Fossil', qty: 1 }] },
{ name: 'Meteorite Fragment', points: 15, items: [{ name: 'Meteorite Fragment', qty: 1 }] },
{ name: 'Vairocana Buddha', points: 100, items: [{ name: 'Vairocana Buddha Sculpture', qty: 1 }] },
{ name: 'Ganesha Sculpture', points: 250, items: [{ name: 'Ganesha Sculpture', qty: 1 }] },
{ name: 'Shabti Sculpture', points: 500, items: [{ name: 'Shabti Sculpture', qty: 1 }] },
{ name: 'Senet Game Set', points: 2000, items: [
{ name: 'Senet Board', qty: 1 },
{ name: 'White Senet Pawn', qty: 5 },
{ name: 'Black Senet Pawn', qty: 5 },
]},
{ name: 'Companion Script Set', points: 1000, items: [
{ name: 'Companion Script : Abdullah', qty: 1 },
{ name: 'Companion Script : Ali', qty: 1 },
{ name: 'Companion Script : Ubay', qty: 1 },
]},
{ name: 'Egyptian Amulet', points: 10000, items: [{ name: 'Egyptian Amulet', qty: 1 }] },
];
// Buy-signal threshold: bazaar < pointsCash * (1 - this) → fire signal.
const POINTS_BUY_DISCOUNT = 0.10;
// localStorage key + freshness window for the captured Points Market rate.
const POINTS_RATE_KEY = 'valigia.pointsRate';
const POINTS_RATE_TTL_MS = 24 * 60 * 60 * 1000;
// Resolve item names to ids via the warm catalog. Reverse-index built
// lazily on first call. Returns null when the catalog isn't warm OR
// the name doesn't match anything (typo / Torn renamed it).
let itemNameToIdCache = null;
function itemIdForName(name) {
if (!itemMetaCache || itemMetaCache.size === 0) return null;
if (!itemNameToIdCache) {
itemNameToIdCache = new Map();
itemMetaCache.forEach(function (meta, id) {
if (meta && meta.name) itemNameToIdCache.set(meta.name, id);
});
}
return itemNameToIdCache.get(name) || null;
}
// Find which set (if any) an item id belongs to. Returns the set object
// with item names already resolved to ids, or null if the id isn't part
// of any set we know about.
function setForItemId(itemId) {
if (!Number.isFinite(itemId)) return null;
for (const set of MUSEUM_SETS) {
for (const member of set.items) {
if (itemIdForName(member.name) === itemId) return set;
}
}
return null;
}
// Compute per-unit points value for an item in a set, weighted by
// current market prices. Returns null if we can't price every member
// (one missing market price would skew the proportion — better to
// suppress the signal than flash a wrong number).
function computePointsForItem(itemId, set, marketByItem) {
let totalSetMarket = 0;
let thisItemMarket = null;
for (const member of set.items) {
const memberId = itemIdForName(member.name);
if (!memberId) return null;
const memberPrice = marketByItem.get(memberId);
if (!Number.isFinite(memberPrice) || memberPrice <= 0) return null;
totalSetMarket += memberPrice * member.qty;
if (memberId === itemId) thisItemMarket = memberPrice;
}
if (thisItemMarket == null || totalSetMarket <= 0) return null;
return set.points * thisItemMarket / totalSetMarket;
}
// Read the captured Points Market rate from the local cache only.
// Returns null if missing or older than POINTS_RATE_TTL_MS. Callers
// that can tolerate one round-trip should `await ensurePointsRate()`
// first to populate the local cache from the shared Supabase pool;
// those that cant (sync render paths) just live with an occasional
// null when this user has never warmed the cache.
function getPointsRate() {
try {
const raw = localStorage.getItem(POINTS_RATE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || !Number.isFinite(parsed.rate) || !parsed.observed_at) return null;
if (Date.now() - parsed.observed_at > POINTS_RATE_TTL_MS) return null;
return parsed.rate;
} catch (_) { return null; }
}
// Warm the local cache from the shared Supabase pool when it's missing
// or stale. Lets a player who has never visited pmarket.php still see
// BUY UNDER thresholds in the museum bar and POINTS BUY rows in the
// bazaar Deals bar — provided some other Valigia user pushed a fresh
// rate in the last POINTS_RATE_TTL_MS. Silent no-op on any failure
// (network, parse, missing seed): the consumer falls back to "no
// rate" placeholders, which is the same UX as before this change.
async function ensurePointsRate() {
if (getPointsRate() != null) return;
try {
const res = await gmRequest({
method: 'GET',
url: POINTS_RATE_URL + '?id=eq.1&select=rate,updated_at',
headers: {
'apikey': SUPABASE_ANON_KEY,
'Authorization': 'Bearer ' + SUPABASE_ANON_KEY,
'Accept': 'application/json',
},
});
if (res.status < 200 || res.status >= 300) return;
const rows = JSON.parse(res.responseText || '[]');
const row = Array.isArray(rows) ? rows[0] : null;
if (!row || !Number.isFinite(Number(row.rate))) return;
const observedAt = row.updated_at ? new Date(row.updated_at).getTime() : 0;
// Honour the same 24h freshness gate as locally-captured rates.
// Catches the seed-row case (observed_at = 1970) and any pool
// that's gone collectively cold.
if (!observedAt || Date.now() - observedAt > POINTS_RATE_TTL_MS) return;
try {
localStorage.setItem(POINTS_RATE_KEY, JSON.stringify({
rate: Number(row.rate),
observed_at: observedAt,
}));
} catch (_) { /* storage full — non-fatal */ }
} catch (e) {
log('points rate Supabase read failed:', e);
}
}
// Push a freshly-captured rate to both the local cache (immediate
// truth for this browser) and the shared Supabase pool (benefits
// every other Valigia user for the next 24h). Returns true if the
// shared write also landed; false if only the local cache was
// updated. The caller surfaces this distinction in the success
// banner so the player knows whether their capture is helping the
// community pool or just their own browser.
async function setPointsRate(rate) {
try {
localStorage.setItem(POINTS_RATE_KEY, JSON.stringify({
rate: rate,
observed_at: Date.now(),
}));
} catch (_) { /* storage full / disabled — non-fatal */ }
try {
await pushPointsRateToSupabase(rate);
return true;
} catch (e) {
log('points rate Supabase write failed:', e);
return false;
}
}
// POST + resolution=merge-duplicates is the proven upsert pattern
// used by sell_prices and bazaar_prices ingest in this same script.
// We tried PATCH /points_market_rate?id=eq.1 in v0.20.0–0.20.2 and
// it silently no-op'd inside PDA's gmRequest — writes appeared to
// succeed client-side but the seeded row never updated. Migration
// 033 added the INSERT policy that makes this upsert path legal.
async function pushPointsRateToSupabase(rate) {
const res = await gmRequest({
method: 'POST',
url: POINTS_RATE_URL,
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_ANON_KEY,
'Authorization': 'Bearer ' + SUPABASE_ANON_KEY,
'Prefer': 'resolution=merge-duplicates,return=minimal',
},
data: JSON.stringify({
id: 1,
rate: Math.round(rate),
updated_at: new Date().toISOString(),
}),
});
if (res && res.status != null && (res.status < 200 || res.status >= 300)) {
throw new Error('Supabase upsert non-2xx: ' + res.status +
(res.responseText ? ' body=' + res.responseText.slice(0, 200) : ''));
}
}
// -- Museum page (museum.php) -------------------------------------------
// Players visiting the museum want to know whether to grind for missing
// artifact pieces or just buy them from the market / a bazaar. This
// runner injects an expandable bar at the top of the page listing every
// Torn-classified artifact alongside its current Item Market floor and
// the cheapest fresh bazaar listing in the shared pool. Pure read
// surface — no scraping, no writes.
const MUSEUM_BAR_ID = 'valigia-museum-bar';
// Match the Lowest Price Found freshness window — anything older and
// the bazaar listing is likely sold or pulled.
const MUSEUM_BAZAAR_MAX_AGE_MS = 30 * 60 * 1000;
// Drop the locked / troll listings same as Lowest Price Found.
const MUSEUM_TOO_GOOD_THRESHOLD = 0.10;
// Cap displayed rows. Torn currently lists ~40 artifact items in the
// catalog; a bounded list keeps the DOM small even if more are added.
const MUSEUM_MAX_ROWS = 60;
function injectMuseumStyles() {
if (document.getElementById('valigia-museum-styles')) return;
const css = [
'#' + MUSEUM_BAR_ID + ' {',
' all: initial;',
' display: block;',
' margin: 8px auto 12px;',
' max-width: 1100px;',
' font-family: Arial, Helvetica, sans-serif;',
' color: #c8cdd8;',
' background: #161a22;',
' border: 1px solid #252a35;',
' border-left: 3px solid #e8c84a;',
' border-radius: 4px;',
' box-sizing: border-box;',
' overflow: hidden;',
'}',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-head {',
' display: flex;',
' align-items: center;',
' gap: 8px;',
' padding: 8px 12px;',
' cursor: pointer;',
' user-select: none;',
'}',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-title {',
' color: #e8c84a;',
' font-weight: 700;',
' font-size: 12px;',
' letter-spacing: 0.12em;',
' text-transform: uppercase;',
'}',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-count {',
' background: #e8c84a;',
' color: #0d0f14;',
' font-weight: 700;',
' font-size: 11px;',
' padding: 1px 7px;',
' border-radius: 999px;',
'}',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-caret {',
' margin-left: auto;',
' color: #e8c84a;',
' font-size: 11px;',
' transition: transform 150ms;',
'}',
'#' + MUSEUM_BAR_ID + '.vgl-mu-open .vgl-mu-caret {',
' transform: rotate(180deg);',
'}',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-body {',
' display: none;',
' padding: 4px 10px 10px;',
' gap: 4px;',
' flex-direction: column;',
'}',
'#' + MUSEUM_BAR_ID + '.vgl-mu-open .vgl-mu-body {',
' display: flex;',
'}',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-row {',
' display: grid;',
' grid-template-columns: minmax(0,1.4fr) minmax(0,1fr) minmax(0,1.2fr) minmax(0,1fr);',
' align-items: center;',
' gap: 8px;',
' padding: 6px 8px;',
' border: 1px solid #252a35;',
' border-radius: 3px;',
' background: rgba(232,200,74,0.04);',
' font-size: 12px;',
'}',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-item { font-weight: 700; color: #c8cdd8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-cell { display: flex; align-items: center; gap: 6px; min-width: 0; }',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-cell a { color: inherit; text-decoration: none; display: flex; align-items: center; gap: 6px; min-width: 0; }',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-cell a:active { opacity: 0.7; }',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-label {',
' font-size: 10px;',
' font-weight: 700;',
' letter-spacing: 0.06em;',
' text-transform: uppercase;',
' padding: 2px 6px;',
' border-radius: 2px;',
' white-space: nowrap;',
'}',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-label--market { background: rgba(232,200,74,0.18); color: #e8c84a; }',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-label--bazaar { background: rgba(74,232,160,0.18); color: #4ae8a0; }',
// Buy-under cell uses an orange/amber palette so it visually
// separates from the gold market price + green bazaar price — a
// third distinct semantic (a target threshold, not an observed
// price). Sits in the rightmost cell so the eye sweeps left-to-
// right: name → what it costs → what its going for → buy if under X.
'#' + MUSEUM_BAR_ID + ' .vgl-mu-label--buy { background: rgba(232,130,74,0.18); color: #e8a14a; }',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-price--buy { color: #e8a14a; font-weight: 700; white-space: nowrap; }',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-price { color: #e8c84a; font-weight: 700; white-space: nowrap; }',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-price--bazaar { color: #4ae8a0; }',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-empty { color: #5a6070; white-space: nowrap; font-style: italic; }',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-age { color: #8a8fa0; font-size: 10px; white-space: nowrap; }',
// Header rate caption: tucks under the title row, small grey text
// that explains where the BUY UNDER thresholds come from. Tinted
// amber when stale/missing so the user knows to visit Points Market.
'#' + MUSEUM_BAR_ID + ' .vgl-mu-rate { font-size: 11px; color: #8a8fa0; margin-left: 12px; }',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-rate strong { color: #e8c84a; font-weight: 700; }',
'#' + MUSEUM_BAR_ID + ' .vgl-mu-rate--missing { color: #e8a14a; }',
// Hit signal: when bazaar < buyUnder, highlight the row gold so a
// scanning eye lands on the actionable rows first.
'#' + MUSEUM_BAR_ID + ' .vgl-mu-row--hit { border-color: rgba(232,200,74,0.45); background: rgba(232,200,74,0.10); }',
// Narrow screens (phone): stack each rows cells vertically. The
// name keeps its prominence on its own line, and each price cell
// (Market / Bazaar / Buy Under) drops below it. Without this, the
// 4-column grid + long values like "$599M" pushed past the
// viewport on phones. Head row also wraps and the rate caption
// gets its own line so the title + count chip dont smush.
'@media (max-width: 700px) {',
' #' + MUSEUM_BAR_ID + ' .vgl-mu-row {',
' grid-template-columns: 1fr;',
' gap: 3px;',
' padding: 8px;',
' }',
' #' + MUSEUM_BAR_ID + ' .vgl-mu-item { padding-bottom: 2px; border-bottom: 1px solid #252a35; margin-bottom: 4px; }',
' #' + MUSEUM_BAR_ID + ' .vgl-mu-head { flex-wrap: wrap; }',
' #' + MUSEUM_BAR_ID + ' .vgl-mu-rate { margin-left: 0; flex-basis: 100%; }',
' #' + MUSEUM_BAR_ID + ' .vgl-mu-caret { margin-left: auto; }',
'}',
].join('\n');
const style = document.createElement('style');
style.id = 'valigia-museum-styles';
style.textContent = css;
document.head.appendChild(style);
}
/**
* Pull every Artifact-typed item from the warm catalog. Returns an
* array of { id, name } sorted by name for stable rendering before
* we know prices.
*/
function listArtifactItems() {
if (!itemMetaCache) return [];
const out = [];
itemMetaCache.forEach(function (meta, id) {
if (meta && meta.type === 'Artifact' && meta.name) {
out.push({ id: id, name: meta.name });
}
});
out.sort(function (a, b) { return a.name.localeCompare(b.name); });
return out;
}
/**
* Bulk-read sell_prices and bazaar_prices for the given ids in two
* PostgREST round-trips. Returns { market: Map<id, {price, min_price,
* updated_at}>, bazaar: Map<id, {price, quantity, owner_id, observed_at}> }
* — bazaar map only contains the cheapest fresh, scam-filtered
* listing per id.
*/
async function fetchMuseumPrices(ids) {
if (!ids || ids.length === 0) {
return { market: new Map(), bazaar: new Map() };
}
const idList = ids.join(',');
const sinceIso = new Date(Date.now() - MUSEUM_BAZAAR_MAX_AGE_MS).toISOString();
const [sellRows, bazaarRows] = await Promise.all([
fetchJSON(
SELL_PRICES_URL +
'?item_id=in.(' + idList + ')' +
'&select=item_id,price,min_price,updated_at'
),
fetchJSON(
BAZAAR_PRICES_URL +
'?item_id=in.(' + idList + ')' +
'&checked_at=gte.' + encodeURIComponent(sinceIso) +
'&price=gt.1' +
'&select=item_id,price,quantity,bazaar_owner_id,checked_at' +
'&order=price.asc'
),
]);
const market = new Map();
if (Array.isArray(sellRows)) {
for (const r of sellRows) {
if (!r || typeof r.item_id !== 'number') continue;
market.set(r.item_id, {
price: Number(r.price),
min_price: r.min_price != null ? Number(r.min_price) : null,
updated_at: r.updated_at ? new Date(r.updated_at).getTime() : 0,
});
}
}
const bazaar = new Map();
if (Array.isArray(bazaarRows)) {
for (const r of bazaarRows) {
if (!r || typeof r.item_id !== 'number') continue;
if (bazaar.has(r.item_id)) continue; // already have the cheapest
const price = Number(r.price);
if (!Number.isFinite(price) || price <= 1) continue;
// Filter scam listings against the same market floor the Lowest
// Price Found bar uses.
const m = market.get(r.item_id);
const floor = m && m.min_price != null ? m.min_price : (m ? m.price : null);
if (Number.isFinite(floor) && floor > 0 && price < floor * MUSEUM_TOO_GOOD_THRESHOLD) {
continue;
}
bazaar.set(r.item_id, {
price: price,
quantity: Number(r.quantity) || 1,
owner_id: r.bazaar_owner_id,
observed_at: r.checked_at ? new Date(r.checked_at).getTime() : 0,
});
}
}
return { market: market, bazaar: bazaar };
}
function buildMuseumBar(rows, pointsRate) {
const bar = document.createElement('div');
bar.id = MUSEUM_BAR_ID;
const head = document.createElement('div');
head.className = 'vgl-mu-head';
const title = document.createElement('span');
title.className = 'vgl-mu-title';
title.textContent = 'Artifact Prices';
const count = document.createElement('span');
count.className = 'vgl-mu-count';
count.textContent = String(rows.length);
// Inline rate caption next to the count. Tells the user where the
// BUY UNDER thresholds come from + nudges them to refresh by visiting
// Points Market when the rate is missing/stale.
const rateCaption = document.createElement('span');
if (Number.isFinite(pointsRate)) {
rateCaption.className = 'vgl-mu-rate';
rateCaption.appendChild(document.createTextNode('Points rate '));
const strong = document.createElement('strong');
strong.textContent = formatMoneyCompact(pointsRate) + '/pt';
rateCaption.appendChild(strong);
} else {
rateCaption.className = 'vgl-mu-rate vgl-mu-rate--missing';
rateCaption.textContent = 'Visit Points Market for buy thresholds';
}
const caret = document.createElement('span');
caret.className = 'vgl-mu-caret';
// Unicode escape, not the literal glyph: cPanel serves .user.js as
// Latin-1 so multi-byte UTF-8 mis-decodes in PDA's webview. Same
// workaround the other bars use.
caret.textContent = '\u25BE';
head.appendChild(title);
head.appendChild(count);
head.appendChild(rateCaption);
head.appendChild(caret);
const body = document.createElement('div');
body.className = 'vgl-mu-body';
for (const r of rows) {
const row = document.createElement('div');
// Highlight the row when the cheapest fresh bazaar listing is at
// or under the points-buy threshold — the actionable state.
const isHit = r.bazaar && Number.isFinite(r.buyUnder) && r.bazaar.price <= r.buyUnder;
row.className = 'vgl-mu-row' + (isHit ? ' vgl-mu-row--hit' : '');
const name = document.createElement('span');
name.className = 'vgl-mu-item';
name.textContent = r.name;
// Market cell: tappable, deep-links to the Item Market search.
const marketCell = document.createElement('span');
marketCell.className = 'vgl-mu-cell';
const marketLink = document.createElement('a');
marketLink.href =
'https://www.torn.com/page.php?sid=ItemMarket#/market/view=search&itemID=' + r.id;
marketLink.target = '_top';
marketLink.rel = 'noopener';
const marketLabel = document.createElement('span');
marketLabel.className = 'vgl-mu-label vgl-mu-label--market';
marketLabel.textContent = 'Market';
marketLink.appendChild(marketLabel);
if (r.market != null) {
const price = document.createElement('span');
price.className = 'vgl-mu-price';
// formatMoneyCompact ($600M) over formatMoney ($599,999,999) so
// the row fits on phone width without horizontal overflow.
price.textContent = formatMoneyCompact(r.market);
marketLink.appendChild(price);
} else {
const empty = document.createElement('span');
empty.className = 'vgl-mu-empty';
empty.textContent = 'no listings';
marketLink.appendChild(empty);
}
marketCell.appendChild(marketLink);
// Bazaar cell: tappable when we have a fresh deal, plain text otherwise.
const bazaarCell = document.createElement('span');
bazaarCell.className = 'vgl-mu-cell';
const bazaarLabel = document.createElement('span');
bazaarLabel.className = 'vgl-mu-label vgl-mu-label--bazaar';
bazaarLabel.textContent = 'Bazaar';
if (r.bazaar) {
const link = document.createElement('a');
link.href = 'https://www.torn.com/bazaar.php?userId=' + r.bazaar.owner_id;
link.target = '_top';
link.rel = 'noopener';
link.appendChild(bazaarLabel);
const price = document.createElement('span');
price.className = 'vgl-mu-price vgl-mu-price--bazaar';
price.textContent = formatMoneyCompact(r.bazaar.price);
link.appendChild(price);
const age = document.createElement('span');
age.className = 'vgl-mu-age';
age.textContent = formatAge(r.bazaar.observed_at);
link.appendChild(age);
bazaarCell.appendChild(link);
} else {
bazaarCell.appendChild(bazaarLabel);
const empty = document.createElement('span');
empty.className = 'vgl-mu-empty';
empty.textContent = 'none fresh';
bazaarCell.appendChild(empty);
}
// Buy-under cell: amber threshold price computed from the items
// share of its museum-set points × current Points Market rate ×
// (1 - POINTS_BUY_DISCOUNT). When we cant compute it (no rate
// cached, or item not in any set), show a dim placeholder so the
// column still aligns.
const buyCell = document.createElement('span');
buyCell.className = 'vgl-mu-cell';
const buyLabel = document.createElement('span');
buyLabel.className = 'vgl-mu-label vgl-mu-label--buy';
buyLabel.textContent = 'Buy Under';
buyCell.appendChild(buyLabel);
if (Number.isFinite(r.buyUnder)) {
const buyPrice = document.createElement('span');
buyPrice.className = 'vgl-mu-price--buy';
buyPrice.textContent = formatMoneyCompact(r.buyUnder);
buyCell.appendChild(buyPrice);
} else {
const empty = document.createElement('span');
empty.className = 'vgl-mu-empty';
empty.textContent = r.buyUnderReason || '\u2014';
buyCell.appendChild(empty);
}
row.appendChild(name);
row.appendChild(marketCell);
row.appendChild(bazaarCell);
row.appendChild(buyCell);
body.appendChild(row);
}
head.addEventListener('click', function () {
bar.classList.toggle('vgl-mu-open');
});
bar.appendChild(head);
bar.appendChild(body);
return bar;
}
/**
* Top-level entry. Idempotent: tears down any prior bar before
* fetching, no-ops silently when the catalog hasn't warmed yet or
* Torn currently lists no Artifact items.
*/
async function runMuseum() {
const existing = document.getElementById(MUSEUM_BAR_ID);
if (existing) existing.remove();
await ensureItemCatalog();
const items = listArtifactItems();
if (items.length === 0) {
log('museum: no artifacts in catalog');
return;
}
const ids = items.map(function (i) { return i.id; });
// Fetch museum prices and warm the Points Market rate from the
// shared Supabase pool in parallel — the latter falls through to
// localStorage cache when fresh, otherwise pulls the community
// rate so a player who's never visited pmarket.php still sees
// BUY UNDER thresholds.
const [{ market, bazaar }] = await Promise.all([
fetchMuseumPrices(ids),
ensurePointsRate(),
]);
// Flatten the {price, min_price, updated_at} shape into the simple
// Map<id, number> that computePointsForItem() expects. We feed it
// every artifact market price we already fetched — set members live
// in there too because listArtifactItems() returns every Artifact-
// typed catalog entry (Senet pawns, board, etc. are all Artifacts).
const marketByItem = new Map();
market.forEach(function (m, id) {
if (m && Number.isFinite(m.price)) marketByItem.set(id, m.price);
});
const pointsRate = getPointsRate();
// Compose rows: keep artifacts with any of (market price, fresh
// bazaar listing, computed buy-under threshold). Sort by market
// price desc — most valuable first — with bazaar-only rows tail-
// sorted by bazaar price desc.
const rows = [];
for (const it of items) {
const m = market.get(it.id);
const b = bazaar.get(it.id);
const marketPrice = m ? Number(m.price) : null;
// Buy-under threshold: per-item points value × current pmarket
// rate × (1 - POINTS_BUY_DISCOUNT). Falls through to null with a
// human-readable reason when we cant compute it, so the cell
// shows a meaningful placeholder instead of an empty box.
let buyUnder = null;
let buyUnderReason = null;
const set = setForItemId(it.id);
if (!set) {
buyUnderReason = 'no set';
} else if (!pointsRate) {
buyUnderReason = 'no rate';
} else {
const ptsForItem = computePointsForItem(it.id, set, marketByItem);
if (Number.isFinite(ptsForItem) && ptsForItem > 0) {
buyUnder = ptsForItem * pointsRate * (1 - POINTS_BUY_DISCOUNT);
} else {
buyUnderReason = 'set incomplete';
}
}
if (marketPrice == null && !b && buyUnder == null) continue;
rows.push({
id: it.id,
name: it.name,
market: Number.isFinite(marketPrice) ? marketPrice : null,
bazaar: b || null,
buyUnder: buyUnder,
buyUnderReason: buyUnderReason,
});
}
rows.sort(function (a, b) {
const av = a.market != null ? a.market : (a.bazaar ? a.bazaar.price : 0);
const bv = b.market != null ? b.market : (b.bazaar ? b.bazaar.price : 0);
return bv - av;
});
const trimmed = rows.slice(0, MUSEUM_MAX_ROWS);
if (trimmed.length === 0) return;
injectMuseumStyles();
const bar = buildMuseumBar(trimmed, pointsRate);
const host =
document.querySelector('#mainContainer .content-wrapper') ||
document.querySelector('.content-wrapper') ||
document.querySelector('#mainContainer') ||
document.body;
host.insertBefore(bar, host.firstChild);
}
// -- Points Market (pmarket.php) ----------------------------------------
// Captures the cheapest cash-per-point listing so the Bazaar Deals bar
// and Museum bar can flag listings priced under their museum-points-
// equivalent value.
//
// DOM-scrape only. We considered using market/?selections=pointsmarket
// for cleaner data but Torn returns "API error 16: Access level not
// high enough" on the standard PDA-injected key tier, and we cant
// upgrade the key from script. The DOM is right there on the page
// and sees the currently-rendered cheapest ~20 listings — plenty,
// since "cheapest" is exactly what we need.
//
// Caches result in localStorage AND the shared Supabase
// points_market_rate row via setPointsRate().
const POINTS_RATE_BAR_ID = 'valigia-points-rate-bar';
function injectPointsRateStyles() {
if (document.getElementById('valigia-points-rate-styles')) return;
const css = [
'#' + POINTS_RATE_BAR_ID + ' {',
' all: initial;',
' display: block;',
' margin: 8px auto 12px;',
' max-width: 1100px;',
' font-family: Arial, Helvetica, sans-serif;',
' color: #c8cdd8;',
' background: #161a22;',
' border: 1px solid #252a35;',
' border-left: 3px solid #e8c84a;',
' border-radius: 4px;',
' padding: 10px 12px;',
' font-size: 12px;',
' box-sizing: border-box;',
'}',
'#' + POINTS_RATE_BAR_ID + ' .vgl-pr-title {',
' color: #e8c84a;',
' font-weight: 700;',
' font-size: 11px;',
' letter-spacing: 0.12em;',
' text-transform: uppercase;',
' margin-right: 10px;',
'}',
'#' + POINTS_RATE_BAR_ID + ' .vgl-pr-rate { color: #e8c84a; font-weight: 700; }',
'#' + POINTS_RATE_BAR_ID + ' .vgl-pr-note { color: #8a8fa0; margin-left: 10px; }',
'#' + POINTS_RATE_BAR_ID + '.vgl-pr-error { border-left-color: #e8824a; }',
'#' + POINTS_RATE_BAR_ID + '.vgl-pr-error .vgl-pr-title { color: #e8824a; }',
].join('\n');
const style = document.createElement('style');
style.id = 'valigia-points-rate-styles';
style.textContent = css;
document.head.appendChild(style);
}
function showPointsRateBanner(rate, isError, diagnostic) {
injectPointsRateStyles();
const existing = document.getElementById(POINTS_RATE_BAR_ID);
if (existing) existing.remove();
const bar = document.createElement('div');
bar.id = POINTS_RATE_BAR_ID;
if (isError) bar.classList.add('vgl-pr-error');
const title = document.createElement('span');
title.className = 'vgl-pr-title';
title.textContent = isError ? 'Points Rate Capture Failed' : 'Points Rate Captured';
bar.appendChild(title);
if (!isError && Number.isFinite(rate)) {
const value = document.createElement('span');
value.className = 'vgl-pr-rate';
value.textContent = formatMoneyCompact(rate) + '/pt';
bar.appendChild(value);
const note = document.createElement('span');
note.className = 'vgl-pr-note';
// Use the runtime diagnostic when provided so the caller can
// surface useful context like "shared with community pool" vs
// "local only — pool write failed". Falls back to a generic
// hint when no diagnostic is passed. Unicode escape on the
// middle-dot separator: cPanel serves .user.js as Latin-1, so a
// literal "·" would mis-decode to "·" in PDA's webview
// (the v0.20.3 success banner showed exactly that bug).
note.textContent = '\u00B7 ' + (diagnostic || 'used for bazaar points-buy signals (24h)');
bar.appendChild(note);
} else {
const note = document.createElement('span');
note.className = 'vgl-pr-note';
// Diagnostic tells the user (and us, remotely) what came back so
// we dont have to guess. Especially useful on iPad with no
// DevTools — the only debug surface is the page itself.
note.textContent = diagnostic || 'try refreshing the page';
bar.appendChild(note);
}
const host =
document.querySelector('#mainContainer .content-wrapper') ||
document.querySelector('.content-wrapper') ||
document.querySelector('#mainContainer') ||
document.body;
host.insertBefore(bar, host.firstChild);
}
// Scrape leaf elements whose entire textContent is a bare "$X" amount
// in the per-point sanity range — the per-listing price labels in
// pmarket.php's row layout. Both v1-table and modern div layouts emit
// that pattern. Real Torn Points Market rates have ranged $20-80k
// over time; the 5k-200k window allows surge spikes while excluding
// sub-5k mis-parses and total-cost labels in the millions.
function scrapePointsMarketDOM() {
const all = document.querySelectorAll('*');
const allMatches = [];
const sample = [];
for (const el of all) {
if (el.children && el.children.length > 0) continue;
const text = (el.textContent || '').trim();
if (!text) continue;
const m = text.match(/^\$\s*([\d,]+(?:\.\d+)?)\s*$/);
if (!m) continue;
const v = parseMoney(m[1]);
if (!Number.isFinite(v)) continue;
allMatches.push(v);
if (sample.length < 8) sample.push(m[0]);
}
const valid = allMatches.filter(function (v) { return v >= 5000 && v <= 200000; });
if (valid.length === 0) {
return {
rate: null,
error: 'DOM scrape: ' + allMatches.length + ' $ leaves found, ' +
'none in 5k-200k range. samples=' + (sample.join(',') || '(empty)'),
};
}
return { rate: Math.min.apply(null, valid), error: null };
}
async function runPointsMarket() {
// Hydration-poll for up to 8s like the other runners — Torn's SPA
// may not have rendered the listings yet on the first dispatch
// tick, especially on slower connections.
const start = Date.now();
let result = { rate: null, error: 'not yet attempted' };
while (Date.now() - start < 8000) {
result = scrapePointsMarketDOM();
if (result.rate != null) break;
await new Promise(function (r) { setTimeout(r, 500); });
}
if (result.rate != null) {
const shared = await setPointsRate(result.rate);
log('pmarket: captured rate=' + result.rate + ' shared=' + shared);
showPointsRateBanner(result.rate, false,
shared
? 'shared with community pool \u00B7 24h cache'
: 'local only \u2014 community pool write failed (see logs)');
return;
}
log('pmarket: scrape failed \u2014 ' + result.error);
showPointsRateBanner(null, true, result.error);
}
// -- Drip-scrape -----------------------------------------------------------
// Background bazaar-pool maintenance. On every dispatch (except the
// bazaar runner, which already writes heavily to bazaar_prices via DOM
// scraping), fire one v2 `market/{id}/bazaar` discovery call against
// a stale-but-valuable item picked from the shared pool. Throttle gate
// (per-user, localStorage) caps the spend at one Torn API call per
// DRIP_MIN_INTERVAL_MS. Distributed across the userbase this keeps
// the pool fresh without any single player burning meaningful API
// budget — and no third-party data dependency.
//
// Candidate selection: top-N items from sell_prices by market value,
// cross-referenced against the freshest bazaar_prices entry for each.
// Items whose freshest bazaar entry is younger than
// DRIP_BAZAAR_FRESH_WINDOW_MS get filtered out — no point re-checking
// an item the pool already knows about. The remaining list is cached
// in localStorage for DRIP_CANDIDATE_TTL_MS so most page visits
// skip the two PostgREST reads entirely.
const DRIP_GATE_KEY = 'valigia_drip_last_at';
const DRIP_CANDIDATE_CACHE_KEY = 'valigia_drip_candidates';
// Min interval between drips for a single user. With ~6 page navs per
// active minute, this caps drip spend at ~1 Torn call per minute per
// user — under 1% of the 100/min key budget.
const DRIP_MIN_INTERVAL_MS = 60 * 1000;
// Candidate list refresh cadence. The list itself is cheap to derive
// (two PostgREST reads), but doing it on every drip would double the
// API spend per user with no real benefit.
const DRIP_CANDIDATE_TTL_MS = 10 * 60 * 1000;
// Pull this many top-value items from sell_prices as the drip pool.
const DRIP_CANDIDATE_POOL_SIZE = 30;
// Don't drip cheap items — a $500 plushie's bazaar coverage doesn't
// matter, and we'd rather spend the budget on the long tail of
// high-value goods.
const DRIP_VALUE_FLOOR = 10_000;
// Skip items whose freshest bazaar entry is younger than this. Web
// app's "Best Run" eligibility window is 10 min, so 30 min gives
// plenty of buffer to avoid double-checking items the pool already
// tracks well.
const DRIP_BAZAAR_FRESH_WINDOW_MS = 30 * 60 * 1000;
let dripInFlight = false;
function dripGateLastAt() {
try { return Number(localStorage.getItem(DRIP_GATE_KEY)) || 0; }
catch (_) { return 0; }
}
function dripGateMark() {
try { localStorage.setItem(DRIP_GATE_KEY, String(Date.now())); }
catch (_) { /* ignore quota / disabled storage */ }
}
/**
* Build (or retrieve from cache) the list of items eligible for the
* next drip. Each item is { item_id, price, age_ms } where age_ms is
* how long since the freshest known bazaar entry for that item (or
* Number.MAX_SAFE_INTEGER if no entries exist at all — those are the
* highest-priority drip targets).
*/
async function loadDripCandidates() {
try {
const raw = localStorage.getItem(DRIP_CANDIDATE_CACHE_KEY);
if (raw) {
const cached = JSON.parse(raw);
if (cached && cached.fetchedAt &&
Date.now() - cached.fetchedAt < DRIP_CANDIDATE_TTL_MS &&
Array.isArray(cached.items)) {
return cached.items;
}
}
} catch (_) { /* corrupt cache - fall through to refetch */ }
const sellRows = await fetchJSON(
SELL_PRICES_URL +
'?price=gte.' + DRIP_VALUE_FLOOR +
'&select=item_id,price' +
'&order=price.desc' +
'&limit=' + DRIP_CANDIDATE_POOL_SIZE
);
if (!Array.isArray(sellRows) || sellRows.length === 0) return [];
const ids = sellRows.map(function (r) { return r.item_id; });
const bazRows = await fetchJSON(
BAZAAR_PRICES_URL +
'?item_id=in.(' + ids.join(',') + ')' +
'&select=item_id,checked_at' +
'&order=checked_at.desc' +
'&limit=500'
);
const freshestByItem = new Map();
if (Array.isArray(bazRows)) {
for (const r of bazRows) {
const t = r.checked_at ? new Date(r.checked_at).getTime() : 0;
const prev = freshestByItem.get(r.item_id) || 0;
if (t > prev) freshestByItem.set(r.item_id, t);
}
}
const now = Date.now();
const candidates = sellRows
.map(function (r) {
const fresh = freshestByItem.get(r.item_id) || 0;
const ageMs = fresh === 0 ? Number.MAX_SAFE_INTEGER : now - fresh;
return { item_id: r.item_id, price: Number(r.price), age_ms: ageMs };
})
.filter(function (c) { return c.age_ms >= DRIP_BAZAAR_FRESH_WINDOW_MS; });
try {
localStorage.setItem(DRIP_CANDIDATE_CACHE_KEY, JSON.stringify({
fetchedAt: now,
items: candidates,
}));
} catch (_) { /* ignore quota / disabled storage */ }
return candidates;
}
/**
* Score candidates by `price × log(age_hours + 1) × jitter`. The log
* softens staleness so a $1M item that's 1 h old still beats a $50k
* item that's 24 h old. Take the top 5 and pick one at random — that
* gives concentrated effort on the most valuable stale items while
* still spreading load when many users converge on the same shortlist.
*/
function pickDripCandidate(candidates) {
if (!Array.isArray(candidates) || candidates.length === 0) return null;
const scored = candidates.map(function (c) {
const ageHours = Math.max(c.age_ms / 3_600_000, 0.1);
const jitter = 0.8 + Math.random() * 0.4;
return Object.assign({}, c, { score: c.price * Math.log(ageHours + 1) * jitter });
});
scored.sort(function (a, b) { return b.score - a.score; });
const top = scored.slice(0, Math.min(5, scored.length));
return top[Math.floor(Math.random() * top.length)];
}
/**
* Parse Torn v2 `market/{id}/bazaar` response. The shape varies:
* sometimes `bazaar` is a flat array of listings, sometimes an object
* keyed by category whose values are arrays. Handle both. Drop $1 and
* sub-$1 entries — they're locked-listing placeholders that would
* pollute the pool.
*/
function parseV2BazaarResponse(data) {
if (!data || !data.bazaar) return [];
const out = [];
function handle(e) {
const id = Number(e && (e.ID != null ? e.ID : e.id));
const price = Number(e && e.price);
const qtyRaw = e && (e.quantity != null ? e.quantity : 1);
const quantity = Number(qtyRaw);
if (!Number.isInteger(id) || id <= 0) return;
if (!Number.isFinite(price) || price <= 1) return;
out.push({
owner_id: id,
price: price,
quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1,
});
}
if (Array.isArray(data.bazaar)) {
for (const e of data.bazaar) handle(e);
} else if (typeof data.bazaar === 'object') {
for (const cat of Object.values(data.bazaar)) {
if (Array.isArray(cat)) for (const e of cat) handle(e);
}
}
return out;
}
/**
* Top-level drip entry. Idempotent and silent — the gate + in-flight
* flag mean rapid repeated calls collapse to one. Always returns
* before any heavy work if the user isn't inside PDA, the gate
* hasn't elapsed, or there's nothing worth dripping. Errors swallowed
* so the dispatcher's main flow is never disrupted.
*/
async function dripScrapeBazaarPool() {
if (dripInFlight) return;
if (!TORN_API_KEY || TORN_API_KEY.indexOf('PDA-APIKEY') !== -1) return;
if (Date.now() - dripGateLastAt() < DRIP_MIN_INTERVAL_MS) return;
dripInFlight = true;
// Mark the gate immediately so a slow drip + rapid nav can't fire
// a second one in flight before this one writes the timestamp.
dripGateMark();
try {
const candidates = await loadDripCandidates();
const pick = pickDripCandidate(candidates);
if (!pick) return;
const res = await gmRequest({
method: 'GET',
url: 'https://api.torn.com/v2/market/' + encodeURIComponent(pick.item_id) +
'/bazaar?key=' + encodeURIComponent(TORN_API_KEY),
headers: { 'Accept': 'application/json' },
});
let data = null;
try { data = JSON.parse(res.responseText); } catch (_) { return; }
if (data && data.error) {
log('drip: torn error', data.error);
return;
}
const listings = parseV2BazaarResponse(data);
if (listings.length === 0) {
log('drip: no listings for item ' + pick.item_id);
return;
}
const rows = listings.map(function (l) {
return {
item_id: pick.item_id,
bazaar_owner_id: l.owner_id,
price: l.price,
quantity: l.quantity,
miss_count: 0,
};
});
const result = await postIngestRows(INGEST_BAZAAR_URL, rows);
if (result.ok) {
log('drip: stored ' + result.count + ' listings for item ' + pick.item_id);
} else {
log('drip: ingest failed', result.error);
}
} catch (err) {
log('drip: unexpected error', err);
} finally {
dripInFlight = false;
}
}
// -- Dispatch ------------------------------------------------------------
// Route the current page to the right runner. The PDA-APIKEY placeholder
// check stays as the single gate: only run inside Torn PDA. Outside PDA
// the script goes fully quiet rather than writing to the community pool
// from an environment we didn't design around.
function detectPage() {
const url = location.href;
if (/\/page\.php\?.*sid=travel\b/i.test(url)) return 'travel';
if (/\/page\.php\?.*sid=ItemMarket\b/i.test(url)) return 'itemmarket';
if (/\/bazaar\.php/i.test(url)) return 'bazaar';
if (/\/item\.php/i.test(url)) return 'itempage';
if (/\/museum\.php/i.test(url)) return 'museum';
if (/\/pmarket\.php/i.test(url)) return 'pmarket';
return null;
}
async function dispatch() {
if (!TORN_API_KEY || TORN_API_KEY.indexOf('PDA-APIKEY') !== -1) {
log('Not running inside PDA - aborting.');
return;
}
const page = detectPage();
// Stakeout badge + auto-rescrape interval are travel-page-only. Tear
// them down on every dispatch and let runTravel() re-mount if
// applicable. Skips the teardown during a stakeout-driven re-run
// (page is still 'travel'), which would otherwise stop its own loop.
if (page !== 'travel') tearDownStakeout();
// Background drip-scrape — fire and forget, in parallel with the
// page runner. Skipped on bazaar pages where the runner already
// writes heavily to bazaar_prices and would just race the drip
// against the per-endpoint rate limiter. Per-user throttle gate
// (60s) inside dripScrapeBazaarPool() makes this safe to call on
// every dispatch.
if (page && page !== 'bazaar') {
dripScrapeBazaarPool().catch(function (e) { log('drip error', e); });
}
switch (page) {
case 'travel': return runTravel();
case 'itemmarket': return runItemMarket();
case 'bazaar': return runBazaar();
case 'itempage': return runItemPage();
case 'museum': return runMuseum();
case 'pmarket': return runPointsMarket();
default:
log('Unmatched page - skipping. url=' + location.href);
}
}
// Dispatch scheduler. Two entry points fire it: the initial DOM-ready
// landing, and any SPA hash change (the Item Market's #/market/view=... URL
// shape is hash-routed, so clicking between items never triggers
// DOMContentLoaded again). The `lastDispatchedUrl` guard skips redundant
// dispatches when the full URL hasn't actually changed, and a small
// debounce collapses bursts of rapid nav events into one run.
let lastDispatchedUrl = null;
let dispatchTimer = null;
function scheduleDispatch(reason) {
if (dispatchTimer) clearTimeout(dispatchTimer);
dispatchTimer = setTimeout(async function () {
dispatchTimer = null;
if (location.href === lastDispatchedUrl) {
log('dispatch skipped (same url) reason=' + reason);
return;
}
lastDispatchedUrl = location.href;
log('dispatch reason=' + reason);
try { await dispatch(); } catch (e) { log('dispatch error:', e); }
}, 400);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded',
function () { scheduleDispatch('initial'); }, { once: true });
} else {
scheduleDispatch('initial');
}
window.addEventListener('hashchange',
function () { scheduleDispatch('hashchange'); });
})();