Greasy Fork is available in English.
Advises which stock blocks to buy next, ranked by daily ROI. Shows top N recommendation cards, your holdings, and a full collapsed rankings table.
当前为
// ==UserScript==
// @name Torn Stock Advisor
// @namespace torn_stock_advisor
// @version 1.4.1
// @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/page.php*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @connect api.torn.com
// ==/UserScript==
// ─── Changelog ───────────────────────────────────────────────────────────────
// v1.4.1 — Polish: table alternating rows, tighter column layout, ROI to 3dp.
// v1.4.0 — Feature: budget filter. Enter available cash in config; blocks
// above (budget × threshold%) are hidden from recommendations,
// greyed in table, or shown normally — user's choice.
// Budget and threshold % both configurable. Budget shown in strip.
// v1.3.2 — Cleanup: removed debug console.log statements. Script is stable.
// v1.3.1 — Fix: migrateConfig IIFE moved to after STOCK_DATA declaration
// to fix ReferenceError on initialization.
// v1.3.0 — Fix: add config version migration — stale threshold overrides
// from old versions are automatically cleared on script update,
// so corrected defaults always take effect without manual reset.
// v1.2.9 — Fix: all remaining passive stock thresholds corrected from in-game
// screenshots. WLT=9M, SYS=3M, LOS=7.5M, TCC=7.5M, IST=100k,
// MSG=300k. TCC confirmed as item (Clothing Cache) every 31 days.
// v1.2.8 — Fix: all thresholds and payouts corrected from in-game detail pages.
// GRN payout $8M (was $4M). TCT payout $3M (was $1M).
// ASS pays 2x Six Pack per block. LSC pays 2x Lottery Vouchers
// per block every 7 days (was 31 days). MCS B3 threshold corrected.
// v1.2.7 — UI: renamed all user-facing "increment"/"incr" labels to "block".
// v1.2.6 — Fix: TSB Block 1 threshold corrected to 3,000,000 (was 1,000,000).
// CNC Block 1 threshold corrected to 7,500,000 (was 1,000,000).
// SYM payout corrected: each increment = 1 Drug Pack (3x shown
// in game means user holds 3 increments). Thresholds verified
// from in-game stock detail pages.
// v1.2.5 — Fix: price correctly read from market.price nested object in
// torn/stocks API response. Recommendations de-duplicated per stock
// to show only the best next increment (not all future ones).
// v1.2.4 — Fix: log full torn/stocks first entry to see acronym value;
// DOM reader improved to handle Torn table structure where price
// is in a div inside the cell, not the raw text.
// v1.2.3 — Fix: /torn/stocks has no acronym field — now matches by stock name
// against STOCK_DATA to resolve ticker. DOM price reader fixed to
// scan all text content for ticker pattern, not just cells[0].
// v1.2.2 — Fix: stock price parsing hardened — log first torn/stocks entry to
// find correct field name; parse price as float; also read price
// directly from the DOM table as reliable fallback.
// v1.2.1 — Fix: stock price lookup now correctly falls back to torn/stocks
// price endpoint for all tickers regardless of payout type.
// Table redesigned: fewer columns, wider cells, cleaner headings.
// Recommendations now show when item prices resolve correctly.
// v1.2.0 — Fix: item price lookup switched from bazaar to itemmarket selection
// (bazaar returns empty for supply pack items). Also tries
// the torn/items endpoint for market_value as final fallback.
// v1.1.9 — Fix: item IDs resolved at runtime via /v2/torn/items API call,
// storing a name->id cache in GM storage. Eliminates hardcoded
// item IDs that were wrong. FHC=367 and Drug Pack=370 confirmed;
// all others now auto-resolved by name match.
// v1.1.8 — Fix: item market prices now use v1 API (confirmed working).
// FHG Feathery Hotel Coupon item ID corrected to 367.
// Item IDs for other stocks flagged for verification.
// v1.1.7 — Fix: item market price endpoint corrected to v2 path format.
// Holdings section now groups by ticker (not per-increment row).
// Recommendations now show correctly when item prices load.
// v1.1.6 — Fix: SyntaxError from malformed comment (newline in string literal)
// that prevented the script from loading entirely.
// v1.1.5 — Fix: API response fields corrected from live inspection.
// /user/stocks returns {id, shares, bonus{...}} — no acronym.
// /torn/stocks used to map id->acronym and get current prices.
// DOM table fallback retained for share counts.
// v1.1.4 — Fix: inject into #stockmarketroot (confirmed via DOM inspection).
// Holdings now read from dividendStatus{TICKER} DOM elements as
// fallback if API returns empty — these are always present on page.
// API parsing also logs full first-entry shape for debugging.
// v1.1.3 — Fix: injection now targets #mainContainer directly (right column);
// previous approach walked into the sidebar instead. API response
// parsing hardened with console debug output.
// v1.1.2 — Fix: injection completely rewritten — finds the stocks content div
// by walking up from the Stocks Filter element; falls back to
// appending to #mainContainer. Removed overly broad @match rule.
// v1.1.1 — Fix: API calls corrected to v2 /user/stocks and /torn/stocks endpoints
// (v1 selections= format was returning empty data). Injection point
// moved to sit directly above the stock table.
// v1.1.0 — Major overhaul:
// - Fixed payout logic: each increment pays its own per-increment
// value independently. Scoring uses incremental cost (shares for
// that block only × price) vs incremental daily payout value.
// - Added confirmed payout intervals (7-day vs 31-day) sourced from
// Torn wiki and community guides. All payouts normalised to daily
// value for fair cross-stock comparison.
// - UI redesigned: top N recommendation cards (default 5), collapsible
// holdings section showing active and partial increments, full
// rankings table collapsed by default.
// - Passive stocks (no active payout) separated; excluded by default
// but shown in table and configurable.
// - All ranked rows show incremental cost/ROI, not cumulative totals.
// v1.0.3 — Fix: SYM Drug Pack item ID corrected to 370.
// v1.0.2 — Fix: complete STOCK_DATA overhaul — all stocks named correctly.
// v1.0.1 — Fix: replaced GM_xmlHttpRequest with native fetch().
// 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 ───────────
const CONFIG_VERSION = '1.3.1';
// ─── Master stock data ────────────────────────────────────────────────────────
//
// PAYOUT LOGIC (Torn Stocks 3.0):
// Each "increment" pays its own per-increment value independently.
// Block 2 gives you a SECOND identical payout each cycle — not double total.
// Scoring: incremental cost (shares for THIS block only × price)
// vs incremental daily value (payout / interval in days).
//
// INTERVALS (sourced: Torn wiki + community guides, verified):
// 7 days: FHG, SYM, PRN, EWM, THS, LAG, BAG, MUN, PTS, EVL, MCS, CBD
// 31 days: GRN, TCT, TMI, IOU, ASS, TSB, CNC, HRG, LSC, 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
// (each block's incremental cost = threshold - prev threshold)
const STOCK_DATA = [
// ── 7-day active dividend stocks ─────────────────────────────────────────
{
ticker: 'FHG', 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', 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', 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', 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', 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', name: 'Legal Authorities Group',
payoutType: 'item', payoutInterval: 7,
perIncrQty: 1, payoutItemName: 'Lawyer Business Card', payoutItemId: null, 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', 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', 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', name: 'PointLess',
// Points value varies; user should set manual value in config
payoutType: 'other', payoutInterval: 7,
perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
payoutDesc: '100 points per block, every 7 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: 'EVL', 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', name: 'Mc Smoogle Corp',
payoutType: 'energy', payoutInterval: 7,
perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
payoutDesc: '200 energy per block, every 7 days',
// Confirmed: B2=1,050,000 (held), B3 needs +1,400,000 = 2,450,000 total
// Payout is 200 energy (not 100) per block per wiki screenshot
increments: [
{ incr: 1, threshold: 350000 },
{ incr: 2, threshold: 1050000 },
{ incr: 3, threshold: 2450000 },
{ incr: 4, threshold: 5250000 },
{ incr: 5, threshold: 10850000 },
],
},
{
ticker: 'CBD', name: 'Herbal Releaf Co.',
payoutType: 'nerve', payoutInterval: 7,
perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
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', name: 'Grain',
payoutType: 'cash', payoutInterval: 31,
perIncrQty: 1, payoutItemId: null, payoutCashValue: 8000000,
payoutDesc: '$8M per block, every 31 days',
increments: [
{ incr: 1, threshold: 500000 },
{ incr: 2, threshold: 1500000 },
{ incr: 3, threshold: 3500000 },
{ incr: 4, threshold: 7500000 },
{ incr: 5, threshold: 15500000 },
],
},
{
ticker: 'TCT', name: 'The Torn City Times',
payoutType: 'cash', payoutInterval: 31,
perIncrQty: 1, payoutItemId: null, payoutCashValue: 3000000,
payoutDesc: '$3M per block, every 31 days',
// Confirmed: B1=100k, B3=700k (you hold), B4 needs 800k more = 1,500k total
increments: [
{ incr: 1, threshold: 100000 },
{ incr: 2, threshold: 300000 },
{ incr: 3, threshold: 700000 },
{ incr: 4, threshold: 1500000 },
{ incr: 5, threshold: 3100000 },
],
},
{
ticker: 'TMI', name: 'TC Music Industries',
payoutType: 'cash', payoutInterval: 31,
perIncrQty: 1, payoutItemId: null, payoutCashValue: 25000000,
payoutDesc: '$25M per block, every 31 days',
increments: [
{ incr: 1, threshold: 6000000 },
{ incr: 2, threshold: 18000000 },
{ incr: 3, threshold: 42000000 },
{ incr: 4, threshold: 90000000 },
{ incr: 5, threshold: 186000000 },
],
},
{
ticker: 'IOU', name: 'Insured On Us',
// Base $12M + class-action lawsuit chance — value listed is base only
payoutType: 'cash', payoutInterval: 31,
perIncrQty: 1, payoutItemId: null, payoutCashValue: 12000000,
payoutDesc: '$12M per block, every 31 days (+ lawsuit payout chance)',
increments: [
{ incr: 1, threshold: 3000000 },
{ incr: 2, threshold: 9000000 },
{ incr: 3, threshold: 21000000 },
{ incr: 4, threshold: 45000000 },
{ incr: 5, threshold: 93000000 },
],
},
{
ticker: 'ASS', name: 'Alcoholics Synonymous',
payoutType: 'item', payoutInterval: 31,
perIncrQty: 2, payoutItemName: 'Six-Pack of Alcohol', payoutItemId: null, payoutCashValue: 0,
payoutDesc: '2× Six Pack of Alcohol per block, every 31 days',
// Confirmed: B1=3,000,000 shares, 2x Six Pack per block per cycle
increments: [
{ incr: 1, threshold: 3000000 },
{ incr: 2, threshold: 9000000 },
{ incr: 3, threshold: 21000000 },
{ incr: 4, threshold: 45000000 },
{ incr: 5, threshold: 93000000 },
],
},
{
ticker: 'TSB', name: 'Torn & Shanghai Banking',
payoutType: 'cash', payoutInterval: 31,
perIncrQty: 1, payoutItemId: null, payoutCashValue: 50000000,
payoutDesc: '$50M per block, every 31 days',
// Confirmed from in-game: Block 1 = 3,000,000 shares
increments: [
{ incr: 1, threshold: 3000000 },
{ incr: 2, threshold: 9000000 },
{ incr: 3, threshold: 21000000 },
{ incr: 4, threshold: 45000000 },
{ incr: 5, threshold: 93000000 },
],
},
{
ticker: 'CNC', name: 'Crude & Co',
payoutType: 'cash', payoutInterval: 31,
perIncrQty: 1, payoutItemId: null, payoutCashValue: 80000000,
payoutDesc: '$80M per block, every 31 days',
// Confirmed from in-game: Block 1 = 7,500,000 shares
increments: [
{ incr: 1, threshold: 7500000 },
{ incr: 2, threshold: 22500000 },
{ incr: 3, threshold: 52500000 },
{ incr: 4, threshold: 112500000 },
{ incr: 5, threshold: 232500000 },
],
},
{
ticker: 'LSC', name: 'Lucky Shot Casino',
// Confirmed: 2x Lottery Vouchers per block, every 7 days. B1=1,500,000 shares.
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', name: 'Home Retail Group',
// Random Property every 31 days (same payout type as LSC)
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', name: 'Torn City Clothing',
// Confirmed: B1=7,500,000 shares. 1x Clothing Cache every 31 days.
// Clothing Cache item ID unknown — set value manually in config.
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', 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', 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', 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', 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', 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', 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', name: 'Syscore MFG',
payoutType: 'passive', payoutInterval: 0,
perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
payoutDesc: 'Advanced firewall (passive)',
increments: [ { incr: 1, threshold: 3000000 } ],
},
{
ticker: 'ELT', 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', 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', 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', 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', name: 'Yazoo',
payoutType: 'passive', payoutInterval: 0,
perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
payoutDesc: 'Free banner advertising (passive)',
increments: [ { incr: 1, threshold: 1000000 } ],
},
{
ticker: 'IST', 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: 8px 12px;
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: 5px 12px; 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: 6px 12px; display: flex; gap: 24px; 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: 10px; }
#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: 12px; }
.tsa-section-title { color: #ff7700; font-size: 12px; font-weight: bold;
text-transform: uppercase; letter-spacing: 1px;
border-bottom: 1px solid #333; padding-bottom: 3px; margin-bottom: 8px;
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: 8px; margin-bottom: 4px; }
.tsa-card { background: #1a1a2e; border: 1px solid #2a2a4a; border-radius: 6px;
padding: 8px 10px; position: relative; }
.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: 5px; }
.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: 4px; }
.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-bottom: 4px; }
.tsa-hold-sublabel + .tsa-hold-sublabel,
.tsa-hold-row + .tsa-hold-sublabel { margin-top: 8px; }
.tsa-hold-row { display: flex; align-items: center; gap: 8px; padding: 4px 0;
border-bottom: 1px solid #111; flex-wrap: wrap; font-size: 11px; }
.tsa-hold-row:last-child { border-bottom: none; }
.tsa-hold-name { color: #ccc; flex: 1; min-width: 140px; }
.tsa-hold-val { color: #44ee88; }
.tsa-hold-partial-txt { color: #ffaa00; font-size: 10px; }
/* 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: 5px 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: 5px 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; }
/* Sticky rank column */
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; }
#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 getBudget = () => parseFloat(load('budget', '0')) || 0;
const getBudgetPct = () => parseFloat(load('budget_pct', '110')) || 110;
// budget_mode: 'hide' | 'grey' | 'show'
const getBudgetMode = () => load('budget_mode', 'grey');
const getTopN = () => parseInt(load('top_n', 5), 10);
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;
}
// ─── 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) {
// Try itemmarket selection first (works for supply pack items).
// Fall back to bazaar, then to item market_value from torn/items cache.
try {
const resp = await fetch(
`https://api.torn.com/v1/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 { /* try next */ }
// Try bazaar as fallback
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 { /* try next */ }
// Final fallback: use market_value from the torn/items cache we already loaded
try {
const cached = load('item_id_cache', '{}');
// item_id_cache stores name->id, not id->market_value, so this won't work directly.
// Instead, try the torn/items endpoint for this specific item's market_value.
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 using the v2 torn/items endpoint.
* Results are cached in GM storage for 24 hours to avoid repeated API calls.
* Returns a 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; // 24 hours
// Try cache first
const cachedTime = load(CACHE_TIME, 0);
const cachedData = load(CACHE_KEY, '{}');
let nameToId = {};
try { nameToId = JSON.parse(cachedData); } catch { nameToId = {}; }
// Check if all needed items are in cache and cache is fresh
const needsRefresh = (Date.now() - cachedTime) > TTL_MS;
const allCached = itemNames.every(n => nameToId[n.toLowerCase()] !== undefined);
if (!needsRefresh && allCached) {
return nameToId;
}
// Fetch from Torn API — v2 torn/items returns all items
// We use the v1 items endpoint which is simpler and returns {items:{id:{name,...}}}
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; // return whatever we have cached
}
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) {
// Confirmed API response shapes (from live inspection):
//
// GET /v2/torn/stocks:
// { stocks: { "1": { acronym, name, current_price, ... }, ... } }
//
// GET /v2/user/stocks:
// { stocks: { "4": { id, shares, transactions:[...], bonus:{...} }, ... } }
// NOTE: no acronym in user/stocks — must join on numeric id from torn/stocks.
const [userData, tornData] = await Promise.all([
apiFetch('/user/stocks', apiKey),
apiFetch('/torn/stocks', apiKey),
]);
// ── Build name→ticker lookup from STOCK_DATA ─────────────────────────────
// /torn/stocks returns name but no acronym field, so we match by name.
const nameToTicker = {};
for (const s of STOCK_DATA) {
nameToTicker[s.name.toLowerCase()] = s.ticker;
}
// ── Build id→ticker and ticker→price maps from /torn/stocks ──────────────
const idToTicker = {}; // "1" → "TSB"
const prices = {}; // "TSB" → 1183.85
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 || '');
// Try acronym field first, then name match
const acronym = (s.acronym || '').toUpperCase();
const ticker = acronym || nameToTicker[(s.name || '').toLowerCase()] || '';
if (id && ticker) idToTicker[id] = ticker;
// price is nested: { market: { price: 1184.08, ... } }
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;
}
// ── DOM price reader — read prices directly from the rendered stock table ──
// Each row: [name cell with "(TCK) Stock Name"] [price cell "NNN.NN"] ...
// The name cell is the first td that contains the (TICKER) pattern.
// The price cell is the NEXT sibling td after the name cell.
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 cellText = cells[i].textContent || '';
const m = cellText.match(/\(([A-Z]{2,4})\)/);
if (!m) continue;
const ticker = m[1];
// Price cell: take first continuous number sequence (ignores 24h change text)
const priceCell = cells[i + 1];
if (!priceCell) break;
// Remove commas, get first float-like number from the cell
const numMatch = (priceCell.textContent || '').replace(/,/g, '').match(/[\d]+\.?\d*/);
const priceVal = numMatch ? parseFloat(numMatch[0]) : 0;
if (priceVal > 0) {
prices[ticker] = priceVal;
}
break; // found ticker in this row, move on
}
}
// ── Build ticker→shares map from /user/stocks ─────────────────────────────
// Each entry: { id, shares (number = total held), bonus:{increment,...} }
const holdings = {};
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;
// shares is a plain number = total shares held across all blocks
const n = typeof s.shares === 'number' ? s.shares : 0;
if (n > 0) holdings[ticker] = (holdings[ticker] || 0) + n;
}
// ── DOM fallback: read Owned column from the stock table ──────────────────
// Catches any stocks missing from API (e.g. if key lacks full access).
// Table structure: Name | Price | 24h | Owned | Dividend
// Name cell contains "(TCK) Stock Name", Owned cell has investment + share count.
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 nameText = cells[0]?.textContent || '';
const tickerMatch = nameText.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;
}
}
}
return { holdings, prices };
}
// ─── Scoring ──────────────────────────────────────────────────────────────────
async function buildScores(apiKey) {
const { holdings, prices } = await fetchUserStocks(apiKey);
// Resolve item names to IDs via API (cached for 24h), then fetch market prices.
// This avoids hardcoding item IDs which can be wrong or change.
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 back to stock data (in-memory only, not mutating STOCK_DATA const)
const resolvedIds = {}; // ticker -> resolved item ID
for (const stock of itemStocks) {
if (stock.payoutItemName) {
const resolvedId = nameToId[stock.payoutItemName.toLowerCase()];
if (resolvedId) resolvedIds[stock.ticker] = resolvedId;
else if (stock.payoutItemId) resolvedIds[stock.ticker] = stock.payoutItemId; // fallback to hardcoded
} else if (stock.payoutItemId) {
resolvedIds[stock.ticker] = stock.payoutItemId;
}
}
// Parallel item price fetches using resolved IDs
const uniqueIds = [...new Set(Object.values(resolvedIds))].filter(Boolean);
const itemPrices = {};
await Promise.all(uniqueIds.map(async id => { itemPrices[id] = await fetchItemPrice(apiKey, id); }));
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;
// Resolve per-increment payout value
let incrValue = 0;
if (payoutType === 'item') {
const resolvedId = resolvedIds[ticker] || payoutItemId;
const unitPrice = resolvedId ? (itemPrices[resolvedId] || 0) : 0;
incrValue = getPayoutOverride(ticker, unitPrice * perIncrQty);
} else {
incrValue = getPayoutOverride(ticker, payoutCashValue);
}
// Daily value = payout per interval / interval length
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;
// INCREMENTAL cost — only the shares needed for this block, not cumulative
const incrShares = threshold - prevThresh;
const incrCost = incrShares * price;
const sharesNeeded = Math.max(0, threshold - sharesHeld);
const costToComplete = sharesNeeded * price;
// Determine status
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';
// Budget filter
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 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;
}
// Sort: scoreable by score desc → held → everything else
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 };
}
// ─── 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 = `
<div id="tsa-header">
<span class="tsa-title">Torn Stock Advisor <span class="tsa-version">v1.4.1</span></span>
<span id="tsa-updated" class="tsa-updated">Not loaded</span>
<button class="tsa-btn-secondary" id="tsa-cfg-toggle">⚙ Config</button>
<button class="tsa-btn-primary" id="tsa-refresh-btn">↻ Refresh</button>
<button class="tsa-btn-secondary" id="tsa-collapse-btn">${load('dashboard_collapsed','0')==='1'?'▼':'▲'}</button>
</div>
<div id="tsa-config-strip">
<span>API Key: <span id="tsa-key-status" class="tsa-key-bad">✗ Not set</span></span>
<span class="tsa-sep">|</span>
<span>Ignored: <span id="tsa-strip-ignored" style="color:#ff7700">None</span></span>
<span class="tsa-sep">|</span>
<span>Excl: <span id="tsa-strip-excl" style="color:#ff7700">None</span></span>
<span class="tsa-sep">|</span>
<span id="tsa-strip-budget-wrap">Budget: <span id="tsa-strip-budget" style="color:#ff7700">Off</span></span>
<span class="tsa-sep">|</span>
<span>Refresh: <span id="tsa-strip-refresh" style="color:#aaa">5 min</span></span>
</div>
<div id="tsa-config-panel">
<span class="tsa-cfg-label">API Key — requires <strong style="color:#ff7700">Stocks</strong> access level</span>
<div class="tsa-cfg-row">
<label>Torn API Key</label>
<input class="tsa-cfg-input" id="tsa-cfg-apikey" type="password" placeholder="Enter API key…" style="width:220px" />
<button class="tsa-btn-primary" id="tsa-cfg-save-key">Save Key</button>
</div>
<div class="tsa-cfg-note">Stored locally only — never sent except to api.torn.com.</div>
<span class="tsa-cfg-label">Ignore stocks (comma-separated tickers — removed from scoring and table)</span>
<div class="tsa-cfg-row">
<label>Ignored tickers</label>
<input class="tsa-cfg-input" id="tsa-cfg-ignored" type="text" placeholder="e.g. TCT, GRN" style="width:220px" />
</div>
<span class="tsa-cfg-label">Exclude payout types from scoring (still visible in table)</span>
<div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-nerve"> <label for="tsa-ex-nerve">Nerve payouts (e.g. CBD)</label></div>
<div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-energy"> <label for="tsa-ex-energy">Energy payouts (e.g. MCS)</label></div>
<div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-happy"> <label for="tsa-ex-happy">Happiness payouts (e.g. EVL)</label></div>
<div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-other"> <label for="tsa-ex-other">Other non-item payouts (points, properties — PTS, LSC, HRG)</label></div>
<div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-passive"> <label for="tsa-ex-passive">Passive stocks (no active payout — TCP, TCI, WLT etc.)</label></div>
<span class="tsa-cfg-label">Budget filter</span>
<div class="tsa-cfg-row">
<label>Available cash ($)</label>
<input class="tsa-cfg-input" id="tsa-cfg-budget" type="number" min="0" placeholder="e.g. 650000000" style="width:160px" />
<span style="font-size:10px;color:#555">Leave blank to disable budget filter</span>
</div>
<div class="tsa-cfg-row">
<label>Max cost (% of budget)</label>
<input class="tsa-cfg-input" id="tsa-cfg-budget-pct" type="number" min="100" max="500" style="width:70px" />
<span style="font-size:10px;color:#555">e.g. 110 = up to 10% above budget</span>
</div>
<div class="tsa-cfg-row">
<label>Over-budget blocks</label>
<select class="tsa-cfg-input" id="tsa-cfg-budget-mode" style="width:180px">
<option value="grey">Grey out (still visible in table)</option>
<option value="hide">Hide from recommendations</option>
<option value="show">Show everything (filter off)</option>
</select>
</div>
<span class="tsa-cfg-label">Display</span>
<div class="tsa-cfg-row">
<label>Recommendation cards to show</label>
<input class="tsa-cfg-input" id="tsa-cfg-topn" type="number" min="1" max="10" style="width:60px" />
</div>
<div class="tsa-cfg-row">
<label>Auto-refresh (minutes)</label>
<input class="tsa-cfg-input" id="tsa-cfg-refresh" type="number" min="1" max="60" style="width:60px" />
</div>
<span class="tsa-cfg-label">Per-stock payout value overrides & block thresholds
<span style="font-size:9px;color:#444;text-transform:none;margin-left:6px">Item stocks use live market price automatically; enter $ override to fix the value. Cash stocks use defaults from community data.</span>
</span>
<div id="tsa-cfg-accordions"></div>
<div style="margin-top:10px;display:flex;gap:8px">
<button class="tsa-btn-primary" id="tsa-cfg-save-all">Save All Config</button>
<button class="tsa-btn-secondary" id="tsa-cfg-reset">Reset Defaults</button>
</div>
</div>
<div id="tsa-stats-bar">
<div class="tsa-stat"><span class="tsa-stat-label">Active Blocks</span><span class="tsa-stat-value" id="tsa-s-active">—</span></div>
<div class="tsa-stat"><span class="tsa-stat-label">Partial</span><span class="tsa-stat-value" id="tsa-s-partial" style="color:#ffaa00">—</span></div>
<div class="tsa-stat"><span class="tsa-stat-label">Daily Payout</span><span class="tsa-stat-value" id="tsa-s-daily">—</span></div>
<div class="tsa-stat"><span class="tsa-stat-label">Best Daily ROI</span><span class="tsa-stat-value" id="tsa-s-roi">—</span></div>
<div class="tsa-stat"><span class="tsa-stat-label">Stocks Tracked</span><span class="tsa-stat-value">${STOCK_DATA.length}</span></div>
</div>
<div id="tsa-body">
<div id="tsa-error"></div>
<div id="tsa-loading"><span class="tsa-spinner"></span>Loading stock data…</div>
<div id="tsa-content" style="display:none">
<div class="tsa-section">
<div class="tsa-section-title" id="tsa-recs-title">Top Recommendations</div>
<div id="tsa-recs-content"><div id="tsa-cards"></div></div>
</div>
<div class="tsa-section">
<div class="tsa-section-title" id="tsa-hold-title">Your Holdings</div>
<div id="tsa-hold-content"></div>
</div>
<div class="tsa-section">
<div class="tsa-section-title" id="tsa-table-title">All Stocks — Full Rankings</div>
<div id="tsa-table-content">
<table class="tsa-table">
<thead><tr>
<th style="width:28px">#</th>
<th style="min-width:200px">Stock</th>
<th style="width:50px">Type</th>
<th style="min-width:90px; text-align:right">Payout</th>
<th style="width:48px; text-align:center">Cycle</th>
<th style="min-width:75px; text-align:right">Held</th>
<th style="min-width:85px; text-align:right">Cost</th>
<th style="min-width:75px; text-align:right">Daily</th>
<th style="min-width:100px; text-align:right">ROI · Score</th>
<th style="width:65px; text-align:center">Status</th>
</tr></thead>
<tbody id="tsa-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div id="tsa-footer">
<span>Torn Stock Advisor · TheOddSod (2640064)</span>
<span>Scores = incremental daily ROI · Item values via live market</span>
</div>
`;
return root;
}
// ─── Populate config ──────────────────────────────────────────────────────────
function populateConfig(root) {
root.querySelector('#tsa-cfg-apikey').value = getApiKey();
root.querySelector('#tsa-cfg-ignored').value = load('ignored', '');
root.querySelector('#tsa-cfg-refresh').value = getRefreshMins();
root.querySelector('#tsa-cfg-budget').value = getBudget() || '';
root.querySelector('#tsa-cfg-budget-pct').value = getBudgetPct();
root.querySelector('#tsa-cfg-budget-mode').value = getBudgetMode();
root.querySelector('#tsa-cfg-topn').value = getTopN();
root.querySelector('#tsa-ex-nerve').checked = getExNerve();
root.querySelector('#tsa-ex-energy').checked = getExEnergy();
root.querySelector('#tsa-ex-happy').checked = getExHappy();
root.querySelector('#tsa-ex-other').checked = getExOther();
root.querySelector('#tsa-ex-passive').checked = getExPassive();
const container = root.querySelector('#tsa-cfg-accordions');
container.innerHTML = '';
for (const stock of STOCK_DATA) {
const { ticker, name, payoutType, payoutInterval, payoutDesc, increments } = stock;
const iLabel = payoutInterval > 0 ? `every ${payoutInterval}d` : 'passive';
const accord = document.createElement('div');
accord.className = 'tsa-accord';
const hdr = document.createElement('div');
hdr.className = 'tsa-accord-hdr';
hdr.innerHTML = `
<span>
<span class="tsa-accord-ticker">${ticker}</span>
${name}
<span style="font-size:10px;color:#555;margin-left:6px">${iLabel}</span>
</span>
<span class="tsa-accord-arrow">▾</span>
`;
const body = document.createElement('div');
body.className = 'tsa-accord-body';
// Per-increment payout value override
const savedPayout = load(`payout_${ticker}`, '');
const placeholder = payoutType === 'item' ? 'auto (live market)' : (stock.payoutCashValue || '0');
const payoutRow = document.createElement('div');
payoutRow.className = 'tsa-incr-row';
payoutRow.innerHTML = `
<label>Per-incr value ($)</label>
<input class="tsa-cfg-input tsa-payout-inp" data-ticker="${ticker}"
type="number" value="${savedPayout}" placeholder="${placeholder}" style="width:130px" />
<span class="tsa-incr-note">${payoutDesc}</span>
`;
body.appendChild(payoutRow);
// Per-block threshold overrides
for (const { incr, threshold } of increments) {
const saved = getThreshOverride(ticker, incr, threshold);
const row = document.createElement('div');
row.className = 'tsa-incr-row';
row.innerHTML = `
<label>Block ${incr} threshold</label>
<input class="tsa-cfg-input tsa-thresh-inp" data-ticker="${ticker}" data-incr="${incr}"
type="number" value="${saved}" style="width:130px" />
<span class="tsa-incr-note">default: ${fmtShares(threshold)} shares total</span>
`;
body.appendChild(row);
}
hdr.addEventListener('click', () => {
hdr.classList.toggle('open');
body.classList.toggle('open');
});
accord.appendChild(hdr);
accord.appendChild(body);
container.appendChild(accord);
}
}
// ─── Config strip ─────────────────────────────────────────────────────────────
function updateStrip(root) {
const key = getApiKey();
const el = root.querySelector('#tsa-key-status');
el.textContent = key ? '✓ Connected' : '✗ Not set';
el.className = key ? 'tsa-key-ok' : 'tsa-key-bad';
const ig = getIgnored();
root.querySelector('#tsa-strip-ignored').textContent = ig.length ? ig.join(', ') : 'None';
const excl = [];
if (getExNerve()) excl.push('Nerve');
if (getExEnergy()) excl.push('Energy');
if (getExHappy()) excl.push('Happy');
if (getExOther()) excl.push('Other');
if (getExPassive()) excl.push('Passive');
root.querySelector('#tsa-strip-excl').textContent = excl.length ? excl.join(', ') : 'None';
root.querySelector('#tsa-strip-refresh').textContent = getRefreshMins() + ' min';
const budget = getBudget();
const budgetEl = root.querySelector('#tsa-strip-budget');
if (budgetEl) {
budgetEl.textContent = budget > 0
? fmtMoney(budget) + ' (' + getBudgetPct() + '%)'
: 'Off';
}
}
// ─── Render: recommendation cards ────────────────────────────────────────────
function renderCards(root, rows) {
const container = root.querySelector('#tsa-cards');
container.innerHTML = '';
const budgetMode = getBudgetMode();
// De-duplicate per stock, apply budget filter to recommendation cards
const seenTickers = new Set();
const dedupedRows = [];
for (const r of rows.filter(r => r.scoreable && r.score > 0)) {
// In 'hide' mode, exclude over-budget blocks from cards entirely
if (budgetMode === 'hide' && r.overBudget) continue;
if (!seenTickers.has(r.ticker)) {
seenTickers.add(r.ticker);
dedupedRows.push(r);
}
}
const topRows = dedupedRows.slice(0, getTopN());
if (!topRows.length) {
container.innerHTML = '<div style="color:#555;font-size:12px;padding:8px 0">No scoreable blocks — check config or API key.</div>';
return;
}
topRows.forEach((r, i) => {
const rank = i + 1;
const rankCls = rank === 1 ? 'tsa-rank-gold' : rank === 2 ? 'tsa-rank-silver' : rank === 3 ? 'tsa-rank-bronze' : '';
const iLabel = r.payoutInterval > 0 ? `every ${r.payoutInterval} days` : '';
let partialHtml = '';
if (r.status === 'partial') {
const pct = Math.round(((r.sharesHeld - r.prevThresh) / r.incrShares) * 100);
partialHtml = `<div class="tsa-card-partial">▶ Already hold ${fmtShares(r.sharesHeld - r.prevThresh)} / ${fmtShares(r.incrShares)} shares (${pct}% there)</div>`;
}
const card = document.createElement('div');
card.className = 'tsa-card';
card.innerHTML = `
<div class="tsa-card-rank ${rankCls}">#${rank}</div>
<div class="tsa-card-head">
<span class="tsa-ticker">${r.ticker}</span> ${r.name}
</div>
<div class="tsa-card-sub">
<span class="tsa-badge tsa-badge-info">Block ${r.incr}</span>
</div>
<div class="tsa-card-line">Payout: <strong>${r.incrValue > 0 ? fmtMoney(r.incrValue) : 'set in config'}</strong> ${iLabel}</div>
<div class="tsa-card-line">Need: <strong>${fmtShares(r.sharesNeeded)} shares</strong> · Cost: <strong>${fmtMoney(r.costToComplete)}</strong></div>
<div class="tsa-card-roi">Daily ROI: ${fmtROI(r.dailyValue, r.incrCost)} · Score: ${r.score.toFixed(1)}/10</div>
${r.overBudget ? `<div style="font-size:10px;color:#ff4444;margin-top:3px">⚠ Over budget (${fmtMoney(r.costToComplete)} needed)</div>` : ''}
${partialHtml}
`;
container.appendChild(card);
});
}
// ─── Render: holdings ────────────────────────────────────────────────────────
function renderHoldings(root, rows) {
const content = root.querySelector('#tsa-hold-content');
content.innerHTML = '';
const held = rows.filter(r => r.status === 'held' && !r.ignored);
const partial = rows.filter(r => r.status === 'partial' && !r.ignored);
if (!held.length && !partial.length) {
content.innerHTML = '<div style="color:#555;font-size:12px;padding:6px 0">No active or partial blocks detected.</div>';
return;
}
if (held.length) {
const lbl = document.createElement('div');
lbl.className = 'tsa-hold-sublabel';
lbl.textContent = 'Active blocks';
content.appendChild(lbl);
// Group by ticker — show one row per stock with total increment count + total daily value
const byTicker = {};
for (const r of held) {
if (!byTicker[r.ticker]) byTicker[r.ticker] = { r, count: 0, totalDaily: 0 };
byTicker[r.ticker].count++;
byTicker[r.ticker].totalDaily += r.dailyValue;
}
for (const { r, count, totalDaily } of Object.values(byTicker)) {
const iLabel = r.payoutInterval > 0 ? `every ${r.payoutInterval}d` : 'passive';
const row = document.createElement('div');
row.className = 'tsa-hold-row';
row.innerHTML = `
<span class="tsa-ticker">${r.ticker}</span>
<span class="tsa-hold-name">${r.name}</span>
<span class="tsa-badge tsa-badge-ok">${count} block${count > 1 ? 's' : ''} ✓</span>
<span class="tsa-hold-val">${totalDaily > 0 ? fmtMoney(totalDaily) + '/day' : '—'}</span>
<span style="color:#555;font-size:10px">${fmtShares(r.sharesHeld)} held · ${iLabel}</span>
`;
content.appendChild(row);
}
}
if (partial.length) {
const lbl = document.createElement('div');
lbl.className = 'tsa-hold-sublabel';
lbl.textContent = 'Partial — working toward next block';
content.appendChild(lbl);
for (const r of partial) {
const pct = Math.round(((r.sharesHeld - r.prevThresh) / r.incrShares) * 100);
const row = document.createElement('div');
row.className = 'tsa-hold-row';
row.innerHTML = `
<span class="tsa-ticker">${r.ticker}</span>
<span class="tsa-hold-name">${r.name} <span style="color:#555">block ${r.incr}</span></span>
<span class="tsa-badge tsa-badge-warn">${pct}%</span>
<span class="tsa-hold-partial-txt">${fmtShares(r.sharesHeld)} / ${fmtShares(r.threshold)} · need ${fmtShares(r.sharesNeeded)} more · ${fmtMoney(r.costToComplete)}</span>
`;
content.appendChild(row);
}
}
}
// ─── Render: table ───────────────────────────────────────────────────────────
function renderTable(root, rows) {
const tbody = root.querySelector('#tsa-tbody');
tbody.innerHTML = '';
let rankIdx = 0;
for (const r of rows) {
const tr = document.createElement('tr');
if (r.ignored) tr.classList.add('tsa-row-ignored');
else if (r.payoutType === 'passive') tr.classList.add('tsa-row-passive');
else if (r.overBudget && getBudgetMode() === 'grey') tr.classList.add('tsa-row-overbudget');
const active = r.scoreable && r.score > 0;
if (active) rankIdx++;
const rCls = rankIdx === 1 ? 'tsa-rank-gold'
: rankIdx === 2 ? 'tsa-rank-silver'
: rankIdx === 3 ? 'tsa-rank-bronze' : '';
const rankCell = active
? `<span class="tsa-rank-num ${rCls}">${rankIdx}</span>`
: `<span class="tsa-rank-num" style="color:#2a2a4a">—</span>`;
const typeBadgeMap = {
cash: 'tsa-badge-cash', item: 'tsa-badge-item',
energy: 'tsa-badge-energy', nerve: 'tsa-badge-nerve',
happy: 'tsa-badge-happy', other: 'tsa-badge-other',
passive: 'tsa-badge-passive',
};
const typeBadge = `<span class="tsa-badge ${typeBadgeMap[r.payoutType] || 'tsa-badge-muted'}">${r.payoutType}</span>`;
let statusBadge;
if (r.ignored) statusBadge = `<span class="tsa-badge tsa-badge-muted">Ignored</span>`;
else if (r.excluded) statusBadge = `<span class="tsa-badge tsa-badge-muted">Excl.</span>`;
else if (r.status === 'held') statusBadge = `<span class="tsa-badge tsa-badge-ok">Active ✓</span>`;
else if (r.status === 'partial') statusBadge = `<span class="tsa-badge tsa-badge-warn">Partial</span>`;
else if (r.status === 'next') statusBadge = `<span class="tsa-badge tsa-badge-info">Next</span>`;
else statusBadge = `<span class="tsa-badge tsa-badge-muted">Future</span>`;
const iLabel = r.payoutInterval > 0 ? `${r.payoutInterval}d` : 'passive';
const payoutCell = r.incrValue > 0
? `<span style="color:#ccc">${fmtMoney(r.incrValue)}</span>`
: `<span style="color:#333">—</span>`;
const heldCell = r.status === 'held'
? `<span style="color:#44ee88">${fmtShares(r.sharesHeld)} ✓</span>`
: `<span style="color:#666">${fmtShares(r.sharesHeld)}</span>`;
const overBudgetBadge = (r.overBudget && getBudgetMode() !== 'show' && r.status !== 'held')
? ` <span style="color:#ff4444;font-size:9px">▲</span>` : '';
const costCell = r.status === 'held'
? `<span style="color:#333">—</span>`
: r.costToComplete > 0
? `<span style="color:${r.overBudget ? '#ff4444' : r.status === 'partial' ? '#ffaa00' : '#aaa'}">${fmtMoney(r.costToComplete)}${overBudgetBadge}</span>`
: `<span style="color:#333">—</span>`;
const dailyCell = r.dailyValue > 0
? `<span style="color:#44ee88">${fmtMoney(r.dailyValue)}</span>`
: `<span style="color:#222">—</span>`;
const roiCell = active
? `<span style="color:#44ee88; font-size:10px">${fmtROI(r.dailyValue, r.incrCost)}</span>
<span style="color:#ff7700; font-weight:bold; margin-left:4px">${r.score.toFixed(1)}</span>`
: `<span style="color:#222">—</span>`;
tr.innerHTML = `
<td style="text-align:center">${rankCell}</td>
<td>
<span class="tsa-ticker" style="margin-right:4px">${r.ticker}</span>
<span style="color:#888; font-size:11px">${r.name}</span>
<span style="color:#444; font-size:10px; margin-left:4px">B${r.incr}</span>
</td>
<td>${typeBadge}</td>
<td style="text-align:right">${payoutCell}</td>
<td style="text-align:center; color:#555; font-size:10px">${iLabel}</td>
<td style="text-align:right">${heldCell}</td>
<td style="text-align:right">${costCell}</td>
<td style="text-align:right">${dailyCell}</td>
<td style="text-align:right">${roiCell}</td>
<td style="text-align:center">${statusBadge}</td>
`;
tbody.appendChild(tr);
}
}
// ─── Stats bar ────────────────────────────────────────────────────────────────
function updateStats(root, rows) {
const active = rows.filter(r => r.status==='held' && !r.ignored).length;
const partial = rows.filter(r => r.status==='partial' && !r.ignored).length;
const daily = rows.filter(r => r.status==='held' && !r.ignored && !r.excluded)
.reduce((s, r) => s + r.dailyValue, 0);
const best = rows.find(r => r.scoreable && r.score > 0);
root.querySelector('#tsa-s-active').textContent = active;
root.querySelector('#tsa-s-partial').textContent = partial;
root.querySelector('#tsa-s-daily').textContent = fmtMoney(daily);
root.querySelector('#tsa-s-roi').textContent = best ? fmtROI(best.dailyValue, best.incrCost) : '—';
}
// ─── Load & render ────────────────────────────────────────────────────────────
async function loadAndRender(root) {
const apiKey = getApiKey();
const errEl = root.querySelector('#tsa-error');
const loadEl = root.querySelector('#tsa-loading');
const contEl = root.querySelector('#tsa-content');
errEl.style.display = 'none';
loadEl.style.display = 'block';
contEl.style.display = 'none';
if (!apiKey) {
loadEl.style.display = 'none';
errEl.style.display = 'block';
errEl.textContent = '⚠ No API key set — open ⚙ Config and enter your Torn API key (Stocks access required).';
return;
}
try {
const { rows } = await buildScores(apiKey);
loadEl.style.display = 'none';
contEl.style.display = 'block';
renderCards(root, rows);
renderHoldings(root, rows);
renderTable(root, rows);
updateStats(root, rows);
root.querySelector('#tsa-updated').textContent = 'Updated ' + new Date().toLocaleTimeString();
} catch (e) {
loadEl.style.display = 'none';
errEl.style.display = 'block';
errEl.textContent = '⚠ Error: ' + e.message;
console.error('[TSA]', e);
}
}
// ─── Event wiring ─────────────────────────────────────────────────────────────
function wireEvents(root) {
root.querySelector('#tsa-collapse-btn').addEventListener('click', () => {
const c = root.classList.toggle('collapsed');
root.querySelector('#tsa-collapse-btn').textContent = c ? '▼' : '▲';
save('dashboard_collapsed', c ? '1' : '0');
});
root.querySelector('#tsa-cfg-toggle').addEventListener('click', () => {
root.querySelector('#tsa-config-panel').classList.toggle('open');
});
root.querySelector('#tsa-cfg-save-key').addEventListener('click', () => {
save('api_key', root.querySelector('#tsa-cfg-apikey').value.trim());
updateStrip(root);
loadAndRender(root);
});
root.querySelector('#tsa-cfg-save-all').addEventListener('click', () => {
save('ignored', root.querySelector('#tsa-cfg-ignored').value);
save('ex_nerve', root.querySelector('#tsa-ex-nerve').checked);
save('ex_energy', root.querySelector('#tsa-ex-energy').checked);
save('ex_happy', root.querySelector('#tsa-ex-happy').checked);
save('ex_other', root.querySelector('#tsa-ex-other').checked);
save('ex_passive', root.querySelector('#tsa-ex-passive').checked);
save('refresh_mins', root.querySelector('#tsa-cfg-refresh').value);
save('budget', root.querySelector('#tsa-cfg-budget').value);
save('budget_pct', root.querySelector('#tsa-cfg-budget-pct').value);
save('budget_mode', root.querySelector('#tsa-cfg-budget-mode').value);
save('top_n', root.querySelector('#tsa-cfg-topn').value);
root.querySelectorAll('.tsa-payout-inp').forEach(el => {
save(`payout_${el.dataset.ticker}`, el.value);
});
root.querySelectorAll('.tsa-thresh-inp').forEach(el => {
save(`thresh_${el.dataset.ticker}_${el.dataset.incr}`, el.value);
});
updateStrip(root);
resetTimer(root);
loadAndRender(root);
});
root.querySelector('#tsa-cfg-reset').addEventListener('click', () => {
if (!confirm('Reset all config to defaults? Your API key will be kept.')) return;
const key = getApiKey();
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);
}
}
['ignored','ex_nerve','ex_energy','ex_happy','ex_other','ex_passive','refresh_mins','top_n'].forEach(k => {
GM_setValue(PREFIX + k, null);
});
save('api_key', key);
populateConfig(root);
updateStrip(root);
loadAndRender(root);
});
root.querySelector('#tsa-refresh-btn').addEventListener('click', () => loadAndRender(root));
// Sections: recs and holdings open by default, table collapsed
wireCollapse(root.querySelector('#tsa-recs-title'), root.querySelector('#tsa-recs-content'), 'collapse_recs', 'open');
wireCollapse(root.querySelector('#tsa-hold-title'), root.querySelector('#tsa-hold-content'), 'collapse_holdings', 'open');
wireCollapse(root.querySelector('#tsa-table-title'), root.querySelector('#tsa-table-content'), 'collapse_table', 'collapsed');
}
// ─── Timer & injection ────────────────────────────────────────────────────────
function resetTimer(root) {
clearInterval(window._tsaRefreshTimer);
window._tsaRefreshTimer = setInterval(() => loadAndRender(root), getRefreshMins() * 60 * 1000);
}
function inject() {
if (!location.href.includes('sid=stocks')) return;
if (document.getElementById('tsa-root')) return;
let attempts = 0;
const tryInsert = setInterval(() => {
attempts++;
if (attempts > 30) { clearInterval(tryInsert); return; }
// #stockmarketroot is the confirmed container for all stock market content.
// We insert our panel as its first child, above the Stocks Filter and table.
const smRoot = document.getElementById('stockmarketroot');
if (!smRoot || !smRoot.firstChild) return;
clearInterval(tryInsert);
const root = buildUI();
smRoot.insertBefore(root, smRoot.firstChild);
populateConfig(root);
updateStrip(root);
wireEvents(root);
loadAndRender(root);
resetTimer(root);
}, 500);
}
inject();
window.addEventListener('hashchange', () => { setTimeout(inject, 400); });
})();