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