Two-click stock buyer for Torn City - navigate and buy max shares with floppy icon
// ==UserScript==
// @name Torn Vault
// @namespace torn-vault
// @version 2.0.0
// @description Two-click stock buyer for Torn City - navigate and buy max shares with floppy icon
// @author Qctsu
// @license MIT
// @match https://www.torn.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// ==/UserScript==
(function () {
'use strict';
// --- Config ---
let CFG = {
mode: GM_getValue('mode', 'stock'), // 'stock' or 'vault'
stockId: GM_getValue('stockId', 10), // default: Crude & Co
stockName: GM_getValue('stockName', 'Crude & Co'),
autoClick: GM_getValue('autoClick', false) // auto-click buy button
};
function saveCfg() {
GM_setValue('mode', CFG.mode);
GM_setValue('stockId', CFG.stockId);
GM_setValue('stockName', CFG.stockName);
GM_setValue('autoClick', CFG.autoClick);
}
// --- Stock list (from Torn API, sorted by price desc) ---
var STOCKS = [
{ id: 2, name: 'Torn City Investments', acronym: 'TCI' },
{ id: 1, name: 'Torn & Shanghai Banking', acronym: 'TSB' },
{ id: 10, name: 'Crude & Co', acronym: 'CNC' },
{ id: 15, name: 'Feathery Hotels Group', acronym: 'FHG' },
{ id: 29, name: 'Mc Smoogle Corp', acronym: 'MCS' },
{ id: 30, name: 'Wind Lines Travel', acronym: 'WLT' },
{ id: 16, name: 'Symbiotic Ltd.', acronym: 'SYM' },
{ id: 3, name: 'Syscore MFG', acronym: 'SYS' },
{ id: 28, name: 'Evil Ducks Candy Corp', acronym: 'EVL' },
{ id: 18, name: 'Performance Ribaldry', acronym: 'PRN' },
{ id: 24, name: 'Munster Beverage Corp.', acronym: 'MUN' },
{ id: 17, name: 'Lucky Shot Casino', acronym: 'LSC' },
{ id: 26, name: 'International School TC', acronym: 'IST' },
{ id: 13, name: 'TC Media Productions', acronym: 'TCP' },
{ id: 31, name: 'Torn City Clothing', acronym: 'TCC' },
{ id: 27, name: "Big Al's Gun Shop", acronym: 'BAG' },
{ id: 4, name: 'Legal Authorities Group', acronym: 'LAG' },
{ id: 33, name: 'Herbal Releaf Co.', acronym: 'CBD' },
{ id: 7, name: 'Torn City Health Service', acronym: 'THS' },
{ id: 32, name: 'Alcoholics Synonymous', acronym: 'ASS' },
{ id: 9, name: 'The Torn City Times', acronym: 'TCT' },
{ id: 21, name: 'Empty Lunchbox Traders', acronym: 'ELT' },
{ id: 6, name: 'Grain', acronym: 'GRN' },
{ id: 20, name: 'Torn City Motors', acronym: 'TCM' },
{ id: 19, name: 'Eaglewood Mercenary', acronym: 'EWM' },
{ id: 11, name: 'Messaging Inc.', acronym: 'MSG' },
{ id: 22, name: 'Home Retail Group', acronym: 'HRG' },
{ id: 12, name: 'TC Music Industries', acronym: 'TMI' },
{ id: 5, name: 'Insured On Us', acronym: 'IOU' },
{ id: 23, name: 'Tell Group Plc.', acronym: 'TGP' },
{ id: 14, name: 'I Industries Ltd.', acronym: 'IIL' },
{ id: 25, name: 'West Side University', acronym: 'WSU' },
{ id: 34, name: 'Lo Squalo Waste', acronym: 'LOS' },
{ id: 35, name: 'PointLess', acronym: 'PTS' },
{ id: 8, name: 'Yazoo', acronym: 'YAZ' }
];
// --- DOM helpers ---
function setReactInput(input, value) {
var nativeSet = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
nativeSet.call(input, String(value));
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
function waitFor(selector, timeout) {
timeout = timeout || 10000;
return new Promise(function (resolve, reject) {
var el = document.querySelector(selector);
if (el) { resolve(el); return; }
var timer = setTimeout(function () {
obs.disconnect();
reject(new Error('timeout waiting for ' + selector));
}, timeout);
var obs = new MutationObserver(function () {
var el = document.querySelector(selector);
if (el) {
obs.disconnect();
clearTimeout(timer);
resolve(el);
}
});
obs.observe(document.body, { childList: true, subtree: true });
});
}
// wait for "Confirm Transaction" button to appear (price update may delay it), then click
function waitForConfirm(buyBlock) {
return new Promise(function (resolve) {
var attempts = 0;
var maxAttempts = 40; // 10s max
var interval = setInterval(function () {
attempts++;
var btns = buyBlock.querySelectorAll('button.buy___fcM6a');
for (var i = 0; i < btns.length; i++) {
if (btns[i].textContent.trim().toLowerCase().indexOf('confirm') !== -1) {
clearInterval(interval);
btns[i].click();
showToast('Confirmed! Shares purchased.', 'ok');
resolve();
return;
}
}
if (attempts >= maxAttempts) {
clearInterval(interval);
showToast('Confirm button did not appear. Check manually.', 'warn');
resolve();
}
}, 250);
});
}
// --- Settings modal ---
function showSettings() {
var existing = document.getElementById('tv-settings');
if (existing) { existing.remove(); return; }
var overlay = document.createElement('div');
overlay.id = 'tv-settings';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:99999;display:flex;align-items:center;justify-content:center';
var box = document.createElement('div');
box.style.cssText = 'background:#2a2a2a;border:1px solid #555;border-radius:6px;min-width:320px;max-width:400px;max-height:90vh;color:#ccc;font:13px/1.5 Arial,sans-serif;display:flex;flex-direction:column';
var inputStyle = 'width:100%;box-sizing:border-box;padding:4px 6px;background:#1a1a1a;border:1px solid #555;color:#fff;border-radius:3px;margin-top:2px;-moz-appearance:textfield';
var selectStyle = 'width:100%;box-sizing:border-box;padding:4px 28px 4px 6px;background:#1a1a1a;border:1px solid #555;color:#fff;border-radius:3px;margin-top:2px;-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer';
var stockOptions = STOCKS.map(function (s) {
var sel = s.id === CFG.stockId ? ' selected' : '';
return '<option value="' + s.id + '"' + sel + '>' + s.acronym + ' - ' + s.name + '</option>';
}).join('');
box.innerHTML = '<style>'
+ '#tv-settings select::-webkit-scrollbar{width:6px}'
+ '#tv-settings select::-webkit-scrollbar-track{background:#1a1a1a;border-radius:3px}'
+ '#tv-settings select::-webkit-scrollbar-thumb{background:#555;border-radius:3px}'
+ '#tv-settings .tv-select-wrap{position:relative;display:block}'
+ '#tv-settings .tv-select-wrap::after{content:"";position:absolute;right:10px;top:50%;transform:translateY(-50%);border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid #888;pointer-events:none}'
+ '</style>'
+ '<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #444">'
+ '<div style="font-size:14px;font-weight:bold;color:#fff">Torn Vault</div>'
+ '<span id="tv-close" style="cursor:pointer;color:#888;font-size:18px;line-height:1;padding:0 2px" title="Close">×</span></div>'
+ '<div style="padding:16px;overflow-y:auto;flex:1">'
+ '<label style="display:block;margin-bottom:10px">Mode<br>'
+ '<span class="tv-select-wrap"><select id="tv-mode" style="' + selectStyle + '">'
+ '<option value="stock"' + (CFG.mode === 'stock' ? ' selected' : '') + '>Stock Market</option>'
+ '<option value="vault"' + (CFG.mode === 'vault' ? ' selected' : '') + '>PI Vault</option>'
+ '</select></span></label>'
+ '<div id="tv-stock-section"><label style="display:block;margin-bottom:10px">Stock<br>'
+ '<span class="tv-select-wrap"><select id="tv-stock" style="' + selectStyle + '">'
+ stockOptions
+ '</select></span></label></div>'
+ '<div id="tv-vault-note" style="display:none;font-size:11px;color:#aa8;padding:6px 8px;background:#1a1a1a;border-radius:3px;border:1px solid #444;margin-bottom:10px">'
+ 'PI Vault: not yet implemented. Coming soon.</div>'
+ '<label style="display:flex;align-items:center;gap:8px;margin-bottom:10px;cursor:pointer">'
+ '<input id="tv-autoclick" type="checkbox"' + (CFG.autoClick ? ' checked' : '') + ' style="width:16px;height:16px;accent-color:#080;cursor:pointer">'
+ '<span>Instant buy<br><span style="font-size:11px;color:#888">Fills max shares, clicks Buy and confirms in one go. Off = fills amount and highlights Buy button for you to click.</span></span></label>'
+ '</div>'
+ '<div style="text-align:right;padding:12px 16px;border-top:1px solid #444">'
+ '<button id="tv-cancel" style="padding:4px 12px;margin-right:6px;background:#555;border:none;color:#ccc;border-radius:3px;cursor:pointer">Cancel</button>'
+ '<button id="tv-save" style="padding:4px 12px;background:#080;border:none;color:#fff;border-radius:3px;cursor:pointer">Save</button></div>';
overlay.appendChild(box);
document.body.appendChild(overlay);
overlay.addEventListener('click', function (e) { if (e.target === overlay) overlay.remove(); });
document.getElementById('tv-close').addEventListener('click', function () { overlay.remove(); });
document.getElementById('tv-cancel').addEventListener('click', function () { overlay.remove(); });
function toggleSections() {
var isStock = document.getElementById('tv-mode').value === 'stock';
document.getElementById('tv-stock-section').style.display = isStock ? '' : 'none';
document.getElementById('tv-vault-note').style.display = isStock ? 'none' : '';
}
document.getElementById('tv-mode').addEventListener('change', toggleSections);
toggleSections();
document.getElementById('tv-save').addEventListener('click', function () {
CFG.mode = document.getElementById('tv-mode').value;
CFG.autoClick = document.getElementById('tv-autoclick').checked;
var stockSelect = document.getElementById('tv-stock');
CFG.stockId = parseInt(stockSelect.value, 10);
CFG.stockName = stockSelect.options[stockSelect.selectedIndex].text;
saveCfg();
overlay.remove();
updateBtnTooltip();
});
}
GM_registerMenuCommand('Torn Vault Settings', showSettings);
// --- Floppy disk button ---
var tornVaultBtn = null;
function updateBtnTooltip() {
if (!tornVaultBtn) return;
if (CFG.mode === 'stock') {
tornVaultBtn.title = 'Buy max shares: ' + CFG.stockName + '\nRight-click for settings';
} else {
tornVaultBtn.title = 'Deposit to PI Vault\nRight-click for settings';
}
}
function createBtn() {
var moneyEl = document.getElementById('user-money');
if (!moneyEl) return;
var container = moneyEl.closest('.point-block___rQyUK');
if (!container) return;
if (container.querySelector('.tv-btn')) return;
var btn = document.createElement('span');
btn.className = 'tv-btn';
btn.style.cssText = 'cursor:pointer;display:inline-flex;align-items:center;justify-content:center;'
+ 'margin-left:5px;vertical-align:middle;opacity:0.7;transition:opacity 0.2s';
// floppy disk SVG
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
+ '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>'
+ '<polyline points="17 21 17 13 7 13 7 21"/>'
+ '<polyline points="7 3 7 8 15 8"/>'
+ '</svg>';
btn.addEventListener('mouseenter', function () { btn.style.opacity = '1'; });
btn.addEventListener('mouseleave', function () { btn.style.opacity = '0.7'; });
btn.addEventListener('contextmenu', function (e) {
e.preventDefault();
e.stopPropagation();
showSettings();
});
btn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
executeAction();
});
container.appendChild(btn);
tornVaultBtn = btn;
updateBtnTooltip();
}
// --- Execute action ---
function isUnavailable() {
// hospital: icon15___
var hospEl = document.querySelector('li[class*="icon15___"] a[aria-label*="Hospital"]');
if (hospEl) return 'You are in hospital';
// traveling: icon17___
var travelEl = document.querySelector('li[class*="icon17___"] a[aria-label*="Traveling"], li[class*="icon17___"] a[aria-label*="travel"]');
if (travelEl) return 'You are traveling';
// abroad (landed): icon18___
var abroadEl = document.querySelector('li[class*="icon18___"] a[aria-label*="Abroad"]');
if (abroadEl) return 'You are abroad';
return null;
}
function executeAction() {
var blocked = isUnavailable();
if (blocked) {
showToast(blocked + ' - cannot trade', 'warn');
return;
}
if (CFG.mode === 'vault') {
showToast('PI Vault not yet implemented', 'warn');
return;
}
if (CFG.mode === 'stock') {
buyStock();
}
}
function buyStock() {
var stockUrl = 'https://www.torn.com/page.php?sid=stocks';
var onStockPage = window.location.href.indexOf('sid=stocks') !== -1;
if (onStockPage) {
executeBuyOnPage();
} else {
showToast('Navigating to Stock Market...', 'info');
window.location.href = stockUrl;
}
}
async function executeBuyOnPage() {
var stockId = CFG.stockId;
var stockName = CFG.stockName;
showToast('Looking for ' + stockName + '...', 'info');
try {
// wait for stock market to render
await waitFor('#stockmarketroot .stock___ElSDB', 15000);
// check for restriction message (traveling, hospital, jail, etc.)
var restrictionMsg = document.querySelector('#stockmarketroot .manageBlock___PfiJh div, #stockmarketroot .sellBlock___A_yTW .manageBlock___PfiJh');
if (restrictionMsg && restrictionMsg.textContent.trim()) {
showToast(restrictionMsg.textContent.trim(), 'warn');
return;
}
// find the stock row by ID
var stockRow = document.querySelector('ul.stock___ElSDB[id="' + stockId + '"]');
if (!stockRow) {
showToast('Stock #' + stockId + ' not found on page', 'error');
return;
}
// click the "owned" tab to expand buy/sell panel
var ownedTab = stockRow.querySelector('li.stockOwned___eXJed');
if (!ownedTab) {
showToast('Cannot find owned tab for stock', 'error');
return;
}
ownedTab.click();
// wait for the buy panel to appear
var dropdown = await waitFor('ul.stock___ElSDB[id="' + stockId + '"] + .stockDropdown___Y2X_v, ul.stock___ElSDB[id="' + stockId + '"] .stockDropdown___Y2X_v', 5000).catch(function () { return null; });
if (!dropdown) {
// dropdown might be a sibling or inside - try broader search
await new Promise(function (r) { setTimeout(r, 1000); });
dropdown = document.querySelector('.stockDropdown___Y2X_v[id="panel-ownedTab"]');
}
if (!dropdown) {
showToast('Buy panel did not open. Click "Owned" tab manually.', 'warn');
return;
}
// find buy input
var buyBlock = dropdown.querySelector('.buyBlock___bIlBS');
if (!buyBlock) {
showToast('Cannot buy right now - buy panel unavailable', 'warn');
return;
}
var buyInput = buyBlock.querySelector('input.input-money:not([type="hidden"])');
if (!buyInput) {
showToast('Cannot buy right now - input unavailable', 'warn');
return;
}
// type max amount - Torn auto-caps to affordable shares
setReactInput(buyInput, '999999999999');
// brief delay for Torn to recalculate
await new Promise(function (r) { setTimeout(r, 500); });
// check if buy button is disabled (no money)
var buyBtn = buyBlock.querySelector('button.buy___fcM6a');
if (buyBtn && buyBtn.disabled) {
showToast('Not enough money to buy any shares', 'warn');
return;
}
// highlight buy button or auto-click
var buyBtn = buyBlock.querySelector('button.buy___fcM6a');
if (buyBtn) {
if (CFG.autoClick) {
showToast('Clicking buy...', 'info');
buyBtn.click();
await waitForConfirm(buyBlock);
} else {
buyBtn.style.background = '#0a0';
buyBtn.style.color = '#fff';
buyBtn.style.boxShadow = '0 0 8px #0f0';
showToast('Ready! Click BUY to confirm.', 'ok');
}
} else {
showToast('Input filled. Find and click the Buy button.', 'ok');
}
} catch (err) {
console.error('[Torn Vault]', err);
showToast('Error: ' + err.message, 'error');
}
}
// --- Toast notifications ---
var activeToast = null;
function showToast(msg, type) {
// remove previous toast immediately
if (activeToast) {
activeToast.remove();
activeToast = null;
}
var colors = { info: '#369', ok: '#080', warn: '#a80', error: '#a00' };
var el = document.createElement('div');
el.style.cssText = 'position:fixed;top:60px;right:20px;z-index:100000;padding:10px 16px;border-radius:4px;'
+ 'color:#fff;font:13px/1.4 Arial,sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.4);transition:opacity 0.3s;'
+ 'background:' + (colors[type] || colors.info);
el.textContent = msg;
document.body.appendChild(el);
activeToast = el;
setTimeout(function () {
el.style.opacity = '0';
setTimeout(function () {
el.remove();
if (activeToast === el) activeToast = null;
}, 300);
}, 3000);
}
// --- Init ---
function init() {
createBtn();
// re-check for button if sidebar loads lazily
var obs = new MutationObserver(function () {
var moneyEl = document.getElementById('user-money');
if (moneyEl && !moneyEl.closest('.point-block___rQyUK').querySelector('.tv-btn')) createBtn();
});
obs.observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();