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.

目前為 2025-03-16 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Torn Bank Investment Calculator with Enhanced Features
// @namespace    http://tampermonkey.net/
// @version      3.12
// @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*
// @license      MIT
// @grant        none
// ==/UserScript==

/*
 * MIT License
 *
 * Copyright (c) 2025 ItzJamiie
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

/* Changelog History (for reference, not displayed in popup):
 * Version 3.11: Fixed "Comparison table body not found" error by improving UI creation and error handling. Added hospital state detection; calculator is disabled when in hospital. Fixed formatting bug in formatCurrency for million-range values.
 * Version 3.10: Added @license MIT tag to comply with Greasy Fork requirements.
 * Version 3.9: Added MIT License to the script as the sole author.
 * Version 3.8: Confirmed changelog popup behavior: shows only current version's updates, appears once in the middle of the page.
 * Version 3.7: Updated changelog popup to show only current version's updates. Version increments now applied for each script change.
 * Version 3.6: Moved UI to the top of the webpage.
 * Version 3.5: Optimized for TornPDA update system.
 */

(function() {
    'use strict';

    const CURRENT_VERSION = '3.12'; // 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.12';

    const CHANGELOG = `
        <strong>Changelog (Version 3.12):</strong>
        <ul>
            <li>Adjusted fetchDynamicBaseRates to correctly interpret weekly rates from Torn bank page.</li>
            <li>Improved comparison logic to reflect weekly reinvestment vs. longer-term investments.</li>
        </ul>
    `;

    function formatCurrency(value) {
        if (value >= 1000000000) return `${(value / 1000000000).toFixed(1)}b`;
        if (value >= 1000000) return `${(value / 1000000).toFixed(1)}m`; // Corrected for million range
        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 weeklyRates = Array.from(rateElements).map(el => {
                const match = el.textContent.trim().match(/([\d.]+)%/); // Match any percentage
                return match ? parseFloat(match[1]) : null;
            }).filter(v => v !== null);
            if (weeklyRates.length >= 5) {
                const meritMultiplier = 1 + (merits * 0.05);
                const stockMultiplier = 1.10; // Assuming TCB stock bonus
                const totalMultiplier = meritMultiplier * stockMultiplier;
                // Assume weeklyRates are effective rates after bonuses; derive base rates
                const baseRates = weeklyRates.map(rate => rate / totalMultiplier);
                investmentOptions.forEach((opt, i) => {
                    opt.baseRate = baseRates[i] || opt.baseRate;
                    opt.label = `${opt.period} Days (${(opt.baseRate * 100).toFixed(2)}% base)`;
                });
                console.log('Torn Bank Calc: Updated rates (base):', investmentOptions);
            } else {
                console.warn('Torn Bank Calc: Insufficient rate 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 isInHospital() {
        const hospitalMessage = document.querySelector('div.msg.right-round[aria-label="Information message"]');
        if (hospitalMessage && hospitalMessage.textContent.includes("This area is unavailable while you're in hospital")) {
            return true;
        }
        return false;
    }

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

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

        if (isInHospital()) {
            console.log('Torn Bank Calc: User is in hospital, creating restricted UI.');
            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';
            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;">
                        <p style="color: #ffffff; text-align: center;">Calculator unavailable while in hospital.</p>
                    </div>
                </details>
            `;
            document.body.prepend(calcDiv);
            console.log('Torn Bank Calc: Restricted UI (hospital mode) added to the top of the page.');
            return;
        }

        let targetContainer = document.body;
        const fallbackContainers = [
            document.querySelector('#mainContainer .content-wrapper'),
            document.querySelector('.content-wrapper'),
            document.querySelector('#bankBlock'),
            document.querySelector('.content')
        ];
        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. Forcing UI creation on document.body.');
            targetContainer = document.body;
        }

        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';

        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>
        `;

        targetContainer.prepend(calcDiv);

        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.');

        showChangelogNotification();

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

        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);
            });
        }

        fetchDynamicBaseRates(parseInt(savedMerits));
    }

    function isInHospital() {
        const hospitalMessage = document.querySelector('div.msg.right-round[aria-label="Information message"]');
        if (hospitalMessage && hospitalMessage.textContent.includes("This area is unavailable while you're in hospital")) {
            return true;
        }
        return false;
    }

    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, additionalFunds = 0) {
        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 + additionalFunds;
            periods++;
            if (periods > 10000) return { periods: Infinity, days: Infinity, profit: 0 };
        }
        return { periods, days: periods * period, profit: amount - principal };
    }

    function calculateProfitAndProjection() {
        try {
            if (isInHospital()) {
                throw new Error('Calculator unavailable while in hospital.');
            }

            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;

            fetchDynamicBaseRates(merits);

            updateComparisonTable(principal, target, merits, hasStockBonus);

            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'}`;
            } else {
                console.error('Torn Bank Calc: Result div not found. UI may not have been created properly.');
            }
        }
    }

    function updateComparisonTable(principal, target, merits, hasStockBonus) {
        const tableBody = document.getElementById('comparisonTableBody');
        if (!tableBody) {
            console.error('Torn Bank Calc: Comparison table body not found. UI may not have been created properly.');
            const resultDiv = document.getElementById('result');
            if (resultDiv) {
                resultDiv.innerHTML = 'Error: Unable to display results. Please refresh the page and try again.';
            }
            return;
        }

        let shortestPath = null;
        let shortestDays = Infinity;

        const meritMultiplier = 1 + (merits * 0.05);
        const stockMultiplier = hasStockBonus ? 1.10 : 1.0;
        const totalMultiplier = meritMultiplier * stockMultiplier;

        investmentOptions.forEach(opt => {
            const effectiveRate = opt.baseRate * totalMultiplier; // Base rate is now the weekly rate before bonuses

            // No reinvest option
            const noReinvest = calculateTimeToTargetNoReinvest(principal, target, effectiveRate, opt.period);
            if (noReinvest.days < shortestDays) {
                shortestDays = noReinvest.days;
                shortestPath = {
                    period: opt.label,
                    method: 'No Reinvest',
                    days: noReinvest.days,
                    profit: noReinvest.profit
                };
            }

            // Reinvest option (with potential to add funds weekly)
            const reinvest = calculateTimeToTargetWithReinvest(principal, target, effectiveRate, opt.period, 0); // No additional funds for now
            if (reinvest.days < shortestDays) {
                shortestDays = reinvest.days;
                shortestPath = {
                    period: opt.label,
                    method: 'Reinvest',
                    days: reinvest.days,
                    profit: reinvest.profit
                };
            }
        });

        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. User may be in hospital or UI creation failed.');
        }
    }

    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();
})();