// ==UserScript== // @name Torn Stock Advisor // @namespace torn_stock_advisor // @version 2.3.3 // @description Advises which stock blocks to buy next, ranked by daily ROI. Shows top N recommendation cards, your holdings, and a full collapsed rankings table. // @author TheOddSod (2640064) // @match https://www.torn.com/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @connect api.torn.com // @downloadURL https://update.greasyfork.icu/scripts/576155/Torn%20Stock%20Advisor.user.js // @updateURL https://update.greasyfork.icu/scripts/576155/Torn%20Stock%20Advisor.meta.js // ==/UserScript== // ─── Changelog ─────────────────────────────────────────────────────────────── // v2.3.3 — Removed: sidebar widget stripped out entirely. It had its own // independent API call path that diverged from the main scoring logic, // a stale/corrupt cache layer, and required every core data fix to be // separately replicated. Will be reintroduced cleanly once the core // dashboard data layer is stable and verified. // v2.3.2 — Fix: removed ALL hardcoded cash dividend values (GRN, TCT, TMI, IOU, // TSB, CNC) — these were fabricated and wrong. TCT confirmed $1M/block // not $3M by user screenshot showing $3M total for 3 blocks. // Cash dividends are now read live from the DOM dividend column on the // stocks page (total ÷ held blocks = per-block value) and cached for // 24h in GM storage. For unowned cash stocks the user must set the // value manually in Config ⚙ per-stock overrides after visiting the // stocks page — the config field placeholder now says "auto (from // stocks page)" to guide them. This approach can never be wrong // because it reads directly from what Torn itself displays. // v2.3.1 — Fix: CONFIG_VERSION bumped to force migration clearing stale payout // overrides in GM storage. Users who installed before v1.2.8 still had // TCT locked at $1M/cycle (was corrected to $3M) and GRN at $4M/cycle // (corrected to $8M) — the migration hadn't re-run since v2.0.0. On // upgrade to v2.3.1 all per-stock payout overrides are cleared so the // corrected STOCK_DATA defaults take effect immediately. // — Fix: recommendation card "Payout:" label renamed to "This block earns:" // to make clear the figure is the incremental gain from this one block // only, not a total across all holdings. When the user already holds // other blocks of the same stock, a "Total after buy: X/day" line now // appears on the card showing the combined daily value post-purchase. // v2.3.0 — Fix: sidebar now shows ALL held stocks regardless of payout type or // exclusion config — nerve/energy/happy/passive stocks were silently // dropped from the cache write, causing missing payout rows. // — Fix: sidebar crash when bonusInfo/bonus is undefined for a ticker // (e.g. LSC, CBD, PTS) — the entire cache write was being silently // swallowed by a try/catch, resulting in stale or partial data. // — Fix: fetchSidebarData crash when bonus object missing — same // undefined access on bonus.available caused silent failure. // — Fix: sidebar now makes its own independent API call when cache is // stale (>5 min) rather than relying on main dashboard having been // visited — daily payout total is recalculated from held blocks // using payoutCashValue/interval data from STOCK_DATA directly. // — Fix: sidebar stale state now shows last-known data with a stale // indicator rather than blank/loading forever on API errors. // — Fix: payout description for energy/nerve/happy stocks in sidebar // now uses STOCK_DATA values instead of hardcoded constants. // v2.2.1 — Fix: sidebar payout list now iterates held rows (not bonusInfo keys) // so all held stocks appear — previously stocks missing from bonusInfo // (e.g. LSC, CBD, PTS) were silently omitted from the sidebar. // v2.1.1 — Fix: sidebar cache now written from main dashboard render so payout // timing always matches the main dashboard exactly, rather than making // independent API calls that could show stale/different data. // v2.1.0 — Feature: sidebar widget on all Torn pages. Shows daily payout total, // next payout countdowns, and savings plan progress. Makes own API // call to stay fresh. Toggle-able with remembered state. Hidden on // stocks page. Config panel gains sidebar section to toggle each // element independently. // v2.0.5 — Fix: dollar sign removed from share counts in savings goal dropdown. // v2.0.4 — Fix: savings plan goal cost now uses costToComplete from row data, // correctly deducting shares already partially held toward the goal // block. Header shows "already hold X of Y shares" when applicable. // v2.0.3 — Fix: stepping stone liquidation value now uses incremental block // shares (incrShares) not total sharesHeld — prevents double-counting // when multiple blocks of the same stock are tagged. // v2.0.2 — Fix: passive stocks now show stepping stone toggle. When a goal // is active, stocks with multiple held blocks expand to individual // rows so each block can be independently tagged as stepping stone. // v2.0.1 — Fix: prices destructured from buildScores return so // renderSavingsPlan receives live share prices correctly. // v2.0.0 — MAJOR: Savings Plan feature. // v1.7.4 — Feature: swap advisor now shows next payout timing. // v1.7.3 — Feature: disclaimer added to footer. // v1.7.2 — Polish: config per-stock accordions sorted alphabetically. // v1.7.1 — Polish: config per-stock accordions sorted (passive grouped). // v1.7.0 — Fix: section spacing fixed. // v1.6.9 — Polish: swap advisor visual separation. // v1.6.8 — Polish: improved vertical spacing throughout. // v1.6.7 — Fix: item price fetch switched to v2 market endpoint. // v1.6.6 — Fix: LAG item name corrected. // v1.6.5 — Fix: points market endpoint corrected. // v1.6.4 — Feature: PTS auto-prices via live points market. // v1.6.3 — Polish: holdings grid column widths. // v1.6.2 — Polish: Greasy Fork ID set. Holdings CSS grid. // v1.6.1 — Feature: "Never sell" list in config. // v1.6.0 — Fix: ASS interval corrected to 7 days. // v1.5.x — Various swap advisor, card click, and table fixes. // v1.4.x — Budget filter feature. // v1.3.x — Config version migration. // v1.2.x — Threshold/payout corrections. // v1.1.x — API/injection fixes. // v1.0.0 — Initial release. // ───────────────────────────────────────────────────────────────────────────── (function () { 'use strict'; // ─── Duplicate injection guard ─────────────────────────────────────────────── if (window._tsaLoaded) return; window._tsaLoaded = true; // ─── Constants ─────────────────────────────────────────────────────────────── const PREFIX = 'tsa_'; const API_BASE = 'https://api.torn.com/v2'; const SCRIPT = 'TornStockAdvisor'; // ─── Config version — migration runs after STOCK_DATA is defined ─────────── // ─── Config version — bumped to 2.3.3 to clear ALL stale cash payout overrides. // payoutCashValue is now always 0 for cash stocks; values come from DOM reading. // Any stored override from previous hardcoded guesses must be wiped. const CONFIG_VERSION = '2.3.3'; // Module-level ticker→stockId map, populated after first API call let tickerToId = {}; // Cached last render data for savings plan re-render on toggle let lastRows = []; let lastPrices = {}; // ─── Master stock data ──────────────────────────────────────────────────────── // // PAYOUT LOGIC (Torn Stocks 3.0): // Each "increment" pays its own per-increment value independently. // Block 2 gives a SECOND identical payout each cycle — not double total. // Scoring: incremental cost (shares for THIS block × price) // vs incremental daily value (payout / interval in days). // // INTERVALS: // 7 days: FHG, SYM, PRN, EWM, THS, LAG, BAG, MUN, PTS, EVL, MCS, CBD, ASS, LSC // 31 days: GRN, TCT, TMI, IOU, TSB, CNC, HRG, TCC // 0 (passive): TCP, TCM, TGP, IIL, TCI, WLT, SYS, ELT, MSG, WSU, LOS, YAZ, IST // // FIELDS: // payoutInterval — days between payouts (7 or 31; 0 = passive, not scored) // perIncrQty — units paid per increment per interval // payoutItemId — Torn item ID for live market lookup (null = not an item) // payoutCashValue— fixed $ per increment per interval (0 if item/passive) // increments — [{incr, threshold}] where threshold = TOTAL shares held const STOCK_DATA = [ // ── 7-day active dividend stocks ───────────────────────────────────────── { ticker: 'FHG', stockId: 7, name: 'Feathery Hotels Group', payoutType: 'item', payoutInterval: 7, perIncrQty: 1, payoutItemName: 'Feathery Hotel Coupon', payoutItemId: 367, payoutCashValue: 0, payoutDesc: '1× Feathery Hotel Coupon per block, every 7 days', increments: [ { incr: 1, threshold: 2000000 }, { incr: 2, threshold: 6000000 }, { incr: 3, threshold: 14000000 }, { incr: 4, threshold: 30000000 }, { incr: 5, threshold: 62000000 }, ], }, { ticker: 'SYM', stockId: 2, name: 'Symbiotic Ltd.', payoutType: 'item', payoutInterval: 7, perIncrQty: 1, payoutItemName: 'Drug Pack', payoutItemId: 370, payoutCashValue: 0, payoutDesc: '1× Drug Pack per block, every 7 days', increments: [ { incr: 1, threshold: 500000 }, { incr: 2, threshold: 1500000 }, { incr: 3, threshold: 3500000 }, { incr: 4, threshold: 7500000 }, { incr: 5, threshold: 15500000 }, ], }, { ticker: 'PRN', stockId: 21, name: 'Performance Ribaldry', payoutType: 'item', payoutInterval: 7, perIncrQty: 1, payoutItemName: 'Erotic DVD', payoutItemId: null, payoutCashValue: 0, payoutDesc: '1× Erotic DVD per block, every 7 days', increments: [ { incr: 1, threshold: 1000000 }, { incr: 2, threshold: 3000000 }, { incr: 3, threshold: 7000000 }, { incr: 4, threshold: 15000000 }, { incr: 5, threshold: 31000000 }, ], }, { ticker: 'EWM', stockId: 10, name: 'Eaglewood Mercenary', payoutType: 'item', payoutInterval: 7, perIncrQty: 1, payoutItemName: 'Box of Grenades', payoutItemId: null, payoutCashValue: 0, payoutDesc: '1× Box of Grenades per block, every 7 days', increments: [ { incr: 1, threshold: 1000000 }, { incr: 2, threshold: 3000000 }, { incr: 3, threshold: 7000000 }, { incr: 4, threshold: 15000000 }, { incr: 5, threshold: 31000000 }, ], }, { ticker: 'THS', stockId: 20, name: 'Torn City Health Service', payoutType: 'item', payoutInterval: 7, perIncrQty: 2, payoutItemName: 'Box of Medical Supplies', payoutItemId: null, payoutCashValue: 0, payoutDesc: '2× Boxes of Medical Supplies per block, every 7 days', increments: [ { incr: 1, threshold: 150000 }, { incr: 2, threshold: 450000 }, { incr: 3, threshold: 1050000 }, { incr: 4, threshold: 2250000 }, { incr: 5, threshold: 4650000 }, ], }, { ticker: 'LAG', stockId: 17, name: 'Legal Authorities Group', payoutType: 'item', payoutInterval: 7, perIncrQty: 1, payoutItemName: "Lawyer's Business Card", payoutItemId: 368, payoutCashValue: 0, payoutDesc: "1× Lawyer's Business Card per block, every 7 days", increments: [ { incr: 1, threshold: 750000 }, { incr: 2, threshold: 2250000 }, { incr: 3, threshold: 5250000 }, { incr: 4, threshold: 11250000 }, { incr: 5, threshold: 23250000 }, ], }, { ticker: 'BAG', stockId: 27, name: "Big Al's Gun Shop", payoutType: 'item', payoutInterval: 7, perIncrQty: 1, payoutItemName: 'Ammunition Pack', payoutItemId: null, payoutCashValue: 0, payoutDesc: '1× Ammunition Pack per block, every 7 days', increments: [ { incr: 1, threshold: 3000000 }, { incr: 2, threshold: 9000000 }, { incr: 3, threshold: 21000000 }, { incr: 4, threshold: 45000000 }, { incr: 5, threshold: 93000000 }, ], }, { ticker: 'MUN', stockId: 12, name: 'Munster Beverage Corp.', payoutType: 'item', payoutInterval: 7, perIncrQty: 1, payoutItemName: 'Six-Pack of Energy Drink', payoutItemId: null, payoutCashValue: 0, payoutDesc: '1× Six-Pack of Energy Drink per block, every 7 days', increments: [ { incr: 1, threshold: 5000000 }, { incr: 2, threshold: 15000000 }, { incr: 3, threshold: 35000000 }, { incr: 4, threshold: 75000000 }, { incr: 5, threshold: 155000000 }, ], }, { ticker: 'PTS', stockId: 22, name: 'PointLess', // Points valued at live points market price × 100 points per block per 7 days payoutType: 'cash', payoutInterval: 7, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '100 points per block, every 7 days (valued at live points market price)', increments: [ { incr: 1, threshold: 10000000 }, { incr: 2, threshold: 30000000 }, { incr: 3, threshold: 70000000 }, { incr: 4, threshold: 150000000 }, { incr: 5, threshold: 310000000 }, ], }, { ticker: 'EVL', stockId: 26, name: 'Evil Ducks Candy Corp', payoutType: 'happy', payoutInterval: 7, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '1000 happiness per block, every 7 days', increments: [ { incr: 1, threshold: 100000 }, { incr: 2, threshold: 300000 }, { incr: 3, threshold: 700000 }, { incr: 4, threshold: 1500000 }, { incr: 5, threshold: 3100000 }, ], }, { ticker: 'MCS', stockId: 23, name: 'Mc Smoogle Corp', payoutType: 'energy', payoutInterval: 7, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, // 200 energy per block per 7 days payoutUnits: 200, payoutUnitLabel: 'energy', payoutDesc: '200 energy per block, every 7 days', increments: [ { incr: 1, threshold: 350000 }, { incr: 2, threshold: 1050000 }, { incr: 3, threshold: 2450000 }, { incr: 4, threshold: 5250000 }, { incr: 5, threshold: 10850000 }, ], }, { ticker: 'CBD', stockId: 18, name: 'Herbal Releaf Co.', payoutType: 'nerve', payoutInterval: 7, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, // 50 nerve per block per 7 days payoutUnits: 50, payoutUnitLabel: 'nerve', payoutDesc: '50 nerve per block, every 7 days', increments: [ { incr: 1, threshold: 350000 }, { incr: 2, threshold: 1050000 }, { incr: 3, threshold: 2450000 }, { incr: 4, threshold: 5250000 }, { incr: 5, threshold: 10850000 }, ], }, // ── 31-day active dividend stocks ──────────────────────────────────────── { ticker: 'GRN', stockId: 16, name: 'Grain', payoutType: 'cash', payoutInterval: 31, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Cash dividend per block, every 31 days — value read from DOM or set in config', increments: [ { incr: 1, threshold: 500000 }, { incr: 2, threshold: 1500000 }, { incr: 3, threshold: 3500000 }, { incr: 4, threshold: 7500000 }, { incr: 5, threshold: 15500000 }, ], }, { ticker: 'TCT', stockId: 9, name: 'The Torn City Times', payoutType: 'cash', payoutInterval: 31, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Cash dividend per block, every 31 days — value read from DOM or set in config', increments: [ { incr: 1, threshold: 100000 }, { incr: 2, threshold: 300000 }, { incr: 3, threshold: 700000 }, { incr: 4, threshold: 1500000 }, { incr: 5, threshold: 3100000 }, ], }, { ticker: 'TMI', stockId: 5, name: 'TC Music Industries', payoutType: 'cash', payoutInterval: 31, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Cash dividend per block, every 31 days — value read from DOM or set in config', increments: [ { incr: 1, threshold: 6000000 }, { incr: 2, threshold: 18000000 }, { incr: 3, threshold: 42000000 }, { incr: 4, threshold: 90000000 }, { incr: 5, threshold: 186000000 }, ], }, { ticker: 'IOU', stockId: 14, name: 'Insured On Us', // Base $12M + class-action lawsuit chance — value listed is base only payoutType: 'cash', payoutInterval: 31, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Cash dividend per block, every 31 days (+ lawsuit payout chance) — value read from DOM or set in config', increments: [ { incr: 1, threshold: 3000000 }, { incr: 2, threshold: 9000000 }, { incr: 3, threshold: 21000000 }, { incr: 4, threshold: 45000000 }, { incr: 5, threshold: 93000000 }, ], }, { ticker: 'ASS', stockId: 24, name: 'Alcoholics Synonymous', payoutType: 'item', payoutInterval: 7, perIncrQty: 2, payoutItemName: 'Six-Pack of Alcohol', payoutItemId: null, payoutCashValue: 0, payoutDesc: '2× Six Pack of Alcohol per block, every 7 days', increments: [ { incr: 1, threshold: 3000000 }, { incr: 2, threshold: 9000000 }, { incr: 3, threshold: 21000000 }, { incr: 4, threshold: 45000000 }, { incr: 5, threshold: 93000000 }, ], }, { ticker: 'TSB', stockId: 1, name: 'Torn & Shanghai Banking', payoutType: 'cash', payoutInterval: 31, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Cash dividend per block, every 31 days — value read from DOM or set in config', increments: [ { incr: 1, threshold: 3000000 }, { incr: 2, threshold: 9000000 }, { incr: 3, threshold: 21000000 }, { incr: 4, threshold: 45000000 }, { incr: 5, threshold: 93000000 }, ], }, { ticker: 'CNC', stockId: 34, name: 'Crude & Co', payoutType: 'cash', payoutInterval: 31, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Cash dividend per block, every 31 days — value read from DOM or set in config', increments: [ { incr: 1, threshold: 7500000 }, { incr: 2, threshold: 22500000 }, { incr: 3, threshold: 52500000 }, { incr: 4, threshold: 112500000 }, { incr: 5, threshold: 232500000 }, ], }, { ticker: 'LSC', stockId: 6, name: 'Lucky Shot Casino', payoutType: 'item', payoutInterval: 7, perIncrQty: 2, payoutItemName: 'Lottery Voucher', payoutItemId: null, payoutCashValue: 0, payoutDesc: '2× Lottery Vouchers per block, every 7 days', increments: [ { incr: 1, threshold: 1500000 }, { incr: 2, threshold: 4500000 }, { incr: 3, threshold: 10500000 }, { incr: 4, threshold: 22500000 }, { incr: 5, threshold: 46500000 }, ], }, { ticker: 'HRG', stockId: 8, name: 'Home Retail Group', payoutType: 'other', payoutInterval: 31, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '1× Random Property per block, every 31 days — set $ value in config', increments: [ { incr: 1, threshold: 10000000 }, { incr: 2, threshold: 30000000 }, { incr: 3, threshold: 70000000 }, { incr: 4, threshold: 150000000 }, { incr: 5, threshold: 310000000 }, ], }, { ticker: 'TCC', stockId: 35, name: 'Torn City Clothing', payoutType: 'item', payoutInterval: 31, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '1× Clothing Cache per block, every 31 days — set value in config', increments: [ { incr: 1, threshold: 7500000 }, { incr: 2, threshold: 22500000 }, { incr: 3, threshold: 52500000 }, { incr: 4, threshold: 112500000 }, { incr: 5, threshold: 232500000 }, ], }, // ── Passive stocks (no active payout — excluded from scoring by default) ─ { ticker: 'TCP', stockId: 13, name: 'TC Media Productions', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Company sales boost (passive — set value in config to score)', increments: [ { incr: 1, threshold: 1000000 } ], }, { ticker: 'TCM', stockId: 4, name: 'Torn City Motors', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '10% racing skill gain boost (passive)', increments: [ { incr: 1, threshold: 1000000 } ], }, { ticker: 'TGP', stockId: 19, name: 'Tell Group Plc.', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Company advertising boost (passive)', increments: [ { incr: 1, threshold: 2500000 } ], }, { ticker: 'IIL', stockId: 25, name: 'I Industries Ltd.', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '50% coding time reduction (passive)', increments: [ { incr: 1, threshold: 1000000 } ], }, { ticker: 'TCI', stockId: 15, name: 'Torn City Investments', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '10% bank interest bonus (passive — hold for 7 days before banking)', increments: [ { incr: 1, threshold: 1500000 } ], }, { ticker: 'WLT', stockId: 11, name: 'Wind Lines Travel', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Private jet access (passive)', increments: [ { incr: 1, threshold: 9000000 } ], }, { ticker: 'SYS', stockId: 3, name: 'Syscore MFG', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Advanced firewall (passive)', increments: [ { incr: 1, threshold: 3000000 } ], }, { ticker: 'ELT', stockId: 28, name: 'Empty Lunchbox Traders', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '10% home upgrade discount (passive)', increments: [ { incr: 1, threshold: 5000000 } ], }, { ticker: 'MSG', stockId: 29, name: 'Messaging Inc.', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Free classified advertising (passive)', increments: [ { incr: 1, threshold: 300000 } ], }, { ticker: 'WSU', stockId: 31, name: 'West Side University', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '10% course time reduction (passive)', increments: [ { incr: 1, threshold: 1000000 } ], }, { ticker: 'LOS', stockId: 32, name: 'Lo Squalo Waste', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '25% mission reward bonus (passive)', increments: [ { incr: 1, threshold: 7500000 } ], }, { ticker: 'YAZ', stockId: 33, name: 'Yazoo', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Free banner advertising (passive)', increments: [ { incr: 1, threshold: 1000000 } ], }, { ticker: 'IST', stockId: 30, name: 'International School TC', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Free education courses (passive)', increments: [ { incr: 1, threshold: 100000 } ], }, ]; // ─── Config migration (runs after STOCK_DATA is defined) ─────────────────── (function migrateConfig() { const storedVersion = GM_getValue(PREFIX + 'cfg_version', ''); if (storedVersion === CONFIG_VERSION) return; console.log(`[TSA] Config version ${storedVersion} → ${CONFIG_VERSION}: clearing stale threshold/payout overrides`); for (const stock of STOCK_DATA) { GM_setValue(PREFIX + `payout_${stock.ticker}`, null); for (const { incr } of stock.increments) { GM_setValue(PREFIX + `thresh_${stock.ticker}_${incr}`, null); } } GM_setValue(PREFIX + 'cfg_version', CONFIG_VERSION); })(); // ─── CSS ────────────────────────────────────────────────────────────────────── GM_addStyle(` #tsa-root * { box-sizing: border-box; margin: 0; padding: 0; } #tsa-root { font-family: Arial, sans-serif; font-size: 13px; color: #e0e0e0; background: #16213e; border-radius: 6px; margin: 12px 0; overflow: hidden; } #tsa-header { background: #1a1a2e; border-bottom: 2px solid #e05a00; border-radius: 6px 6px 0 0; padding: 10px 14px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .tsa-title { color: #ff7700; font-size: 15px; font-weight: bold; } .tsa-version { font-size: 10px; opacity: 0.5; font-weight: normal; } .tsa-updated { color: #888; font-size: 11px; margin-left: auto; } .tsa-btn-primary { background: #e05a00; border: none; border-radius: 4px; color: #fff; padding: 4px 10px; cursor: pointer; font-size: 12px; } .tsa-btn-primary:hover { background: #ff7700; } .tsa-btn-secondary { background: #1a2a4a; border: 1px solid #2a4a7a; border-radius: 4px; color: #aaa; padding: 3px 8px; cursor: pointer; font-size: 11px; } .tsa-btn-secondary:hover { background: #2a3a5a; color: #fff; } #tsa-config-strip { background: #16213e; border-bottom: 1px solid #1a2a4a; padding: 7px 14px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 11px; color: #888; } .tsa-sep { color: #2a2a4a; } .tsa-key-ok { color: #44ee88; font-size: 10px; font-weight: bold; } .tsa-key-bad { color: #ff4444; font-size: 10px; font-weight: bold; } #tsa-config-panel { background: #111827; border-bottom: 2px solid #e05a00; padding: 10px 12px; display: none; } #tsa-config-panel.open { display: block; } .tsa-cfg-label { font-size: 9px; color: #666; text-transform: uppercase; letter-spacing: .5px; margin: 10px 0 5px; display: block; } .tsa-cfg-label:first-child { margin-top: 0; } .tsa-cfg-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; } .tsa-cfg-row label { font-size: 11px; color: #aaa; min-width: 160px; } .tsa-cfg-input { background: #0f3460; border: 1px solid #2a4a7a; border-radius: 4px; color: #fff; padding: 4px 8px; font-size: 12px; } .tsa-cfg-input:focus { outline: none; border-color: #ff7700; } .tsa-cfg-input option { background: #0f1a30; color: #e0e0e0; } .tsa-row-overbudget td { opacity: 0.4; } .tsa-cfg-note { font-size: 10px; color: #555; margin-top: 3px; } .tsa-cfg-check-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; } .tsa-cfg-check-row label { font-size: 11px; color: #aaa; } .tsa-accord { border: 1px solid #1a2a4a; border-radius: 4px; margin-bottom: 4px; } .tsa-accord-hdr { background: #0f1a30; padding: 5px 10px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: #aaa; border-radius: 4px; user-select: none; } .tsa-accord-hdr:hover { background: #1a2a4a; color: #fff; } .tsa-accord-ticker { background: #1a2a4a; color: #7aadff; font-size: 10px; font-weight: bold; padding: 1px 6px; border-radius: 3px; font-family: monospace; margin-right: 8px; } .tsa-accord-arrow { font-size: 10px; transition: transform .2s; } .tsa-accord-hdr.open .tsa-accord-arrow { transform: rotate(180deg); } .tsa-accord-body { display: none; padding: 8px 10px; background: #0a1020; border-top: 1px solid #1a2a4a; } .tsa-accord-body.open { display: block; } .tsa-incr-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; font-size: 11px; flex-wrap: wrap; } .tsa-incr-row label { color: #666; min-width: 130px; } .tsa-incr-row .tsa-cfg-input { width: 130px; } .tsa-incr-note { font-size: 10px; color: #444; } #tsa-stats-bar { background: #0f1a30; border-bottom: 1px solid #1a2a4a; padding: 10px 14px; display: flex; gap: 32px; flex-wrap: wrap; align-items: center; } .tsa-stat { display: flex; flex-direction: column; } .tsa-stat-label { font-size: 10px; color: #aaa; text-transform: uppercase; letter-spacing: .5px; } .tsa-stat-value { font-size: 16px; font-weight: bold; color: #ff7700; } #tsa-body { padding: 16px 14px; } #tsa-error { background: #2a0000; border: 1px solid #882200; border-radius: 5px; padding: 8px 12px; margin-bottom: 10px; font-size: 12px; color: #ff8866; display: none; } #tsa-loading { text-align: center; padding: 20px; color: #555; font-size: 12px; display: none; } .tsa-spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid #444; border-top-color: #ff7700; border-radius: 50%; animation: tsa-spin .7s linear infinite; vertical-align: middle; margin-right: 6px; } @keyframes tsa-spin { to { transform: rotate(360deg); } } .tsa-section { margin-bottom: 8px; padding-top: 18px; border-top: 1px solid #1a2a4a; } .tsa-section:first-child { padding-top: 0; border-top: none; } .tsa-section-title { color: #ff7700; font-size: 12px; font-weight: bold; text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px solid #333; padding-bottom: 6px; margin-bottom: 10px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none; } .tsa-section-title::after { content: '▾'; font-size: 10px; transition: transform .2s; } .tsa-section-title.collapsed::after { transform: rotate(-90deg); } /* Recommendation cards */ #tsa-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(270px,100%),1fr)); gap: 12px; margin-bottom: 10px; } .tsa-card { background: #1a1a2e; border: 1px solid #2a2a4a; border-radius: 6px; padding: 12px 14px; position: relative; } .tsa-card[title]:hover { border-color: #ff7700; background: #1e1e38; } .tsa-card-rank { position: absolute; top: 8px; right: 10px; font-size: 12px; font-weight: bold; color: #333; } .tsa-rank-gold { color: #ffcc44; } .tsa-rank-silver { color: #aaa; } .tsa-rank-bronze { color: #cc7722; } .tsa-card-head { font-size: 13px; font-weight: bold; color: #ccc; margin-bottom: 3px; padding-right: 24px; } .tsa-card-sub { margin-bottom: 8px; } .tsa-card-line { font-size: 11px; color: #888; margin-bottom: 3px; } .tsa-card-line strong { color: #e0e0e0; } .tsa-card-roi { font-size: 10px; color: #ff7700; font-weight: bold; margin-top: 7px; } .tsa-card-partial { border-top: 1px solid #2a2a4a; margin-top: 5px; padding-top: 4px; font-size: 10px; color: #ffaa00; } /* Holdings */ .tsa-hold-sublabel { font-size: 9px; color: #555; text-transform: uppercase; letter-spacing: .5px; margin: 12px 0 6px; } .tsa-hold-sublabel:first-child { margin-top: 0; } .tsa-hold-row { display: grid; grid-template-columns: 44px minmax(160px, 1fr) 80px 100px 130px; align-items: center; gap: 0 10px; padding: 7px 0; border-bottom: 1px solid #0d1525; font-size: 11px; } .tsa-hold-row:last-child { border-bottom: none; } .tsa-hold-name { color: #ccc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tsa-hold-val { color: #44ee88; text-align: right; } .tsa-hold-partial-txt { color: #ffaa00; font-size: 10px; } .tsa-hold-partial-row { display: grid; grid-template-columns: 44px minmax(160px,1fr) 44px auto; align-items: center; gap: 0 10px; padding: 5px 0; border-bottom: 1px solid #0d1525; font-size: 11px; } .tsa-hold-partial-row:last-child { border-bottom: none; } /* Table */ table.tsa-table { width: 100%; border-collapse: collapse; font-size: 11px; } table.tsa-table th { color: #666; font-weight: normal; font-size: 10px; text-transform: uppercase; letter-spacing: .5px; padding: 7px 10px; border-bottom: 2px solid #1a2a4a; text-align: left; white-space: nowrap; background: #0f1a30; } table.tsa-table th.r, table.tsa-table td.r { text-align: right; } table.tsa-table td { padding: 7px 10px; border-bottom: 1px solid #0d1525; color: #ccc; white-space: nowrap; vertical-align: middle; } table.tsa-table tbody tr:nth-child(even) td { background: #111827; } table.tsa-table tbody tr:nth-child(odd) td { background: #0f1520; } table.tsa-table tr:hover td { background: #1e1e36 !important; } table.tsa-table tr.tsa-row-ignored td { opacity: 0.25; } table.tsa-table tr.tsa-row-passive td { opacity: 0.40; } table.tsa-table th:first-child, table.tsa-table td:first-child { text-align: center; padding: 5px 6px; border-right: 1px solid #1a2a4a; } /* Badges */ .tsa-badge { font-size: 10px; padding: 1px 5px; border-radius: 3px; font-weight: bold; white-space: nowrap; display: inline-block; } .tsa-badge-ok { background: #004422; color: #44ee88; } .tsa-badge-warn { background: #2a1a00; color: #ff9900; } .tsa-badge-info { background: #0f3460; color: #7aadff; } .tsa-badge-muted { background: #111; color: #444; } .tsa-badge-energy { background: #1a2a00; color: #aaee44; } .tsa-badge-nerve { background: #330033; color: #dd44dd; } .tsa-badge-happy { background: #2a1a00; color: #ffcc44; } .tsa-badge-passive { background: #1a1a2e; color: #555; } .tsa-badge-cash { background: #004422; color: #44ee88; } .tsa-badge-item { background: #0f3460; color: #7aadff; } .tsa-badge-other { background: #111; color: #888; } .tsa-ticker { font-family: monospace; font-weight: bold; color: #fff; background: #0f1a30; padding: 1px 5px; border-radius: 3px; font-size: 11px; } .tsa-rank-num { font-size: 11px; font-weight: bold; color: #555; display: inline-block; width: 18px; text-align: right; } /* Swap table */ table.tsa-swap-table { width: 100%; border-collapse: collapse; font-size: 11px; } table.tsa-swap-table th { color: #666; font-weight: normal; font-size: 10px; text-transform: uppercase; letter-spacing: .5px; padding: 5px 10px; border-bottom: 2px solid #1a2a4a; text-align: left; white-space: nowrap; background: #0f1a30; } table.tsa-swap-table td { padding: 5px 10px; border-bottom: 1px solid #0d1525; color: #ccc; white-space: nowrap; vertical-align: middle; font-size: 11px; } table.tsa-swap-table tbody tr:nth-child(even) td { background: #111827; } table.tsa-swap-table tbody tr:nth-child(odd) td { background: #0f1520; } table.tsa-swap-table tr:hover td { background: #1e1e36 !important; } .tsa-swap-gain-pos { color: #44ee88; font-weight: bold; } .tsa-swap-gain-neg { color: #ff4444; } .tsa-swap-arrow { color: #ff7700; font-size: 12px; margin: 0 4px; } .tsa-swap-note { font-size: 10px; color: #555; margin-top: 14px; } /* Savings plan */ #tsa-savings-banner { margin-bottom: 12px; padding: 10px 14px; background: #001a00; border: 2px solid #44ee88; border-radius: 6px; font-size: 12px; color: #44ee88; display: flex; align-items: flex-start; gap: 10px; animation: tsa-pulse-green 2s ease-in-out infinite; } @keyframes tsa-pulse-green { 0%,100% { box-shadow: 0 0 0 0 rgba(68,238,136,0); } 50% { box-shadow: 0 0 8px 2px rgba(68,238,136,0.25); } } #tsa-savings-banner .tsa-ban-icon { font-size: 20px; flex-shrink: 0; } #tsa-savings-banner .tsa-ban-body { flex: 1; } #tsa-savings-banner .tsa-ban-title { font-weight: bold; font-size: 13px; margin-bottom: 4px; } #tsa-savings-banner .tsa-ban-sell { font-size: 11px; color: #88ddaa; margin-top: 4px; } #tsa-savings-banner .tsa-ban-dismiss { background: none; border: 1px solid #44ee88; color: #44ee88; border-radius: 3px; padding: 2px 8px; cursor: pointer; font-size: 10px; flex-shrink: 0; align-self: flex-start; } #tsa-savings-banner .tsa-ban-dismiss:hover { background: #44ee88; color: #000; } .tsa-plan-goal { background: #0f1a30; border: 1px solid #1a2a4a; border-radius: 6px; padding: 12px 14px; margin-bottom: 10px; } .tsa-plan-goal-hd { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; flex-wrap: wrap; gap: 6px; } .tsa-plan-goal-name { font-size: 13px; font-weight: bold; color: #fff; } .tsa-plan-goal-meta { font-size: 11px; color: #666; } .tsa-plan-progress { height: 8px; background: #0a1020; border-radius: 4px; overflow: hidden; margin: 6px 0; } .tsa-plan-progress-fill { height: 100%; border-radius: 4px; background: #ff7700; transition: width .4s ease; } .tsa-plan-progress-fill.done { background: #44ee88; } .tsa-plan-stats { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 8px; font-size: 11px; } .tsa-plan-stat { display: flex; flex-direction: column; gap: 2px; } .tsa-plan-stat-lbl { font-size: 9px; color: #555; text-transform: uppercase; letter-spacing: .5px; } .tsa-plan-stat-val { font-weight: bold; color: #ff7700; } .tsa-plan-recs { margin-top: 12px; } .tsa-plan-rec-hd { font-size: 9px; color: #555; text-transform: uppercase; letter-spacing: .5px; margin-bottom: 6px; } .tsa-plan-rec-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #0d1525; font-size: 11px; flex-wrap: wrap; } .tsa-plan-rec-row:last-child { border-bottom: none; } /* Stepping stone toggle in holdings */ .tsa-step-toggle { background: none; border: 1px solid #2a2a4a; border-radius: 3px; color: #444; font-size: 10px; padding: 1px 6px; cursor: pointer; transition: all .15s; white-space: nowrap; } .tsa-step-toggle:hover { border-color: #ff7700; color: #ff7700; } .tsa-step-toggle.active { background: #1a2a00; border-color: #44aa44; color: #44ee88; } #tsa-disclaimer { border-top: 1px solid #1a2a4a; padding: 7px 14px; font-size: 10px; color: #886600; background: #1a1500; text-align: center; line-height: 1.5; } #tsa-footer { border-top: 1px solid #1a2a4a; padding: 6px 12px; font-size: 10px; color: #444; display: flex; justify-content: space-between; } /* Dashboard collapse */ #tsa-root.collapsed > *:not(#tsa-header) { display: none !important; } #tsa-root.collapsed { border-radius: 6px; } #tsa-root.collapsed #tsa-header { border-radius: 6px; border-bottom: none; } `); // ─── Helpers ───────────────────────────────────────────────────────────────── function fmtMoney(v) { if (v === null || v === undefined || isNaN(v)) return '—'; if (v >= 1e9) return '$' + (v / 1e9).toFixed(2) + 'B'; if (v >= 1e6) return '$' + (v / 1e6).toFixed(2) + 'M'; if (v >= 1e3) return '$' + (v / 1e3).toFixed(1) + 'k'; return '$' + Math.round(v).toLocaleString(); } function fmtShares(n) { if (!n) return '0'; if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'; if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'; if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'; return n.toLocaleString(); } function fmtROI(dailyVal, cost) { if (!cost || !dailyVal || cost <= 0) return '—'; return ((dailyVal / cost) * 100).toFixed(3) + '%/day'; } function save(k, v) { GM_setValue(PREFIX + k, v); } function load(k, d) { const v = GM_getValue(PREFIX + k, d); return (v !== undefined && v !== null) ? v : d; } function wireCollapse(titleEl, contentEl, storeKey, def = 'open') { const saved = load(storeKey, def); if (saved === 'collapsed') { contentEl.style.display = 'none'; titleEl.classList.add('collapsed'); } titleEl.addEventListener('click', () => { const hidden = contentEl.style.display === 'none'; contentEl.style.display = hidden ? '' : 'none'; titleEl.classList.toggle('collapsed', !hidden); save(storeKey, hidden ? 'open' : 'collapsed'); }); } // ─── Config accessors ───────────────────────────────────────────────────────── const getApiKey = () => load('api_key', ''); const getIgnored = () => load('ignored', '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean); const getExNerve = () => load('ex_nerve', true); const getExEnergy = () => load('ex_energy', true); const getExHappy = () => load('ex_happy', false); const getExOther = () => load('ex_other', false); const getExPassive = () => load('ex_passive', true); const getRefreshMins = () => parseInt(load('refresh_mins', 5), 10); const getSwapNoSell = () => load('swap_no_sell', '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean); const getBudget = () => parseFloat(load('budget', '0')) || 0; const getBudgetPct = () => parseFloat(load('budget_pct', '110')) || 110; const getBudgetMode = () => load('budget_mode', 'grey'); const getTopN = () => parseInt(load('top_n', 5), 10); const getSavingsGoal = () => load('savings_goal', ''); const getSavingsInvest = () => parseFloat(load('savings_invest', '0')) || 0; // Stepping stone tags function isSteppingStone(ticker, incr) { return load(`step_${ticker}_${incr}`, '') === '1'; } function setSteppingStone(ticker, incr, val) { save(`step_${ticker}_${incr}`, val ? '1' : ''); } function getPayoutOverride(ticker, def) { const v = parseFloat(load(`payout_${ticker}`, '')); return (!isNaN(v) && v > 0) ? v : def; } function getThreshOverride(ticker, incr, def) { const v = parseInt(load(`thresh_${ticker}_${incr}`, ''), 10); return (!isNaN(v) && v > 0) ? v : def; } // ─── Payout short description builder ──────────────────────────────────────── // Used by the config accordion placeholder and sidebar if re-added in future. // heldBlocks: number of complete blocks the user holds for this stock. // Returns a plain string like "2× Drug Pack", "$50.00M", "200 energy", etc. function buildPayoutShort(stock, heldBlocks) { const qty = (stock.perIncrQty || 1) * heldBlocks; if (stock.ticker === 'PTS') { return `${heldBlocks * 100} pts`; } if (stock.payoutType === 'cash') { // payoutCashValue is always 0 — use DOM-read cache or user config override const perBlockVal = getPayoutOverride(stock.ticker, (() => { try { return JSON.parse(load('dom_dividends', '{}'))[stock.ticker] || 0; } catch { return 0; } })()); return perBlockVal > 0 ? fmtMoney(perBlockVal * heldBlocks) : 'see stocks page'; } if (stock.payoutType === 'energy') { return `${(stock.payoutUnits || 200) * heldBlocks} energy`; } if (stock.payoutType === 'nerve') { return `${(stock.payoutUnits || 50) * heldBlocks} nerve`; } if (stock.payoutType === 'happy') { return `${1000 * heldBlocks} happy`; } if (stock.payoutItemName) { // Shorten item names for compact display const itemShort = stock.payoutItemName .replace('Feathery Hotel Coupon', 'Hotel Coupon') .replace('Six-Pack of Alcohol', 'Six-Pack') .replace('Six-Pack of Energy Drink', 'Six-Pack') .replace('Box of Medical Supplies', 'Med Box') .replace('Box of Grenades', 'Grenade Box') .replace('Ammunition Pack', 'Ammo Pack') .replace('Lottery Voucher', 'Lotto Voucher') .replace("Lawyer's Business Card", 'Lawyer Card') .replace('Clothing Cache', 'Clothing Cache') .replace('Drug Pack', 'Drug Pack'); return `${qty}× ${itemShort}`; } // Fallback for 'other' type (e.g. HRG property) return stock.payoutDesc?.split(',')[0] || ''; } // ─── API ────────────────────────────────────────────────────────────────────── async function apiFetch(path, apiKey) { const sep = path.includes('?') ? '&' : '?'; const resp = await fetch(`${API_BASE}${path}${sep}key=${apiKey}&comment=${SCRIPT}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); if (data.error) throw new Error(`API ${data.error.code}: ${data.error.error}`); return data; } async function fetchItemPrice(apiKey, itemId) { // v2 itemmarket first, then v1 bazaar fallback, then torn/items market_value try { const resp = await fetch( `https://api.torn.com/v2/market/${itemId}?selections=itemmarket&key=${apiKey}&comment=${SCRIPT}` ); const data = await resp.json(); if (!data.error) { const listings = data.itemmarket || []; if (listings.length) return listings[0].cost || listings[0].price || 0; } } catch { /* fall through */ } try { const resp = await fetch( `https://api.torn.com/v1/market/${itemId}?selections=bazaar&key=${apiKey}&comment=${SCRIPT}` ); const data = await resp.json(); if (!data.error) { const listings = data.bazaar || []; if (listings.length) return listings[0].cost || listings[0].price || 0; } } catch { /* fall through */ } try { const resp = await fetch( `https://api.torn.com/v1/torn/${itemId}?selections=items&key=${apiKey}&comment=${SCRIPT}` ); const data = await resp.json(); if (!data.error) { const item = (data.items || {})[itemId]; if (item && item.market_value) return item.market_value; } } catch { /* give up */ } return 0; } /** * Resolve item names to Torn item IDs. Cached for 24h to avoid repeated calls. * Returns map of { itemName (lowercase) -> itemId }. */ async function resolveItemIds(apiKey, itemNames) { const CACHE_KEY = 'item_id_cache'; const CACHE_TIME = 'item_id_cache_time'; const TTL_MS = 24 * 60 * 60 * 1000; const cachedTime = load(CACHE_TIME, 0); const cachedData = load(CACHE_KEY, '{}'); let nameToId = {}; try { nameToId = JSON.parse(cachedData); } catch { nameToId = {}; } const needsRefresh = (Date.now() - cachedTime) > TTL_MS; const allCached = itemNames.every(n => nameToId[n.toLowerCase()] !== undefined); if (!needsRefresh && allCached) return nameToId; try { const resp = await fetch( `https://api.torn.com/v1/torn/?selections=items&key=${apiKey}&comment=${SCRIPT}` ); const data = await resp.json(); if (data.error) { console.warn('[TSA] item lookup error:', data.error); return nameToId; } const items = data.items || {}; for (const [id, item] of Object.entries(items)) { const name = (item.name || '').toLowerCase(); nameToId[name] = parseInt(id, 10); } save(CACHE_KEY, JSON.stringify(nameToId)); save(CACHE_TIME, Date.now()); } catch (e) { console.warn('[TSA] failed to fetch item IDs:', e); } return nameToId; } async function fetchUserStocks(apiKey) { const [userData, tornData] = await Promise.all([ apiFetch('/user/stocks', apiKey), apiFetch('/torn/stocks', apiKey), ]); // Build name→ticker lookup from STOCK_DATA const nameToTicker = {}; for (const s of STOCK_DATA) { nameToTicker[s.name.toLowerCase()] = s.ticker; } // Build id→ticker and ticker→price from /torn/stocks const idToTicker = {}; const prices = {}; const tornStocksRaw = tornData.stocks || {}; const tornStocksArr = Array.isArray(tornStocksRaw) ? tornStocksRaw : Object.entries(tornStocksRaw).map(([id, s]) => ({ ...s, _id: id })); for (const s of tornStocksArr) { const id = String(s.id || s._id || ''); const acronym = (s.acronym || '').toUpperCase(); const ticker = acronym || nameToTicker[(s.name || '').toLowerCase()] || ''; if (id && ticker) idToTicker[id] = ticker; const market = (typeof s.market === 'object' && s.market) ? s.market : {}; const price = parseFloat(market.price || market.current_price || s.price || s.current_price || 0); if (ticker && price > 0) prices[ticker] = price; } // Populate module-level tickerToId map for (const [id, tkr] of Object.entries(idToTicker)) { tickerToId[tkr] = id; } // DOM price reader — supplement API prices with what's visible on-page const domRows = document.querySelectorAll('#stockmarketroot table tr'); for (const row of domRows) { const cells = [...row.querySelectorAll('td')]; if (cells.length < 2) continue; for (let i = 0; i < cells.length - 1; i++) { const m = (cells[i].textContent || '').match(/\(([A-Z]{2,4})\)/); if (!m) continue; const ticker = m[1]; const numMatch = (cells[i + 1]?.textContent || '').replace(/,/g, '').match(/[\d]+\.?\d*/); const priceVal = numMatch ? parseFloat(numMatch[0]) : 0; if (priceVal > 0) prices[ticker] = priceVal; break; } } // Build ticker→shares and bonus timing from /user/stocks const holdings = {}; const bonusInfo = {}; const userStocksRaw = userData.stocks || {}; const userStocksArr = Array.isArray(userStocksRaw) ? userStocksRaw : Object.entries(userStocksRaw).map(([id, s]) => ({ ...s, _id: id })); for (const s of userStocksArr) { const id = String(s.id || s._id || ''); const ticker = idToTicker[id]; if (!ticker) continue; const n = typeof s.shares === 'number' ? s.shares : 0; if (n > 0) holdings[ticker] = (holdings[ticker] || 0) + n; // Store bonus info only if the object exists — never assume it's present if (s.bonus && typeof s.bonus === 'object') { bonusInfo[ticker] = { available: !!s.bonus.available, progress: s.bonus.progress || 0, frequency: s.bonus.frequency || 0, }; } } // DOM fallback for holdings if (Object.keys(holdings).length === 0) { const tableRows = document.querySelectorAll('#stockmarketroot table tr'); for (const row of tableRows) { const cells = row.querySelectorAll('td'); if (cells.length < 4) continue; const tickerMatch = (cells[0]?.textContent || '').match(/\(([A-Z]{2,4})\)/); if (!tickerMatch) continue; const ticker = tickerMatch[1]; const ownedText = (cells[3]?.textContent || '').replace(/[$,]/g, ''); const nums = ownedText.match(/\d+/g); if (nums && nums.length >= 2) { const shares = parseInt(nums[nums.length - 1], 10); if (!isNaN(shares) && shares > 0) holdings[ticker] = shares; } } } // ── DOM dividend reader ──────────────────────────────────────────────────── // Reads cash dividend values directly from the rendered stocks table so we // never rely on hardcoded guesses. The dividend column shows the TOTAL payout // for all held blocks, e.g. TCT with 3 blocks shows $3,000,000. // We divide by heldBlocks to get the per-block value and cache it with a 24h // TTL so it remains available after navigating away from the stocks page. // // Table column layout (confirmed from DOM inspection): // 0: Name cell containing "(TCK) Stock Name" // 1: Share price // 2: 24h change // 3: Dividend cell — contains text like "$3,000,000\n700,000" when held const DOM_DIV_CACHE_KEY = 'dom_dividends'; const DOM_DIV_TTL_MS = 24 * 60 * 60 * 1000; // Load existing cache — may contain values from a prior stocks-page visit let domDividends = {}; try { const cached = JSON.parse(load(DOM_DIV_CACHE_KEY, '{}')); const cacheTime = load(DOM_DIV_CACHE_KEY + '_ts', 0); if ((Date.now() - cacheTime) < DOM_DIV_TTL_MS) domDividends = cached; } catch { domDividends = {}; } // DOM read is only possible on the stocks page itself if (location.href.includes('sid=stocks')) { const freshDomDivs = {}; const tableRows = document.querySelectorAll('#stockmarketroot table tr'); for (const row of tableRows) { const cells = [...row.querySelectorAll('td')]; if (cells.length < 4) continue; const tickerMatch = (cells[0]?.textContent || '').match(/\(([A-Z]{2,4})\)/); if (!tickerMatch) continue; const ticker = tickerMatch[1]; const stockDef = STOCK_DATA.find(s => s.ticker === ticker); if (!stockDef || stockDef.payoutType !== 'cash') continue; // Extract the total dividend $ amount from the dividend cell const divText = (cells[3]?.textContent || '').replace(/,/g, ''); const divMatch = divText.match(/\$(\d+)/); if (!divMatch) continue; const totalDividend = parseInt(divMatch[1], 10); if (!totalDividend || totalDividend <= 0) continue; // Divide total by number of complete blocks held to derive per-block value const sharesHeld = holdings[ticker] || 0; let heldBlocks = 0; for (const { threshold } of stockDef.increments) { if (sharesHeld >= threshold) heldBlocks++; } if (heldBlocks <= 0) continue; const perBlockValue = Math.round(totalDividend / heldBlocks); if (perBlockValue > 0) { freshDomDivs[ticker] = perBlockValue; console.log(`[TSA] DOM dividend: ${ticker} = ${perBlockValue}/block (${totalDividend} total / ${heldBlocks} blocks)`); } } // Merge fresh readings into cache — unread tickers keep their previous values if (Object.keys(freshDomDivs).length > 0) { domDividends = { ...domDividends, ...freshDomDivs }; try { save(DOM_DIV_CACHE_KEY, JSON.stringify(domDividends)); save(DOM_DIV_CACHE_KEY + '_ts', Date.now()); } catch { /* ignore */ } } } return { holdings, prices, bonusInfo, domDividends }; } // ─── Scoring ────────────────────────────────────────────────────────────────── async function buildScores(apiKey) { const { holdings, prices, bonusInfo, domDividends } = await fetchUserStocks(apiKey); const itemStocks = STOCK_DATA.filter(s => s.payoutType === 'item'); const itemNames = [...new Set(itemStocks.map(s => s.payoutItemName).filter(Boolean))]; const nameToId = await resolveItemIds(apiKey, itemNames); // Assign resolved IDs (in-memory only) const resolvedIds = {}; for (const stock of itemStocks) { if (stock.payoutItemName) { const resolvedId = nameToId[stock.payoutItemName.toLowerCase()]; resolvedIds[stock.ticker] = resolvedId || stock.payoutItemId; } else if (stock.payoutItemId) { resolvedIds[stock.ticker] = stock.payoutItemId; } } const uniqueIds = [...new Set(Object.values(resolvedIds))].filter(Boolean); const itemPrices = {}; const [, pointsPrice] = await Promise.all([ Promise.all(uniqueIds.map(async id => { itemPrices[id] = await fetchItemPrice(apiKey, id); })), (async () => { try { const resp = await fetch( `https://api.torn.com/market/?selections=pointsmarket&key=${apiKey}&comment=${SCRIPT}` ); const data = await resp.json(); if (data.error) return 0; const listings = data.pointsmarket || {}; let cheapest = 0; for (const id of Object.keys(listings)) { const cost = listings[id].cost; if (cost && cost > 1000 && (!cheapest || cost < cheapest)) cheapest = cost; } return cheapest; } catch { return 0; } })(), ]); const ignoredTickers = getIgnored(); const excludeMap = { nerve: getExNerve(), energy: getExEnergy(), happy: getExHappy(), other: getExOther(), passive: getExPassive(), }; const rows = []; for (const stock of STOCK_DATA) { const { ticker, name, payoutType, payoutInterval, perIncrQty, payoutItemId, payoutCashValue, payoutDesc, increments } = stock; const ignored = ignoredTickers.includes(ticker); const excluded = excludeMap[payoutType] || false; const sharesHeld = holdings[ticker] || 0; const price = prices[ticker] || 0; let incrValue = 0; if (payoutType === 'item') { const resolvedId = resolvedIds[ticker] || payoutItemId; const unitPrice = resolvedId ? (itemPrices[resolvedId] || 0) : 0; incrValue = getPayoutOverride(ticker, unitPrice * perIncrQty); } else if (ticker === 'PTS' && pointsPrice > 0) { incrValue = getPayoutOverride(ticker, pointsPrice * 100); } else if (payoutType === 'cash') { // Priority: user config override > DOM-read live value > 0 // DOM value is read from the stocks page dividend column and cached for 24h. // payoutCashValue is always 0 for cash stocks — we do not hardcode these. const domVal = domDividends[ticker] || 0; incrValue = getPayoutOverride(ticker, domVal); } else { incrValue = getPayoutOverride(ticker, payoutCashValue); } const dailyValue = payoutInterval > 0 ? incrValue / payoutInterval : 0; for (let i = 0; i < increments.length; i++) { const { incr, threshold: defThresh } = increments[i]; const threshold = getThreshOverride(ticker, incr, defThresh); const prevThresh = i > 0 ? getThreshOverride(ticker, increments[i-1].incr, increments[i-1].threshold) : 0; const incrShares = threshold - prevThresh; const incrCost = incrShares * price; const sharesNeeded = Math.max(0, threshold - sharesHeld); const costToComplete = sharesNeeded * price; let status; if (ignored) status = 'ignored'; else if (sharesHeld >= threshold) status = 'held'; else if (sharesHeld > prevThresh) status = 'partial'; else status = (i === 0 || sharesHeld >= prevThresh) ? 'next' : 'future'; const budget = getBudget(); const budgetMax = budget > 0 ? budget * (getBudgetPct() / 100) : Infinity; const overBudget = budget > 0 && costToComplete > budgetMax; const scoreable = !ignored && !excluded && status !== 'held' && dailyValue > 0 && incrCost > 0; const dailyROI = scoreable ? dailyValue / incrCost : 0; rows.push({ ticker, name, incr, threshold, prevThresh, incrShares, incrCost, sharesHeld, sharesNeeded, costToComplete, price, payoutType, payoutInterval, payoutDesc, incrValue, dailyValue, status, ignored, excluded, scoreable, dailyROI, overBudget, score: 0, }); } } // Normalise scores to 0–10 const scoreable = rows.filter(r => r.scoreable); const maxROI = scoreable.length ? Math.max(...scoreable.map(r => r.dailyROI)) : 1; for (const r of rows) { r.score = (r.scoreable && maxROI > 0) ? Math.round((r.dailyROI / maxROI) * 100) / 10 : 0; } rows.sort((a, b) => { if (a.scoreable && b.scoreable) return b.score - a.score; if (a.scoreable) return -1; if (b.scoreable) return 1; if (a.status === 'held' && b.status !== 'held') return -1; if (a.status !== 'held' && b.status === 'held') return 1; return 0; }); return { rows, holdings, prices, bonusInfo }; } // ─── Swap advisor engine ───────────────────────────────────────────────────── function fmtNextPayout(info) { if (!info) return null; if (info.available) return 'Ready to collect now'; if (!info.frequency || info.frequency <= 0) return null; const daysLeft = info.frequency - info.progress; if (daysLeft <= 0) return 'Ready to collect now'; return `Next payout in ${daysLeft}d (${info.progress}/${info.frequency} days into cycle)`; } function buildSwaps(rows) { const noSellTickers = getSwapNoSell(); const heldByTicker = {}; for (const r of rows.filter(r => r.status === 'held' && !r.ignored && r.dailyValue > 0 && !noSellTickers.includes(r.ticker))) { if (!heldByTicker[r.ticker]) heldByTicker[r.ticker] = []; heldByTicker[r.ticker].push(r); } const targets = rows.filter(r => r.scoreable && r.score > 0 && r.costToComplete > 0 && r.dailyValue > 0 ); function makeSwap(sellTicker, sellName, sellType, sellDesc, cashReleased, dailyLost, interval, target, extraSells) { if (target.ticker === sellTicker) return null; if (target.costToComplete > cashReleased) return null; const netDailyGain = target.dailyValue - dailyLost; if (netDailyGain <= 0) return null; const transCost = dailyLost * (interval || 1); const paybackDays = Math.ceil(transCost / netDailyGain); return { sellTicker, sellName, sellType, sellDesc, cashReleased, dailyLost, transCost, leftoverCash: cashReleased - target.costToComplete, target, netDailyGain, paybackDays, extraSells: extraSells || [], combined: false, }; } const swaps = []; const sellProfiles = []; for (const [sellTicker, heldBlocks] of Object.entries(heldByTicker)) { const sorted = [...heldBlocks].sort((a, b) => b.incr - a.incr); const topBlock = sorted[0]; const price = topBlock.price; if (!price || price <= 0) continue; const totalShares = topBlock.sharesHeld; const cashFullSell = totalShares * price; const dailyLostFull = heldBlocks.reduce((s, r) => s + r.dailyValue, 0); const maxInterval = Math.max(...heldBlocks.map(r => r.payoutInterval || 0)); const fullTargets = targets .map(t => makeSwap(sellTicker, topBlock.name, 'full', `Sell all ${fmtShares(totalShares)} shares`, cashFullSell, dailyLostFull, maxInterval, t, [])) .filter(Boolean) .sort((a, b) => b.netDailyGain - a.netDailyGain) .slice(0, 2); swaps.push(...fullTargets); sellProfiles.push({ ticker: sellTicker, name: topBlock.name, cash: cashFullSell, dailyLost: dailyLostFull, interval: maxInterval, }); if (topBlock.prevThresh > 0) { const sellShares = totalShares - topBlock.prevThresh; const cashSellDown = sellShares * price; const dailyLostDown = topBlock.dailyValue; if (cashSellDown > 0) { const downTargets = targets .map(t => makeSwap(sellTicker, topBlock.name, 'down', `Drop to Block ${topBlock.incr - 1} (sell ${fmtShares(sellShares)} shares)`, cashSellDown, dailyLostDown, topBlock.payoutInterval || 1, t, [])) .filter(Boolean) .sort((a, b) => b.netDailyGain - a.netDailyGain) .slice(0, 2); swaps.push(...downTargets); } } } // Combined sell detection const profiles = sellProfiles; for (let i = 0; i < Math.min(profiles.length, 8); i++) { for (let j = i + 1; j < Math.min(profiles.length, 8); j++) { const pA = profiles[i]; const pB = profiles[j]; const combinedCash = pA.cash + pB.cash; const combinedLost = pA.dailyLost + pB.dailyLost; const combinedInterval = Math.max(pA.interval, pB.interval); const combinedTargets = targets .filter(t => t.ticker !== pA.ticker && t.ticker !== pB.ticker && t.costToComplete > pA.cash && t.costToComplete > pB.cash && t.costToComplete <= combinedCash ) .map(t => { const netDailyGain = t.dailyValue - combinedLost; if (netDailyGain <= 0) return null; const transCost = combinedLost * combinedInterval; const paybackDays = Math.ceil(transCost / netDailyGain); return { sellTicker: `${pA.ticker}+${pB.ticker}`, sellName: `${pA.name} + ${pB.name}`, sellType: 'combined', sellDesc: `Sell all ${pA.ticker} + all ${pB.ticker}`, cashReleased: combinedCash, dailyLost: combinedLost, transCost, leftoverCash: combinedCash - t.costToComplete, target: t, netDailyGain, paybackDays, extraSells: [pA.ticker, pB.ticker], combined: true, }; }) .filter(Boolean) .sort((a, b) => b.netDailyGain - a.netDailyGain) .slice(0, 1); swaps.push(...combinedTargets); } } swaps.sort((a, b) => { const aPb = isFinite(a.paybackDays) ? a.paybackDays : 999999; const bPb = isFinite(b.paybackDays) ? b.paybackDays : 999999; return aPb - bPb; }); return swaps; } // ─── UI build ───────────────────────────────────────────────────────────────── function buildUI() { const root = document.createElement('div'); root.id = 'tsa-root'; if (load('dashboard_collapsed', '0') === '1') root.classList.add('collapsed'); root.innerHTML = `