Torn Vault

Two-click stock buyer for Torn City - navigate and buy max shares with floppy icon

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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">&times;</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();
  }
})();