Torn Bank Investment Calculator with Enhanced Features

Calculates Torn bank investment profit with merits, TCB stock, and displays the shortest path to target amount with profit and time.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Torn Bank Investment Calculator with Enhanced Features
// @namespace    http://tampermonkey.net/
// @version      3.6
// @description  Calculates Torn bank investment profit with merits, TCB stock, and displays the shortest path to target amount with profit and time.
// @author       Jvmie
// @match        https://www.torn.com/bank.php*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const CURRENT_VERSION = '3.6'; // Must match @version
    const SCRIPT_URL = 'https://raw.githubusercontent.com/ItzJamiie/torn-bank-calc/main/torn-bank-calc.user.js';

    // Function to check for updates (for compatibility with TornPDA)
    function checkForScriptUpdate() {
        fetch(SCRIPT_URL)
            .then(response => response.text())
            .then(text => {
                const versionMatch = text.match(/@version\s+([\d.]+)/);
                if (versionMatch && versionMatch[1] && versionMatch[1] !== CURRENT_VERSION) {
                    console.log(`Torn Bank Calc: New version ${versionMatch[1]} detected. Notify TornPDA to update.`);
                    // TornPDA may handle the update prompt; this log helps debugging
                }
            })
            .catch(err => console.error('Torn Bank Calc: Update check failed:', err));
    }

    setInterval(checkForScriptUpdate, 300000); // Check every 5 minutes

    let investmentOptions = [
        { period: 7, baseRate: 0.6889, label: '7 Days (0.69% base)' },
        { period: 14, baseRate: 0.800, label: '14 Days (0.80% base)' },
        { period: 30, baseRate: 0.833, label: '30 Days (0.83% base)' },
        { period: 60, baseRate: 0.953, label: '60 Days (0.95% base)' },
        { period: 90, baseRate: 0.953, label: '90 Days (0.95% base)' }
    ];

    const MAX_INVESTMENT = 2000000000;
    const VERSION = '3.6';

    const CHANGELOG = `
        <strong>Changelog:</strong>
        <ul>
            <li><strong>Version 3.6:</strong> Moved UI to the top of the webpage.</li>
            <li><strong>Version 3.5:</strong> Optimized for TornPDA update system.</li>
        </ul>
    `;

    function formatCurrency(value) {
        if (value >= 1000000000) return `${(value / 1000000000).toFixed(1)}b`;
        if (value >= 1000000) return `${(value / 1000000).toFixed(1)}m`;
        if (value >= 1000) return `${(value / 1000).toFixed(0)}k`;
        return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
    }

    function parseInput(value) {
        value = value.trim().toLowerCase().replace(/[^0-9.kmb]/g, '');
        if (value.endsWith('k')) return parseFloat(value.replace('k', '')) * 1000;
        if (value.endsWith('m')) return parseFloat(value.replace('m', '')) * 1000000;
        if (value.endsWith('b')) return parseFloat(value.replace('b', '')) * 1000000000;
        return parseFloat(value) || 0;
    }

    function formatDays(days) {
        if (days === Infinity) return 'N/A (Cap Reached)';
        if (days === 0) return '0d';
        const years = Math.floor(days / 365);
        const months = Math.floor((days % 365) / 30);
        const daysRemain = days % 30;
        let result = '';
        if (years > 0) result += `${years}y `;
        if (months > 0 || years > 0) result += `${months}m `;
        result += `${daysRemain}d`;
        return result.trim();
    }

    function fetchDynamicBaseRates(merits) {
        try {
            const rateElements = document.querySelectorAll('.bar-label, .apr-value, [class*="apr"]');
            if (!rateElements.length) {
                console.warn('Torn Bank Calc: Using default rates.');
                return;
            }
            const aprValues = Array.from(rateElements).map(el => {
                const match = el.textContent.trim().match(/(\d+\.\d+)%/);
                return match ? parseFloat(match[1]) : null;
            }).filter(v => v !== null);
            if (aprValues.length >= 5) {
                const meritMultiplier = 1 + (merits * 0.05);
                const baseRates = aprValues.map(apr => (apr / 52 / meritMultiplier) * 100);
                investmentOptions.forEach((opt, i) => opt.baseRate = baseRates[i] || opt.baseRate);
                investmentOptions.forEach(opt => opt.label = `${opt.period} Days (${opt.baseRate.toFixed(2)}% base)`);
                console.log('Torn Bank Calc: Updated rates:', investmentOptions);
            } else {
                console.warn('Torn Bank Calc: Insufficient APR data.');
            }
        } catch (e) {
            console.error('Torn Bank Calc: Rate fetch error:', e);
        }
    }

    function showChangelogNotification() {
        const lastSeen = localStorage.getItem('tornBankCalcLastSeenVersion');
        if (lastSeen === VERSION) return;
        const div = document.createElement('div');
        div.id = 'torn-bank-calc-changelog';
        div.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#1c2526;border:1px solid #3e4a50;border-radius:5px;padding:10px;max-width:300px;color:#fff;font-family:Arial,sans-serif;font-size:14px;z-index:10000;box-shadow:0 2px 5px rgba(0,0,0,0.5)';
        div.innerHTML = `<div style="margin-bottom:10px;">${CHANGELOG}</div><button id="closeChangelogBtn" style="display:block;margin:0 auto;padding:5px 10px;background:#28a745;color:#fff;border:none;border-radius:3px;cursor:pointer">Close</button>`;
        document.body.appendChild(div);
        document.getElementById('closeChangelogBtn').addEventListener('click', () => {
            div.remove();
            localStorage.setItem('tornBankCalcLastSeenVersion', VERSION);
        });
    }

    function createCalculatorUI() {
        console.log('Torn Bank Calc: Attempting to create UI...');

        // Prioritize document.body to ensure top placement, with fallback containers
        let targetContainer = document.body; // Start with body for absolute top placement
        const fallbackContainers = [
            document.querySelector('#mainContainer .content-wrapper'),
            document.querySelector('.content-wrapper'),
            document.querySelector('#bankBlock'),
            document.querySelector('.content')
        ];
        // Use the first valid fallback if body insertion fails
        if (!targetContainer || targetContainer.children.length === 0) {
            for (let container of fallbackContainers) {
                if (container) {
                    targetContainer = container;
                    break;
                }
            }
        }

        if (!targetContainer) {
            console.error('Torn Bank Calc: Could not find target container. Aborting UI creation.');
            return;
        }

        if (document.getElementById('torn-bank-calc')) {
            console.log('Torn Bank Calc: UI already exists, skipping creation.');
            return;
        }

        const calcDiv = document.createElement('div');
        calcDiv.id = 'torn-bank-calc';
        calcDiv.style.marginTop = '20px';
        calcDiv.style.fontFamily = 'Arial, sans-serif';
        calcDiv.style.fontSize = '14px';
        calcDiv.style.maxWidth = '400px';
        calcDiv.style.overflowX = 'hidden';

        // Load saved preferences
        const savedMerits = localStorage.getItem('tornBankCalcMerits') || '0';
        const savedStockBonus = localStorage.getItem('tornBankCalcStockBonus') === 'true';

        const meritOptionsHTML = Array.from({ length: 11 }, (_, i) => 
            `<option value="${i}" ${i === parseInt(savedMerits) ? 'selected' : ''}>${i} Merits (+${i * 5}%)</option>`
        ).join('');

        calcDiv.innerHTML = `
            <details id="calcDetails" style="margin-bottom: 10px; border: 1px solid #2a3439; border-radius: 5px;">
                <summary style="cursor: pointer; padding: 10px; background: #28a745; border-radius: 3px; color: #fff; text-align: center; font-weight: bold;">Investment Calculator</summary>
                <div style="padding: 15px; background: #1c2526; border-radius: 0 0 3px 3px;">
                    <label style="display: block; margin-bottom: 5px; color: #d0d0d0;">Principal ($):</label>
                    <input type="text" id="principal" placeholder="Enter amount (e.g., 2000m)" style="width: 100%; padding: 5px; background: #2a3439; color: #fff; border: 1px solid #3e4a50; border-radius: 3px; margin-bottom: 10px;">
                    
                    <label style="display: block; margin-bottom: 5px; color: #d0d0d0;">Target Amount ($):</label>
                    <input type="text" id="targetAmount" placeholder="Enter target (e.g., 3000m)" style="width: 100%; padding: 5px; background: #2a3439; color: #fff; border: 1px solid #3e4a50; border-radius: 3px; margin-bottom: 10px;">
                    
                    <label style="display: block; margin-bottom: 5px; color: #d0d0d0;">Bank Merits:</label>
                    <select id="meritSelect" style="width: 100%; padding: 5px; background: #2a3439; color: #fff; border: 1px solid #3e4a50; border-radius: 3px; margin-bottom: 10px;">
                        ${meritOptionsHTML}
                    </select>
                    
                    <label style="display: block; margin-bottom: 10px; color: #d0d0d0;">
                        <input type="checkbox" id="stockBonus" style="vertical-align: middle; margin-right: 5px;" ${savedStockBonus ? 'checked' : ''}> Own TCB Stock (+10%)
                    </label>
                    
                    <button id="calculateBtn" style="width: 100%; padding: 8px; background: #28a745; color: #fff; border: none; border-radius: 3px; cursor: pointer;">Calculate</button>

                    <div id="result" style="margin-top: 15px;">
                        <label style="display: block; margin-top: 10px; color: #fff;">Shortest Path to Target:</label>
                        <table id="comparisonTable" style="width: 100%; max-width: 400px; border-collapse: collapse; margin-top: 10px; table-layout: fixed;">
                            <thead>
                                <tr style="background: #2a3439;">
                                    <th style="padding: 5px; border: 1px solid #3e4a50; color: #ffffff; width: 25%;">Period</th>
                                    <th style="padding: 5px; border: 1px solid #3e4a50; color: #ffffff; width: 25%;">Method</th>
                                    <th style="padding: 5px; border: 1px solid #3e4a50; color: #ffffff; width: 25%;">Time to Target</th>
                                    <th style="padding: 5px; border: 1px solid #3e4a50; color: #ffffff; width: 25%;">Profit</th>
                                </tr>
                            </thead>
                            <tbody id="comparisonTableBody">
                                <tr><td colspan="4" style="text-align: center; padding: 5px; color: #ffffff;">Enter values and calculate to compare</td></tr>
                            </tbody>
                        </table>
                    </div>
                </div>
            </details>
        `;

        // Insert the calculator at the top of the target container (prioritizing body)
        targetContainer.prepend(calcDiv);

        // Add version number to bottom left corner
        const versionDiv = document.createElement('div');
        versionDiv.id = 'torn-bank-calc-version';
        versionDiv.style.position = 'fixed';
        versionDiv.style.bottom = '10px';
        versionDiv.style.left = '10px';
        versionDiv.style.color = '#ffffff';
        versionDiv.style.fontSize = '12px';
        versionDiv.style.fontFamily = 'Arial, sans-serif';
        versionDiv.style.zIndex = '1000';
        versionDiv.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
        versionDiv.style.padding = '2px 5px';
        versionDiv.style.borderRadius = '3px';
        versionDiv.innerHTML = `Torn Bank Calc V${VERSION}`;
        document.body.appendChild(versionDiv);

        console.log('Torn Bank Calc: Calculator UI added to the top of the page.');

        // Show changelog notification if version is new
        showChangelogNotification();

        // Collapse by default
        const details = document.getElementById('calcDetails');
        if (details) {
            details.open = false;
        }

        // Save preferences when changed
        const meritSelect = document.getElementById('meritSelect');
        const stockBonus = document.getElementById('stockBonus');
        if (meritSelect) {
            meritSelect.addEventListener('change', () => {
                localStorage.setItem('tornBankCalcMerits', meritSelect.value);
                fetchDynamicBaseRates(parseInt(meritSelect.value));
            });
        }
        if (stockBonus) {
            stockBonus.addEventListener('change', () => {
                localStorage.setItem('tornBankCalcStockBonus', stockBonus.checked);
            });
        }

        // Initial fetch with saved merits
        fetchDynamicBaseRates(parseInt(savedMerits));
    }

    function calculateTimeToTargetNoReinvest(principal, target, rate, period) {
        if (principal >= target) return { periods: 0, days: 0, profit: 0 };
        const ratePerPeriod = rate / 100;
        const profitPerPeriod = principal * ratePerPeriod;
        const periodsNeeded = Math.ceil((target - principal) / profitPerPeriod);
        return { periods: periodsNeeded, days: periodsNeeded * period, profit: periodsNeeded * profitPerPeriod };
    }

    function calculateTimeToTargetWithReinvest(principal, target, rate, period) {
        if (principal >= target) return { periods: 0, days: 0, profit: 0 };
        const ratePerPeriod = rate / 100;
        let amount = principal, periods = 0;
        while (amount < target) {
            let invest = Math.min(amount, MAX_INVESTMENT);
            amount += invest * ratePerPeriod;
            periods++;
            if (periods > 10000) return { periods: Infinity, days: Infinity, profit: 0 };
        }
        return { periods, days: periods * period, profit: amount - principal };
    }

    function calculateProfitAndProjection() {
        try {
            const principalInput = document.getElementById('principal').value.trim();
            let principal = parseInput(principalInput);
            if (principal < 1000) {
                throw new Error('Principal too low. Enter a valid amount (e.g., 2000m).');
            }

            const targetInput = document.getElementById('targetAmount').value.trim();
            let target = parseInput(targetInput);
            if (target <= principal) {
                throw new Error('Target amount must be greater than principal.');
            }

            const meritSelect = document.getElementById('meritSelect');
            const merits = meritSelect ? parseInt(meritSelect.value) : 0;
            const stockBonus = document.getElementById('stockBonus');
            const hasStockBonus = stockBonus ? stockBonus.checked : false;

            // Update base rates based on current merits
            fetchDynamicBaseRates(merits);

            // Update comparison table with shortest path
            const comparisonTableBody = document.getElementById('comparisonTableBody');
            if (comparisonTableBody) {
                updateComparisonTable(principal, target, merits, hasStockBonus);
            } else {
                throw new Error('Comparison table body not found.');
            }

            console.log('Torn Bank Calc: Principal:', principal, 'Target:', target, 'Merits:', merits, 'Stock Bonus:', hasStockBonus);
        } catch (e) {
            console.error('Torn Bank Calc Error:', e);
            const resultDiv = document.getElementById('result');
            if (resultDiv) {
                resultDiv.innerHTML = `Error: ${e.message || 'Check inputs'}`;
            }
        }
    }

    function updateComparisonTable(principal, target, merits, hasStockBonus) {
        const tableBody = document.getElementById('comparisonTableBody');
        if (!tableBody) {
            console.error('Comparison table body not found.');
            return;
        }

        let shortestPath = null;
        let shortestDays = Infinity;

        // Evaluate each period for both reinvest and no-reinvest options
        investmentOptions.forEach(opt => {
            const meritMultiplier = 1 + (merits * 0.05);
            let effectiveRate = opt.baseRate * meritMultiplier;
            if (hasStockBonus) {
                effectiveRate *= 1.10;
            }
            const weeks = opt.period / 7;
            const ratePerPeriod = effectiveRate * weeks;

            // Calculate time to target without reinvestment
            const noReinvest = calculateTimeToTargetNoReinvest(principal, target, ratePerPeriod, opt.period);
            if (noReinvest.days < shortestDays) {
                shortestDays = noReinvest.days;
                shortestPath = {
                    period: opt.label,
                    method: 'No Reinvest',
                    days: noReinvest.days,
                    profit: noReinvest.profit
                };
            }

            // Calculate time to target with reinvestment
            const reinvest = calculateTimeToTargetWithReinvest(principal, target, ratePerPeriod, opt.period);
            if (reinvest.days < shortestDays) {
                shortestDays = reinvest.days;
                shortestPath = {
                    period: opt.label,
                    method: 'Reinvest',
                    days: reinvest.days,
                    profit: reinvest.profit
                };
            }
        });

        // Display the shortest path
        let row = '';
        if (shortestPath) {
            row = `
                <tr>
                    <td style="padding: 5px; border: 1px solid #3e4a50; color: #ffffff; text-align: center; width: 25%;">${shortestPath.period}</td>
                    <td style="padding: 5px; border: 1px solid #3e4a50; color: #ffffff; text-align: center; width: 25%;">${shortestPath.method}</td>
                    <td style="padding: 5px; border: 1px solid #3e4a50; color: #ffffff; text-align: center; width: 25%;">${formatDays(shortestPath.days)}</td>
                    <td style="padding: 5px; border: 1px solid #3e4a50; color: #ffffff; text-align: center; width: 25%;">${shortestPath.days === Infinity ? 'N/A' : formatCurrency(shortestPath.profit)}</td>
                </tr>
            `;
        } else {
            row = `<tr><td colspan="4" style="text-align: center; padding: 5px; color: #ffffff;">No valid path found</td></tr>`;
        }

        tableBody.innerHTML = row;
    }

    function init() {
        console.log('Torn Bank Calc: Initializing script...');
        createCalculatorUI();
        const calculateBtn = document.getElementById('calculateBtn');
        if (calculateBtn) {
            calculateBtn.addEventListener('click', calculateProfitAndProjection);
            console.log('Torn Bank Calc: Calculate button event listener added.');
        } else {
            console.error('Torn Bank Calc: Calculate button not found after UI creation.');
        }
    }

    function waitForPageLoad() {
        console.log('Torn Bank Calc: Waiting for page load...');
        if (document.readyState === 'complete') {
            console.log('Torn Bank Calc: Page already loaded, initializing...');
            init();
        } else {
            window.addEventListener('load', () => {
                console.log('Torn Bank Calc: Page load event triggered, initializing...');
                init();
            });
            let attempts = 0;
            const maxAttempts = 10;
            const interval = setInterval(() => {
                attempts++;
                console.log(`Torn Bank Calc: Attempt ${attempts} to initialize...`);
                if (document.readyState === 'complete') {
                    clearInterval(interval);
                    init();
                } else if (attempts >= maxAttempts) {
                    clearInterval(interval);
                    console.error('Torn Bank Calc: Max attempts reached, forcing initialization...');
                    init();
                }
            }, 1000);
        }
    }

    waitForPageLoad();
})();