Torn BJ Strategy

Real-time blackjack strategy advisor for Torn City. Displays optimal plays based on 8-deck, S17, Early Surrender, DAS, Hit Split Aces, 6-Card Charlie rules. Passive display only — no automation.

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Torn BJ Strategy
// @namespace    torn.bj.strategy
// @version      1.0.0
// @description  Real-time blackjack strategy advisor for Torn City. Displays optimal plays based on 8-deck, S17, Early Surrender, DAS, Hit Split Aces, 6-Card Charlie rules. Passive display only — no automation.
// @author       Your Greasyfork Name
// @match        https://www.torn.com/page.php?sid=blackjack*
// @match        https://www.torn.com/pda.php*step=blackjack*
// @grant        none
// @license      MIT
// @homepageURL  https://greasyfork.org/
// ==/UserScript==

/*
 * DATA STORAGE:  Only locally (browser localStorage for panel position)
 * DATA SHARING:  Nobody
 * PURPOSE:       Public community tool — passive strategy display only
 * KEY STORAGE:   No API key used or stored
 * KEY ACCESS:    None required
 *
 * This script reads only the page you are actively viewing.
 * It makes zero network requests and performs zero automated actions.
 * Fully compliant with Torn scripting rules.
 */

(function () {
    'use strict';

    // ─────────────────────────────────────────────────────────────────
    // STRATEGY TABLES
    // Source: beatingbonuses.com — 8 Decks, Dealer Stands Soft 17,
    // Full Early Surrender, Double Any 2 Cards, DAS, Hit Split Aces,
    // 6-Card Charlie. House edge: -0.38%
    //
    // Index = dealer upcard: [0]=unused, [1]=A, [2]=2, [3]=3, [4]=4,
    //         [5]=5, [6]=6, [7]=7, [8]=8, [9]=9, [10]=10/J/Q/K
    // ─────────────────────────────────────────────────────────────────

    const A = 'H';    // Hit
    const S = 'S';    // Stand
    const D = 'D';    // Double (else Hit)
    const P = 'P';    // Split
    const R = 'R';    // Surrender (else Hit)
    const Rs = 'Rs';  // Surrender (else Stand)
    const S3 = 'S3';  // Stand ≤3 cards, else Hit
    const S4 = 'S4';  // Stand ≤4 cards, else Hit
    const DS3 = 'DS3'; // Double if possible, else S3
    const DS4 = 'DS4'; // Double if possible, else S4 (not in this table but kept for robustness)
    const RS4 = 'RS4'; // Surrender if possible, else S4

    // Hard totals. Key = player total. Value = array [x, A, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    // Index 0 unused; 1=Ace, 2-10=pip value
    const HARD = {
        5:  [0, R,  A,  A,  A,  A,  A,  A,  A,  A,  R],
        6:  [0, R,  A,  A,  A,  A,  A,  A,  A,  A,  R],
        7:  [0, R,  A,  A,  A,  A,  A,  A,  A,  A,  R],
        8:  [0, A,  A,  A,  A,  A,  A,  A,  A,  A,  A],
        9:  [0, A,  A,  D,  D,  D,  D,  A,  A,  A,  A],
        10: [0, A,  D,  D,  D,  D,  D,  D,  D,  D,  A],
        11: [0, A,  D,  D,  D,  D,  D,  D,  D,  D,  A],
        12: [0, R,  A,  A, S3, S3, S3,  A,  A,  A,  R],
        13: [0, R, S3, S3, S4, S4, S4,  A,  A,  A,  R],
        14: [0, R, S4, S4, S4, S4, S4,  A,  A,  R,  R],
        15: [0, R, S4, S4, S4, S4, S4,  A,  A,  R,  R],
        16: [0, R, S4, S4,  S,  S,  S,  A,  A,  R, Rs],
        17: [0,RS4, S,  S,  S,  S,  S,  S, S4, S4,  S],
        18: [0, S,  S,  S,  S,  S,  S,  S,  S,  S,  S],
        19: [0, S,  S,  S,  S,  S,  S,  S,  S,  S,  S],
        20: [0, S,  S,  S,  S,  S,  S,  S,  S,  S,  S],
        21: [0, S,  S,  S,  S,  S,  S,  S,  S,  S,  S],
    };

    // Soft totals (Ace counted as 11). Key = total (13=A+2 … 20=A+9, 21=BJ)
    const SOFT = {
        13: [0, A,  A,  A,  A,  A,  D,  A,  A,  A,  A],  // A,2
        14: [0, A,  A,  A,  A,  D,  D,  A,  A,  A,  A],  // A,3
        15: [0, A,  A,  A,  A,  D,  D,  A,  A,  A,  A],  // A,4
        16: [0, A,  A,  A,  D,  D,  D,  A,  A,  A,  A],  // A,5
        17: [0, A,  A,  D,  D,  D,  D,  A,  A,  A,  A],  // A,6
        18: [0, A, S3,DS3,DS3,DS3,DS3, S4, S3,  A,  A],  // A,7
        19: [0, S4, S4, S4, S4, S4, S4, S4, S4, S4, S4], // A,8
        20: [0, S4, S4, S4, S4, S4, S4, S4, S4, S4, S4], // A,9
        21: [0, S,  S,  S,  S,  S,  S,  S,  S,  S,  S],  // A,10 (BJ)
    };

    // Pairs. Key = card value of each card (A=11, 2-10 face value; J/Q/K→10)
    const PAIRS = {
        2:  [0, A,  P,  P,  P,  P,  P,  P,  A,  A,  A],
        3:  [0, R,  A,  P,  P,  P,  P,  P,  A,  A,  R],
        4:  [0, A,  A,  A,  A,  P,  P,  A,  A,  A,  A],
        5:  [0, A,  D,  D,  D,  D,  D,  D,  D,  D,  A],  // treat as hard 10
        6:  [0, R,  P,  P,  P,  P,  P,  A,  A,  A,  R],
        7:  [0, R,  P,  P,  P,  P,  P,  P,  A,  R,  R],
        8:  [0, R,  P,  P,  P,  P,  P,  P,  P, Rs,  R],
        9:  [0, S,  P,  P,  P,  P,  P,  S,  P,  P,  S],
        10: [0, S,  S,  S,  S,  S,  S,  S,  S,  S,  S],
        11: [0, P,  P,  P,  P,  P,  P,  P,  P,  P,  P],  // A,A
    };

    // ─────────────────────────────────────────────────────────────────
    // ACTION METADATA
    // ─────────────────────────────────────────────────────────────────
    const ACTION_META = {
        H:   { label: 'Hit',       short: 'HIT',       color: '#f97316', bg: 'rgba(120,45,0,0.88)',   border: '#f97316' },
        S:   { label: 'Stand',     short: 'STAND',     color: '#22c55e', bg: 'rgba(15,60,25,0.88)',   border: '#22c55e' },
        S3:  { label: 'Stand',     short: 'STAND',     color: '#22c55e', bg: 'rgba(15,60,25,0.88)',   border: '#22c55e', note: 'Stand ≤3 cards, Hit with 4+' },
        S4:  { label: 'Stand',     short: 'STAND',     color: '#22c55e', bg: 'rgba(15,60,25,0.88)',   border: '#22c55e', note: 'Stand ≤4 cards, Hit with 5+' },
        D:   { label: 'Double',    short: 'DOUBLE',    color: '#3b82f6', bg: 'rgba(15,30,80,0.88)',   border: '#3b82f6' },
        DS3: { label: 'Double',    short: 'DOUBLE',    color: '#3b82f6', bg: 'rgba(15,30,80,0.88)',   border: '#3b82f6', note: 'Double if allowed, else Stand ≤3' },
        DS4: { label: 'Double',    short: 'DOUBLE',    color: '#3b82f6', bg: 'rgba(15,30,80,0.88)',   border: '#3b82f6', note: 'Double if allowed, else Stand ≤4' },
        P:   { label: 'Split',     short: 'SPLIT',     color: '#a855f7', bg: 'rgba(45,10,70,0.88)',   border: '#a855f7' },
        R:   { label: 'Surrender', short: 'SURRENDER', color: '#ef4444', bg: 'rgba(80,10,10,0.88)',   border: '#ef4444', note: 'Surrender if allowed, else Hit' },
        Rs:  { label: 'Surrender', short: 'SURRENDER', color: '#ef4444', bg: 'rgba(80,10,10,0.88)',   border: '#ef4444', note: 'Surrender if allowed, else Stand' },
        RS4: { label: 'Surrender', short: 'SURRENDER', color: '#ef4444', bg: 'rgba(80,10,10,0.88)',   border: '#ef4444', note: 'Surrender if allowed, else Stand ≤4' },
        CHARLIE: { label: 'Stand', short: 'STAND',     color: '#fbbf24', bg: 'rgba(60,45,0,0.88)',    border: '#fbbf24', note: '6-Card Charlie — automatic win!' },
        IDLE: { label: '---',      short: '---',       color: '#9ca3af', bg: 'rgba(20,20,20,0.88)',   border: '#374151' },
    };

    // ─────────────────────────────────────────────────────────────────
    // CARD READING
    // ─────────────────────────────────────────────────────────────────

    /**
     * Given a card DOM element, return its numeric value.
     * Torn uses class names like: card-hearts-A, card-spades-10, card-clubs-K, etc.
     * Returns: 2-10 for pips, 10 for J/Q/K, 11 for Ace, 0 if unreadable.
     */
    function readCardValue(el) {
        if (!el) return 0;
        // Try class-based rank detection
        const cls = el.className || '';
        const m = cls.match(/card-\w+-(\w+)/i);
        const rank = m ? m[1].toUpperCase() : '';
        if (!rank) return 0;
        if (rank === 'A') return 11;
        if (['J', 'Q', 'K'].includes(rank)) return 10;
        const n = parseInt(rank, 10);
        return isNaN(n) ? 0 : n;
    }

    /**
     * Read all card values from a container selector.
     * Returns { values, total, softTotal, isSoft, cardCount, isPair }
     */
    function readHand(containerSelector) {
        const container = document.querySelector(containerSelector);
        if (!container) return null;

        const cardEls = container.querySelectorAll('div[class*="card-"]');
        if (!cardEls.length) return null;

        const rawValues = Array.from(cardEls).map(readCardValue).filter(v => v > 0);
        if (!rawValues.length) return null;

        let total = rawValues.reduce((a, b) => a + b, 0);
        let aces = rawValues.filter(v => v === 11).length;

        // Reduce aces from 11→1 to avoid bust
        while (total > 21 && aces > 0) {
            total -= 10;
            aces--;
        }

        const isSoft = aces > 0 && total <= 21;
        const isPair = rawValues.length === 2 && rawValues[0] === rawValues[1];

        return {
            values: rawValues,
            total,
            isSoft,
            cardCount: rawValues.length,
            isPair,
            // For pair lookup: use original value (don't reduce aces here)
            pairCardValue: isPair ? (rawValues[0] === 11 ? 11 : rawValues[0]) : null,
        };
    }

    // ─────────────────────────────────────────────────────────────────
    // STRATEGY LOOKUP
    // ─────────────────────────────────────────────────────────────────

    /**
     * Dealer upcard value → table index [1-10]
     * Ace=11 → index 1; 10/J/Q/K=10 → index 10; else face value
     */
    function dealerIndex(dealerValue) {
        if (dealerValue === 11) return 1;
        if (dealerValue >= 10) return 10;
        return dealerValue;
    }

    /**
     * Resolve the raw table action (S3, S4, DS3, etc.) into a final
     * concrete recommendation given the current card count and whether
     * doubling / surrendering is available on the UI.
     *
     * Returns: { action: string, meta: object, resolved: string }
     *   action   = raw table code (e.g. 'S3')
     *   resolved = concrete move to show ('S'|'H'|'D'|'P'|'R')
     *   meta     = ACTION_META entry for display
     */
    function resolveAction(rawAction, cardCount, canDouble, canSurrender) {
        let resolved = rawAction;

        switch (rawAction) {
            case 'S3':
                resolved = cardCount <= 3 ? 'S' : 'H';
                break;
            case 'S4':
                resolved = cardCount <= 4 ? 'S' : 'H';
                break;
            case 'DS3':
                if (canDouble) resolved = 'D';
                else resolved = cardCount <= 3 ? 'S' : 'H';
                break;
            case 'DS4':
                if (canDouble) resolved = 'D';
                else resolved = cardCount <= 4 ? 'S' : 'H';
                break;
            case 'D':
                resolved = canDouble ? 'D' : 'H';
                break;
            case 'R':
                resolved = canSurrender ? 'R' : 'H';
                break;
            case 'Rs':
                resolved = canSurrender ? 'R' : 'S';
                break;
            case 'RS4':
                if (canSurrender) resolved = 'R';
                else resolved = cardCount <= 4 ? 'S' : 'H';
                break;
        }

        return {
            rawAction,
            resolved,
            meta: ACTION_META[rawAction] || ACTION_META[resolved] || ACTION_META.IDLE,
        };
    }

    /**
     * Main strategy decision.
     * Returns a result object with rawAction, resolved, meta, note.
     */
    function getAdvice(dealerVal, playerHand, canDouble, canSurrender) {
        const di = dealerIndex(dealerVal);
        let rawAction;

        // 6-Card Charlie — if player has 6+ cards and hasn't busted, it's an automatic win
        if (playerHand.cardCount >= 6 && playerHand.total <= 21) {
            return { rawAction: 'CHARLIE', resolved: 'S', meta: ACTION_META.CHARLIE };
        }

        // Check pairs first
        if (playerHand.isPair) {
            const pv = playerHand.pairCardValue;
            const row = PAIRS[pv];
            rawAction = row ? (row[di] || 'H') : 'H';
        } else if (playerHand.isSoft) {
            const row = SOFT[playerHand.total];
            rawAction = row ? (row[di] || 'S') : 'S';
        } else {
            // Hard total — clamp to table range
            const clampedTotal = Math.min(Math.max(playerHand.total, 5), 21);
            const row = HARD[clampedTotal];
            rawAction = row ? (row[di] || 'S') : 'S';
        }

        return resolveAction(rawAction, playerHand.cardCount, canDouble, canSurrender);
    }

    // ─────────────────────────────────────────────────────────────────
    // SESSION TRACKER
    // ─────────────────────────────────────────────────────────────────

    const session = {
        hands: 0,
        wins: 0,
        losses: 0,
        pushes: 0,
        surrenders: 0,
        blackjacks: 0,
    };

    // We detect hand outcomes by watching for result text appearing in the DOM.
    // Torn typically shows "You win!", "You lose!", "Push!", "Blackjack!" etc.
    // These strings may vary — we match broadly.
    let lastResultObserved = '';

    function detectHandResult(mutationOrScan) {
        // Look for result indicators in the game area
        const gameArea = document.querySelector('.blackjack, .blackjack-wrap');
        if (!gameArea) return;

        const text = gameArea.innerText || '';
        // Simple heuristic — look for result phrases that just appeared
        const resultKey = text.match(/(you win|you lose|push|blackjack|bust|surrender)/i)?.[0]?.toLowerCase() || '';
        if (!resultKey || resultKey === lastResultObserved) return;
        lastResultObserved = resultKey;

        if (resultKey.includes('blackjack')) { session.blackjacks++; session.wins++; session.hands++; }
        else if (resultKey.includes('you win')) { session.wins++; session.hands++; }
        else if (resultKey.includes('you lose') || resultKey.includes('bust')) { session.losses++; session.hands++; }
        else if (resultKey.includes('push')) { session.pushes++; session.hands++; }
        else if (resultKey.includes('surrender')) { session.surrenders++; session.losses++; session.hands++; }

        updateSessionPanel();
    }

    // Reset result key when a new hand starts (cards dealt)
    function resetResultKey() {
        lastResultObserved = '';
    }

    // ─────────────────────────────────────────────────────────────────
    // UI
    // ─────────────────────────────────────────────────────────────────

    const STORAGE_KEY = 'tornBjHelperPos_v2';

    function injectStyles() {
        const style = document.createElement('style');
        style.id = 'torn-bj-helper-styles';
        style.textContent = `
            #tbj-panel {
                position: fixed;
                z-index: 99999;
                width: 170px;
                font-family: 'Segoe UI', Arial, sans-serif;
                user-select: none;
                border-radius: 10px;
                overflow: hidden;
                box-shadow: 0 6px 24px rgba(0,0,0,0.6);
                transition: border-color 0.2s, background-color 0.2s;
                border: 2px solid #374151;
                background: rgba(20,20,20,0.88);
            }

            #tbj-header {
                display: flex;
                align-items: center;
                justify-content: space-between;
                padding: 5px 9px;
                background: rgba(0,0,0,0.5);
                cursor: move;
                border-bottom: 1px solid rgba(255,255,255,0.07);
            }

            #tbj-header-label {
                font-size: 9px;
                font-weight: 700;
                letter-spacing: 0.12em;
                text-transform: uppercase;
                color: #6b7280;
            }

            #tbj-toggle-stats {
                font-size: 9px;
                color: #4b5563;
                cursor: pointer;
                padding: 1px 4px;
                border-radius: 3px;
                border: 1px solid #374151;
                background: none;
                transition: color 0.15s, border-color 0.15s;
                line-height: 1;
            }
            #tbj-toggle-stats:hover { color: #9ca3af; border-color: #6b7280; }

            #tbj-main {
                padding: 12px 10px 10px;
                text-align: center;
            }

            #tbj-action {
                font-size: 26px;
                font-weight: 900;
                letter-spacing: 0.04em;
                line-height: 1;
                color: #9ca3af;
                text-shadow: 0 0 12px rgba(0,0,0,0.8);
                transition: color 0.2s;
            }

            #tbj-note {
                font-size: 10px;
                margin-top: 5px;
                color: #6b7280;
                min-height: 14px;
                line-height: 1.3;
                transition: color 0.2s;
            }

            #tbj-hand-info {
                font-size: 11px;
                margin-top: 6px;
                color: #4b5563;
                transition: color 0.15s;
            }

            #tbj-divider {
                height: 1px;
                background: rgba(255,255,255,0.07);
                margin: 0 6px;
            }

            #tbj-stats {
                padding: 7px 9px 8px;
                font-size: 10px;
                color: #6b7280;
            }

            #tbj-stats.hidden { display: none; }

            .tbj-stat-row {
                display: flex;
                justify-content: space-between;
                align-items: center;
                margin-bottom: 2px;
            }
            .tbj-stat-row:last-child { margin-bottom: 0; }

            .tbj-stat-label { color: #4b5563; }
            .tbj-stat-val   { font-weight: 700; }
            .tbj-stat-val.win  { color: #22c55e; }
            .tbj-stat-val.loss { color: #ef4444; }
            .tbj-stat-val.push { color: #f59e0b; }
            .tbj-stat-val.bj   { color: #fbbf24; }
            .tbj-stat-val.surr { color: #9ca3af; }
            .tbj-stat-val.total{ color: #9ca3af; }

            /* Active state coloring */
            #tbj-panel.state-H   { border-color: #f97316; background: rgba(120,45,0,0.88); }
            #tbj-panel.state-S   { border-color: #22c55e; background: rgba(15,60,25,0.88); }
            #tbj-panel.state-D   { border-color: #3b82f6; background: rgba(15,30,80,0.88); }
            #tbj-panel.state-P   { border-color: #a855f7; background: rgba(45,10,70,0.88); }
            #tbj-panel.state-R   { border-color: #ef4444; background: rgba(80,10,10,0.88); }
            #tbj-panel.state-C   { border-color: #fbbf24; background: rgba(60,45,0,0.88); }
        `;
        document.head.appendChild(style);
    }

    function buildPanel() {
        if (document.getElementById('tbj-panel')) return;

        const panel = document.createElement('div');
        panel.id = 'tbj-panel';
        panel.innerHTML = `
            <div id="tbj-header">
                <span id="tbj-header-label">BJ Strategy</span>
                <button id="tbj-toggle-stats" title="Toggle session stats">stats</button>
            </div>
            <div id="tbj-main">
                <div id="tbj-action">---</div>
                <div id="tbj-note"></div>
                <div id="tbj-hand-info">Waiting for hand…</div>
            </div>
            <div id="tbj-divider"></div>
            <div id="tbj-stats" class="hidden">
                <div class="tbj-stat-row">
                    <span class="tbj-stat-label">Hands</span>
                    <span class="tbj-stat-val total" id="tbj-s-hands">0</span>
                </div>
                <div class="tbj-stat-row">
                    <span class="tbj-stat-label">Wins</span>
                    <span class="tbj-stat-val win" id="tbj-s-wins">0</span>
                </div>
                <div class="tbj-stat-row">
                    <span class="tbj-stat-label">Losses</span>
                    <span class="tbj-stat-val loss" id="tbj-s-losses">0</span>
                </div>
                <div class="tbj-stat-row">
                    <span class="tbj-stat-label">Pushes</span>
                    <span class="tbj-stat-val push" id="tbj-s-pushes">0</span>
                </div>
                <div class="tbj-stat-row">
                    <span class="tbj-stat-label">Blackjacks</span>
                    <span class="tbj-stat-val bj" id="tbj-s-bj">0</span>
                </div>
                <div class="tbj-stat-row">
                    <span class="tbj-stat-label">Surrenders</span>
                    <span class="tbj-stat-val surr" id="tbj-s-surr">0</span>
                </div>
            </div>
        `;

        // Default position: bottom-right
        const saved = localStorage.getItem(STORAGE_KEY);
        if (saved) {
            try {
                const { top, left } = JSON.parse(saved);
                panel.style.top = top;
                panel.style.left = left;
            } catch (_) {
                panel.style.bottom = '20px';
                panel.style.right = '20px';
            }
        } else {
            panel.style.bottom = '20px';
            panel.style.right = '20px';
        }

        document.body.appendChild(panel);
        makeDraggable(panel);

        document.getElementById('tbj-toggle-stats').addEventListener('click', (e) => {
            e.stopPropagation();
            const stats = document.getElementById('tbj-stats');
            stats.classList.toggle('hidden');
        });
    }

    function updatePanel(result) {
        const panel = document.getElementById('tbj-panel');
        const actionEl = document.getElementById('tbj-action');
        const noteEl = document.getElementById('tbj-note');
        const handEl = document.getElementById('tbj-hand-info');
        if (!panel || !actionEl || !noteEl || !handEl) return;

        if (!result) {
            panel.className = '';
            actionEl.textContent = '---';
            actionEl.style.color = '#9ca3af';
            noteEl.textContent = '';
            handEl.textContent = 'Waiting for hand…';
            return;
        }

        const { resolved, rawAction, meta, playerHand, dealerVal } = result;

        // State class for background/border
        panel.className = '';
        if (rawAction === 'CHARLIE') panel.classList.add('state-C');
        else if (resolved === 'H') panel.classList.add('state-H');
        else if (resolved === 'S') panel.classList.add('state-S');
        else if (resolved === 'D') panel.classList.add('state-D');
        else if (resolved === 'P') panel.classList.add('state-P');
        else if (resolved === 'R') panel.classList.add('state-R');

        actionEl.textContent = meta.short;
        actionEl.style.color = meta.color;

        // Build note: any conditional note + card-count context
        let note = meta.note || '';
        if (rawAction === 'S3' && playerHand.cardCount >= 3) {
            note = playerHand.cardCount === 3 ? 'Last card to stand' : 'Hit (4+ cards)';
        } else if (rawAction === 'S4' && playerHand.cardCount >= 4) {
            note = playerHand.cardCount === 4 ? 'Last card to stand' : 'Hit (5+ cards)';
        }
        noteEl.textContent = note;
        noteEl.style.color = meta.color + 'bb';

        // Hand info line
        const handType = playerHand.isPair ? 'pair' : playerHand.isSoft ? 'soft' : 'hard';
        handEl.textContent = `${handType} ${playerHand.total} vs dealer ${dealerVal === 11 ? 'A' : dealerVal}`;
        handEl.style.color = '#6b7280';
    }

    function updateSessionPanel() {
        const set = (id, val) => {
            const el = document.getElementById(id);
            if (el) el.textContent = val;
        };
        set('tbj-s-hands', session.hands);
        set('tbj-s-wins', session.wins);
        set('tbj-s-losses', session.losses);
        set('tbj-s-pushes', session.pushes);
        set('tbj-s-bj', session.blackjacks);
        set('tbj-s-surr', session.surrenders);
    }

    // ─────────────────────────────────────────────────────────────────
    // DRAGGABLE
    // ─────────────────────────────────────────────────────────────────

    function makeDraggable(el) {
        const header = el.querySelector('#tbj-header');
        if (!header) return;

        let dragging = false, ox = 0, oy = 0;

        const onStart = (e) => {
            dragging = true;
            const ev = e.touches ? e.touches[0] : e;
            ox = ev.clientX - el.offsetLeft;
            oy = ev.clientY - el.offsetTop;
            el.style.right = '';
            el.style.bottom = '';
            e.preventDefault();
            window.addEventListener('mousemove', onMove, { passive: false });
            window.addEventListener('touchmove', onMove, { passive: false });
            window.addEventListener('mouseup', onEnd);
            window.addEventListener('touchend', onEnd);
        };

        const onMove = (e) => {
            if (!dragging) return;
            e.preventDefault();
            const ev = e.touches ? e.touches[0] : e;
            const nx = ev.clientX - ox;
            const ny = ev.clientY - oy;
            // Clamp to viewport
            const maxX = window.innerWidth - el.offsetWidth - 4;
            const maxY = window.innerHeight - el.offsetHeight - 4;
            el.style.left = Math.max(4, Math.min(nx, maxX)) + 'px';
            el.style.top  = Math.max(4, Math.min(ny, maxY)) + 'px';
        };

        const onEnd = () => {
            dragging = false;
            localStorage.setItem(STORAGE_KEY, JSON.stringify({ top: el.style.top, left: el.style.left }));
            window.removeEventListener('mousemove', onMove);
            window.removeEventListener('touchmove', onMove);
            window.removeEventListener('mouseup', onEnd);
            window.removeEventListener('touchend', onEnd);
        };

        header.addEventListener('mousedown', onStart);
        header.addEventListener('touchstart', onStart, { passive: false });
    }

    // ─────────────────────────────────────────────────────────────────
    // MAIN OBSERVER LOOP
    // ─────────────────────────────────────────────────────────────────

    // Torn's blackjack DOM selectors — may need tuning if Torn updates its markup.
    // We try a few common patterns.
    const DEALER_CARD_SELECTORS = [
        '.dealer-cards div[class*="card-"]',
        '.dealer div[class*="card-"]',
        '[class*="dealer"] div[class*="card-"]',
    ];
    const PLAYER_CARD_SELECTORS = [
        '.player-cards div[class*="card-"]',
        '.player div[class*="card-"]',
        '[class*="player-hand"] div[class*="card-"]',
    ];
    const PLAYER_CONTAINER_SELECTORS = [
        '.player-cards',
        '.player',
        '[class*="player-hand"]',
    ];

    // Action button detection
    function findButton(keywords) {
        const buttons = document.querySelectorAll('button, [class*="btn"], [class*="button"], input[type="button"]');
        for (const b of buttons) {
            const txt = (b.textContent || b.value || b.className || '').toLowerCase();
            if (keywords.some(k => txt.includes(k))) return b;
        }
        return null;
    }

    function canPlayerDouble() {
        return !!findButton(['double']);
    }

    function canPlayerSurrender() {
        return !!findButton(['surrender']);
    }

    function canPlayerAct() {
        return !!(findButton(['hit']) || findButton(['stand']));
    }

    function getDealerUpcard() {
        for (const sel of DEALER_CARD_SELECTORS) {
            const el = document.querySelector(sel);
            if (el) {
                const v = readCardValue(el);
                if (v > 0) return v;
            }
        }
        return 0;
    }

    function getPlayerHand() {
        for (const sel of PLAYER_CONTAINER_SELECTORS) {
            const hand = readHand(sel);
            if (hand) return hand;
        }
        return null;
    }

    function tick() {
        const dealerVal = getDealerUpcard();
        const playerHand = getPlayerHand();
        const acting = canPlayerAct();

        // Detect new hand (reset result key when cards appear fresh)
        if (playerHand && playerHand.cardCount === 2) {
            resetResultKey();
        }

        // Check for result text
        detectHandResult();

        if (!dealerVal || !playerHand || playerHand.cardCount < 2) {
            updatePanel(null);
            return;
        }

        // If the player has already resolved (bust, BJ, 21) or can't act, show idle
        if (!acting && playerHand.total !== 21) {
            // Still update with last advice but don't force idle
        }

        const result = getAdvice(
            dealerVal,
            playerHand,
            canPlayerDouble(),
            canPlayerSurrender()
        );

        updatePanel({ ...result, playerHand, dealerVal });
    }

    // ─────────────────────────────────────────────────────────────────
    // INIT
    // ─────────────────────────────────────────────────────────────────

    function init() {
        // Wait for game container to appear
        const gameContainer = document.querySelector('.blackjack, .blackjack-wrap, [class*="blackjack"]');
        if (!gameContainer) {
            // Retry — Torn loads content dynamically
            setTimeout(init, 800);
            return;
        }

        injectStyles();
        buildPanel();
        updateSessionPanel();

        // Observe the game container for DOM changes (card deals, button state changes)
        const observer = new MutationObserver(() => tick());
        observer.observe(gameContainer, { childList: true, subtree: true, attributes: true });

        // Initial tick
        tick();
    }

    // Torn PDA uses a different load pattern; observe body for the game appearing
    const bodyObserver = new MutationObserver(() => {
        const gameContainer = document.querySelector('.blackjack, .blackjack-wrap, [class*="blackjack"]');
        if (gameContainer && !document.getElementById('tbj-panel')) {
            init();
        }
    });

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => {
            bodyObserver.observe(document.body, { childList: true, subtree: true });
            init();
        });
    } else {
        bodyObserver.observe(document.body, { childList: true, subtree: true });
        init();
    }

})();