Track purchases, sales, trades, museum sets & profits for Torn City. Syncs with Google Sheets.
// ==UserScript==
// @name Torn Trading Assistant
// @namespace https://oatshead.dev/torn-trading-assistant
// @version 1.3.5
// @description Track purchases, sales, trades, museum sets & profits for Torn City. Syncs with Google Sheets.
// @author Oatshead
// @match https://www.torn.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @connect trading-assistant.oatshead.workers.dev
// @connect api.torn.com
// @connect script.google.com
// @connect script.googleusercontent.com
// @icon https://www.torn.com/images/items/186/large.png
// @run-at document-idle
// ==/UserScript==
/*
╔══════════════════════════════════════════════════════════════════╗
║ TORN TRADING ASSISTANT — ToS ║
╠══════════════════════════════════════════════════════════════════╣
║ Data Storage │ Only locally (GM storage in browser/app) ║
║ Data Sharing │ Only to YOUR Google Sheet (via YOUR web app) ║
║ Purpose of Use │ Personal profit/loss tracking & museum sets ║
║ Key Storage │ Stored locally / Not shared ║
║ Key Access Level │ Limited Access (user → log, money, travel) ║
╠══════════════════════════════════════════════════════════════════╣
║ This script ONLY uses the Torn API and monitors pages you are ║
║ currently viewing. It makes NO automated non-API requests to ║
║ Torn. All data is stored locally in your browser/app. ║
║ API calls are triggered by YOU navigating pages. ║
╚══════════════════════════════════════════════════════════════════╝
*/
(function () {
"use strict";
// ========================================================================
// CONFIGURATION & CONSTANTS
// ========================================================================
const WORKER_URL = "https://trading-assistant.oatshead.workers.dev";
const SHARED_SECRET = "k7Hs9Qp2mX4wR8vL1nB6jT3yC0fA5eD";
const VERSION = "1.0.0";
// Log type IDs we care about
const LOG_TYPES = {
ITEM_MARKET_BUY: 1112,
ITEM_MARKET_SELL: 1113,
BAZAAR_BUY: 1225,
BAZAAR_SELL: 1226,
ABROAD_BUY: 4201,
TRADE_ITEMS_OUT: 4445,
TRADE_ITEMS_IN: 4446,
TRADE_COMPLETED: 4430,
TRADE_ACCEPTED: 4431,
TRADE_MONEY_OUT: 4440,
TRADE_MONEY_IN: 4441,
MUSEUM_EXCHANGE: 7000,
POINTS_BUY: 5010,
POINTS_SELL: 5011,
SHOP_BUY: 4200,
CITY_ITEM_FIND: 7011,
};
// All known plushies and flowers
const MUSEUM_ITEM_IDS = new Set([
186, 187, 215, 258, 618, 261, 266, 268, 269, 273, 274, 384, 281, 260, 617,
263, 264, 267, 271, 272, 277, 276, 385, 282,
]);
// Complete item name→id map (loaded from storage or built from API)
let ITEM_MAP = {};
// ========================================================================
// STORAGE HELPERS
// ========================================================================
function getSetting(key, defaultVal) {
const v = GM_getValue("tta_" + key);
return v !== undefined ? v : defaultVal;
}
function setSetting(key, val) {
GM_setValue("tta_" + key, val);
}
function getConfig() {
return {
licenseKey: getSetting("licenseKey", ""),
licenseValid: getSetting("licenseValid", false),
tornApiKey: getSetting("tornApiKey", ""),
sheetWebAppUrl: getSetting("sheetWebAppUrl", ""),
sheetSecret: getSetting("sheetSecret", ""),
whitelist: JSON.parse(getSetting("whitelist", "[]")),
lastLogCheck: getSetting("lastLogCheck", 0),
processedLogs: JSON.parse(getSetting("processedLogs", "{}")),
panelX: getSetting("panelX", 10),
panelY: getSetting("panelY", 10),
panelMinimized: getSetting("panelMinimized", true),
enabled: getSetting("enabled", true),
pollInterval: getSetting("pollInterval", 30), // seconds
};
}
function saveConfig(cfg) {
setSetting("licenseKey", cfg.licenseKey);
setSetting("licenseValid", cfg.licenseValid);
setSetting("tornApiKey", cfg.tornApiKey);
setSetting("sheetWebAppUrl", cfg.sheetWebAppUrl);
setSetting("sheetSecret", cfg.sheetSecret);
setSetting("whitelist", JSON.stringify(cfg.whitelist));
setSetting("lastLogCheck", cfg.lastLogCheck);
setSetting("processedLogs", JSON.stringify(cfg.processedLogs));
setSetting("panelX", cfg.panelX);
setSetting("panelY", cfg.panelY);
setSetting("panelMinimized", cfg.panelMinimized);
setSetting("enabled", cfg.enabled);
setSetting("pollInterval", cfg.pollInterval);
}
function markLogProcessed(logId) {
const cfg = getConfig();
cfg.processedLogs[logId] = Date.now();
// Keep only last 5000 entries to prevent storage bloat
const keys = Object.keys(cfg.processedLogs);
if (keys.length > 5000) {
const sorted = keys.sort(
(a, b) => cfg.processedLogs[a] - cfg.processedLogs[b],
);
for (let i = 0; i < keys.length - 5000; i++) {
delete cfg.processedLogs[sorted[i]];
}
}
setSetting("processedLogs", JSON.stringify(cfg.processedLogs));
}
function isLogProcessed(logId) {
const cfg = getConfig();
return !!cfg.processedLogs[logId];
}
// ========================================================================
// GM_xmlhttpRequest WRAPPER
// ========================================================================
function fetchGM(url, opts = {}) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: opts.method || "GET",
url: url,
headers: opts.headers || { "Content-Type": "application/json" },
data: opts.body || undefined,
timeout: opts.timeout || 15000,
onload: function (resp) {
try {
resolve({
status: resp.status,
data: JSON.parse(resp.responseText),
});
} catch (e) {
resolve({ status: resp.status, data: resp.responseText });
}
},
onerror: function (err) {
reject(err);
},
ontimeout: function () {
reject(new Error("Request timed out"));
},
});
});
}
// ========================================================================
// DEBUG — Run from Status tab to diagnose sync issues
// ========================================================================
async function debugLogFetch() {
const cfg = getConfig();
if (!cfg.tornApiKey) {
alert("No API key set!");
return;
}
const results = [];
// ── Test 1: Raw log fetch (no filtering) ──
results.push("=== TEST 1: Raw log (v1, no filter) ===");
try {
const url1 = `https://api.torn.com/user/?key=${cfg.tornApiKey}&selections=log`;
const resp1 = await fetchGM(url1);
if (resp1.data && resp1.data.log) {
const entries = Object.entries(resp1.data.log);
results.push(`Returned ${entries.length} log entries`);
// Show all log types found
const typeMap = {};
entries.forEach(([id, entry]) => {
const t = entry.log;
if (!typeMap[t])
typeMap[t] = { title: entry.title, count: 0, sample: null };
typeMap[t].count++;
if (!typeMap[t].sample)
typeMap[t].sample = { id, data: entry.data, params: entry.params };
});
for (const [type, info] of Object.entries(typeMap)) {
results.push(` Type ${type} (${info.title}): ${info.count}x`);
results.push(` Sample data: ${JSON.stringify(info.sample.data)}`);
}
// Check for item market buy specifically
const marketBuys = entries.filter(([id, e]) => e.log === 1112);
results.push(`\nItem Market Buy (1112) entries: ${marketBuys.length}`);
if (marketBuys.length > 0) {
results.push(` Full entry: ${JSON.stringify(marketBuys[0][1])}`);
}
// Check for ALL purchase/sale related types
const relevantTypes = [
1112, 1113, 1110, 1111, 1225, 1226, 4200, 4201, 4445, 4446, 4430,
7000, 5010, 5011,
];
const relevant = entries.filter(([id, e]) =>
relevantTypes.includes(e.log),
);
results.push(`\nAll relevant entries: ${relevant.length}`);
relevant.forEach(([id, entry]) => {
results.push(
` [${id}] Type ${entry.log} (${entry.title}): ${JSON.stringify(entry.data)}`,
);
});
} else if (resp1.data && resp1.data.error) {
results.push(`API Error: ${JSON.stringify(resp1.data.error)}`);
} else {
results.push(
`Unexpected response: ${JSON.stringify(resp1.data).substring(0, 500)}`,
);
}
} catch (e) {
results.push(`Fetch error: ${e.message}`);
}
// ── Test 2: Try v2 log endpoint ──
results.push("\n=== TEST 2: v2 log endpoint ===");
try {
const url2 = `https://api.torn.com/v2/user/log?key=${cfg.tornApiKey}`;
const resp2 = await fetchGM(url2);
if (resp2.data) {
results.push(`v2 response keys: ${Object.keys(resp2.data).join(", ")}`);
results.push(
`v2 preview: ${JSON.stringify(resp2.data).substring(0, 1000)}`,
);
}
} catch (e) {
results.push(`v2 error: ${e.message}`);
}
// ── Test 3: Try v2 log with category filter ──
results.push("\n=== TEST 3: v2 log with cat filter ===");
try {
const url3 = `https://api.torn.com/v2/user/log?key=${cfg.tornApiKey}&cat=trades`;
const resp3 = await fetchGM(url3);
if (resp3.data) {
results.push(
`v2 trades: ${JSON.stringify(resp3.data).substring(0, 1000)}`,
);
}
} catch (e) {
results.push(`v2 trades error: ${e.message}`);
}
// ── Test 4: Check whitelist status ──
results.push("\n=== TEST 4: Whitelist check ===");
results.push(`Whitelist IDs: ${JSON.stringify(cfg.whitelist)}`);
results.push(
`Sheep Plushie (186) whitelisted: ${cfg.whitelist.includes(186)}`,
);
results.push(
`Sheep Plushie (186) in museum set: ${MUSEUM_ITEM_IDS.has(186)}`,
);
// ── Test 5: Check processed logs ──
results.push("\n=== TEST 5: Processed log IDs ===");
const processedCount = Object.keys(cfg.processedLogs).length;
results.push(`${processedCount} log IDs already processed`);
// ── Test 6: Check sheet connection ──
results.push("\n=== TEST 6: Sheet connection ===");
results.push(`Sheet URL set: ${!!cfg.sheetWebAppUrl}`);
results.push(`Sheet secret set: ${!!cfg.sheetSecret}`);
// ── Show results ──
showDebugDialog(results.join("\n"));
console.log("[TTA DEBUG]\n" + results.join("\n"));
}
function showDebugDialog(text) {
const existing = document.getElementById("tta-debug-dialog");
if (existing) existing.remove();
const dialog = document.createElement("div");
dialog.id = "tta-debug-dialog";
dialog.style.cssText = `
position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
z-index:9999999;background:#1a1a2e;border:2px solid #ff9800;
border-radius:12px;padding:20px;color:#e0e0e0;
max-height:80vh;overflow-y:auto;width:600px;
box-shadow:0 8px 32px rgba(0,0,0,0.6);font-family:monospace;font-size:11px;
`;
dialog.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<h3 style="margin:0;color:#ff9800;">🔍 Debug Log</h3>
<div>
<button class="tta-btn tta-btn-primary tta-btn-sm" id="tta-debug-copy">📋 Copy</button>
<button class="tta-btn tta-btn-danger tta-btn-sm" id="tta-debug-close">✕ Close</button>
</div>
</div>
<pre style="white-space:pre-wrap;word-break:break-all;background:#0f0f1a;
padding:12px;border-radius:6px;max-height:60vh;overflow-y:auto;
border:1px solid #333;">${text}</pre>
`;
document.body.appendChild(dialog);
document
.getElementById("tta-debug-close")
.addEventListener("click", () => dialog.remove());
document.getElementById("tta-debug-copy").addEventListener("click", () => {
navigator.clipboard.writeText(text).then(() => {
document.getElementById("tta-debug-copy").textContent = "✅ Copied!";
});
});
}
// ========================================================================
// LICENSE VERIFICATION
// ========================================================================
async function verifyLicense(key) {
try {
const resp = await fetchGM(WORKER_URL + "/api/verify", {
method: "POST",
body: JSON.stringify({ licenseKey: key, sharedSecret: SHARED_SECRET }),
});
return resp.data && resp.data.valid === true;
} catch (e) {
console.error("[TTA] License verification failed:", e);
return false;
}
}
// ========================================================================
// TORN API CALLS (all compliant — user-triggered, uses API only)
// ========================================================================
async function fetchTornApi(endpoint, params = "") {
const cfg = getConfig();
if (!cfg.tornApiKey) return null;
const url = `https://api.torn.com/${endpoint}?key=${cfg.tornApiKey}${params}`;
try {
const resp = await fetchGM(url);
if (resp.data && resp.data.error) {
console.warn("[TTA] Torn API error:", resp.data.error);
return null;
}
return resp.data;
} catch (e) {
console.error("[TTA] Torn API fetch failed:", e);
return null;
}
}
// ========================================================================
// TORN API CALLS (Split to handle 10-ID limit)
// ========================================================================
async function fetchUserLog() {
// We have 12 types, but API only allows 10 per call.
// Split into two batches.
const batch1 = [
LOG_TYPES.ITEM_MARKET_BUY,
LOG_TYPES.ITEM_MARKET_SELL,
LOG_TYPES.BAZAAR_BUY,
LOG_TYPES.BAZAAR_SELL,
LOG_TYPES.ABROAD_BUY,
LOG_TYPES.SHOP_BUY,
LOG_TYPES.TRADE_ITEMS_OUT,
LOG_TYPES.TRADE_ITEMS_IN,
LOG_TYPES.TRADE_COMPLETED,
LOG_TYPES.TRADE_ACCEPTED, // Added just in case
];
const batch2 = [
LOG_TYPES.MUSEUM_EXCHANGE,
LOG_TYPES.POINTS_BUY,
LOG_TYPES.POINTS_SELL,
// Add any future types here
];
try {
// Run both requests in parallel
const [resp1, resp2] = await Promise.all([
fetchTornApi("user/", "&selections=log&log=" + batch1.join(",")),
fetchTornApi("user/", "&selections=log&log=" + batch2.join(",")),
]);
// Combine the 'log' objects from both responses
const combinedLogs = {};
if (resp1 && resp1.log) {
Object.assign(combinedLogs, resp1.log);
}
if (resp2 && resp2.log) {
Object.assign(combinedLogs, resp2.log);
}
// Return strictly the combined structure expected by pollLogs
return { log: combinedLogs };
} catch (e) {
console.error("[TTA] Error fetching batched logs:", e);
return null;
}
}
// ========================================================================
// ITEM MAP BUILDER
// ========================================================================
async function buildItemMap() {
const cached = getSetting("itemMap", null);
const cacheTime = getSetting("itemMapTime", 0);
// Cache for 24 hours
if (cached && Date.now() - cacheTime < 86400000) {
ITEM_MAP = JSON.parse(cached);
return;
}
const data = await fetchTornApi("torn/", "&selections=items");
if (!data || !data.items) return;
ITEM_MAP = {};
for (const [id, item] of Object.entries(data.items)) {
ITEM_MAP[id] = {
name: item.name,
type: item.type,
tradeable: item.tradeable,
};
}
setSetting("itemMap", JSON.stringify(ITEM_MAP));
setSetting("itemMapTime", Date.now());
}
function getItemName(id) {
return ITEM_MAP[id] ? ITEM_MAP[id].name : "Unknown Item #" + id;
}
function getItemType(id) {
return ITEM_MAP[id] ? ITEM_MAP[id].type : "Unknown";
}
function reverseItemLookup(nameStr) {
if (!nameStr) return 0;
const lower = nameStr.toLowerCase().trim();
for (const [id, info] of Object.entries(ITEM_MAP)) {
if (info.name && info.name.toLowerCase() === lower) {
return Number(id);
}
}
return 0;
}
// ========================================================================
// LOG PROCESSOR — Convert API logs to transactions
// ========================================================================
function processLogEntry(logId, entry, whitelist) {
const whitelistSet = new Set(whitelist.map((id) => Number(id)));
const logType = entry.log;
const data = entry.data || {};
const ts = entry.timestamp;
// ── Extract item ID from various API data formats ──
// Item Market & Bazaar use: data.items = [{ id, uid, qty }]
// Shop Buy & Abroad Buy use: data.item = 269, data.quantity = 29
// Trades use: data.item or data.items
let itemId = 0;
let qty = 1;
if (data.items && Array.isArray(data.items) && data.items.length > 0) {
// Item Market Buy/Sell, Bazaar Buy/Sell format
itemId = data.items[0].id || 0;
qty = data.items[0].qty || data.quantity || data.amount || 1;
} else {
// Shop Buy, Abroad Buy, Trade format
let rawItem =
data.item || data.itemid || data.item_id || data.itemID || 0;
if (typeof rawItem === "number" && rawItem > 0) {
itemId = rawItem;
} else if (typeof rawItem === "string") {
const parsed = Number(rawItem);
if (!isNaN(parsed) && parsed > 0) {
itemId = parsed;
} else {
itemId = reverseItemLookup(rawItem);
}
}
qty = data.quantity || data.amount || 1;
}
const costEach = data.cost_each || data.cost || data.price || 0;
const costTotal = data.cost_total || data.total || costEach * qty;
// Skip if not whitelisted (unless it's a museum/points/trade event)
const isMuseumItem = MUSEUM_ITEM_IDS.has(Number(itemId));
const isWhitelisted = whitelistSet.has(Number(itemId));
const isPointsOrMuseum = [
LOG_TYPES.MUSEUM_EXCHANGE,
LOG_TYPES.POINTS_BUY,
LOG_TYPES.POINTS_SELL,
].includes(logType);
const isTrade = [
LOG_TYPES.TRADE_COMPLETED,
LOG_TYPES.TRADE_ACCEPTED,
LOG_TYPES.TRADE_ITEMS_IN,
LOG_TYPES.TRADE_ITEMS_OUT,
].includes(logType);
if (!isWhitelisted && !isMuseumItem && !isPointsOrMuseum && !isTrade) {
console.log(
`[TTA] Skipped log ${logId} type=${logType}: itemId=${itemId}, ` +
`whitelisted=${isWhitelisted}, museumItem=${isMuseumItem}, ` +
`pointsOrMuseum=${isPointsOrMuseum}, trade=${isTrade}`,
);
return null;
}
const itemName = getItemName(itemId);
const itemType = getItemType(itemId);
let transaction = null;
switch (logType) {
// ── Purchases ──
case LOG_TYPES.ITEM_MARKET_BUY:
if (!isWhitelisted && !isMuseumItem) return null;
// Handle multi-item market buys (each items[] entry)
// For now we handle the first item; multi-item support can be added later
transaction = {
logId,
type: "purchase",
timestamp: ts,
source: "Item Market",
sourceDetail: data.seller ? String(data.seller) : "",
itemId,
itemName,
itemType,
quantity: qty,
unitPrice: costEach,
totalPrice: costTotal,
};
break;
case LOG_TYPES.BAZAAR_BUY:
if (!isWhitelisted && !isMuseumItem) return null;
transaction = {
logId,
type: "purchase",
timestamp: ts,
source: "Bazaar",
sourceDetail: data.seller ? String(data.seller) : "",
itemId,
itemName,
itemType,
quantity: qty,
unitPrice: costEach,
totalPrice: costTotal,
};
break;
case LOG_TYPES.ABROAD_BUY:
if (!isWhitelisted && !isMuseumItem) return null;
const areas = {
1: "Torn",
2: "Mexico",
3: "Cayman Islands",
4: "Canada",
5: "Hawaii",
6: "United Kingdom",
7: "Argentina",
8: "Switzerland",
9: "Japan",
10: "China",
11: "United Arab Emirates",
12: "South Africa",
};
transaction = {
logId,
type: "abroad_buy",
timestamp: ts,
source: "Abroad",
sourceDetail: areas[data.area] || "Unknown",
itemId,
itemName,
itemType,
quantity: qty,
unitPrice: costEach,
totalPrice: costTotal,
};
break;
case LOG_TYPES.SHOP_BUY:
if (!isWhitelisted && !isMuseumItem) return null;
transaction = {
logId,
type: "purchase",
timestamp: ts,
source: "City Shop",
sourceDetail: "",
itemId,
itemName,
itemType,
quantity: qty,
unitPrice: costEach,
totalPrice: costTotal,
};
break;
// ── Sales ──
case LOG_TYPES.ITEM_MARKET_SELL:
if (!isWhitelisted && !isMuseumItem) return null;
transaction = {
logId,
type: "sale",
timestamp: ts,
source: "Item Market",
sourceDetail: data.buyer ? String(data.buyer) : "",
itemId,
itemName,
itemType,
quantity: qty,
unitPrice: costEach,
totalPrice: costTotal,
fees: data.fee || 0,
netAmount: costTotal - (data.fee || 0),
};
break;
case LOG_TYPES.BAZAAR_SELL:
if (!isWhitelisted && !isMuseumItem) return null;
transaction = {
logId,
type: "sale",
timestamp: ts,
source: "Bazaar",
sourceDetail: data.buyer ? String(data.buyer) : "",
itemId,
itemName,
itemType,
quantity: qty,
unitPrice: costEach,
totalPrice: costTotal,
fees: data.fee || 0,
netAmount: costTotal - (data.fee || 0),
};
break;
// ── Trade items ──
case LOG_TYPES.TRADE_ITEMS_IN:
if (!isWhitelisted && !isMuseumItem) return null;
transaction = {
logId,
type: "trade_in",
timestamp: ts,
source: "Trade",
sourceDetail: data.sender ? String(data.sender) : "",
counterparty: data.sender ? String(data.sender) : "",
itemId,
itemName,
itemType,
quantity: qty,
unitPrice: 0,
totalPrice: 0, // Trade value unknown
};
break;
case LOG_TYPES.TRADE_ITEMS_OUT:
if (!isWhitelisted && !isMuseumItem) return null;
transaction = {
logId,
type: "trade_out",
timestamp: ts,
source: "Trade",
sourceDetail: data.receiver ? String(data.receiver) : "",
counterparty: data.receiver ? String(data.receiver) : "",
itemId,
itemName,
itemType,
quantity: qty,
unitPrice: 0,
totalPrice: 0,
};
break;
// ── Museum exchange ──
case LOG_TYPES.MUSEUM_EXCHANGE:
transaction = {
logId,
type: "museum_exchange",
timestamp: ts,
setType: data.set || "Unknown",
quantity: data.quantity || 1,
pointsReceived: data.points_received || (data.quantity || 1) * 10,
};
break;
// ── Points ──
case LOG_TYPES.POINTS_BUY:
transaction = {
logId,
type: "points_buy",
timestamp: ts,
action: "Points Bought",
details: "Bought " + (data.quantity || 0) + " points",
quantity: data.quantity || 0,
unitPrice: data.cost_each || data.cost || data.price || 0,
totalValue:
data.cost_total ||
data.total ||
(data.cost_each || data.cost || 0) * (data.quantity || 0),
};
break;
case LOG_TYPES.POINTS_SELL:
transaction = {
logId,
type: "points_sell",
timestamp: ts,
action: "Points Sold",
details: "Sold " + (data.quantity || 0) + " points",
quantity: data.quantity || 0,
unitPrice: data.cost_each || data.cost || data.price || 0,
totalValue:
data.cost_total ||
data.total ||
(data.cost_each || data.cost || 0) * (data.quantity || 0),
};
break;
}
return transaction;
}
// ========================================================================
// GOOGLE SHEETS SYNC
// ========================================================================
async function syncToSheet(transactions) {
const cfg = getConfig();
if (!cfg.sheetWebAppUrl || transactions.length === 0) return;
// Separate by type
const purchases = [];
const sales = [];
const museums = [];
const points = [];
for (const tx of transactions) {
if (tx.type === "museum_exchange") {
museums.push(tx);
} else if (tx.type === "points_buy" || tx.type === "points_sell") {
points.push(tx);
} else if (["purchase", "abroad_buy", "trade_in"].includes(tx.type)) {
purchases.push(tx);
} else if (["sale", "trade_out"].includes(tx.type)) {
sales.push(tx);
}
}
// Send purchases/sales in one batch
const allTxns = [...purchases, ...sales];
if (allTxns.length > 0) {
try {
await fetchGM(cfg.sheetWebAppUrl, {
method: "POST",
body: JSON.stringify({
secret: cfg.sheetSecret,
action: "logTransactions",
transactions: allTxns,
}),
});
} catch (e) {
console.error("[TTA] Sheet sync failed:", e);
}
}
// Send museum exchanges
for (const m of museums) {
try {
await fetchGM(cfg.sheetWebAppUrl, {
method: "POST",
body: JSON.stringify({
secret: cfg.sheetSecret,
action: "logMuseumExchange",
setType: m.setType,
quantity: m.quantity,
pointsReceived: m.pointsReceived,
timestamp: m.timestamp,
}),
});
} catch (e) {
console.error("[TTA] Museum sync failed:", e);
}
}
// Send points transactions
for (const p of points) {
try {
await fetchGM(cfg.sheetWebAppUrl, {
method: "POST",
body: JSON.stringify({
secret: cfg.sheetSecret,
action: "logPointsTransaction",
data: p,
}),
});
} catch (e) {
console.error("[TTA] Points sync failed:", e);
}
}
}
// ========================================================================
// MAIN LOG POLLER
// ========================================================================
async function pollLogs() {
const cfg = getConfig();
if (!cfg.enabled || !cfg.licenseValid || !cfg.tornApiKey) return;
try {
const logData = await fetchUserLog();
if (!logData || !logData.log) return;
const newTransactions = [];
for (const [logId, entry] of Object.entries(logData.log)) {
if (isLogProcessed(logId)) continue;
const tx = processLogEntry(logId, entry, cfg.whitelist);
if (tx) {
newTransactions.push(tx);
markLogProcessed(logId);
} else {
// Mark as processed even if not whitelisted (so we don't recheck)
markLogProcessed(logId);
}
}
if (newTransactions.length > 0) {
console.log(`[TTA] Found ${newTransactions.length} new transactions`);
await syncToSheet(newTransactions);
updateStatusBadge(newTransactions.length);
}
} catch (e) {
console.error("[TTA] Poll error:", e);
}
}
// ========================================================================
// UI — STYLES
// ========================================================================
GM_addStyle(`
#tta-panel {
position: fixed;
z-index: 999999;
background: #1a1a2e;
border: 1px solid #16213e;
border-radius: 8px;
color: #e0e0e0;
font-family: Arial, sans-serif;
font-size: 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
min-width: 44px;
max-width: 380px;
user-select: none;
transition: width 0.2s, height 0.2s;
}
#tta-panel.minimized {
width: 44px !important;
height: 44px !important;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
}
#tta-panel.minimized #tta-body { display: none; }
#tta-panel.minimized #tta-header-text { display: none; }
#tta-panel.minimized #tta-header-buttons { display: none; }
#tta-header {
background: #16213e;
padding: 8px 12px;
border-radius: 8px 8px 0 0;
display: flex;
align-items: center;
justify-content: space-between;
cursor: move;
}
#tta-panel.minimized #tta-header {
border-radius: 50%;
justify-content: center;
padding: 10px;
}
#tta-logo {
font-size: 18px;
line-height: 1;
}
#tta-header-text {
font-weight: bold;
font-size: 13px;
margin-left: 8px;
flex: 1;
}
#tta-header-buttons button {
background: none;
border: none;
color: #aaa;
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
}
#tta-header-buttons button:hover { color: #fff; }
#tta-body {
padding: 10px 12px;
max-height: 500px;
overflow-y: auto;
}
#tta-badge {
position: absolute;
top: -4px;
right: -4px;
background: #e74c3c;
color: #fff;
font-size: 10px;
font-weight: bold;
border-radius: 50%;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
display: none;
}
.tta-section {
margin-bottom: 10px;
}
.tta-section-title {
font-weight: bold;
font-size: 11px;
color: #888;
text-transform: uppercase;
margin-bottom: 4px;
border-bottom: 1px solid #333;
padding-bottom: 2px;
}
.tta-input {
width: 100%;
padding: 6px 8px;
background: #0f0f1a;
border: 1px solid #333;
border-radius: 4px;
color: #e0e0e0;
font-size: 12px;
margin: 2px 0;
box-sizing: border-box;
}
.tta-input:focus { border-color: #4285F4; outline: none; }
.tta-btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: bold;
margin: 2px;
transition: background 0.15s;
}
.tta-btn-primary { background: #4285F4; color: #fff; }
.tta-btn-primary:hover { background: #3367d6; }
.tta-btn-success { background: #0F9D58; color: #fff; }
.tta-btn-success:hover { background: #0b7a45; }
.tta-btn-danger { background: #DB4437; color: #fff; }
.tta-btn-danger:hover { background: #b8382d; }
.tta-btn-sm { padding: 3px 8px; font-size: 10px; }
.tta-status {
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
margin: 4px 0;
}
.tta-status-ok { background: #0f3d0f; color: #4caf50; }
.tta-status-warn { background: #3d2f0f; color: #ff9800; }
.tta-status-err { background: #3d0f0f; color: #f44336; }
.tta-whitelist-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 0;
border-bottom: 1px solid #222;
}
.tta-whitelist-item span { flex: 1; }
.tta-tabs {
display: flex;
border-bottom: 1px solid #333;
margin-bottom: 8px;
}
.tta-tab {
padding: 6px 12px;
cursor: pointer;
color: #888;
font-size: 11px;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.tta-tab.active {
color: #4285F4;
border-bottom-color: #4285F4;
}
.tta-tab:hover { color: #ccc; }
.tta-log-entry {
padding: 3px 0;
font-size: 11px;
border-bottom: 1px solid #1a1a2e;
}
.tta-log-buy { color: #4caf50; }
.tta-log-sell { color: #f44336; }
.tta-log-trade { color: #ff9800; }
.tta-log-museum { color: #9c27b0; }
`);
// ========================================================================
// UI — PANEL BUILDER
// ========================================================================
function createPanel() {
const cfg = getConfig();
const panel = document.createElement("div");
panel.id = "tta-panel";
if (cfg.panelMinimized) panel.classList.add("minimized");
panel.style.left = cfg.panelX + "px";
panel.style.top = cfg.panelY + "px";
panel.innerHTML = `
<div id="tta-badge">0</div>
<div id="tta-header">
<span id="tta-logo">📊</span>
<span id="tta-header-text">Trading Assistant</span>
<span id="tta-header-buttons">
<button id="tta-btn-min" title="Minimize">_</button>
</span>
</div>
<div id="tta-body">
<div class="tta-tabs">
<div class="tta-tab active" data-tab="status">Status</div>
<div class="tta-tab" data-tab="whitelist">Whitelist</div>
<div class="tta-tab" data-tab="settings">Settings</div>
<div class="tta-tab" data-tab="log">Log</div>
</div>
<div id="tta-tab-status" class="tta-tab-content">
<div id="tta-status-content"></div>
</div>
<div id="tta-tab-whitelist" class="tta-tab-content" style="display:none">
<div id="tta-whitelist-content"></div>
</div>
<div id="tta-tab-settings" class="tta-tab-content" style="display:none">
<div id="tta-settings-content"></div>
</div>
<div id="tta-tab-log" class="tta-tab-content" style="display:none">
<div id="tta-log-content"></div>
</div>
</div>
`;
document.body.appendChild(panel);
// ── Tab switching ──
panel.querySelectorAll(".tta-tab").forEach((tab) => {
tab.addEventListener("click", () => {
panel
.querySelectorAll(".tta-tab")
.forEach((t) => t.classList.remove("active"));
panel
.querySelectorAll(".tta-tab-content")
.forEach((c) => (c.style.display = "none"));
tab.classList.add("active");
document.getElementById("tta-tab-" + tab.dataset.tab).style.display =
"block";
});
});
// ── Minimize toggle ──
document.getElementById("tta-btn-min").addEventListener("click", (e) => {
e.stopPropagation();
panel.classList.add("minimized");
const c = getConfig();
c.panelMinimized = true;
saveConfig(c);
});
panel.addEventListener("click", () => {
if (panel.classList.contains("minimized")) {
panel.classList.remove("minimized");
const c = getConfig();
c.panelMinimized = false;
saveConfig(c);
}
});
// ── Dragging ──
makeDraggable(panel);
// ── Render tabs ──
renderStatusTab();
renderWhitelistTab();
renderSettingsTab();
renderLogTab();
}
// ========================================================================
// UI — DRAG HANDLER
// ========================================================================
function makeDraggable(el) {
const header = el.querySelector("#tta-header");
let isDragging = false,
startX,
startY,
origX,
origY;
header.addEventListener("mousedown", startDrag);
header.addEventListener("touchstart", startDrag, { passive: false });
function startDrag(e) {
if (el.classList.contains("minimized")) return;
isDragging = true;
const ev = e.touches ? e.touches[0] : e;
startX = ev.clientX;
startY = ev.clientY;
origX = el.offsetLeft;
origY = el.offsetTop;
e.preventDefault();
document.addEventListener("mousemove", drag);
document.addEventListener("touchmove", drag, { passive: false });
document.addEventListener("mouseup", stopDrag);
document.addEventListener("touchend", stopDrag);
}
function drag(e) {
if (!isDragging) return;
const ev = e.touches ? e.touches[0] : e;
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
el.style.left = Math.max(0, origX + dx) + "px";
el.style.top = Math.max(0, origY + dy) + "px";
e.preventDefault();
}
function stopDrag() {
isDragging = false;
document.removeEventListener("mousemove", drag);
document.removeEventListener("touchmove", drag);
document.removeEventListener("mouseup", stopDrag);
document.removeEventListener("touchend", stopDrag);
const c = getConfig();
c.panelX = el.offsetLeft;
c.panelY = el.offsetTop;
saveConfig(c);
}
}
// ========================================================================
// UI — STATUS TAB
// ========================================================================
function renderStatusTab() {
const cfg = getConfig();
const el = document.getElementById("tta-status-content");
if (!el) return;
const licensed = cfg.licenseValid;
const apiOk = !!cfg.tornApiKey;
const sheetOk = !!cfg.sheetWebAppUrl;
const processedCount = Object.keys(cfg.processedLogs).length;
el.innerHTML = `
<div class="tta-section">
<div class="tta-status ${licensed ? "tta-status-ok" : "tta-status-err"}">
License: ${licensed ? "✅ Active" : "❌ Not verified"}
</div>
<div class="tta-status ${apiOk ? "tta-status-ok" : "tta-status-warn"}">
API Key: ${apiOk ? "✅ Set" : "⚠️ Not set"}
</div>
<div class="tta-status ${sheetOk ? "tta-status-ok" : "tta-status-warn"}">
Sheet: ${sheetOk ? "✅ Connected" : "⚠️ Not set"}
</div>
<div class="tta-status ${cfg.enabled ? "tta-status-ok" : "tta-status-warn"}">
Tracking: ${cfg.enabled ? "✅ Active" : "⏸ Paused"}
</div>
</div>
<div class="tta-section">
<div class="tta-section-title">Stats</div>
<div>📋 ${cfg.whitelist.length} items whitelisted</div>
<div>📝 ${processedCount} log entries processed</div>
<div>📊 ${JSON.parse(getSetting("recentLog", "[]")).length} recent transactions</div>
</div>
<div class="tta-section">
<button class="tta-btn tta-btn-primary" id="tta-btn-poll">🔄 Check Now</button>
<button class="tta-btn ${cfg.enabled ? "tta-btn-danger" : "tta-btn-success"}" id="tta-btn-toggle">
${cfg.enabled ? "⏸ Pause" : "▶ Resume"}
</button>
</div>
<div class="tta-section">
<button class="tta-btn tta-btn-success tta-btn-sm" id="tta-btn-setstock">📦 Set Starting Stock</button>
<button class="tta-btn tta-btn-sm" style="background:#ff9800;color:#fff;" id="tta-btn-debug">🔍 Debug Log Fetch</button>
<button class="tta-btn tta-btn-sm" style="background:#9c27b0;color:#fff;" id="tta-btn-clearprocessed">🗑 Clear Processed IDs</button>
</div>
<div style="font-size:10px;color:#555;margin-top:8px;">v${VERSION}</div>
`;
document
.getElementById("tta-btn-poll")
.addEventListener("click", async () => {
const btn = document.getElementById("tta-btn-poll");
btn.textContent = "⏳ Checking...";
btn.disabled = true;
await pollLogs();
btn.textContent = "🔄 Check Now";
btn.disabled = false;
});
document.getElementById("tta-btn-toggle").addEventListener("click", () => {
const c = getConfig();
c.enabled = !c.enabled;
saveConfig(c);
if (c.enabled) startPollLoop();
else stopPollLoop();
renderStatusTab();
});
document
.getElementById("tta-btn-setstock")
.addEventListener("click", () => {
showSetStockDialog();
});
document
.getElementById("tta-btn-debug")
.addEventListener("click", async () => {
const btn = document.getElementById("tta-btn-debug");
btn.textContent = "⏳ Fetching...";
btn.disabled = true;
await debugLogFetch();
btn.textContent = "🔍 Debug Log Fetch";
btn.disabled = false;
});
document
.getElementById("tta-btn-clearprocessed")
.addEventListener("click", () => {
if (
confirm(
"Clear all processed log IDs? This will re-process logs on next check (may create duplicates if sheet already has them).",
)
) {
const c = getConfig();
c.processedLogs = {};
saveConfig(c);
renderStatusTab();
alert("✅ Cleared! Next poll will re-check all visible logs.");
}
});
}
// ========================================================================
// UI — WHITELIST TAB
// ========================================================================
function renderWhitelistTab() {
const cfg = getConfig();
const el = document.getElementById("tta-whitelist-content");
if (!el) return;
let whitelistHtml = cfg.whitelist
.map((id) => {
const name = getItemName(id);
return `
<div class="tta-whitelist-item">
<span>${name} (${id})</span>
<button class="tta-btn tta-btn-danger tta-btn-sm tta-wl-remove" data-id="${id}">✕</button>
</div>
`;
})
.join("");
if (!whitelistHtml) {
whitelistHtml =
'<div style="color:#888;padding:8px 0;">No items whitelisted yet. Search below to add.</div>';
}
el.innerHTML = `
<div class="tta-section">
<div class="tta-section-title">Search & Add Items</div>
<input type="text" class="tta-input" id="tta-wl-search"
placeholder="Search by name or ID (case insensitive)..." />
<div id="tta-wl-results" style="max-height:150px;overflow-y:auto;margin:4px 0;"></div>
</div>
<div class="tta-section">
<div class="tta-section-title">Tracked Items (${cfg.whitelist.length})</div>
<div id="tta-wl-list">${whitelistHtml}</div>
</div>
`;
// Search handler
document.getElementById("tta-wl-search").addEventListener("input", (e) => {
const query = e.target.value.toLowerCase().trim();
const results = document.getElementById("tta-wl-results");
if (query.length < 2) {
results.innerHTML = "";
return;
}
const matches = [];
for (const [id, item] of Object.entries(ITEM_MAP)) {
if (!item.tradeable) continue;
const nameMatch = item.name.toLowerCase().includes(query);
const idMatch = id.includes(query);
if (nameMatch || idMatch) {
const alreadyAdded = cfg.whitelist.includes(Number(id));
matches.push({
id: Number(id),
name: item.name,
type: item.type,
added: alreadyAdded,
});
}
if (matches.length >= 20) break;
}
results.innerHTML = matches
.map(
(m) => `
<div class="tta-whitelist-item">
<span>${m.name} (${m.id}) <small style="color:#666">${m.type}</small></span>
${
m.added
? '<span style="color:#4caf50;font-size:10px;">✓ Added</span>'
: `<button class="tta-btn tta-btn-success tta-btn-sm tta-wl-add" data-id="${m.id}">+ Add</button>`
}
</div>
`,
)
.join("");
// Add button handlers
results.querySelectorAll(".tta-wl-add").forEach((btn) => {
btn.addEventListener("click", () => {
const c = getConfig();
const itemId = Number(btn.dataset.id);
if (!c.whitelist.includes(itemId)) {
c.whitelist.push(itemId);
saveConfig(c);
renderWhitelistTab();
}
});
});
});
// Remove button handlers
el.querySelectorAll(".tta-wl-remove").forEach((btn) => {
btn.addEventListener("click", () => {
const c = getConfig();
const itemId = Number(btn.dataset.id);
c.whitelist = c.whitelist.filter((id) => id !== itemId);
saveConfig(c);
renderWhitelistTab();
});
});
}
// ========================================================================
// UI — SETTINGS TAB
// ========================================================================
function renderSettingsTab() {
const cfg = getConfig();
const el = document.getElementById("tta-settings-content");
if (!el) return;
el.innerHTML = `
<div class="tta-section">
<div class="tta-section-title">License Key</div>
<input type="text" class="tta-input" id="tta-set-license"
placeholder="XXXX-XXXX-XXXX-XXXX" value="${cfg.licenseKey}" />
<button class="tta-btn tta-btn-primary" id="tta-btn-verify">Verify License</button>
<div id="tta-license-status"></div>
</div>
<div class="tta-section">
<div class="tta-section-title">Torn API Key</div>
<input type="password" class="tta-input" id="tta-set-apikey"
placeholder="Your 16-char API key" value="${cfg.tornApiKey}" />
<div style="font-size:10px;color:#888;">Requires Limited Access (log, money, travel)</div>
</div>
<div class="tta-section">
<div class="tta-section-title">Google Sheet Web App URL</div>
<input type="text" class="tta-input" id="tta-set-sheeturl"
placeholder="https://script.google.com/macros/s/..." value="${cfg.sheetWebAppUrl}" />
</div>
<div class="tta-section">
<div class="tta-section-title">Sheet Secret Passphrase</div>
<input type="password" class="tta-input" id="tta-set-sheetsecret"
placeholder="Same as set in Apps Script" value="${cfg.sheetSecret}" />
</div>
<div class="tta-section">
<div class="tta-section-title">Poll Interval (seconds)</div>
<input type="number" class="tta-input" id="tta-set-interval"
min="15" max="300" value="${cfg.pollInterval}" />
<div style="font-size:10px;color:#888;">How often to check for new transactions (min 15s)</div>
</div>
<div class="tta-section">
<button class="tta-btn tta-btn-success" id="tta-btn-save">💾 Save Settings</button>
<button class="tta-btn tta-btn-primary" id="tta-btn-test">🔗 Test Sheet Connection</button>
</div>
<div class="tta-section">
<div class="tta-section-title">Danger Zone</div>
<button class="tta-btn tta-btn-danger tta-btn-sm" id="tta-btn-reset">🗑 Reset All Data</button>
</div>
`;
// Verify license
document
.getElementById("tta-btn-verify")
.addEventListener("click", async () => {
const key = document.getElementById("tta-set-license").value.trim();
const statusEl = document.getElementById("tta-license-status");
statusEl.innerHTML =
'<div class="tta-status tta-status-warn">⏳ Verifying...</div>';
const valid = await verifyLicense(key);
const c = getConfig();
c.licenseKey = key;
c.licenseValid = valid;
saveConfig(c);
statusEl.innerHTML = valid
? '<div class="tta-status tta-status-ok">✅ License verified!</div>'
: '<div class="tta-status tta-status-err">❌ Invalid license key</div>';
renderStatusTab();
});
// Save settings
document.getElementById("tta-btn-save").addEventListener("click", () => {
const c = getConfig();
c.tornApiKey = document.getElementById("tta-set-apikey").value.trim();
c.sheetWebAppUrl = document
.getElementById("tta-set-sheeturl")
.value.trim();
c.sheetSecret = document
.getElementById("tta-set-sheetsecret")
.value.trim();
c.pollInterval = Math.max(
15,
parseInt(document.getElementById("tta-set-interval").value) || 30,
);
saveConfig(c);
// Rebuild item map with new API key
buildItemMap();
alert("✅ Settings saved!");
renderStatusTab();
});
// Test sheet
document
.getElementById("tta-btn-test")
.addEventListener("click", async () => {
const c = getConfig();
if (!c.sheetWebAppUrl) {
alert("Set Sheet URL first!");
return;
}
try {
const resp = await fetchGM(c.sheetWebAppUrl, {
method: "POST",
body: JSON.stringify({ secret: c.sheetSecret, action: "ping" }),
});
if (resp.data && resp.data.success) {
alert("✅ Sheet connected! Response: " + resp.data.message);
} else {
alert(
"❌ Sheet responded but failed: " + JSON.stringify(resp.data),
);
}
} catch (e) {
alert("❌ Connection failed: " + e.message);
}
});
// Reset
document.getElementById("tta-btn-reset").addEventListener("click", () => {
if (
confirm(
"⚠️ This will erase ALL settings, whitelist, and log history.\n\nAre you sure?",
)
) {
[
"licenseKey",
"licenseValid",
"tornApiKey",
"sheetWebAppUrl",
"sheetSecret",
"whitelist",
"lastLogCheck",
"processedLogs",
"panelX",
"panelY",
"panelMinimized",
"enabled",
"pollInterval",
"itemMap",
"itemMapTime",
"recentLog",
].forEach((k) => GM_deleteValue("tta_" + k));
location.reload();
}
});
}
// ========================================================================
// UI — LOG TAB
// ========================================================================
function renderLogTab() {
const el = document.getElementById("tta-log-content");
if (!el) return;
const logs = JSON.parse(getSetting("recentLog", "[]"));
if (logs.length === 0) {
el.innerHTML =
'<div style="color:#888;padding:8px 0;">No transactions logged yet.</div>';
return;
}
el.innerHTML = logs
.slice(-50)
.reverse()
.map((log) => {
const cls =
log.type.includes("sale") || log.type === "trade_out"
? "tta-log-sell"
: log.type === "museum_exchange"
? "tta-log-museum"
: log.type.includes("trade")
? "tta-log-trade"
: "tta-log-buy";
const time = new Date(log.timestamp * 1000).toLocaleString();
return `
<div class="tta-log-entry ${cls}">
<strong>${log.type.toUpperCase()}</strong>
${log.itemName ? log.itemName + " x" + (log.quantity || 1) : log.setType || log.action || ""}
<br><small>${time} | ${log.source || ""}</small>
</div>
`;
})
.join("");
}
function addToRecentLog(transactions) {
const logs = JSON.parse(getSetting("recentLog", "[]"));
for (const tx of transactions) {
logs.push(tx);
}
// Keep last 200
const trimmed = logs.slice(-200);
setSetting("recentLog", JSON.stringify(trimmed));
}
// ========================================================================
// UI — SET STARTING STOCK DIALOG
// ========================================================================
function showSetStockDialog() {
const existing = document.getElementById("tta-stock-dialog");
if (existing) existing.remove();
// Define items locally (these match the Apps Script PLUSHIES/FLOWERS arrays)
const KNOWN_PLUSHIES = [
{ id: 186, name: "Sheep Plushie" },
{ id: 187, name: "Teddy Bear Plushie" },
{ id: 215, name: "Kitten Plushie" },
{ id: 258, name: "Jaguar Plushie" },
{ id: 618, name: "Stingray Plushie" },
{ id: 261, name: "Wolverine Plushie" },
{ id: 266, name: "Nessie Plushie" },
{ id: 268, name: "Red Fox Plushie" },
{ id: 269, name: "Monkey Plushie" },
{ id: 273, name: "Chamois Plushie" },
{ id: 274, name: "Panda Plushie" },
{ id: 384, name: "Camel Plushie" },
{ id: 281, name: "Lion Plushie" },
];
const KNOWN_FLOWERS = [
{ id: 260, name: "Dahlia" },
{ id: 617, name: "Banana Orchid" },
{ id: 263, name: "Crocus" },
{ id: 264, name: "Orchid" },
{ id: 267, name: "Heather" },
{ id: 271, name: "Ceibo Flower" },
{ id: 272, name: "Edelweiss" },
{ id: 277, name: "Cherry Blossom" },
{ id: 276, name: "Peony" },
{ id: 385, name: "Tribulus Omanense" },
{ id: 282, name: "African Violet" },
];
const dialog = document.createElement("div");
dialog.id = "tta-stock-dialog";
dialog.style.cssText = `
position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
z-index:9999999;background:#1a1a2e;border:2px solid #4285F4;
border-radius:12px;padding:20px;color:#e0e0e0;
max-height:80vh;overflow-y:auto;width:320px;
box-shadow:0 8px 32px rgba(0,0,0,0.6);font-family:Arial;font-size:12px;
`;
dialog.innerHTML = `
<h3 style="margin:0 0 12px;color:#4285F4;">📦 Set Starting Stock</h3>
<div style="font-size:10px;color:#888;margin-bottom:10px;">
Enter how many of each item you currently own.
This sets the baseline for tracking.
</div>
<div style="font-weight:bold;color:#DB4437;margin:8px 0 4px;">🧸 Plushies</div>
${KNOWN_PLUSHIES.map(
(i) => `
<div style="display:flex;align-items:center;margin:3px 0;">
<span style="flex:1;">${i.name}</span>
<input type="number" min="0" value="0" style="width:60px;padding:4px;
background:#0f0f1a;border:1px solid #333;color:#e0e0e0;border-radius:4px;"
data-itemid="${i.id}" class="tta-stock-input" />
</div>
`,
).join("")}
<div style="font-weight:bold;color:#0F9D58;margin:12px 0 4px;">🌸 Flowers</div>
${KNOWN_FLOWERS.map(
(i) => `
<div style="display:flex;align-items:center;margin:3px 0;">
<span style="flex:1;">${i.name}</span>
<input type="number" min="0" value="0" style="width:60px;padding:4px;
background:#0f0f1a;border:1px solid #333;color:#e0e0e0;border-radius:4px;"
data-itemid="${i.id}" class="tta-stock-input" />
</div>
`,
).join("")}
<div style="margin-top:12px;display:flex;gap:8px;">
<button class="tta-btn tta-btn-success" id="tta-stock-save">💾 Save to Sheet</button>
<button class="tta-btn tta-btn-danger" id="tta-stock-cancel">Cancel</button>
</div>
<div id="tta-stock-status" style="margin-top:8px;"></div>
`;
document.body.appendChild(dialog);
document
.getElementById("tta-stock-cancel")
.addEventListener("click", () => {
dialog.remove();
});
document
.getElementById("tta-stock-save")
.addEventListener("click", async () => {
const cfg = getConfig();
if (!cfg.sheetWebAppUrl) {
alert("Set your Sheet Web App URL in settings first!");
return;
}
const statusEl = document.getElementById("tta-stock-status");
statusEl.innerHTML =
'<div class="tta-status tta-status-warn">⏳ Saving...</div>';
const stockItems = [];
dialog.querySelectorAll(".tta-stock-input").forEach((input) => {
const qty = parseInt(input.value) || 0;
const itemId = parseInt(input.dataset.itemid);
if (itemId) {
stockItems.push({ itemId, quantity: qty });
}
});
try {
const resp = await fetchGM(cfg.sheetWebAppUrl, {
method: "POST",
body: JSON.stringify({
secret: cfg.sheetSecret,
action: "setInitialStock",
items: stockItems,
}),
});
if (resp.data && resp.data.success) {
statusEl.innerHTML = `<div class="tta-status tta-status-ok">✅ Updated ${resp.data.updated} items!</div>`;
setTimeout(() => dialog.remove(), 2000);
} else {
statusEl.innerHTML = `<div class="tta-status tta-status-err">❌ ${resp.data ? resp.data.error : "Unknown error"}</div>`;
}
} catch (e) {
statusEl.innerHTML = `<div class="tta-status tta-status-err">❌ ${e.message}</div>`;
}
});
}
// ========================================================================
// UI — STATUS BADGE
// ========================================================================
function updateStatusBadge(count) {
const badge = document.getElementById("tta-badge");
if (!badge) return;
if (count > 0) {
badge.style.display = "flex";
badge.textContent = count;
setTimeout(() => {
badge.style.display = "none";
}, 5000);
}
}
// ========================================================================
// PAGE MONITOR — Detect transactions on currently viewed pages
// ========================================================================
/**
* COMPLIANCE NOTE:
* This only reads data from the page the user is CURRENTLY VIEWING.
* It does NOT make any additional requests to Torn.
* It does NOT scrape pages not being viewed.
* It only observes DOM changes on the active page.
*/
function setupPageMonitor() {
const path = window.location.pathname;
// Monitor item market page
if (path.includes("/imarket") || path.includes("/itemmarket")) {
observePageForTransactions("itemmarket");
}
// Monitor bazaar page
if (path.includes("/bazaar")) {
observePageForTransactions("bazaar");
}
// Monitor trade page
if (path.includes("/trade")) {
observePageForTransactions("trade");
}
// Monitor museum page
if (path.includes("/museum")) {
observePageForTransactions("museum");
}
// Monitor abroad page
if (path.includes("/travelagency") || path.includes("/abroad")) {
observePageForTransactions("abroad");
}
}
function observePageForTransactions(pageType) {
// Use MutationObserver to detect when new content loads
// This is compliant — we're only reading the page the user is viewing
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue;
// Look for success messages / confirmation dialogs
const text = node.textContent || "";
// Item market / bazaar buy confirmations
if (pageType === "itemmarket" || pageType === "bazaar") {
if (
text.includes("You bought") ||
text.includes("purchase") ||
text.includes("sold") ||
text.includes("listed")
) {
// Trigger a log poll to catch the transaction
setTimeout(() => pollLogs(), 3000);
}
}
// Trade completions
if (pageType === "trade") {
if (text.includes("Trade completed") || text.includes("accepted")) {
setTimeout(() => pollLogs(), 3000);
}
}
// Museum exchanges
if (pageType === "museum") {
if (
text.includes("exchanged") ||
text.includes("museum") ||
text.includes("points")
) {
setTimeout(() => pollLogs(), 3000);
}
}
// Abroad purchases
if (pageType === "abroad") {
if (text.includes("bought") || text.includes("purchase")) {
setTimeout(() => pollLogs(), 3000);
}
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
}
// ========================================================================
// ENHANCED LOG POLLER WITH SHEET SYNC
// ========================================================================
// Override the original pollLogs with one that also updates the log tab
const _originalPollLogs = pollLogs;
pollLogs = async function () {
const cfg = getConfig();
if (!cfg.enabled || !cfg.licenseValid || !cfg.tornApiKey) return;
try {
const logData = await fetchUserLog();
if (!logData || !logData.log) return;
const newTransactions = [];
for (const [logId, entry] of Object.entries(logData.log)) {
if (isLogProcessed(logId)) continue;
const tx = processLogEntry(logId, entry, cfg.whitelist);
if (tx) {
newTransactions.push(tx);
markLogProcessed(logId);
} else {
markLogProcessed(logId);
}
}
if (newTransactions.length > 0) {
console.log(`[TTA] Found ${newTransactions.length} new transactions`);
// Add to recent log for display
addToRecentLog(newTransactions);
// Sync to sheet
await syncToSheet(newTransactions);
// Update badge & notification
updateStatusBadge(newTransactions.length);
showNotification(newTransactions);
}
// ALWAYS refresh UI — even when 0 new transactions
// (so processed count updates after Check Now)
renderStatusTab();
renderLogTab();
} catch (e) {
console.error("[TTA] Poll error:", e);
}
};
// ========================================================================
// NOTIFICATIONS
// ========================================================================
function showNotification(transactions) {
const existing = document.getElementById("tta-notification");
if (existing) existing.remove();
const purchases = transactions.filter((t) =>
["purchase", "abroad_buy", "trade_in"].includes(t.type),
);
const sales = transactions.filter((t) =>
["sale", "trade_out"].includes(t.type),
);
const other = transactions.filter(
(t) =>
!["purchase", "abroad_buy", "trade_in", "sale", "trade_out"].includes(
t.type,
),
);
let msg = "";
if (purchases.length) msg += `📥 ${purchases.length} purchase(s) logged\n`;
if (sales.length) msg += `📤 ${sales.length} sale(s) logged\n`;
if (other.length) msg += `📋 ${other.length} other event(s) logged\n`;
const notif = document.createElement("div");
notif.id = "tta-notification";
notif.style.cssText = `
position:fixed;bottom:20px;right:20px;z-index:9999999;
background:#1a1a2e;border:1px solid #4285F4;border-radius:8px;
padding:12px 16px;color:#e0e0e0;font-family:Arial;font-size:12px;
box-shadow:0 4px 16px rgba(0,0,0,0.4);max-width:280px;
animation:ttaSlideIn 0.3s ease;
`;
notif.innerHTML = `
<div style="font-weight:bold;margin-bottom:4px;">📊 Trading Assistant</div>
<div style="white-space:pre-line;">${msg.trim()}</div>
`;
// Add animation
GM_addStyle(`
@keyframes ttaSlideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`);
document.body.appendChild(notif);
setTimeout(() => {
if (notif.parentNode) {
notif.style.transition = "opacity 0.3s";
notif.style.opacity = "0";
setTimeout(() => notif.remove(), 300);
}
}, 4000);
}
// ========================================================================
// MAIN POLL LOOP
// ========================================================================
let pollTimer = null;
function startPollLoop() {
const cfg = getConfig();
if (pollTimer) clearInterval(pollTimer);
const interval = Math.max(15, cfg.pollInterval) * 1000;
// Initial poll after 5 seconds (let page load)
setTimeout(() => pollLogs(), 5000);
// Then poll on interval
pollTimer = setInterval(() => {
pollLogs();
}, interval);
}
function stopPollLoop() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
// ========================================================================
// INITIALIZATION
// ========================================================================
async function init() {
console.log("[TTA] Torn Trading Assistant v" + VERSION + " starting...");
const cfg = getConfig();
// Build item map (from API, cached 24h)
if (cfg.tornApiKey) {
await buildItemMap();
}
// Create the UI panel
createPanel();
// Setup page monitoring (compliant — current page only)
setupPageMonitor();
// Register GM menu command for quick access
GM_registerMenuCommand("📊 Trading Assistant Settings", () => {
const panel = document.getElementById("tta-panel");
if (panel) {
panel.classList.remove("minimized");
const c = getConfig();
c.panelMinimized = false;
saveConfig(c);
// Switch to settings tab
panel
.querySelectorAll(".tta-tab")
.forEach((t) => t.classList.remove("active"));
panel
.querySelectorAll(".tta-tab-content")
.forEach((c) => (c.style.display = "none"));
panel.querySelector('[data-tab="settings"]').classList.add("active");
document.getElementById("tta-tab-settings").style.display = "block";
}
});
// Auto-verify license on load (if key exists)
if (cfg.licenseKey && !cfg.licenseValid) {
const valid = await verifyLicense(cfg.licenseKey);
if (valid) {
const c = getConfig();
c.licenseValid = true;
saveConfig(c);
renderStatusTab();
console.log("[TTA] License auto-verified ✓");
}
}
// Start polling if licensed and configured
if (cfg.licenseValid && cfg.tornApiKey) {
startPollLoop();
console.log("[TTA] Poll loop started");
} else {
console.log("[TTA] Not starting poll loop — license or API key missing");
}
}
// Wait for page to be ready
if (
document.readyState === "complete" ||
document.readyState === "interactive"
) {
setTimeout(init, 1000);
} else {
window.addEventListener("load", () => setTimeout(init, 1000));
}
})();