Immersive Labouchere strategy command center for Torn.com Russian Roulette — V8 redesign with hero layout, revolver visualization, animated sequence cards, and smart game-list integration
// ==UserScript==
// @name LabTrack Controller
// @namespace http://tampermonkey.net/
// @version 8.8
// @description Immersive Labouchere strategy command center for Torn.com Russian Roulette — V8 redesign with hero layout, revolver visualization, animated sequence cards, and smart game-list integration
// @author Nimo313 (Enhanced by Claude AI)
// @match https://www.torn.com/page.php?sid=russianRoulette*
// @match https://www.torn.com/trade.php*
// @icon https://www.google.com/s2/favicons?domain=torn.com
// @license MIT
// @homepage https://greasyfork.org/de/scripts/561167-labtrack-controller-v7-00-enhanced-performance-modern-es6
// @supportURL https://greasyfork.org/de/scripts/561167-labtrack-controller-v7-00-enhanced-performance-modern-es6/feedback
// @grant none
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
// Prevent multiple instances
if (window.LabTrackRunning) return;
window.LabTrackRunning = true;
// =============================================================================
// CONFIGURATION CONSTANTS - V7.00 Enhancement
// =============================================================================
const CONFIG = Object.freeze({
VERSION: '8.8',
RACE_LOCK_MS: 3000, // Race condition protection
DOM_DELAY_MS: 50, // DOM spy delay
POLL_MS: 500, // Polling interval
TOAST_MS: 2000, // Toast notification duration
MANUAL_BET_TIMEOUT_MS: 30000, // Manual bet tracking timeout
STORAGE_SAVE: 'lt_standalone_save',
STORAGE_SETTINGS: 'lt_settings',
MAX_UNDO_STACK: 50,
MAX_STORAGE_MB: 5,
MULTIPLIERS: { '1x': 1, 'k': 1000, 'm': 1000000, 'b': 1000000000 },
MIN_BET: 0.1,
MIN_INT_BET: 1
});
// =============================================================================
// LOGGER UTILITY - V7.00 Enhancement
// =============================================================================
class Logger {
static LEVELS = Object.freeze({ DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 });
static currentLevel = Logger.LEVELS.INFO;
static log(level, tag, msg) {
if (level < this.currentLevel) return;
const prefix = `[LabTrack v${CONFIG.VERSION}][${tag}]`;
const timestamp = new Date().toISOString();
const fullMsg = `${timestamp} ${prefix} ${msg}`;
switch(level) {
case this.LEVELS.DEBUG: console.debug(fullMsg); break;
case this.LEVELS.INFO: console.log(fullMsg); break;
case this.LEVELS.WARN: console.warn(fullMsg); break;
case this.LEVELS.ERROR: console.error(fullMsg); break;
}
// Forward to audit log
if (window.ltAuditLog) {
const levelName = Object.keys(this.LEVELS).find(k => this.LEVELS[k] === level) || 'INFO';
window.ltAuditLog.record(levelName, tag, msg);
}
}
static debug(tag, msg) { this.log(this.LEVELS.DEBUG, tag, msg); }
static info(tag, msg) { this.log(this.LEVELS.INFO, tag, msg); }
static warn(tag, msg) { this.log(this.LEVELS.WARN, tag, msg); }
static error(tag, msg) { this.log(this.LEVELS.ERROR, tag, msg); }
}
// =============================================================================
// AUDIT LOG
// =============================================================================
class AuditLog {
constructor() {
this.entries = [];
this.MAX_ENTRIES = 5000;
this.MAX_STORAGE_ENTRIES = 1000;
this.STORAGE_KEY = 'lt_audit_log';
this._saveTimer = null;
this._loadFromStorage();
}
_loadFromStorage() {
try {
const data = localStorage.getItem(this.STORAGE_KEY);
if (data) {
const saved = JSON.parse(data);
if (Array.isArray(saved)) this.entries = saved;
}
} catch(e) {}
}
_scheduleSave() {
clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => {
try {
const toSave = this.entries.slice(-this.MAX_STORAGE_ENTRIES);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(toSave));
} catch(e) {}
}, 2000);
}
record(level, category, message, data = null) {
const entry = { ts: new Date().toISOString(), level, cat: category, msg: message, data };
this.entries.push(entry);
if (this.entries.length > this.MAX_ENTRIES) this.entries.shift();
this._scheduleSave();
}
download() {
const header = [
'='.repeat(70),
` LabTrack v${CONFIG.VERSION} — Audit Log`,
` Generated : ${new Date().toISOString()}`,
` Entries : ${this.entries.length}`,
'='.repeat(70),
''
].join('\n');
const body = this.entries.map(e => {
const dataStr = e.data ? `\n >> ${JSON.stringify(e.data)}` : '';
return `[${e.ts}] [${e.level.padEnd(5)}] [${e.cat.padEnd(16)}] ${e.msg}${dataStr}`;
}).join('\n');
const blob = new Blob([header + body], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `labtrack_audit_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
Utils.showToast(`Log downloaded (${this.entries.length} entries)`);
}
}
// =============================================================================
// VALIDATOR UTILITY - V7.00 Enhancement
// =============================================================================
class Validator {
static isBetValid(bet) {
return typeof bet === 'number' &&
!isNaN(bet) &&
isFinite(bet) &&
bet > 0 &&
bet < Number.MAX_SAFE_INTEGER;
}
static isSequenceValid(seq) {
if (!Array.isArray(seq)) return false;
return seq.every(item =>
item &&
typeof item === 'object' &&
typeof item.id === 'string' &&
item.id.length > 0 &&
typeof item.value === 'number' &&
item.value > 0
);
}
static isStateValid(state) {
if (!state || typeof state !== 'object') return false;
return this.isSequenceValid(state.sequence || []) &&
typeof state.totalProfit === 'number' &&
typeof state.roundCount === 'number' &&
Array.isArray(state.roundHistory);
}
static isSettingsValid(settings) {
if (!settings || typeof settings !== 'object') return false;
return ['bankroll', 'target'].includes(settings.mode) &&
typeof settings.multVal === 'number' &&
settings.multVal > 0;
}
}
// --- TORNPDA / MOBILE CSS INJECTOR (Native) ---
function addCustomStyle(css) {
const style = document.createElement('style');
style.id = 'lt-custom-styles';
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
}
// --- CSS STYLES ---
addCustomStyle(`
/* Real UI typography — Inter for text, JetBrains Mono for numbers.
Loaded from Google Fonts CDN. Falls back to system fonts if blocked. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap');
#lt-dashboard {
position: relative; width: 100%; margin-bottom: 20px;
background: #181818; border: 1px solid #7c3aed; border-radius: 8px;
color: #e2e8f0;
font-family: 'Inter', 'Segoe UI', Tahoma, system-ui, sans-serif;
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
font-feature-settings: 'cv11', 'ss01', 'ss03'; /* Inter stylistic alternates */
z-index: 10;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
display: none; flex-direction: column; font-size: 13px; box-sizing: border-box;
}
#lt-dashboard.visible { display: flex; }
#lt-dashboard.flash-win { border-color: #4ade80; box-shadow: 0 0 20px rgba(74, 222, 128, 0.2); }
#lt-dashboard.flash-loss { border-color: #ef4444; box-shadow: 0 0 20px rgba(239, 68, 68, 0.2); }
/* Hospital = pulsing red border. Other states keep the default calm purple. */
#lt-dashboard {
transition: border-color .5s ease, box-shadow .5s ease;
}
#lt-dashboard.lt-state-danger {
border-color: #ef4444;
animation: lt-glow-danger 1.2s ease-in-out infinite;
}
@keyframes lt-glow-danger {
0%,100% { box-shadow: 0 0 16px rgba(239,68,68,0.40), 0 0 0 1px rgba(239,68,68,0.15) inset; }
50% { box-shadow: 0 0 32px 4px rgba(239,68,68,0.75), 0 0 0 1px rgba(239,68,68,0.30) inset; }
}
/* Full Screen Flash Overlay */
#lt-flash-overlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
pointer-events: none; z-index: 999995; opacity: 0; transition: opacity 0.3s ease-out;
}
#lt-flash-overlay.win { background: rgba(74, 222, 128, 0.25); opacity: 1; }
#lt-flash-overlay.loss { background: rgba(239, 68, 68, 0.25); opacity: 1; }
/* Celebration particles — bursts from the bet display on each round result.
Lives inside #lt-flash-overlay (fixed viewport positioning) and uses CSS
custom properties for per-particle direction/rotation/distance. */
.lt-particle {
position: absolute;
font-size: 22px; font-weight: 900; line-height: 1;
pointer-events: none; user-select: none; will-change: transform, opacity;
transform: translate(-50%, -50%);
animation: lt-particle-burst 950ms cubic-bezier(.18,.79,.41,1) forwards;
}
.lt-particle.win { color: #4ade80; text-shadow: 0 0 10px rgba(74,222,128,0.85), 0 0 22px rgba(34,197,94,0.45); }
.lt-particle.loss { color: #f87171; text-shadow: 0 0 10px rgba(239,68,68,0.7); }
@keyframes lt-particle-burst {
0% { transform: translate(-50%, -50%) scale(0.35) rotate(0deg); opacity: 0; }
15% { opacity: 1; transform: translate(calc(-50% + var(--lt-dx, 0px) * 0.15), calc(-50% + var(--lt-dy, 0px) * 0.15)) scale(1.15) rotate(calc(var(--lt-rot, 0deg) * 0.2)); }
100% { transform: translate(calc(-50% + var(--lt-dx, 0px)), calc(-50% + var(--lt-dy, 0px))) scale(0.7) rotate(var(--lt-rot, 0deg)); opacity: 0; }
}
#lt-header {
padding: 8px 15px; background: rgba(139, 92, 246, 0.15); display: flex;
justify-content: space-between; align-items: center; font-weight: bold;
border-bottom: 1px solid #333; border-radius: 8px 8px 0 0; user-select: none;
}
#lt-content { padding: 15px; display: flex; flex-direction: column; gap: 12px; position: relative; }
/* Hospital warning — floats over the top of the content area so it never
shifts the create-game buttons (which would risk a misclick). */
#lt-hospital-warning {
position: absolute; top: 8px; left: 15px; right: 15px;
z-index: 6; margin: 0;
}
.lt-grid-main { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; align-items: start; }
.lt-btn {
border: none; border-radius: 6px; padding: 10px 12px; cursor: pointer; font-weight: bold;
transition: all 0.2s; font-size: 12px; color: white; display: flex; align-items: center; justify-content: center; gap: 6px; width: 100%;
}
.lt-btn:hover { filter: brightness(1.2); transform: translateY(-1px); }
.lt-btn:active { transform: translateY(0); }
.lt-btn-primary { background: #7c3aed; }
.lt-btn-win { background: rgba(34, 197, 94, 0.15); border: 1px solid #22c55e; color: #4ade80; }
.lt-btn-loss { background: rgba(239, 68, 68, 0.15); border: 1px solid #ef4444; color: #f87171; }
.lt-btn-action { background: #334155; color: #cbd5e1; border: 1px solid #475569; }
.lt-btn-active { background: #f97316 !important; color: white !important; border-color: #ea580c !important; }
.lt-btn-confirm { background: #15803d; color: white; border: 1px solid #22c55e; margin-top: 5px; }
.lt-btn-play-again { background: #22c55e; color: white; font-size: 14px; padding: 15px; margin-top: 10px; box-shadow: 0 0 15px rgba(34, 197, 94, 0.4); }
.lt-btn-new-random { background: #334155; color: #e2e8f0; font-size: 11px; padding: 8px; margin-top: 5px; border: 1px solid #475569; }
.lt-btn-reset { background: #7f1d1d; color: #fecaca; font-size: 11px; padding: 8px; margin-top: 15px; border: 1px solid #ef4444; }
#lt-btn-auto { background: #334155; color: #cbd5e1; border: 1px solid #475569; }
#lt-btn-auto.active { background: rgba(34, 197, 94, 0.2); color: #4ade80; border-color: #22c55e; }
/* Stats Row */
.lt-stat-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-size: 11px; color: #94a3b8; margin-bottom: 5px; }
.lt-stat-item { display: flex; justify-content: space-between; background: rgba(0,0,0,0.2); padding: 4px 8px; border-radius: 4px; }
.lt-stat-val { font-weight: bold; color: #e2e8f0; }
.lt-bet-box {
text-align:center; margin: 10px 0; padding:15px; background:rgba(0,0,0,0.2);
border-radius:8px; border:1px solid rgba(255,255,255,0.05); cursor: pointer; transition: border-color 0.2s;
}
.lt-bet-box:hover { border-color: #7c3aed; }
.lt-bet-box.override { border-color: #eab308; background: rgba(234, 179, 8, 0.05); }
.lt-big-val { font-size: 32px; font-weight: 900; color: #fff; text-align: center; margin: 5px 0; text-shadow: 0 0 20px rgba(168, 85, 247, 0.3); }
.lt-big-val.override { color: #eab308; text-shadow: 0 0 20px rgba(234, 179, 8, 0.3); }
.lt-seq-container {
display: flex; flex-wrap: wrap; gap: 8px; padding: 12px; background: rgba(0,0,0,0.3);
border-radius: 8px; border: 1px solid rgba(255,255,255,0.05); min-height: 50px; justify-content: center; align-items: center;
}
.lt-badge {
background: #1e293b; border: 1px solid rgba(255,255,255,0.1); padding: 6px 10px; border-radius: 6px;
font-family: monospace; font-size: 13px; font-weight: bold; cursor: pointer; user-select: none; transition: all 0.1s;
}
.lt-badge:hover { background: #7c3aed; color: white; border-color: #8b5cf6; }
.lt-badge.selected { background: #f97316; color: white; border-color: #ea580c; transform: scale(1.05); }
.lt-badge.dragging { opacity: 0.4; border: 1px dashed #fff; }
/* Drag Visuals */
.lt-badge.drop-left { border-left: 3px solid #38bdf8; margin-left: -3px; }
.lt-badge.drop-right { border-right: 3px solid #38bdf8; margin-right: -3px; }
.lt-input-group { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; position: relative; max-width: 280px; }
.lt-input { background: #0f172a; border: 1px solid #334155; color: white; padding: 8px; border-radius: 4px; width: 100%; font-size: 12px; transition: border-color 0.2s; }
.lt-input.error { border-color: #ef4444; }
.lt-label { font-size: 11px; color: #94a3b8; width: 70px; flex-shrink: 0; }
.lt-suggest-btn {
flex-shrink: 0; cursor: pointer; padding: 6px 10px; border-radius: 4px; font-size: 11px;
background: rgba(124,58,237,0.15); color: #c4b5fd; border: 1px solid rgba(124,58,237,0.4);
transition: background .15s, color .15s;
}
.lt-suggest-btn:hover { background: rgba(124,58,237,0.35); color: #fff; }
.lt-input-preview { position: absolute; right: 10px; color: #4ade80; font-size: 10px; font-weight: bold; pointer-events: none; background: rgba(15, 23, 42, 0.9); padding-left: 5px; }
/* Multiplier Buttons */
.lt-mult-group { display: flex; gap: 4px; margin-bottom: 12px; justify-content: center; background: #0f172a; padding: 4px; border-radius: 6px; }
.lt-mult-btn {
flex: 1; background: #1e293b; border: 1px solid #334155; color: #94a3b8;
padding: 4px; font-size: 11px; font-weight: bold; cursor: pointer; border-radius: 4px; transition: all 0.2s;
}
.lt-mult-btn:hover { background: #334155; }
.lt-mult-btn.active { background: #7c3aed; color: white; border-color: #8b5cf6; }
.lt-checkbox-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 12px; color: #cbd5e1; cursor: pointer; }
.lt-checkbox { width: 16px; height: 16px; cursor: pointer; accent-color: #7c3aed; }
.lt-hidden { display: none !important; }
.lt-tabs { display: flex; gap: 4px; margin-bottom: 12px; background: #0f172a; padding: 3px; border-radius: 6px; }
.lt-tab { flex: 1; text-align: center; padding: 6px; cursor: pointer; border-radius: 4px; font-size: 12px; color: #94a3b8; transition: all 0.2s; }
.lt-tab.active { background: #7c3aed; color: white; font-weight: bold; }
.lt-toast {
position: absolute; top: 10px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.9); color: white; padding: 8px 16px; border-radius: 20px;
font-size: 12px; font-weight: bold; pointer-events: none; opacity: 0; transition: opacity 0.3s;
z-index: 1000000; border: 1px solid #7c3aed; white-space: nowrap;
}
.lt-toast.show { opacity: 1; }
/* History list: locked to ~10 rounds (≈350px) — older entries scroll
instead of growing the panel and shifting everything below it. */
.lt-history-list { height: 350px; max-height: 350px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
.lt-history-list::-webkit-scrollbar { width: 8px; }
.lt-history-list::-webkit-scrollbar-track { background: rgba(0,0,0,0.2); border-radius: 4px; }
.lt-history-list::-webkit-scrollbar-thumb { background: rgba(124, 58, 237, 0.5); border-radius: 4px; }
.lt-history-list::-webkit-scrollbar-thumb:hover { background: rgba(124, 58, 237, 0.7); }
.lt-history-item { display: flex; justify-content: space-between; align-items: center; background: rgba(255,255,255,0.03); padding: 8px; border-radius: 4px; font-size: 12px; }
.lt-history-win { border-left: 3px solid #4ade80; }
.lt-history-loss { border-left: 3px solid #ef4444; }
.lt-history-empty {
border-left: 3px solid rgba(100,116,139,0.25);
background: rgba(255,255,255,0.015);
justify-content: center;
opacity: 0.45;
}
.lt-hist-empty-line {
color: #475569; font-size: 10px; font-style: italic;
letter-spacing: 1px;
}
.lt-hist-badge { padding: 2px 6px; border-radius: 4px; font-weight: bold; font-size: 10px; }
.lt-hist-win-bg { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.lt-hist-loss-bg { background: rgba(239, 68, 68, 0.2); color: #f87171; }
.lt-status-bar { font-size: 10px; text-align: center; margin-bottom: 8px; color: #64748b; font-weight: bold; text-transform: uppercase; letter-spacing: 0.5px; }
/* RR motif glyph — small "chamber cross-section" (outer ring + bullet core).
Used as a thematic dot in section titles and accent points instead of
generic ◆/⊕/▣ symbols. Two sizes: default (.lt-glyph-chamber) for inline
text accents, .lt-glyph-lg for the header logo. */
.lt-glyph-chamber {
display: inline-block;
width: 8px; height: 8px; border-radius: 50%;
background: radial-gradient(circle at center, #fbbf24 0%, #fbbf24 28%, #181818 32%, #181818 50%, #a78bfa 55%, #7c3aed 100%);
box-shadow: 0 0 6px rgba(168, 85, 247, 0.55), inset 0 0 0 0.5px rgba(255,255,255,0.1);
vertical-align: middle; flex-shrink: 0;
transition: transform .15s ease;
}
.lt-glyph-chamber.lt-glyph-lg {
width: 11px; height: 11px;
box-shadow: 0 0 10px rgba(168, 85, 247, 0.75), inset 0 0 0 0.5px rgba(255,255,255,0.15);
}
.lt-section-title:hover .lt-glyph-chamber,
.lt-lobby-section-title:hover .lt-glyph-chamber,
.lt-coll-head:hover .lt-glyph-chamber { transform: rotate(60deg); }
/* INFO PANEL & TOS - V7.00 Enhanced */
.lt-info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px; }
.lt-info-card { background: rgba(124, 58, 237, 0.08); border: 1px solid rgba(124, 58, 237, 0.2); border-radius: 8px; padding: 12px; transition: all 0.2s; }
.lt-info-card:hover { background: rgba(124, 58, 237, 0.12); border-color: rgba(124, 58, 237, 0.4); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(124, 58, 237, 0.15); }
.lt-info-head { font-weight: bold; color: #a78bfa; margin-bottom: 10px; font-size: 14px; border-bottom: 2px solid rgba(124, 58, 237, 0.3); padding-bottom: 6px; display: flex; align-items: center; gap: 8px; }
.lt-info-list li { margin-bottom: 8px; color: #cbd5e1; list-style: none; position: relative; padding-left: 18px; line-height: 1.5; }
.lt-info-list li::before { content: "▸"; color: #a78bfa; position: absolute; left: 0; font-weight: bold; font-size: 14px; }
/* ToS Table - V7.00 Enhanced */
.lt-tos-table { width: 100%; border-collapse: collapse; font-size: 11px; color: #ffffff; background: rgba(0,0,0,0.2); border-radius: 6px; overflow: hidden; }
.lt-tos-table th { text-align: left; padding: 8px 10px; background: rgba(124, 58, 237, 0.15); color: #ffffff; font-weight: bold; border-bottom: 2px solid #7c3aed; }
.lt-tos-table td { padding: 8px 10px; border-bottom: 1px solid rgba(124, 58, 237, 0.1); vertical-align: top; color: #e2e8f0; }
.lt-tos-table td:first-child { color: #ffffff; font-weight: 600; width: 120px; }
.lt-tos-table tr:hover { background: rgba(124, 58, 237, 0.08); }
.lt-tos-table tr:last-child td { border-bottom: none; }
/* Custom Scrollbars - V7.00 */
#lt-info-panel::-webkit-scrollbar,
#lt-gen-preview-seq::-webkit-scrollbar { width: 6px; }
#lt-info-panel::-webkit-scrollbar-track,
#lt-gen-preview-seq::-webkit-scrollbar-track { background: rgba(0,0,0,0.3); border-radius: 4px; }
#lt-info-panel::-webkit-scrollbar-thumb,
#lt-gen-preview-seq::-webkit-scrollbar-thumb { background: rgba(124, 58, 237, 0.5); border-radius: 4px; }
#lt-info-panel::-webkit-scrollbar-thumb:hover,
#lt-gen-preview-seq::-webkit-scrollbar-thumb:hover { background: rgba(124, 58, 237, 0.7); }
/* V8.4: Game History permanently attached to the right edge of the dashboard */
#lt-history-side {
position: absolute; top: 0; left: 100%; margin-left: 12px;
width: 290px; max-height: 85vh;
background: linear-gradient(180deg, #1a1d27 0%, #14161f 100%);
border: 1px solid #7c3aed; border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 0 0 1px rgba(124,58,237,0.15) inset;
display: flex; flex-direction: column; overflow: hidden;
}
.lt-hist-side-head {
display: flex; align-items: center; padding: 12px 14px; flex-shrink: 0;
background: linear-gradient(90deg, rgba(124,58,237,0.25), rgba(124,58,237,0.08));
border-bottom: 1px solid rgba(124,58,237,0.2);
font-size: 11px; font-weight: bold; color: #c4b5fd;
text-transform: uppercase; letter-spacing: 1px;
}
#lt-hist-side-list { height: 360px; max-height: 360px; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 5px; }
#lt-hist-side-list::-webkit-scrollbar { width: 8px; }
#lt-hist-side-list::-webkit-scrollbar-track { background: rgba(0,0,0,0.2); border-radius: 4px; }
#lt-hist-side-list::-webkit-scrollbar-thumb { background: rgba(124, 58, 237, 0.5); border-radius: 4px; }
#lt-hist-side-list::-webkit-scrollbar-thumb:hover { background: rgba(124, 58, 237, 0.7); }
/* Hidden when too narrow to fit beside the dashboard */
@media (max-width: 1180px) { #lt-history-side { display: none !important; } }
/* When the wide RR layout is active, keep the dashboard a bit narrower so the
history panel (absolute at its right edge) stays on-screen. */
body.lt-rr-wide #lt-dashboard { max-width: calc(100% - 314px); }
/* V8.3: Mini-Game Popups (draggable, floating over Torn) */
.lt-popup {
position: fixed; z-index: 1000000;
background: #0a0e1a; border: 1px solid #7c3aed; border-radius: 12px;
box-shadow: 0 16px 56px rgba(0,0,0,0.7), 0 0 0 1px rgba(124,58,237,0.25);
overflow: hidden;
}
.lt-popup-header {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px; cursor: move; user-select: none;
background: linear-gradient(90deg, rgba(124,58,237,0.35), rgba(124,58,237,0.1));
border-bottom: 1px solid rgba(124,58,237,0.25);
}
.lt-popup-title { font-size: 12px; font-weight: bold; color: #c4b5fd; letter-spacing: 0.4px; }
.lt-popup-close {
cursor: pointer; color: #f87171; font-weight: bold; font-size: 15px;
line-height: 1; padding: 2px 6px; border-radius: 4px; transition: all .15s;
}
.lt-popup-close:hover { color: #fff; background: rgba(239,68,68,0.3); }
.lt-popup-body { padding: 12px; }
.lt-popup-body canvas { display: block; border-radius: 6px; cursor: pointer; }
/* ═══════════════════════════════════════════════════════════
V8.0 — IMMERSIVE REDESIGN
═══════════════════════════════════════════════════════════ */
/* Dashboard polish */
#lt-dashboard {
background: linear-gradient(180deg, #1a1d27 0%, #14161f 100%);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(124, 58, 237, 0.15) inset;
}
/* Header upgrade */
#lt-header {
background: linear-gradient(90deg, rgba(124, 58, 237, 0.25) 0%, rgba(124, 58, 237, 0.08) 100%);
padding: 10px 18px;
}
#lt-header-profit {
font-size: 11px; color: #94a3b8; font-weight: bold; margin-left: 14px;
padding: 3px 10px; border-radius: 10px; background: rgba(0,0,0,0.3);
}
#lt-header-profit.pos { color: #4ade80; background: rgba(34,197,94,0.12); }
#lt-header-profit.neg { color: #f87171; background: rgba(239,68,68,0.12); }
/* Hero grid */
.lt-hero {
display: grid;
grid-template-columns: 1fr 1.6fr 1fr;
gap: 14px;
padding: 0;
margin-bottom: 14px;
}
@media (max-width: 900px) { .lt-hero { grid-template-columns: 1fr; } }
.lt-hero-side {
display: flex; flex-direction: column; gap: 10px;
}
/* Stat cards */
.lt-stat-card {
background: rgba(0,0,0,0.3);
border: 1px solid rgba(124, 58, 237, 0.18);
border-radius: 10px;
padding: 12px 14px;
text-align: center;
transition: border-color .2s, transform .15s;
}
.lt-stat-card:hover {
border-color: rgba(124, 58, 237, 0.45);
transform: translateY(-1px);
}
.lt-stat-card .sc-label {
font-size: 10px; color: #64748b; font-weight: bold;
text-transform: uppercase; letter-spacing: 0.7px;
margin-bottom: 4px;
}
.lt-stat-card .sc-value {
font-size: 22px; font-weight: 800; color: #e2e8f0;
line-height: 1.1; font-variant-numeric: tabular-nums;
}
.lt-stat-card .sc-sub {
font-size: 10px; color: #94a3b8; margin-top: 2px;
}
/* Streak flame — grows hotter with the streak length.
Three tiers: warm (1-2), hot (3-5), inferno (6+). LOSS streaks of 2+
get a cold ❄ counterpart so a bad run reads instantly too. */
#lt-streak-val {
display: inline-flex; align-items: center; gap: 6px;
transition: text-shadow .25s ease;
}
.lt-streak-icon {
display: inline-block; line-height: 1;
transition: transform .3s cubic-bezier(.34,1.56,.64,1), filter .3s ease;
transform-origin: 50% 80%;
}
.lt-streak-num { font-variant-numeric: tabular-nums; }
/* Fire tier 1: warm — 1-2 WIN */
#lt-streak-val.lt-streak-fire-1 { color: #fbbf24; }
#lt-streak-val.lt-streak-fire-1 .lt-streak-icon {
transform: scale(1.05);
filter: drop-shadow(0 0 4px rgba(251,191,36,0.55));
}
/* Fire tier 2: hot — 3-5 WIN */
#lt-streak-val.lt-streak-fire-2 {
color: #fb923c;
text-shadow: 0 0 10px rgba(251,146,60,0.55);
}
#lt-streak-val.lt-streak-fire-2 .lt-streak-icon {
transform: scale(1.20);
filter: drop-shadow(0 0 8px rgba(251,146,60,0.85)) drop-shadow(0 0 14px rgba(239,68,68,0.4));
animation: lt-flame-flicker 1.4s ease-in-out infinite;
}
/* Fire tier 3: inferno — 6+ WIN */
#lt-streak-val.lt-streak-fire-3 {
color: #f87171;
text-shadow: 0 0 14px rgba(239,68,68,0.7), 0 0 24px rgba(251,146,60,0.45);
}
#lt-streak-val.lt-streak-fire-3 .lt-streak-icon {
transform: scale(1.35);
filter: drop-shadow(0 0 10px rgba(239,68,68,0.95)) drop-shadow(0 0 20px rgba(251,146,60,0.7));
animation: lt-flame-flicker 0.9s ease-in-out infinite;
}
@keyframes lt-flame-flicker {
0%,100% { transform: scale(var(--lt-flame-scale, 1.20)) translateY(0); filter: brightness(1); }
25% { transform: scale(calc(var(--lt-flame-scale, 1.20) * 1.06)) translateY(-1px); filter: brightness(1.15); }
50% { transform: scale(calc(var(--lt-flame-scale, 1.20) * 0.97)) translateY(1px); filter: brightness(0.95); }
75% { transform: scale(calc(var(--lt-flame-scale, 1.20) * 1.04)) translateY(-1px); filter: brightness(1.10); }
}
#lt-streak-val.lt-streak-fire-2 .lt-streak-icon { --lt-flame-scale: 1.20; }
#lt-streak-val.lt-streak-fire-3 .lt-streak-icon { --lt-flame-scale: 1.35; }
/* Cold streak (2+ losses in a row) */
#lt-streak-val.lt-streak-cold {
color: #93c5fd;
text-shadow: 0 0 10px rgba(147,197,253,0.45);
}
#lt-streak-val.lt-streak-cold .lt-streak-icon {
transform: scale(1.10);
filter: drop-shadow(0 0 6px rgba(147,197,253,0.85));
}
/* Hero center */
.lt-hero-center {
background: radial-gradient(ellipse at center, rgba(124, 58, 237, 0.12) 0%, rgba(0,0,0,0.2) 70%);
border: 1px solid rgba(124, 58, 237, 0.25);
border-radius: 14px;
padding: 22px 18px 18px;
display: flex; flex-direction: column; align-items: center; gap: 14px;
position: relative; overflow: hidden;
cursor: default;
}
.lt-hero-center.override { border-color: rgba(251, 191, 36, 0.45); }
.lt-hero-center::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, transparent, rgba(168, 85, 247, 0.6), transparent);
}
.lt-bet-label-v8 {
font-size: 10px; color: #a78bfa; font-weight: bold;
text-transform: uppercase; letter-spacing: 2px;
}
#lt-bet-display.lt-mega {
font-size: 56px; font-weight: 900; color: #fff;
text-shadow: 0 0 32px rgba(168, 85, 247, 0.5), 0 2px 4px rgba(0,0,0,0.5);
line-height: 1; cursor: pointer; transition: text-shadow .25s, transform .15s;
font-variant-numeric: tabular-nums;
}
#lt-bet-display.lt-mega:hover {
text-shadow: 0 0 48px rgba(168, 85, 247, 0.9), 0 2px 4px rgba(0,0,0,0.5);
transform: scale(1.02);
}
#lt-bet-display.lt-mega.override {
color: #fbbf24;
text-shadow: 0 0 32px rgba(251, 191, 36, 0.5), 0 2px 4px rgba(0,0,0,0.5);
}
.lt-action-row-mega {
display: flex; gap: 12px; width: 100%;
}
.lt-btn-mega {
flex: 1; padding: 16px 12px; border-radius: 10px;
font-size: 14px; font-weight: 800; letter-spacing: 1px;
cursor: pointer; transition: all .15s;
border: 1px solid; text-transform: uppercase;
}
.lt-btn-mega-win {
background: linear-gradient(180deg, rgba(34, 197, 94, 0.25), rgba(34, 197, 94, 0.08));
border-color: #22c55e; color: #4ade80;
}
.lt-btn-mega-win:hover {
background: linear-gradient(180deg, rgba(34, 197, 94, 0.4), rgba(34, 197, 94, 0.15));
box-shadow: 0 0 24px rgba(34, 197, 94, 0.35);
transform: translateY(-1px);
}
.lt-btn-mega-loss {
background: linear-gradient(180deg, rgba(239, 68, 68, 0.25), rgba(239, 68, 68, 0.08));
border-color: #ef4444; color: #f87171;
}
.lt-btn-mega-loss:hover {
background: linear-gradient(180deg, rgba(239, 68, 68, 0.4), rgba(239, 68, 68, 0.15));
box-shadow: 0 0 24px rgba(239, 68, 68, 0.35);
transform: translateY(-1px);
}
.lt-btn-mega:active { transform: translateY(0); }
/* Auto:ON greys out manual WIN/LOSS to prevent double-counting */
.lt-btn-mega.lt-disabled-auto {
background: rgba(100, 116, 139, 0.18) !important;
border-color: rgba(100, 116, 139, 0.35) !important;
color: #64748b !important;
cursor: not-allowed; opacity: 0.55;
box-shadow: none !important; transform: none !important;
filter: grayscale(0.6);
}
.lt-btn-mega.lt-disabled-auto:hover { background: rgba(100, 116, 139, 0.18) !important; box-shadow: none !important; transform: none !important; }
.lt-mini-row { display: flex; gap: 8px; width: 100%; }
.lt-mini-btn {
flex: 1; padding: 7px; border-radius: 6px; font-size: 11px; font-weight: bold;
cursor: pointer; border: 1px solid #334155; background: rgba(15, 23, 42, 0.6);
color: #cbd5e1; transition: all .15s;
}
.lt-mini-btn:hover { border-color: #7c3aed; color: #fff; background: rgba(124, 58, 237, 0.15); }
#lt-btn-auto.lt-mini-btn.active { background: rgba(34, 197, 94, 0.15); color: #4ade80; border-color: #22c55e; }
/* Sequence section */
.lt-section {
background: rgba(0,0,0,0.25);
border: 1px solid rgba(124, 58, 237, 0.15);
border-radius: 10px;
padding: 12px 14px;
margin-bottom: 12px;
}
.lt-section-title {
font-size: 10px; color: #a78bfa; font-weight: bold;
text-transform: uppercase; letter-spacing: 1.5px;
margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;
}
.lt-section-title .meta { color: #64748b; letter-spacing: 0.5px; }
/* Sequence cards (V8 upgrade) */
.lt-seq-card {
position: relative;
display: inline-flex; align-items: center; justify-content: center;
min-width: 58px; padding: 10px 14px;
background: linear-gradient(180deg, #1e293b, #0f172a);
border: 1px solid #334155; border-radius: 8px;
font-family: 'Segoe UI', monospace; font-size: 15px; font-weight: 700;
color: #cbd5e1; cursor: pointer; user-select: none;
transition: all .15s;
box-shadow: 0 2px 6px rgba(0,0,0,0.3), 0 1px 0 rgba(255,255,255,0.05) inset;
}
.lt-seq-card:hover { transform: translateY(-2px); border-color: #7c3aed; }
/* Delete (×) badge on each card */
.lt-seq-card-del {
position: absolute; top: -7px; right: -7px;
width: 17px; height: 17px; border-radius: 50%;
background: #ef4444; color: #fff; font-size: 12px; font-weight: bold;
display: none; align-items: center; justify-content: center; line-height: 1;
cursor: pointer; border: 1px solid #0f172a; z-index: 2;
}
.lt-seq-card:hover .lt-seq-card-del { display: flex; }
.lt-seq-card-del:hover { background: #dc2626; transform: scale(1.15); }
/* Add (+) card */
.lt-seq-add {
color: #a78bfa; border-style: dashed; border-color: #7c3aed;
background: rgba(124,58,237,0.08); font-size: 20px; min-width: 42px;
}
.lt-seq-add:hover { background: rgba(124,58,237,0.2); color: #fff; transform: translateY(-2px); }
/* Collapsible sequence section */
.lt-seq-toggle { cursor: pointer; }
.lt-seq-toggle .lt-coll-icon { transition: transform .2s; font-size: 10px; margin-left: 4px; }
.lt-seq-section.collapsed .lt-coll-icon { transform: rotate(-90deg); }
.lt-seq-collapse-body { overflow: hidden; transition: max-height .25s ease-out; max-height: 1000px; }
.lt-seq-section.collapsed .lt-seq-collapse-body { max-height: 0; }
/* Big profit chart — clean stock-style: one dot per round, the connecting
segment between two rounds is coloured by direction (green = up, red = down).
Axis labels left + bottom, dashed zero baseline, no fill area. */
.lt-profit-chart-wrap {
position: relative; width: 100%; height: 290px;
background: #1f1f1f;
border: 1px solid rgba(124,58,237,0.18); border-radius: 10px;
padding: 12px 12px 26px; box-sizing: border-box;
}
.lt-profit-chart { width: 100%; height: 100%; overflow: visible; display: block; }
.lt-chart-grid line { stroke: rgba(255,255,255,0.06); stroke-width: 1; }
.lt-chart-axis-y text, .lt-chart-axis-x text { fill: #94a3b8; font-size: 10px; font-family: monospace; }
.lt-chart-segments line { stroke-width: 2; stroke-linecap: round; }
.lt-chart-dot {
stroke: #1f1f1f; stroke-width: 1.5;
transition: r .15s ease, filter .15s ease;
cursor: pointer;
}
.lt-chart-dot.win { fill: #4ade80; }
.lt-chart-dot.loss { fill: #ef4444; }
.lt-chart-dot:hover { r: 5; filter: drop-shadow(0 0 8px currentColor) brightness(1.25); }
.lt-chart-legend {
position: absolute; left: 0; right: 0; bottom: 6px;
text-align: center; font-size: 11px; color: #94a3b8;
font-family: 'Segoe UI', sans-serif;
}
.lt-chart-legend-dot {
display: inline-block; width: 9px; height: 9px; border-radius: 50%;
background: #4ade80; margin-right: 4px; vertical-align: middle;
box-shadow: 0 0 6px rgba(74,222,128,0.6);
}
.lt-chart-empty {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: #64748b; font-size: 12px; font-style: italic; pointer-events: none;
}
.lt-chart-empty.hidden { display: none; }
.lt-chart-tooltip {
position: absolute; pointer-events: none;
background: rgba(15,23,42,0.98); border: 1px solid rgba(167,139,250,0.55);
border-radius: 8px; padding: 10px 14px; font-size: 13px; color: #e2e8f0;
box-shadow: 0 6px 22px rgba(0,0,0,0.65);
opacity: 0; transition: opacity .12s ease;
white-space: nowrap; z-index: 10;
font-family: 'JetBrains Mono', 'Consolas', monospace; line-height: 1.55;
}
.lt-chart-tooltip.show { opacity: 1; }
.lt-chart-tooltip b {
font-family: 'Inter', 'Segoe UI', sans-serif;
font-weight: 700; font-size: 13px;
}
.lt-seq-card.edge {
border-color: #22c55e; color: #4ade80;
background: linear-gradient(180deg, rgba(34,197,94,0.18), #0f172a);
box-shadow: 0 0 14px rgba(34, 197, 94, 0.35), 0 1px 0 rgba(255,255,255,0.05) inset;
}
.lt-seq-card.selected {
background: linear-gradient(180deg, #f97316, #ea580c);
border-color: #ea580c; color: #fff; transform: scale(1.06);
}
.lt-seq-card.dragging { opacity: 0.4; border: 1px dashed #fff; }
.lt-seq-card.drop-left { border-left: 3px solid #38bdf8; }
.lt-seq-card.drop-right { border-right: 3px solid #38bdf8; }
/* Stop-loss / sequence-length warning */
.lt-stop-warning {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 10px 14px; margin: 0 0 12px;
background: linear-gradient(90deg, rgba(220,38,38,0.22), rgba(220,38,38,0.10));
border: 1px solid rgba(248,113,113,0.55); border-left: 4px solid #ef4444;
border-radius: 10px; animation: lt-stop-pulse 1.6s ease-in-out infinite;
}
@keyframes lt-stop-pulse {
0%,100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.0); }
50% { box-shadow: 0 0 12px 1px rgba(239,68,68,0.35); }
}
.lt-stop-warning-text { display: flex; flex-direction: column; gap: 2px; }
.lt-stop-warning-title { font-size: 13px; font-weight: 700; color: #fecaca; }
.lt-stop-warning-reason { font-size: 11px; color: #fca5a5; }
.lt-stop-warning-btn {
flex-shrink: 0; cursor: pointer; padding: 8px 14px; border-radius: 8px;
background: #ef4444; color: #fff; border: none; font-size: 12px; font-weight: 600;
transition: background .15s, transform .1s;
}
.lt-stop-warning-btn:hover { background: #dc2626; transform: translateY(-1px); }
.lt-stop-warning-close {
flex-shrink: 0; cursor: pointer; width: 24px; height: 24px; border-radius: 50%;
background: transparent; color: #fca5a5; border: 1px solid rgba(248, 113, 113, 0.4);
font-size: 11px; line-height: 1; padding: 0;
display: flex; align-items: center; justify-content: center;
transition: background .15s, color .15s, transform .1s;
}
.lt-stop-warning-close:hover { background: rgba(239, 68, 68, 0.25); color: #fee2e2; transform: scale(1.1); }
/* Action bar at bottom */
.lt-action-bar {
display: flex; gap: 16px; padding: 10px 14px; margin-bottom: 12px;
background: rgba(0,0,0,0.25); border: 1px solid rgba(124, 58, 237, 0.12);
border-radius: 10px; flex-wrap: wrap;
}
.lt-action-group { display: flex; gap: 6px; flex-wrap: wrap; }
.lt-action-group + .lt-action-group {
padding-left: 16px; border-left: 1px solid rgba(124, 58, 237, 0.15);
}
/* V8.2: Hide Torn's appContainer off-screen (still clickable for forwarding).
Synthetic clicks work (START opens the confirmation) — we build our own UI
and forward user-initiated clicks to Torn's real elements. */
div[class^="appContainer"] {
position: absolute !important;
left: -99999px !important; top: 0 !important;
width: 1px !important; height: 1px !important;
overflow: hidden !important; opacity: 0 !important;
}
/* ════════════ Custom appContainer (V8.2 Rebuild) ════════════ */
#lt-app { background: linear-gradient(180deg, rgba(124,58,237,0.04), rgba(0,0,0,0.25)); }
.lt-app-header {
background: linear-gradient(90deg, rgba(124,58,237,0.22) 0%, rgba(124,58,237,0.04) 100%);
padding: 12px 18px; display: flex; justify-content: space-between; align-items: center;
border-bottom: 1px solid rgba(124,58,237,0.2); flex-wrap: wrap; gap: 10px;
}
.lt-app-title { color: #e2e8f0; font-size: 15px; font-weight: 700; letter-spacing: 0.6px; display: flex; align-items: center; gap: 8px; }
.lt-app-title::before { content: '◉'; color: #a78bfa; font-size: 14px; }
.lt-app-nav { display: flex; gap: 4px; flex-wrap: wrap; }
.lt-nav-btn {
padding: 5px 12px; color: #94a3b8; font-size: 11px; font-weight: bold; cursor: pointer;
border-radius: 4px; border: 1px solid transparent; background: rgba(0,0,0,0.2); transition: all .15s;
}
.lt-nav-btn:hover { color: #fff; background: rgba(124,58,237,0.18); border-color: rgba(124,58,237,0.4); }
/* IN-GAME */
.lt-igs { padding: 18px; }
.lt-igs-top {
display: grid; grid-template-columns: 1fr auto 1fr; gap: 16px; align-items: center;
margin-bottom: 16px; padding: 14px 18px; background: rgba(0,0,0,0.3);
border-radius: 10px; border: 1px solid rgba(124,58,237,0.15);
}
.lt-igs-block { display: flex; flex-direction: column; gap: 3px; }
.lt-igs-block.right { align-items: flex-end; text-align: right; }
.lt-igs-label { font-size: 10px; color: #64748b; font-weight: bold; text-transform: uppercase; letter-spacing: 1px; }
.lt-igs-timer { font-size: 28px; font-weight: 900; color: #fbbf24; font-variant-numeric: tabular-nums; line-height: 1; text-shadow: 0 0 16px rgba(251,191,36,0.3); }
.lt-igs-pot { font-size: 32px; font-weight: 900; color: #4ade80; line-height: 1; text-shadow: 0 0 16px rgba(74,222,128,0.3); }
.lt-igs-sound { background: rgba(124,58,237,0.15); color: #a78bfa; border: 1px solid #7c3aed; padding: 8px 14px; border-radius: 6px; font-size: 11px; font-weight: bold; cursor: pointer; transition: all .15s; }
.lt-igs-sound:hover { background: rgba(124,58,237,0.3); color: #fff; }
.lt-igs-msg { color: #cbd5e1; font-size: 13px; font-style: italic; padding: 12px 16px; background: rgba(0,0,0,0.25); border-radius: 8px; border-left: 3px solid #7c3aed; margin-bottom: 16px; }
.lt-igs-players { display: grid; grid-template-columns: 1fr 24px 1fr; gap: 12px; align-items: center; margin-bottom: 16px; }
.lt-vs { color: #ef4444; font-weight: 900; font-size: 18px; text-align: center; text-shadow: 0 0 12px rgba(239,68,68,0.5); }
.lt-player { position: relative; background: rgba(0,0,0,0.35); border: 1px solid rgba(124,58,237,0.2); border-radius: 10px; padding: 14px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; min-height: 100px; justify-content: center; }
.lt-player.waiting { opacity: 0.6; border-style: dashed; }
.lt-player-avatar { width: 48px; height: 48px; border-radius: 50%; border: 2px solid #7c3aed; background: #1e293b; object-fit: cover; }
.lt-player-avatar-ph { width: 48px; height: 48px; border-radius: 50%; border: 2px dashed #475569; background: rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center; color: #64748b; font-size: 20px; }
.lt-player-name { color: #e2e8f0; font-weight: bold; font-size: 13px; }
.lt-player-name.waiting { color: #64748b; font-style: italic; }
.lt-player-link { color: inherit; text-decoration: none; }
.lt-player-link:hover { color: #c4b5fd; text-decoration: underline; }
.lt-igs-controls { display: flex; gap: 10px; justify-content: center; }
.lt-leave-btn { padding: 10px 28px; border-radius: 6px; background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid #ef4444; font-weight: bold; font-size: 12px; cursor: pointer; letter-spacing: 0.8px; transition: all .15s; text-transform: uppercase; }
.lt-leave-btn:hover { background: rgba(239,68,68,0.3); color: #fff; box-shadow: 0 0 20px rgba(239,68,68,0.4); }
/* Shot buttons (shoot / x2 / x3) */
.lt-igs-shots { display: flex; gap: 10px; justify-content: center; margin-bottom: 16px; flex-wrap: wrap; }
.lt-shot-btn { padding: 14px 30px; border-radius: 8px; font-weight: 800; font-size: 15px; cursor: pointer; text-transform: uppercase; letter-spacing: 1px; transition: all .15s; border: 1px solid; }
.lt-shot-btn:active { transform: translateY(1px); }
.lt-shot-btn.primary { background: linear-gradient(180deg, rgba(239,68,68,0.3), rgba(239,68,68,0.1)); border-color: #ef4444; color: #fca5a5; }
.lt-shot-btn.primary:hover { background: linear-gradient(180deg, rgba(239,68,68,0.5), rgba(239,68,68,0.2)); box-shadow: 0 0 24px rgba(239,68,68,0.45); color: #fff; }
.lt-shot-btn.mult { background: linear-gradient(180deg, rgba(251,191,36,0.25), rgba(251,191,36,0.08)); border-color: #fbbf24; color: #fbbf24; }
.lt-shot-btn.mult:hover { background: linear-gradient(180deg, rgba(251,191,36,0.4), rgba(251,191,36,0.15)); box-shadow: 0 0 24px rgba(251,191,36,0.4); color: #fff; }
.lt-shot-btn.armed {
background: linear-gradient(180deg, rgba(239,68,68,0.55), rgba(239,68,68,0.25));
border-color: #ef4444; color: #fff;
animation: lt-shot-armed-pulse 0.7s ease-in-out infinite;
}
@keyframes lt-shot-armed-pulse {
0%,100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.0); }
50% { box-shadow: 0 0 28px 4px rgba(239,68,68,0.65); }
}
.lt-shot-btn.take-action { background: linear-gradient(180deg, rgba(168,85,247,0.3), rgba(168,85,247,0.1)); border-color: #a855f7; color: #c4b5fd; }
.lt-shot-btn.take-action:hover { background: linear-gradient(180deg, rgba(168,85,247,0.5), rgba(168,85,247,0.2)); box-shadow: 0 0 24px rgba(168,85,247,0.45); color: #fff; }
/* Revolver cylinder (top view), sound-synced */
.lt-revolver { display: flex; justify-content: center; align-items: center; margin: 6px 0 18px; }
.lt-revolver.fire { animation: lt-rev-recoil 0.5s cubic-bezier(0.2, 0.8, 0.3, 1); }
@keyframes lt-rev-recoil {
0% { transform: translateY(0) scale(1); }
10% { transform: translateY(-13px) scale(1.11); } /* sharp kick */
28% { transform: translateY(5px) scale(0.97); } /* bounce */
55% { transform: translateY(-2px) scale(1.01); }
100% { transform: translateY(0) scale(1); }
}
.lt-rev-cyl {
position: relative; width: 104px; height: 104px; border-radius: 50%;
background:
radial-gradient(circle at 50% 50%, transparent 53%, rgba(0,0,0,0.45) 55%, transparent 57%),
radial-gradient(circle at 38% 30%, #535a76, #1e222d 74%);
border: 3px solid #5d6486;
box-shadow: 0 0 0 4px #23283c, 0 0 0 6px #363c56, 0 8px 22px rgba(0,0,0,0.6),
inset 0 2px 14px rgba(0,0,0,0.7), inset 0 0 0 1px rgba(255,255,255,0.07);
/* snappy mechanical click into place (strong overshoot, short duration) */
transition: transform 0.25s cubic-bezier(0.18, 1.7, 0.4, 1);
}
.lt-rev-cyl.click { animation: lt-rev-click 0.22s ease-out; }
@keyframes lt-rev-click { 0% { filter: brightness(1); } 35% { filter: brightness(1.55); } 100% { filter: brightness(1); } }
.lt-rev-chamber {
position: absolute; width: 22px; height: 22px; border-radius: 50%;
background: radial-gradient(circle at 42% 35%, #12151f 28%, #000 86%);
border: 2px solid #6c7398;
box-shadow: inset 0 2px 6px rgba(0,0,0,0.95), inset 0 -1px 2px rgba(255,255,255,0.09);
top: 50%; left: 50%;
}
.lt-rev-hub {
position: absolute; top: 50%; left: 50%; width: 22px; height: 22px;
transform: translate(-50%,-50%); border-radius: 50%;
background: radial-gradient(circle at 38% 32%, #868eb2, #2c3146);
border: 1px solid #8b92b8; z-index: 2;
box-shadow: 0 1px 3px rgba(0,0,0,0.6), inset 0 1px 2px rgba(255,255,255,0.35);
}
.lt-rev-flash {
position: absolute; top: 50%; left: 50%; width: 10px; height: 10px;
transform: translate(-50%,-50%); border-radius: 50%; opacity: 0;
background: radial-gradient(circle, #fff 0%, #fff 14%, #fde68a 36%, #f97316 60%, rgba(239,68,68,0.45) 80%, transparent 88%);
pointer-events: none; z-index: 4; mix-blend-mode: screen;
}
.lt-rev-flash.flash { animation: lt-rev-flash 0.38s ease-out; }
@keyframes lt-rev-flash {
0% { width: 12px; height: 12px; opacity: 1; }
30% { opacity: 1; }
100% { width: 210px; height: 210px; opacity: 0; }
}
/* Death cross over a player */
.lt-player.dead { border-color: #ef4444 !important; box-shadow: 0 0 22px rgba(239,68,68,0.5); }
.lt-player-cross { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; z-index: 5; }
.lt-player-cross::before {
content: '✕'; color: #ef4444; font-size: 60px; font-weight: 900; line-height: 1;
text-shadow: 0 0 22px rgba(239,68,68,0.9); animation: lt-cross-in 0.35s ease-out;
}
@keyframes lt-cross-in { 0% { transform: scale(2.2); opacity: 0; } 60% { transform: scale(0.9); } 100% { transform: scale(1); opacity: 1; } }
/* CONFIRM (game create/join) */
/* Constant top padding reserves a zone for the hospital warning so the YES/NO
buttons never shift — you can spam-click YES without the layout moving. */
.lt-confirm { position: relative; padding: 52px 20px 24px; display: flex; flex-direction: column; align-items: center; gap: 18px; }
.lt-confirm-text { color: #e2e8f0; font-size: 15px; text-align: center; line-height: 1.5; }
.lt-confirm-text b { color: #fbbf24; }
.lt-confirm-btns { display: flex; gap: 14px; }
.lt-confirm-yes { background: linear-gradient(180deg, rgba(34,197,94,0.25), rgba(34,197,94,0.08)); border: 1px solid #22c55e; color: #4ade80; padding: 11px 36px; border-radius: 6px; font-weight: bold; font-size: 13px; cursor: pointer; transition: all .15s; text-transform: uppercase; letter-spacing: 0.6px; }
.lt-confirm-yes:hover { background: linear-gradient(180deg, rgba(34,197,94,0.4), rgba(34,197,94,0.15)); box-shadow: 0 0 16px rgba(34,197,94,0.35); }
.lt-confirm-no { background: rgba(239,68,68,0.12); border: 1px solid #ef4444; color: #f87171; padding: 11px 36px; border-radius: 6px; font-weight: bold; font-size: 13px; cursor: pointer; transition: all .15s; text-transform: uppercase; letter-spacing: 0.6px; }
.lt-confirm-no:hover { background: rgba(239,68,68,0.25); color: #fff; }
/* Absolutely positioned in the reserved top zone → does NOT push the buttons. */
.lt-confirm-warn {
position: absolute; top: 10px; left: 16px; right: 16px;
background: rgba(239,68,68,0.18); border: 1px solid #ef4444; color: #fca5a5;
padding: 8px 14px; border-radius: 8px; font-size: 13px; font-weight: bold;
text-align: center; letter-spacing: 0.3px;
box-shadow: 0 0 16px rgba(239,68,68,0.25);
visibility: hidden;
animation: lt-warn-pulse 1.5s ease-in-out infinite;
}
.lt-confirm-warn.show { visibility: visible; }
@keyframes lt-warn-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.65; } }
/* LOBBY */
.lt-lobby { padding: 18px; }
.lt-lobby-section { margin-bottom: 14px; }
.lt-lobby-section:last-child { margin-bottom: 0; }
.lt-lobby-section-title { font-size: 10px; color: #a78bfa; font-weight: bold; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 10px; display: flex; align-items: center; gap: 6px; justify-content: space-between; }
.lt-quick-toggle {
cursor: pointer; padding: 4px 10px; border-radius: 999px; font-size: 10px;
background: rgba(100, 116, 139, 0.18); color: #94a3b8;
border: 1px solid rgba(100, 116, 139, 0.35); letter-spacing: 0.5px;
transition: background .15s, color .15s, border-color .15s;
}
.lt-quick-toggle:hover { background: rgba(100, 116, 139, 0.30); color: #cbd5e1; }
.lt-quick-toggle.on {
background: rgba(250, 204, 21, 0.18); color: #fde68a;
border-color: rgba(250, 204, 21, 0.55); box-shadow: 0 0 8px rgba(250, 204, 21, 0.25);
}
.lt-quick-toggle.on:hover { background: rgba(250, 204, 21, 0.30); }
.lt-quick-toggle b { font-weight: 800; }
.lt-start-form { display: flex; gap: 8px; align-items: center; padding: 12px; background: rgba(0,0,0,0.25); border-radius: 8px; border: 1px solid rgba(124,58,237,0.15); flex-wrap: wrap; }
.lt-start-label { color: #94a3b8; font-size: 12px; }
.lt-start-input { background: #0f172a; border: 1px solid #334155; color: #fff; padding: 8px 12px; border-radius: 6px; font-size: 13px; font-family: monospace; width: 150px; transition: border-color .15s; }
.lt-start-input:focus { outline: none; border-color: #7c3aed; }
.lt-start-input.error { border-color: #ef4444; box-shadow: 0 0 0 2px rgba(239,68,68,0.25); }
.lt-start-prefill { padding: 8px 12px; background: rgba(124,58,237,0.15); color: #a78bfa; border: 1px solid #7c3aed; border-radius: 6px; font-size: 11px; font-weight: bold; cursor: pointer; transition: all .15s; }
.lt-start-prefill:hover { background: rgba(124,58,237,0.3); color: #fff; }
.lt-start-go { padding: 8px 22px; background: linear-gradient(180deg, #7c3aed, #6d28d9); color: #fff; border: 1px solid #8b5cf6; border-radius: 6px; font-size: 12px; font-weight: bold; cursor: pointer; letter-spacing: 0.8px; transition: all .15s; text-transform: uppercase; margin-left: auto; }
.lt-start-go:hover { background: linear-gradient(180deg, #8b5cf6, #7c3aed); box-shadow: 0 0 16px rgba(124,58,237,0.4); }
/* Collapsible game list */
.lt-coll { background: rgba(0,0,0,0.25); border: 1px solid rgba(124,58,237,0.15); border-radius: 8px; overflow: hidden; }
.lt-coll-head { padding: 10px 14px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; background: rgba(124,58,237,0.08); transition: background .15s; border-bottom: 1px solid rgba(124,58,237,0.12); }
.lt-coll.collapsed .lt-coll-head { border-bottom-color: transparent; }
.lt-coll-head:hover { background: rgba(124,58,237,0.16); }
.lt-coll-title { font-size: 12px; font-weight: bold; color: #e2e8f0; }
.lt-coll-count { color: #a78bfa; margin-left: 4px; }
.lt-coll-icon { color: #a78bfa; transition: transform .2s; font-size: 10px; }
.lt-coll.collapsed .lt-coll-icon { transform: rotate(-90deg); }
.lt-coll-body { max-height: 420px; overflow-y: auto; transition: max-height .25s ease-out; }
.lt-coll.collapsed .lt-coll-body { max-height: 0; }
.lt-game-row { display: grid; grid-template-columns: auto 1fr auto auto; gap: 10px; align-items: center; padding: 10px 14px; border-bottom: 1px solid rgba(255,255,255,0.04); transition: background .15s; }
.lt-game-row:hover { background: rgba(124,58,237,0.06); }
.lt-game-row.match { background: linear-gradient(90deg, rgba(34,197,94,0.15), transparent 70%) !important; box-shadow: 0 0 0 1px rgba(34,197,94,0.4) inset; }
.lt-game-row.close { background: linear-gradient(90deg, rgba(251,191,36,0.1), transparent 70%) !important; }
.lt-game-row-status { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; flex-shrink: 0; }
.lt-game-row-name { color: #cbd5e1; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lt-game-row-bet { color: #fbbf24; font-weight: bold; font-size: 13px; font-variant-numeric: tabular-nums; }
.lt-game-row-bet.match-bet { color: #4ade80; }
.lt-game-row-join { padding: 5px 12px; background: rgba(124,58,237,0.2); color: #a78bfa; border: 1px solid #7c3aed; border-radius: 4px; font-size: 10px; font-weight: bold; cursor: pointer; transition: all .15s; }
.lt-game-row-join:hover { background: #7c3aed; color: #fff; box-shadow: 0 0 8px rgba(124,58,237,0.4); }
.lt-coll-empty { padding: 18px; text-align: center; color: #64748b; font-size: 12px; font-style: italic; }
.lt-game-row-join:disabled { opacity: 0.4; cursor: not-allowed; background: rgba(100,116,139,0.2); border-color: #475569; color: #64748b; }
/* Reserved empty slot — same height as a real row, faded so it doesn't draw the eye */
.lt-game-row.lt-game-row-empty {
display: flex; align-items: center; justify-content: center;
padding: 10px 14px; opacity: 0.35;
background: transparent !important; box-shadow: none !important;
}
.lt-game-row.lt-game-row-empty:hover { background: transparent !important; }
.lt-game-row-empty-line {
color: #475569; font-size: 10px; font-style: italic; letter-spacing: 1px;
}
/* In-game alert banner (shown in lobby while a game of yours is waiting) */
.lt-ingame-banner {
display: flex; justify-content: space-between; align-items: center; gap: 12px;
padding: 12px 16px; margin-bottom: 14px;
background: rgba(239,68,68,0.12); border: 1px solid #ef4444; border-radius: 8px;
}
.lt-ingame-msg { color: #fca5a5; font-size: 12px; line-height: 1.4; }
.lt-ingame-msg b { color: #fff; }
.lt-ingame-btns { display: flex; gap: 8px; flex-shrink: 0; }
.lt-ingame-return {
background: linear-gradient(180deg, rgba(34,197,94,0.25), rgba(34,197,94,0.08));
border: 1px solid #22c55e; color: #4ade80; padding: 8px 16px; border-radius: 6px;
font-weight: bold; font-size: 12px; cursor: pointer; white-space: nowrap; transition: all .15s;
}
.lt-ingame-return:hover { background: rgba(34,197,94,0.4); color: #fff; box-shadow: 0 0 12px rgba(34,197,94,0.3); }
.lt-ingame-leave {
background: rgba(239,68,68,0.15); border: 1px solid #ef4444; color: #f87171;
padding: 8px 14px; border-radius: 6px; font-weight: bold; font-size: 12px; cursor: pointer; white-space: nowrap; transition: all .15s;
}
.lt-ingame-leave:hover { background: rgba(239,68,68,0.3); color: #fff; }
/* V8: Embedded Torn Game Window (legacy placeholder — hidden in v8.1) */
#lt-game-frame {
margin-bottom: 12px;
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 12px;
overflow: hidden;
background: linear-gradient(180deg, rgba(124, 58, 237, 0.05), rgba(0, 0, 0, 0.3));
box-shadow: 0 0 24px rgba(124, 58, 237, 0.08), 0 4px 16px rgba(0, 0, 0, 0.4);
display: none;
}
#lt-game-frame.active { display: block; }
.lt-frame-title {
font-size: 10px; color: #a78bfa; font-weight: bold;
text-transform: uppercase; letter-spacing: 1.8px;
padding: 10px 16px;
background: linear-gradient(90deg, rgba(124, 58, 237, 0.18), rgba(124, 58, 237, 0.04));
border-bottom: 1px solid rgba(124, 58, 237, 0.2);
display: flex; justify-content: space-between; align-items: center;
}
.lt-frame-title .lt-frame-meta { color: #64748b; letter-spacing: 0.6px; font-size: 9px; }
#lt-game-frame-slot { position: relative; }
/* Tone down Torn's app header inside our frame (we already have a title) */
#lt-game-frame-slot div[class^="appHeaderWrapper"] {
background: rgba(0,0,0,0.15) !important;
border-bottom: 1px solid rgba(124, 58, 237, 0.1);
}
/* Make the whole dashboard breathe wider on big screens */
@media (min-width: 1200px) {
#lt-dashboard { max-width: 100%; }
}
/* Smart Game-List highlight (V8) */
.lt-rr-game-match {
background: linear-gradient(90deg, rgba(34, 197, 94, 0.18), transparent 60%) !important;
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.4) inset, 0 0 12px rgba(34, 197, 94, 0.2);
position: relative;
}
.lt-rr-game-match::before {
content: '🎯 MATCH'; position: absolute; top: 50%; right: 8px;
transform: translateY(-50%); font-size: 10px; font-weight: bold;
color: #4ade80; background: rgba(0,0,0,0.7); padding: 3px 8px;
border-radius: 10px; border: 1px solid #22c55e; pointer-events: none;
z-index: 5;
}
.lt-rr-game-close {
background: linear-gradient(90deg, rgba(251, 191, 36, 0.12), transparent 60%) !important;
box-shadow: 0 0 0 1px rgba(251, 191, 36, 0.3) inset;
}
/* ──────────────────────────────────────────────────────────────────
IN-GAME ANIMATIONS — make a round feel alive.
Timer urgency, pot golden flash, shot-button breathing, dashboard shake
on bang, player card entrance. */
/* Timer: pulse orange under 10s, faster pulsing red under 5s */
.lt-igs-timer.urgent {
color: #f97316 !important;
text-shadow: 0 0 18px rgba(249,115,22,0.65) !important;
animation: lt-timer-pulse 1.0s ease-in-out infinite;
}
.lt-igs-timer.critical {
color: #ef4444 !important;
text-shadow: 0 0 22px rgba(239,68,68,0.85), 0 0 40px rgba(239,68,68,0.45) !important;
animation: lt-timer-pulse 0.55s ease-in-out infinite;
}
@keyframes lt-timer-pulse {
0%,100% { transform: scale(1); }
50% { transform: scale(1.10); }
}
/* Pot: golden flash + scale on every increase */
.lt-igs-pot.lt-pot-flash { animation: lt-pot-flash 0.65s ease-out; }
@keyframes lt-pot-flash {
0% { transform: scale(1); color: #4ade80; text-shadow: 0 0 16px rgba(74,222,128,0.3); }
30% { transform: scale(1.18); color: #fbbf24; text-shadow: 0 0 30px rgba(251,191,36,0.95), 0 0 60px rgba(251,191,36,0.5); }
100% { transform: scale(1); color: #4ade80; text-shadow: 0 0 16px rgba(74,222,128,0.3); }
}
/* Shot buttons: gentle breathing glow so your eye is drawn when it's your turn */
.lt-shot-btn.primary:not(.armed) { animation: lt-breath-red 1.8s ease-in-out infinite; }
.lt-shot-btn.mult:not(.armed) { animation: lt-breath-gold 2.2s ease-in-out infinite; }
@keyframes lt-breath-red {
0%,100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); }
50% { box-shadow: 0 0 22px rgba(239,68,68,0.55); }
}
@keyframes lt-breath-gold {
0%,100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
50% { box-shadow: 0 0 22px rgba(251,191,36,0.45); }
}
/* Dashboard shake on bang — sharp horizontal jolt, then back */
#lt-dashboard.lt-shake { animation: lt-shake 0.42s cubic-bezier(.36,.07,.19,.97); }
@keyframes lt-shake {
10%, 90% { transform: translate3d(-2px, 0, 0); }
20%, 80% { transform: translate3d( 4px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-6px, 0, 0); }
40%, 60% { transform: translate3d( 6px, 0, 0); }
}
/* Player cards slide in when a round starts (staggered) */
.lt-igs-players .lt-player { animation: lt-player-enter 0.45s cubic-bezier(.34,1.56,.64,1) both; }
.lt-igs-players .lt-player:nth-child(1) { animation-delay: 0.00s; }
.lt-igs-players .lt-player:nth-child(3) { animation-delay: 0.10s; }
@keyframes lt-player-enter {
0% { opacity: 0; transform: translateY(18px) scale(0.94); }
100% { opacity: 1; transform: translateY(0) scale(1.00); }
}
/* The VS divider mirrors the entrance */
.lt-igs-players .lt-vs { animation: lt-vs-enter 0.45s cubic-bezier(.34,1.56,.64,1) both; animation-delay: 0.05s; }
@keyframes lt-vs-enter {
0% { opacity: 0; transform: scale(0.4) rotate(-30deg); }
100% { opacity: 1; transform: scale(1) rotate(0deg); }
}
/* Death flash: card desaturates + flickers when the death-cross appears */
.lt-player.dead { animation: lt-death-flicker 0.6s ease-out forwards; }
@keyframes lt-death-flicker {
0% { filter: none; }
20% { filter: brightness(2.4) saturate(0.5); }
40% { filter: brightness(0.4); }
60% { filter: brightness(1.7); }
100% { filter: grayscale(0.85) brightness(0.7); }
}
.lt-player-cross { animation: lt-cross-draw 0.4s cubic-bezier(.5,1.8,.5,1) both; }
@keyframes lt-cross-draw {
0% { opacity: 0; transform: scale(2.5) rotate(-25deg); }
100% { opacity: 1; transform: scale(1) rotate(0deg); }
}
/* ──────────────────────────────────────────────────────────────────
RISK RING — circular progress around the bet display.
Fill arc grows + tweens green→yellow→red with current loss vs stop. */
.lt-bet-ring-wrap {
position: relative; width: 220px; height: 220px;
display: flex; align-items: center; justify-content: center;
}
.lt-bet-ring {
position: absolute; inset: 0; width: 100%; height: 100%;
pointer-events: none;
}
.lt-bet-ring-fill {
transition: stroke-dashoffset .55s cubic-bezier(.22,.61,.36,1),
stroke .35s ease,
filter .35s ease;
}
.lt-bet-ring-fill.danger {
animation: lt-ring-pulse 1.2s ease-in-out infinite;
}
@keyframes lt-ring-pulse {
0%,100% { filter: drop-shadow(0 0 6px rgba(239,68,68,0.5)); }
50% { filter: drop-shadow(0 0 18px rgba(239,68,68,0.95)) brightness(1.2); }
}
.lt-bet-ring-wrap #lt-bet-display { position: relative; z-index: 2; }
.lt-bet-ring-val {
position: absolute; bottom: 6px; left: 50%; transform: translateX(-50%);
font-size: 10px; font-weight: 700; letter-spacing: 0.15em;
font-family: 'JetBrains Mono', 'Consolas', monospace;
color: #94a3b8; text-transform: uppercase;
transition: color .25s ease;
text-shadow: 0 1px 2px rgba(0,0,0,0.6);
pointer-events: none;
}
/* ──────────────────────────────────────────────────────────────────
GLASSMORPHISM REFRESH — translucent surfaces + blurred backdrops on
stat cards, hero, lobby, and the various inset panels. Layered
shadows give depth without bloating the markup. */
.lt-stat-card {
background: linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02)) !important;
border: 1px solid rgba(255,255,255,0.07) !important;
backdrop-filter: blur(10px) saturate(140%);
-webkit-backdrop-filter: blur(10px) saturate(140%);
box-shadow:
0 1px 0 rgba(255,255,255,0.05) inset,
0 8px 24px -8px rgba(0,0,0,0.55);
transition: transform .2s ease, box-shadow .25s ease, border-color .25s ease;
}
.lt-stat-card:hover {
transform: translateY(-1px);
border-color: rgba(167,139,250,0.35) !important;
box-shadow:
0 1px 0 rgba(255,255,255,0.08) inset,
0 12px 28px -8px rgba(124,58,237,0.30);
}
/* Context-coloured halos behind specific stats */
.lt-stat-card:has(#lt-prof-val) { box-shadow: 0 1px 0 rgba(255,255,255,0.05) inset, 0 8px 24px -10px rgba(74,222,128,0.30); }
.lt-stat-card:has(#lt-streak-val) { box-shadow: 0 1px 0 rgba(255,255,255,0.05) inset, 0 8px 24px -10px rgba(251,146,60,0.25); }
.lt-stat-card:has(#lt-winrate-val) { box-shadow: 0 1px 0 rgba(255,255,255,0.05) inset, 0 8px 24px -10px rgba(96,165,250,0.25); }
.lt-hero-center {
backdrop-filter: blur(8px) saturate(130%);
-webkit-backdrop-filter: blur(8px) saturate(130%);
box-shadow:
0 1px 0 rgba(255,255,255,0.06) inset,
0 16px 36px -10px rgba(124,58,237,0.25);
}
.lt-lobby-section, .lt-profit-chart-wrap, .lt-action-bar {
backdrop-filter: blur(6px) saturate(120%);
-webkit-backdrop-filter: blur(6px) saturate(120%);
}
/* ──────────────────────────────────────────────────────────────────
TYPOGRAPHY REFRESH — applied last so it overrides earlier rules.
• Inter for UI text, JetBrains Mono for numeric / monetary values.
• Tightened tracking on hero numbers, widened on uppercase labels.
• Tabular figures everywhere a number can change in place. */
/* All number-style values get sharp tabular Mono → no width jitter on tween */
#lt-bet-display,
.lt-stat-card .sc-value,
#lt-rem-val, #lt-prof-val, #lt-rounds-val, #lt-winrate-val,
#lt-header-profit,
.lt-igs-pot, #lt-igs-pot,
#lt-igs-timer,
.lt-game-row-bet,
.lt-input-preview,
.lt-seq-card,
.lt-history-item b,
.lt-chart-axis-y text, .lt-chart-axis-x text,
.lt-streak-num {
font-family: 'JetBrains Mono', ui-monospace, 'Consolas', 'Menlo', monospace;
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum', 'zero', 'ss01';
}
/* Big hero numbers: tighter tracking + slightly heavier weight */
#lt-bet-display { letter-spacing: -0.02em; font-weight: 800; }
.lt-igs-pot, #lt-igs-pot { letter-spacing: -0.01em; font-weight: 800; }
/* Section / hero labels: cleaner uppercase rhythm */
.lt-section-title,
.lt-lobby-section-title,
.lt-hist-side-head,
.lt-coll-head,
.lt-stat-card .sc-label,
.lt-bet-label-v8,
.lt-status-bar {
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
font-weight: 700; letter-spacing: 0.10em;
}
.lt-stat-card .sc-label { font-size: 9px; letter-spacing: 0.14em; color: #7d8aa3; }
.lt-bet-label-v8 { letter-spacing: 0.20em; font-weight: 600; }
/* Header (LabTrack logo / version) */
#lt-header { font-family: 'Inter', system-ui, sans-serif; letter-spacing: 0.08em; font-weight: 700; }
#lt-header-profit { font-family: 'JetBrains Mono', 'Consolas', monospace; letter-spacing: 0; font-weight: 700; }
/* Buttons: cleaner button weight */
.lt-btn, .lt-btn-mega, .lt-shot-btn, .lt-start-go, .lt-start-prefill,
.lt-game-row-join, .lt-stop-warning-btn, .lt-quick-toggle, .lt-suggest-btn {
font-family: 'Inter', 'Segoe UI', sans-serif; font-weight: 700;
letter-spacing: 0.04em;
}
.lt-btn-mega { letter-spacing: 0.12em; }
/* Inputs use Mono so 0/1/. don't visually jitter */
.lt-input, .lt-start-input {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-variant-numeric: tabular-nums;
}
`);
// --- CORE UTILS ---
const Utils = {
formatNumber: (n) => {
if (!n && n !== 0) return "0";
const sign = n < 0 ? '-' : '';
const abs = Math.abs(n);
if (abs >= 1_000_000_000) return `${sign}${(abs / 1_000_000_000).toFixed(2).replace(/\.00$/, '')}b`;
if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(2).replace(/\.00$/, '')}m`;
if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(1).replace(/\.0$/, '')}k`;
return `${sign}${Math.round(abs * 10) / 10}`;
},
generateSequence: (target, parts, uniform, integers) => {
if (parts <= 0) {
Logger.warn('Utils', `Invalid parts: ${parts}`);
return [];
}
const minVal = integers ? CONFIG.MIN_INT_BET : CONFIG.MIN_BET;
const step = integers ? 1 : 0.1;
const r = (n) => integers ? Math.round(n) : Math.round(n * 10) / 10; // kill float noise
// 1) Build ideal (float) values that sum to `target`.
let ideal;
if (uniform) {
ideal = new Array(parts).fill(target / parts);
} else {
// Gentle ascending gradient (smallest → largest) with mild jitter —
// a clean Labouchere line that still varies between generations.
const weights = [];
for (let i = 0; i < parts; i++) {
const ramp = parts === 1 ? 1 : 0.55 + (i / (parts - 1)) * 0.9; // 0.55 → 1.45
weights.push(ramp * (0.85 + Math.random() * 0.30)); // ±15% jitter
}
weights.sort((a, b) => a - b);
const wsum = weights.reduce((a, b) => a + b, 0) || 1;
ideal = weights.map(w => (w / wsum) * target);
}
// 2) Largest-remainder rounding to `step` → sum hits `target` EXACTLY,
// every element stays ≥ minVal (0% deviation by construction).
const vals = ideal.map(v => Math.max(minVal, r(Math.floor(v / step) * step)));
let used = vals.reduce((a, b) => a + b, 0);
let leftover = Math.round((target - used) / step); // steps left to assign
if (leftover > 0) {
// Hand remaining steps to the parts with the largest fractional remainder.
const order = ideal
.map((v, i) => ({ i, frac: (v / step) - Math.floor(v / step) }))
.sort((a, b) => b.frac - a.frac);
for (let k = 0; leftover > 0; k++, leftover--) {
const idx = order[k % parts].i;
vals[idx] = r(vals[idx] + step);
}
} else if (leftover < 0) {
// Min-clamp overshot the target — trim from the largest parts (never below min).
let over = -leftover, guard = 0;
const order = vals.map((v, i) => ({ i, v })).sort((a, b) => b.v - a.v);
for (let k = 0; over > 0 && guard < parts * 5000; k++, guard++) {
const idx = order[k % parts].i;
if (vals[idx] - step >= minVal - 1e-9) { vals[idx] = r(vals[idx] - step); over--; }
}
}
return vals.map(v => r(Math.max(minVal, v)));
},
makeId: () => Math.random().toString(36).substr(2, 9) + Date.now().toString(36),
showToast: (msg) => {
let toast = document.getElementById('lt-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'lt-toast';
toast.className = 'lt-toast';
document.getElementById('lt-dashboard')?.appendChild(toast);
}
if (toast) {
toast.innerText = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), CONFIG.TOAST_MS);
}
}
};
const auditLog = new AuditLog();
window.ltAuditLog = auditLog;
// --- GAME ENGINE ---
class GameEngine {
constructor() {
this.state = this.loadState() || this.getDefaultState();
this.listeners = [];
this.pendingBet = null;
this.history = [];
this.lastActionTime = 0; // V6.31: Hard lock for race conditions
if (this.state.autoDetect === undefined) this.state.autoDetect = true;
if (this.state.customBet === undefined) this.state.customBet = null;
if (this.state.roundCount === undefined) this.state.roundCount = 0;
if (this.state.roundHistory === undefined) this.state.roundHistory = [];
if (this.state.initialSequence === undefined) this.state.initialSequence = [];
if (this.state.multiplier === undefined) this.state.multiplier = 1; // Base internal multiplier
window.addEventListener('storage', (e) => {
if (e.key === 'lt_standalone_save') {
this.state = this.loadState() || this.getDefaultState();
this.notify();
}
});
}
getDefaultState() {
// First-time players start with a ready-to-go sequence: 10M target, 10 parts.
const defSeq = Utils.generateSequence(10000000, 10, true, false)
.map(v => ({ id: Utils.makeId(), value: v }));
return {
sequence: JSON.parse(JSON.stringify(defSeq)),
multiplier: 1,
totalProfit: 0,
autoDetect: true,
customBet: null,
roundCount: 0,
roundHistory: [],
initialSequence: defSeq
};
}
loadState() {
try {
const data = localStorage.getItem(CONFIG.STORAGE_SAVE);
if (!data) return null;
const state = JSON.parse(data);
if (!Validator.isStateValid(state)) {
Logger.warn('GameEngine', 'Invalid state loaded, using default');
return null;
}
return state;
} catch(e) {
Logger.error('GameEngine', `Load failed: ${e.message}`);
return null;
}
}
saveState() {
try {
const data = JSON.stringify(this.state);
if (data.length > CONFIG.MAX_STORAGE_MB * 1000000) {
Logger.warn('GameEngine', 'State too large, compacting history');
this.compactHistory();
}
localStorage.setItem(CONFIG.STORAGE_SAVE, data);
this.notify();
} catch(e) {
if (e.name === 'QuotaExceededError') {
Logger.error('GameEngine', 'Storage quota exceeded');
Utils.showToast("Storage full - clearing old data");
this.compactHistory();
try {
localStorage.setItem(CONFIG.STORAGE_SAVE, JSON.stringify(this.state));
this.notify();
} catch(retryErr) {
Logger.error('GameEngine', 'Retry save failed');
}
} else {
Logger.error('GameEngine', `Save failed: ${e.message}`);
}
}
}
compactHistory() {
if (this.state.roundHistory.length > 20) {
this.state.roundHistory = this.state.roundHistory.slice(0, 20);
}
if (this.history.length > 10) {
this.history = this.history.slice(-10);
}
}
subscribe(fn) { this.listeners.push(fn); }
notify() { this.listeners.forEach(fn => fn(this.state)); }
resetData() {
localStorage.removeItem('lt_standalone_save');
localStorage.removeItem('lt_settings');
this.state = this.getDefaultState();
this.notify();
Utils.showToast("Factory Reset Complete");
}
pushHistory() {
try {
this.history.push(JSON.stringify(this.state));
if (this.history.length > CONFIG.MAX_UNDO_STACK) {
this.history.shift();
}
} catch(e) {
Logger.error('GameEngine', `Push history failed: ${e.message}`);
}
}
undo() {
if (!this.history.length) {
Utils.showToast("Nothing to undo");
return;
}
try {
this.state = JSON.parse(this.history.pop());
this.saveState();
Utils.showToast("Undo: Last Round");
Logger.info('GameEngine', 'Undo successful');
} catch(e) {
Logger.error('GameEngine', `Undo failed: ${e.message}`);
Utils.showToast("Undo failed");
}
}
toggleAutoDetect() { this.state.autoDetect = !this.state.autoDetect; this.saveState(); }
setCustomBet(amount) { this.state.customBet = amount; this.saveState(); }
clearCustomBet() { this.state.customBet = null; this.saveState(); }
resetBet() {
this.state.customBet = null;
this.pendingBet = null;
this.saveState();
Logger.info('GameEngine', 'Bet reset - customBet and pendingBet cleared');
}
getEffectiveBet() {
if (this.state.customBet !== null && this.state.customBet > 0) return this.state.customBet;
// Calculate base bet from sequence
const baseBet = this.getNextBaseBet();
// Apply selected multiplier (default 1 if not set)
let mult = 1;
try {
const s = JSON.parse(localStorage.getItem('lt_settings'));
if(s && s.multVal) mult = parseFloat(s.multVal);
} catch(e){}
return Math.round(baseBet * mult);
}
setPendingBet(amount) {
if (!Validator.isBetValid(amount)) {
Logger.warn('GameEngine', `Invalid bet: ${amount}`);
return;
}
Logger.info('GameEngine', `Bet captured: ${amount}`);
this.pendingBet = amount;
auditLog.record('NET', 'BET', `Pending: ${amount}`);
}
generateNew(target, parts, uniform, integers) {
this.pushHistory();
const seq = Utils.generateSequence(target, parts, uniform, integers);
this.state.initialSequence = seq.map(v => ({ id: Utils.makeId(), value: v }));
this.state.sequence = JSON.parse(JSON.stringify(this.state.initialSequence));
this.state.totalProfit = 0; this.state.roundCount = 0; this.state.roundHistory = []; this.state.customBet = null;
this.saveState();
Utils.showToast("Sequence Generated");
}
restartGame() {
this.pushHistory();
if(this.state.initialSequence && this.state.initialSequence.length > 0) {
this.state.sequence = JSON.parse(JSON.stringify(this.state.initialSequence));
this.state.sequence.forEach(i => i.id = Utils.makeId());
this.state.totalProfit = 0; this.state.roundCount = 0; this.state.roundHistory = []; this.state.customBet = null;
this.saveState();
Utils.showToast("Game Restarted");
} else { Utils.showToast("No previous sequence"); }
}
// V6.29: Helper for raw sequence value
getNextBaseBet() {
const seq = this.state.sequence;
if(seq.length===0) return 0;
if(seq.length===1) return seq[0].value;
return seq[0].value + seq[seq.length-1].value;
}
// V7.09: Bulletproof deduplication using set-first-check-after
processWin() {
const now = Date.now();
// V7.09 FIX: SET marker FIRST, then check previous value
// This is the ONLY way to prevent TOCTOU
const prevMarker = this._winProcessingTime;
this._winProcessingTime = now; // SET IMMEDIATELY
// Race-protection debounce. Only enforced while Auto-Detect is ON —
// there the risk is auto + manual firing for the same round. In pure
// manual mode we use a tiny 300 ms button-mash guard instead so you can
// simulate/log rounds rapidly.
const guardMs = this.state.autoDetect ? 3000 : 300;
if (prevMarker && (now - prevMarker) < guardMs) {
Logger.warn('GameEngine', `Win SKIPPED - duplicate call within ${guardMs}ms`);
this.pendingBet = null;
return;
}
const bet = this.pendingBet || this.getEffectiveBet();
// Also check history as backup (same auto-only logic).
if (this.state.autoDetect && this.state.roundHistory.length > 0) {
const last = this.state.roundHistory[0];
if (last.result === 'WIN' && (now - last.time) < 3000) {
Logger.warn('GameEngine', 'Win SKIPPED - already in history within 3s');
this.pendingBet = null;
return;
}
}
this.pushHistory();
if (this.state.sequence.length === 0) {
Logger.warn('GameEngine', 'Win processed with empty sequence');
return;
}
let mult = 1;
try {
const settings = JSON.parse(localStorage.getItem(CONFIG.STORAGE_SETTINGS));
mult = parseFloat(settings?.multVal) || 1;
} catch(e) {
Logger.debug('GameEngine', 'Using default multiplier');
}
const baseProfit = bet / mult;
this.state.roundHistory.unshift({
result: 'WIN',
bet,
profit: bet,
time: now // Use the same 'now' timestamp
});
// Capture removed values BEFORE modifying sequence
const seq = this.state.sequence;
const seqBefore = seq.map(i => i.value);
const removedFirst = seq.length > 0 ? seq[0].value : null;
const removedLast = seq.length > 1 ? seq[seq.length - 1].value : null;
if (this.state.sequence.length > 1) {
this.state.sequence.shift();
this.state.sequence.pop();
} else {
this.state.sequence = [];
}
this.state.totalProfit += baseProfit;
// V7.26: Custom bet correction — keep sequence sum invariant intact
// Invariant: sequence_sum + totalProfit = initial_target
// If customBet ≠ first+last, the sequence sum is now off by the difference.
// We correct it by appending a remainder element or trimming from the end.
if (this.state.customBet !== null) {
const standardBet = (removedFirst ?? 0) + (removedLast ?? 0);
const remainder = Math.round((standardBet - baseProfit) * 1e6) / 1e6;
if (remainder > 0.0001) {
// Custom bet was smaller → sequence still "owes" this amount
this.state.sequence.push({ id: Utils.makeId(), value: remainder });
Logger.info('CustomBet', `Sequence corrected: remainder +${remainder} appended to end`);
} else if (remainder < -0.0001) {
// Custom bet was larger → we over-won; trim that surplus from end
let toRemove = Math.abs(remainder);
while (toRemove > 0.0001 && this.state.sequence.length > 0) {
const tail = this.state.sequence[this.state.sequence.length - 1];
if (tail.value <= toRemove + 0.0001) {
toRemove = Math.round((toRemove - tail.value) * 1e6) / 1e6;
this.state.sequence.pop();
} else {
tail.value = Math.round((tail.value - toRemove) * 1e6) / 1e6;
toRemove = 0;
}
}
Logger.info('CustomBet', `Sequence corrected: -${Math.abs(remainder)} trimmed from end`);
}
}
this.state.roundCount++;
this.pendingBet = null;
// V7.03: Clear customBet after use (one-time use only)
if (this.state.customBet) {
Logger.info('CustomBet', `Clearing after use: ${Utils.formatNumber(this.state.customBet)}`);
this.state.customBet = null;
}
this.saveState();
Logger.info('GameEngine', `Win processed: ${Utils.formatNumber(bet)}`);
const removed = removedLast !== null ? [removedFirst, removedLast] : [removedFirst];
const seqAfterWin = this.state.sequence.map(i => i.value);
auditLog.record('EVENT', 'ENGINE/WIN', `WIN — removed [${removed.join(', ')}] | seq: [${seqBefore.join(',')}] → [${seqAfterWin.join(',')}]`, {
bet, removed, seqBefore, seqAfter: seqAfterWin,
seqRemaining: this.state.sequence.length,
totalProfit: Math.round(this.state.totalProfit * mult * 100) / 100,
round: this.state.roundCount
});
}
processLoss(amount) {
const now = Date.now();
// V7.09 FIX: SET marker FIRST, then check previous value
const prevMarker = this._lossProcessingTime;
this._lossProcessingTime = now; // SET IMMEDIATELY
// Race-protection debounce (auto-only). 300 ms button-mash guard in manual.
const guardMs = this.state.autoDetect ? 3000 : 300;
if (prevMarker && (now - prevMarker) < guardMs) {
Logger.warn('GameEngine', `Loss SKIPPED - duplicate call within ${guardMs}ms`);
this.pendingBet = null;
return;
}
const bet = amount || this.pendingBet || this.getEffectiveBet();
// History-backup check only while Auto is ON.
if (this.state.autoDetect && this.state.roundHistory.length > 0) {
const last = this.state.roundHistory[0];
if (last.result === 'LOSS' && (now - last.time) < 3000) {
Logger.warn('GameEngine', 'Loss SKIPPED - already in history within 3s');
this.pendingBet = null;
return;
}
}
this.pushHistory();
let mult = 1;
try {
const settings = JSON.parse(localStorage.getItem(CONFIG.STORAGE_SETTINGS));
mult = parseFloat(settings?.multVal) || 1;
} catch(e) {
Logger.debug('GameEngine', 'Using default multiplier');
}
const baseLoss = bet / mult;
const seqBeforeLoss = this.state.sequence.map(i => i.value);
this.state.roundHistory.unshift({
result: 'LOSS',
bet,
profit: -bet,
time: now
});
this.state.sequence.push({
id: Utils.makeId(),
value: baseLoss
});
this.state.totalProfit -= baseLoss;
this.state.roundCount++;
this.pendingBet = null;
// V7.03: Clear customBet after use (one-time use only)
if (this.state.customBet) {
Logger.info('CustomBet', `Clearing after use: ${Utils.formatNumber(this.state.customBet)}`);
this.state.customBet = null;
}
this.saveState();
Logger.info('GameEngine', `Loss processed: ${Utils.formatNumber(bet)}`);
const seqAfterLoss = this.state.sequence.map(i => i.value);
auditLog.record('EVENT', 'ENGINE/LOSS', `LOSS — added ${baseLoss} | seq: [${seqBeforeLoss.join(',')}] → [${seqAfterLoss.join(',')}]`, {
bet, added: baseLoss, mult, seqBefore: seqBeforeLoss, seqAfter: seqAfterLoss,
seqLength: this.state.sequence.length,
totalProfit: Math.round(this.state.totalProfit * mult * 100) / 100,
round: this.state.roundCount
});
}
shuffleSequence() { this.pushHistory(); this.state.sequence.sort(() => Math.random() - 0.5); this.saveState(); Utils.showToast("Shuffled"); }
loadFromValues(values) {
this.pushHistory();
this.state.sequence = values.map(v => ({ id: Utils.makeId(), value: v }));
this.state.initialSequence = JSON.parse(JSON.stringify(this.state.sequence));
this.state.totalProfit = 0;
this.state.roundCount = 0;
this.state.roundHistory = [];
this.state.customBet = null;
this.saveState();
auditLog.record('EVENT', 'SEQ/PASTE', `Sequence loaded from clipboard`, { values, count: values.length });
}
// DRAG & DROP LOGIC
reorderSequence(fromIdx, toIdx) {
if(fromIdx === toIdx) return;
let adjust = 0;
if (fromIdx < toIdx) adjust = -1;
const finalTo = toIdx + adjust;
if (finalTo < 0 || finalTo > this.state.sequence.length) return;
this.pushHistory();
const item = this.state.sequence.splice(fromIdx, 1)[0];
this.state.sequence.splice(finalTo, 0, item);
this.saveState();
Utils.showToast("Reordered");
}
mergeList(indices) {
this.pushHistory(); if(indices.length<2) return;
indices.sort((a,b)=>a-b); let sum=0;
indices.forEach(i=>sum+=this.state.sequence[i].value);
const pos = indices[0];
for(let i=indices.length-1; i>=0; i--) this.state.sequence.splice(indices[i], 1);
this.state.sequence.splice(pos, 0, { id:Utils.makeId(), value:sum });
this.saveState();
Utils.showToast("Merged");
}
splitItem(idx, val) {
this.pushHistory();
const item = this.state.sequence[idx]; if(!item) return;
const rem = item.value - val; if(rem<=0) return;
this.state.sequence.splice(idx, 1, {id:Utils.makeId(), value:val}, {id:Utils.makeId(), value:rem});
this.saveState();
Utils.showToast("Split");
}
removeItem(idx) {
if (idx < 0 || idx >= this.state.sequence.length) return;
this.pushHistory();
this.state.sequence.splice(idx, 1);
this.saveState();
Utils.showToast("Number removed");
}
addItem(value) {
if (!(value > 0)) return;
this.pushHistory();
this.state.sequence.push({ id: Utils.makeId(), value });
this.saveState();
Utils.showToast("Number added");
}
}
const engine = new GameEngine();
// --- UI MANAGER ---
class OverlayUI {
constructor() {
this.mergeMode = false; this.selectedForMerge = [];
this.dragSrcIndex = null;
let s; try{ s=JSON.parse(localStorage.getItem('lt_settings')); }catch(e){}
// V6.29: Added multKey ('1x','k','m','b') and multVal (numeric)
this.savedSettings = s || { mode:'bankroll', bankroll:'400000000', risk:'2.5', target:'', parts:'10', integers:false, uniform:false, multKey:'1x', multVal:1 };
if(!this.savedSettings.multKey) { this.savedSettings.multKey='1x'; this.savedSettings.multVal=1; }
this.genMode = this.savedSettings.mode || 'bankroll';
}
saveSettingsFromUI() {
['bankroll','risk','target','parts'].forEach(k => { const el=document.getElementById('lt-inp-'+k); if(el) this.savedSettings[k]=el.value; });
['integers','uniform'].forEach(k => { const el=document.getElementById('lt-chk-'+k); if(el) this.savedSettings[k]=el.checked; });
this.savedSettings.mode = this.genMode;
localStorage.setItem('lt_settings', JSON.stringify(this.savedSettings));
this.updateInputPreviews();
}
setMultiplier(key) {
this.savedSettings.multKey = key;
if(key === 'k') this.savedSettings.multVal = 1000;
else if(key === 'm') this.savedSettings.multVal = 1000000;
else if(key === 'b') this.savedSettings.multVal = 1000000000;
else this.savedSettings.multVal = 1;
// Update UI buttons
['1x','k','m','b'].forEach(k => {
const btn = document.getElementById('lt-btn-mult-'+k);
if(btn) {
if(k===key) btn.classList.add('active');
else btn.classList.remove('active');
}
});
this.saveSettingsFromUI();
this.update(); // Refresh display numbers
}
// Pick the largest unit (B/M/K/1x) where the displayed target is still
// ≥ 10 — i.e. a two-digit-or-more number in that unit. So a 20M target
// stays in M, anything under 10M drops to K, etc. The generator hits the
// target exactly (largest-remainder), so the unit never drives deviation.
_pickAutoMult(realTarget) {
const units = [1e9, 1e6, 1e3, 1];
for (const m of units) { if (realTarget / m >= 10) return m; }
return 1;
}
_multKeyFor(v) { return v === 1e9 ? 'b' : v === 1e6 ? 'm' : v === 1e3 ? 'k' : '1x'; }
_fmtUnit(v) { return (Math.round(v * 10) / 10).toString(); }
// Auto-fit the multiplier to the number the user actually types: the
// BANKROLL in bankroll mode, the TARGET in target mode. The unit follows
// that field (kept ≥ 10 in its unit), not the much smaller derived target —
// so a 400M bankroll stays in M even at low risk. Rescales the field so the
// real money amount is unchanged.
autoFitMultiplier() {
const curMult = this.savedSettings.multVal || 1;
const fieldId = this.genMode === 'bankroll' ? 'lt-inp-bankroll' : 'lt-inp-target';
const el = document.getElementById(fieldId);
const realValue = (parseFloat(el?.value) || 0) * curMult;
if (realValue <= 0) return;
const best = this._pickAutoMult(realValue);
if (best === curMult) return;
if (el) el.value = this._fmtUnit(realValue / best); // keep real money identical
this.setMultiplier(this._multKeyFor(best)); // updates buttons, saves, refreshes
}
// Suggest a parts count from Risk %. Higher risk = more aggressive = fewer
// parts (bigger bets); lower risk = more parts (smaller, flatter bets).
// parts ≈ 25/risk (Risk 2.5% → 10, Risk 5% → 5, Risk 1% → 25).
// Clamped to a sane 4–40 range. (Target mode has no risk → defaults to 10.)
suggestParts() {
let parts = 10;
if (this.genMode === 'bankroll') {
const risk = parseFloat(document.getElementById('lt-inp-risk')?.value) || 0;
if (risk > 0) parts = Math.round(25 / risk);
}
parts = Math.max(4, Math.min(40, parts));
const el = document.getElementById('lt-inp-parts');
if (el) el.value = parts;
this.saveSettingsFromUI();
this.autoFitMultiplier();
this.update();
Utils.showToast(`Suggested ${parts} parts`);
}
updateInputPreviews() {
const mult = this.savedSettings.multVal || 1;
const suffix = this.savedSettings.multKey !== '1x' ? this.savedSettings.multKey.toUpperCase() : '';
const bindPreview = (id) => {
const el = document.getElementById(id);
if(!el) return;
let prev = el.parentNode.querySelector('.lt-input-preview');
if(!prev) { prev=document.createElement('span'); prev.className='lt-input-preview'; el.parentNode.appendChild(prev); }
const val = parseFloat(el.value);
if(isNaN(val) || val === 0) prev.innerText = '';
else {
const total = val * mult;
prev.innerText = Utils.formatNumber(total);
}
};
bindPreview('lt-inp-bankroll');
bindPreview('lt-inp-target');
}
init() {
this.checkAndMount();
engine.subscribe(() => { this.update(); this.checkAndMount(); });
setInterval(() => this.checkAndMount(), 1000);
// FLASH OVERLAY MOUNT
if(!document.getElementById('lt-flash-overlay')) {
const fl = document.createElement('div');
fl.id = 'lt-flash-overlay';
document.body.appendChild(fl);
}
}
checkAndMount() {
if(!window.location.href.includes("russianRoulette")) { const el=document.getElementById('lt-dashboard'); if(el) el.style.display='none'; return; }
const content = document.querySelector('.content-wrapper') || document.querySelector('#mainContainer');
if(content) {
let dash = document.getElementById('lt-dashboard');
if(!dash) this.render(content);
else { if(dash.style.display==='none') dash.style.display='flex'; if(content.firstChild!==dash) content.insertBefore(dash, content.firstChild); }
}
this.syncHospitalWarning(); // poll every tick — independent of engine events
this.syncManualButtons();
this.syncBorderState();
}
// Disable the manual WIN / LOSS buttons while Auto: ON — a stray click
// would double-count the round. Reads autoDetect from engine.state if not
// passed (so the 1s poll can run it too, not only on engine events).
syncManualButtons(autoOn) {
if (autoOn === undefined) autoOn = !!engine.state.autoDetect;
const winBtn = document.getElementById('lt-btn-win');
const lossBtn = document.getElementById('lt-btn-loss');
[winBtn, lossBtn].forEach(b => {
if (!b) return;
if (b.disabled !== !!autoOn) b.disabled = !!autoOn;
b.classList.toggle('lt-disabled-auto', !!autoOn);
b.title = autoOn ? 'Disabled while Auto: ON — turn Auto off to log a round manually' : '';
});
}
// Toggle the dashboard hospital banner from a live DOM check (Torn's active
// "Hospital: …" status icon). Runs on the 1s checkAndMount loop so it stays
// in sync whether or not an engine state change fires.
syncHospitalWarning() {
const hospEl = document.getElementById('lt-hospital-warning');
if (!hospEl) return;
const inHospital = !!document.querySelector('[aria-label^="Hospital:" i]');
const want = inHospital ? 'flex' : 'none';
if (hospEl.style.display !== want) hospEl.style.display = want;
}
flash(type) {
const el = document.getElementById('lt-flash-overlay');
if(el) {
el.className = type === 'win' ? 'win' : 'loss';
void el.offsetWidth; // Force Reflow
setTimeout(() => el.className = '', 300);
}
const db = document.getElementById('lt-dashboard');
if(db) {
db.classList.add(type==='win'?'flash-win':'flash-loss');
setTimeout(()=>db.classList.remove('flash-win','flash-loss'), 600);
}
}
// Full celebration on each new round result: dashboard glow + particle burst
// from the bet display. WIN sprays $/✦ upward, LOSS drops ✕ downward.
celebrate(type) {
this.flash(type);
const overlay = document.getElementById('lt-flash-overlay');
const host = document.getElementById('lt-bet-box') || document.getElementById('lt-dashboard');
if (!overlay || !host) return;
const rect = host.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const isWin = type === 'win';
const count = isWin ? 16 : 12;
const symbols = isWin ? ['$', '$', '✦', '✦', '★'] : ['✕', '✕', '✕', '☠'];
for (let i = 0; i < count; i++) {
const p = document.createElement('span');
p.className = `lt-particle ${type}`;
// WIN: upward sweep (angles 180°–360° → sin < 0); LOSS: downward (0°–180° → sin > 0)
const angle = isWin
? Math.PI + Math.random() * Math.PI
: Math.random() * Math.PI;
const dist = 70 + Math.random() * 110;
const dx = Math.cos(angle) * dist;
const dy = Math.sin(angle) * dist;
p.style.left = cx + 'px';
p.style.top = cy + 'px';
p.style.setProperty('--lt-dx', dx + 'px');
p.style.setProperty('--lt-dy', dy + 'px');
p.style.setProperty('--lt-rot', (Math.random() * 720 - 360) + 'deg');
p.textContent = symbols[Math.floor(Math.random() * symbols.length)];
overlay.appendChild(p);
setTimeout(() => p.remove(), 1000);
}
}
render(container) {
const div = document.createElement('div'); div.id = 'lt-dashboard'; div.className = 'visible';
div.innerHTML = `
<div id="lt-header">
<span style="display:flex;align-items:center;gap:8px;">
<span class="lt-glyph-chamber lt-glyph-lg" title="LabTrack"></span>
LabTrack <span style="color:#a78bfa;font-weight:400;">V${CONFIG.VERSION}</span>
<span id="lt-header-profit">0</span>
</span>
<div style="display:flex;gap:10px;align-items:center;">
<button id="lt-btn-dl-log" class="lt-btn lt-btn-action" style="padding:4px 8px;width:auto;" title="Audit Log herunterladen">📥 Log</button>
<button id="lt-btn-info" class="lt-btn lt-btn-action" style="padding:4px 8px;width:auto;">ℹ️ Info</button>
<span id="lt-toggle-ui" style="cursor:pointer;padding:4px 8px;background:rgba(0,0,0,0.2);border-radius:4px;">_</span>
</div>
</div>
<div id="lt-content">
<div id="lt-status-bar" class="lt-status-bar">WAITING FOR LOBBY...</div>
<!-- V8: HERO LAYOUT -->
<div class="lt-hero">
<!-- LEFT STATS COLUMN -->
<div class="lt-hero-side">
<div class="lt-stat-card">
<div class="sc-label">Rounds</div>
<div class="sc-value" id="lt-rounds-val">0</div>
</div>
<div class="lt-stat-card">
<div class="sc-label">Winrate</div>
<div class="sc-value" id="lt-winrate-val">0%</div>
</div>
<div class="lt-stat-card">
<div class="sc-label">Remaining</div>
<div class="sc-value" id="lt-rem-val">0</div>
</div>
</div>
<!-- CENTER HERO -->
<div class="lt-hero-center" id="lt-bet-box">
<div class="lt-bet-label-v8"><span class="lt-glyph-chamber"></span> Next Bet <span class="lt-glyph-chamber"></span></div>
<div class="lt-bet-ring-wrap">
<svg class="lt-bet-ring" viewBox="0 0 200 200" aria-hidden="true">
<circle class="lt-bet-ring-track" cx="100" cy="100" r="92" fill="none" stroke="rgba(255,255,255,0.06)" stroke-width="6"/>
<circle class="lt-bet-ring-fill" cx="100" cy="100" r="92" fill="none" stroke="rgba(124,58,237,0.35)" stroke-width="6" stroke-linecap="round"
id="lt-bet-ring-fill" data-circumference="578.05" stroke-dasharray="578.05" stroke-dashoffset="578.05"
transform="rotate(-90 100 100)"/>
</svg>
<div id="lt-bet-display" class="lt-mega">0</div>
<div id="lt-bet-ring-val" class="lt-bet-ring-val"></div>
</div>
<div class="lt-action-row-mega">
<button id="lt-btn-win" class="lt-btn-mega lt-btn-mega-win">WIN</button>
<button id="lt-btn-loss" class="lt-btn-mega lt-btn-mega-loss">LOSS</button>
</div>
<div class="lt-mini-row">
<button id="lt-btn-auto" class="lt-mini-btn">Auto: ON</button>
<button id="lt-btn-reset-bet" class="lt-mini-btn">🔄 Reset Bet</button>
<button id="lt-btn-undo-hero" class="lt-mini-btn" title="Undo last result">↩ Undo</button>
</div>
</div>
<!-- RIGHT STATS COLUMN -->
<div class="lt-hero-side">
<div class="lt-stat-card">
<div class="sc-label">Streak</div>
<div class="sc-value" id="lt-streak-val">0</div>
</div>
<div class="lt-stat-card">
<div class="sc-label">Profit</div>
<div class="sc-value" id="lt-prof-val">0</div>
</div>
<div class="lt-stat-card">
<div class="sc-label">Status</div>
<div class="sc-value" id="lt-status-mini" style="font-size:13px;color:#a78bfa;">READY</div>
</div>
</div>
</div>
<!-- HOSPITAL WARNING — always visible on the dashboard while hospitalized -->
<div id="lt-hospital-warning" class="lt-stop-warning" style="display:none;">
<div class="lt-stop-warning-text">
<span class="lt-stop-warning-title">🏥 You are in the hospital</span>
<span class="lt-stop-warning-reason">You can't join or create RR games until you're out.</span>
</div>
</div>
<!-- STOP-LOSS / SEQUENCE-LENGTH WARNING (Labouchere discipline) -->
<div id="lt-stop-warning" class="lt-stop-warning" style="display:none;">
<div class="lt-stop-warning-text">
<span class="lt-stop-warning-title">⚠️ Abort recommended</span>
<span id="lt-stop-warning-reason" class="lt-stop-warning-reason"></span>
</div>
<button id="lt-stop-warning-reset" class="lt-stop-warning-btn">↺ New standard line</button>
<button id="lt-stop-warning-close" class="lt-stop-warning-close" title="Dismiss — reappears if the situation gets worse">✕</button>
</div>
<!-- V8.x: CUSTOM REBUILD OF TORN'S APPCONTAINER -->
<div id="lt-game-frame">
<div id="lt-game-frame-slot"></div>
</div>
<!-- PROFIT CHART (collapsible) — every round as a dot, line through them -->
<div class="lt-section lt-seq-section" id="lt-chart-section">
<div class="lt-section-title lt-seq-toggle" id="lt-chart-toggle">
<span><span class="lt-glyph-chamber"></span> Profit History <span class="lt-coll-icon">▼</span></span>
<span class="meta" id="lt-chart-meta">— rounds</span>
</div>
<div class="lt-seq-collapse-body" id="lt-chart-body">
<div class="lt-profit-chart-wrap">
<svg id="lt-profit-chart" class="lt-profit-chart" preserveAspectRatio="none" viewBox="0 0 600 240">
<g class="lt-chart-grid" id="lt-chart-grid"></g>
<g class="lt-chart-axis-y" id="lt-chart-axis-y"></g>
<g class="lt-chart-axis-x" id="lt-chart-axis-x"></g>
<text class="lt-chart-axis-title" x="10" y="14" fill="#94a3b8" font-size="10" font-family="monospace">$</text>
<line class="lt-chart-zero" id="lt-chart-zero" x1="0" x2="600" y1="0" y2="0" stroke="rgba(255,255,255,0.20)" stroke-dasharray="3,3"/>
<g id="lt-chart-segments"></g>
<g id="lt-chart-dots"></g>
</svg>
<div class="lt-chart-empty" id="lt-chart-empty">No rounds yet — play to see your profit chart.</div>
<div class="lt-chart-tooltip" id="lt-chart-tooltip"></div>
<div class="lt-chart-legend"><span class="lt-chart-legend-dot"></span> Total</div>
</div>
</div>
</div>
<!-- SEQUENCE SECTION (collapsible) -->
<div class="lt-section lt-seq-section" id="lt-seq-section">
<div class="lt-section-title lt-seq-toggle" id="lt-seq-toggle">
<span><span class="lt-glyph-chamber"></span> Sequence <span class="lt-coll-icon">▼</span></span>
<span class="meta" id="lt-seq-meta">— numbers</span>
</div>
<div class="lt-seq-collapse-body" id="lt-seq-body">
<div id="lt-sequence" class="lt-seq-container" style="min-height:60px;"></div>
<div id="lt-play-again-container" style="display:none;flex-direction:column;justify-content:center;">
<div class="lt-finished-msg" style="text-align:center;color:#4ade80;font-weight:bold;margin-bottom:10px;">✓ Sequence Complete!</div>
<button id="lt-btn-play-again" class="lt-btn lt-btn-play-again">🔄 Play Again (Same)</button>
<button id="lt-btn-new-random" class="lt-btn lt-btn-new-random">🎲 New Random</button>
</div>
<button id="lt-btn-confirm-merge" class="lt-btn lt-btn-confirm lt-hidden">✓ Confirm Merge</button>
</div>
</div>
<!-- ACTION BAR -->
<div class="lt-action-bar">
<div class="lt-action-group">
<button id="lt-btn-gen-show" class="lt-btn lt-btn-action">⚙ Setup</button>
<button id="lt-btn-undo" class="lt-btn lt-btn-action">↩ Undo</button>
</div>
<div class="lt-action-group">
<button id="lt-btn-shuffle" class="lt-btn lt-btn-action">🔀 Shuffle</button>
<button id="lt-btn-merge" class="lt-btn lt-btn-action">⊕ Merge</button>
<button id="lt-btn-copy-seq" class="lt-btn lt-btn-action" title="Copy sequence">📋 Copy</button>
<button id="lt-btn-paste-seq" class="lt-btn lt-btn-action" title="Paste sequence">📥 Paste</button>
</div>
<div class="lt-action-group">
<button id="lt-btn-game" class="lt-btn lt-btn-action" title="Flappy Bird">🐦</button>
<button id="lt-btn-snake" class="lt-btn lt-btn-action" title="Snake">🐍</button>
</div>
</div>
<div id="lt-gen-panel" class="lt-hidden" style="background:#0f172a;padding:15px;border-radius:8px;border:1px solid #334155;margin-top:10px;">
<div class="lt-tabs"><div class="lt-tab active" id="lt-tab-bankroll">Bankroll</div><div class="lt-tab" id="lt-tab-target">Target</div></div>
<div class="lt-mult-group">
<div id="lt-btn-mult-1x" class="lt-mult-btn ${this.savedSettings.multKey==='1x'?'active':''}">1x</div>
<div id="lt-btn-mult-k" class="lt-mult-btn ${this.savedSettings.multKey==='k'?'active':''}">K (Thous)</div>
<div id="lt-btn-mult-m" class="lt-mult-btn ${this.savedSettings.multKey==='m'?'active':''}">M (Mill)</div>
<div id="lt-btn-mult-b" class="lt-mult-btn ${this.savedSettings.multKey==='b'?'active':''}">B (Bill)</div>
</div>
<div id="lt-mode-bankroll"><div class="lt-input-group"><span class="lt-label">Bankroll</span><input id="lt-inp-bankroll" class="lt-input" type="number" value="${this.savedSettings.bankroll}"></div><div class="lt-input-group"><span class="lt-label">Risk %</span><input id="lt-inp-risk" class="lt-input" type="number" value="${this.savedSettings.risk}"></div></div>
<div id="lt-mode-target" class="lt-hidden"><div class="lt-input-group"><span class="lt-label">Target</span><input id="lt-inp-target" class="lt-input" type="number" value="${this.savedSettings.target}"></div></div>
<div class="lt-input-group"><span class="lt-label">Parts</span><input id="lt-inp-parts" class="lt-input" type="number" value="${this.savedSettings.parts}"><button id="lt-btn-suggest-parts" class="lt-suggest-btn" type="button" title="Suggest a parts count from your Risk % so the opening bet stays ~0.5% of bankroll">Suggest</button></div>
<div style="margin-top:12px;display:flex;gap:15px;">
<label class="lt-checkbox-row"><input type="checkbox" id="lt-chk-integers" class="lt-checkbox" ${this.savedSettings.integers?'checked':''}> Whole Numbers</label>
<label class="lt-checkbox-row"><input type="checkbox" id="lt-chk-uniform" class="lt-checkbox" ${this.savedSettings.uniform?'checked':''}> Equal Split</label>
</div>
<!-- V7.00: Preview Section -->
<div id="lt-gen-preview" style="margin-top:15px;padding:12px;background:rgba(124,58,237,0.08);border:1px solid rgba(124,58,237,0.2);border-radius:6px;display:none;">
<div style="font-size:11px;color:#a78bfa;font-weight:bold;margin-bottom:8px;">📊 PREVIEW</div>
<div id="lt-gen-preview-stats" style="font-size:11px;color:#cbd5e1;margin-bottom:8px;"></div>
<div id="lt-gen-preview-seq" style="display:flex;flex-wrap:wrap;gap:4px;max-height:60px;overflow-y:auto;"></div>
</div>
<button id="lt-btn-generate" class="lt-btn lt-btn-primary" style="margin-top:15px;">Generate</button>
<button id="lt-btn-reset-all" class="lt-btn lt-btn-reset">⚠️ Reset Data</button>
</div>
<div id="lt-history-panel" class="lt-hidden" style="background:#0f172a;padding:10px;border-radius:8px;border:1px solid #334155;margin-top:10px;">
<h4 style="color:#e2e8f0;margin:0 0 10px 0;font-size:14px;text-align:center;">History</h4><div id="lt-history-list" class="lt-history-list"></div><button id="lt-btn-close-hist" class="lt-btn lt-btn-action" style="margin-top:10px;">Close</button>
</div>
<div id="lt-info-panel" class="lt-hidden" style="background:#0f172a;padding:15px;border-radius:8px;border:1px solid #334155;margin-top:10px;max-height:400px;overflow-y:auto;">
<div class="lt-info-grid">
<div class="lt-info-card">
<div class="lt-info-head">📖 Strategy Guide (Labouchere)</div>
<ul class="lt-info-list">
<li>Set a <b>Target</b> (Profit Goal).</li>
<li>The script splits this into a sequence.</li>
<li><b>Bet:</b> First + Last Number.</li>
<li><b>Win:</b> Numbers are crossed out.</li>
<li><b>Loss:</b> Bet is added to the end.</li>
</ul>
</div>
<div class="lt-info-card">
<div class="lt-info-head">🚀 Features</div>
<ul class="lt-info-list">
<li><b>Auto-Detect:</b> Detects Wins/Losses automatically.</li>
<li><b>Smart Drag & Drop:</b> Reorder numbers via mouse.</li>
<li><b>Multiplier:</b> Use K/M/B buttons for input.</li>
<li><b>Pot Scanner:</b> Infers bet from Pot Money.</li>
<li><b>Safe Lock:</b> Prevents double-counting wins.</li>
</ul>
</div>
<div class="lt-info-card">
<div class="lt-info-head">🎛️ Controls</div>
<ul class="lt-info-list">
<li><b>Split:</b> Click a number to split it.</li>
<li><b>Merge:</b> Select two numbers to merge them.</li>
<li><b>Undo:</b> "Last Round" reverts the last action.</li>
<li><b>Custom Bet:</b> Click the purple box.</li>
</ul>
</div>
<div class="lt-info-card">
<div class="lt-info-head">⚖️ Terms of Service & Risk Disclosure</div>
<table class="lt-tos-table">
<tr><th>Item</th><th>Details</th></tr>
<tr><td>License</td><td>Free to use. Provided "as is" without warranty.</td></tr>
<tr><td>Risk</td><td>Gambling involves financial risk. The Labouchere strategy can lead to high bets during losing streaks.</td></tr>
<tr><td>Torn Rules</td><td>This is a helper tool. It does not automate clicks (Macroing). Use responsibly to avoid bans.</td></tr>
<tr><td>Data Privacy</td><td>Operates 100% locally. Reads User ID & Game Data to function. No data is sent to external servers.</td></tr>
<tr><td>Responsibility</td><td>The author is not responsible for any virtual money lost while using this script.</td></tr>
</table>
</div>
</div>
<button id="lt-btn-close-info" class="lt-btn lt-btn-action" style="margin-top:15px;">Close</button>
</div>
<!-- V8.4: Fixed Game History panel on the right -->
<div id="lt-history-side">
<div class="lt-hist-side-head"><span>📜 Game History</span></div>
<div id="lt-hist-side-list"></div>
</div>
</div>`;
container.insertBefore(div, container.firstChild);
// Bind Multiplier Buttons
['1x','k','m','b'].forEach(k => {
const btn = document.getElementById('lt-btn-mult-'+k);
if(btn) btn.onclick = () => this.setMultiplier(k);
});
['lt-inp-bankroll','lt-inp-risk','lt-inp-target','lt-inp-parts','lt-chk-integers','lt-chk-uniform'].forEach(id=>{
const el=document.getElementById(id); if(el){ el.addEventListener('change',()=>this.saveSettingsFromUI()); el.addEventListener('input',()=>this.saveSettingsFromUI()); }
});
// Stop the mouse wheel from changing number inputs — blur on wheel so the
// page scrolls normally instead of silently editing the value.
// Also auto-fit the multiplier once the user commits a value (on change,
// not on every keystroke, so typing stays smooth).
['lt-inp-bankroll','lt-inp-risk','lt-inp-target','lt-inp-parts'].forEach(id=>{
const el=document.getElementById(id);
if(el) {
el.addEventListener('wheel', () => { if(document.activeElement===el) el.blur(); }, { passive:true });
el.addEventListener('change', () => this.autoFitMultiplier());
}
});
this.switchGenMode(this.savedSettings.mode);
document.getElementById('lt-toggle-ui').onclick = () => { const c = document.getElementById('lt-content'); c.style.display = c.style.display === 'none' ? 'flex' : 'none'; };
document.getElementById('lt-btn-win').onclick = () => engine.processWin();
document.getElementById('lt-btn-loss').onclick = () => engine.processLoss();
// Toggle Panels
const closeAllPanels = () => {
['lt-gen-panel', 'lt-history-panel', 'lt-info-panel'].forEach(id => { const el = document.getElementById(id); if (el) el.classList.add('lt-hidden'); });
// Mini-game popups are independent (floating) — NOT closed here
};
document.getElementById('lt-btn-gen-show').onclick = () => {
const p = document.getElementById('lt-gen-panel');
const wasHidden = p.classList.contains('lt-hidden');
closeAllPanels();
if(wasHidden) { p.classList.remove('lt-hidden'); this.updateInputPreviews(); }
};
document.getElementById('lt-btn-info').onclick = () => {
const p = document.getElementById('lt-info-panel');
const wasHidden = p.classList.contains('lt-hidden');
closeAllPanels();
if(wasHidden) p.classList.remove('lt-hidden');
};
document.getElementById('lt-btn-close-hist').onclick = () => document.getElementById('lt-history-panel').classList.add('lt-hidden');
document.getElementById('lt-btn-close-info').onclick = () => document.getElementById('lt-info-panel').classList.add('lt-hidden');
document.getElementById('lt-btn-game').onclick = () => this.toggleGamePopup('flappy');
document.getElementById('lt-btn-snake').onclick = () => this.toggleGamePopup('snake');
document.getElementById('lt-tab-bankroll').onclick = () => this.switchGenMode('bankroll');
document.getElementById('lt-tab-target').onclick = () => this.switchGenMode('target');
document.getElementById('lt-btn-generate').onclick = () => this.handleGenerate();
document.getElementById('lt-btn-reset-all').onclick = () => { if(confirm("Reset all data?")) engine.resetData(); };
// V7.00: Preview on input change
const previewInputs = ['lt-inp-bankroll', 'lt-inp-risk', 'lt-inp-target', 'lt-inp-parts'];
previewInputs.forEach(id => {
const el = document.getElementById(id);
if (el) {
el.addEventListener('input', () => this.updatePreview());
}
});
['lt-chk-integers', 'lt-chk-uniform'].forEach(id => {
const el = document.getElementById(id);
if (el) {
el.addEventListener('change', () => this.updatePreview());
}
});
document.getElementById('lt-btn-play-again').onclick = () => engine.restartGame();
document.getElementById('lt-btn-new-random').onclick = () => this.handleGenerate();
document.getElementById('lt-btn-shuffle').onclick = () => engine.shuffleSequence();
document.getElementById('lt-btn-merge').onclick = () => { this.mergeMode = !this.mergeMode; this.selectedForMerge = []; this.update(); };
document.getElementById('lt-seq-toggle').onclick = () => document.getElementById('lt-seq-section').classList.toggle('collapsed');
document.getElementById('lt-chart-toggle').onclick = () => document.getElementById('lt-chart-section').classList.toggle('collapsed');
document.getElementById('lt-btn-dl-log').onclick = () => auditLog.download();
document.getElementById('lt-btn-copy-seq').onclick = () => {
const seq = engine.state.sequence;
if (!seq.length) { Utils.showToast('No sequence available'); return; }
const text = seq.map(i => i.value).join(',');
navigator.clipboard.writeText(text)
.then(() => {
Utils.showToast(`Copied (${seq.length} values)`);
auditLog.record('EVENT', 'SEQ/COPY', `Sequence copied`, { values: seq.map(i => i.value) });
})
.catch(() => Utils.showToast('Copy failed'));
};
document.getElementById('lt-btn-paste-seq').onclick = () => {
navigator.clipboard.readText()
.then(text => {
const values = text.split(',')
.map(s => parseFloat(s.trim()))
.filter(v => !isNaN(v) && v > 0);
if (!values.length) { Utils.showToast('No valid values in clipboard'); return; }
if (!confirm(`Paste ${values.length} values?\nCurrent sequence and profit will be reset.`)) return;
engine.loadFromValues(values);
Utils.showToast(`Pasted (${values.length} values)`);
this.update();
})
.catch(() => Utils.showToast('Paste failed'));
};
document.getElementById('lt-btn-confirm-merge').onclick = () => { engine.mergeList(this.selectedForMerge); this.selectedForMerge=[]; this.mergeMode=false; this.update(); };
document.getElementById('lt-btn-auto').onclick = () => engine.toggleAutoDetect();
document.getElementById('lt-btn-undo').onclick = () => engine.undo();
document.getElementById('lt-btn-suggest-parts').onclick = () => this.suggestParts();
document.getElementById('lt-btn-undo-hero').onclick = () => engine.undo();
document.getElementById('lt-stop-warning-close').onclick = () => {
const s = engine.state;
const targetUnits = (s.initialSequence || []).reduce((a, b) => a + (b.value || 0), 0);
const lossUnits = s.totalProfit < 0 ? -s.totalProfit : 0;
// Snapshot the current "badness" — banner stays hidden until it grows.
this._stopWarnDismissed = { length: s.sequence.length, lossUnits, targetUnits };
document.getElementById('lt-stop-warning').style.display = 'none';
};
document.getElementById('lt-stop-warning-reset').onclick = () => {
engine.restartGame();
Utils.showToast('New standard line');
this.update();
};
document.getElementById('lt-btn-reset-bet').onclick = () => {
engine.resetBet();
Utils.showToast('Bet Reset');
this.update();
};
document.getElementById('lt-bet-box').onclick = (e) => {
// V8: Only open custom-bet dialog when clicking the bet display or its label
// (not WIN/LOSS/Auto/Reset which now live inside the hero-center)
if (e.target.id !== 'lt-bet-display' && !e.target.classList.contains('lt-bet-label-v8')) return;
// V7.02: Improved Custom Bet dialog
const currentCustom = engine.state.customBet;
const expectedBet = engine.getEffectiveBet();
let promptText = "Custom Bet (Total Amount):";
if (currentCustom !== null) {
promptText = `Custom Bet Active: ${Utils.formatNumber(currentCustom)}\n\nEnter new amount (0 to clear):`;
} else {
promptText = `Expected Bet: ${Utils.formatNumber(expectedBet)}\n\nEnter Custom Bet (0 to use expected):`;
}
const inp = prompt(promptText, currentCustom || expectedBet);
if(inp !== null) {
const v = parseFloat(inp);
if(!isNaN(v) && v > 0) {
engine.setCustomBet(v);
Utils.showToast(`Custom Bet Set: ${Utils.formatNumber(v)}`);
} else if(v === 0) {
engine.clearCustomBet();
Utils.showToast("Custom Bet Cleared");
}
}
};
this.update();
}
// --- RESTORED METHODS ---
switchGenMode(mode) {
this.genMode = mode; this.saveSettingsFromUI();
const b = document.getElementById('lt-mode-bankroll'); const t = document.getElementById('lt-mode-target');
const tb = document.getElementById('lt-tab-bankroll'); const tt = document.getElementById('lt-tab-target');
if(mode==='bankroll'){ b.classList.remove('lt-hidden'); t.classList.add('lt-hidden'); tb.classList.add('active'); tt.classList.remove('active'); }
else { b.classList.add('lt-hidden'); t.classList.remove('lt-hidden'); tb.classList.remove('active'); tt.classList.add('active'); }
this.updateInputPreviews();
this.updatePreview();
}
// V7.00: Live Preview of Sequence
updatePreview() {
const preview = document.getElementById('lt-gen-preview');
const statsEl = document.getElementById('lt-gen-preview-stats');
const seqEl = document.getElementById('lt-gen-preview-seq');
if (!preview || !statsEl || !seqEl) return;
try {
// Get current inputs
const parts = parseInt(document.getElementById('lt-inp-parts')?.value) || 10;
const useIntegers = document.getElementById('lt-chk-integers')?.checked || false;
const uniform = document.getElementById('lt-chk-uniform')?.checked || false;
let target = 0;
let errorMsg = null;
if (this.genMode === 'bankroll') {
const bankroll = parseFloat(document.getElementById('lt-inp-bankroll')?.value) || 0;
const risk = parseFloat(document.getElementById('lt-inp-risk')?.value) || 0;
if (bankroll > 0 && risk > 0) {
target = bankroll * (risk / 100);
} else {
errorMsg = bankroll <= 0 ? 'Enter Bankroll' : 'Enter Risk %';
}
} else {
target = parseFloat(document.getElementById('lt-inp-target')?.value) || 0;
if (target <= 0) {
errorMsg = 'Enter Target';
}
}
// Hide preview if invalid
if (errorMsg || target <= 0 || parts <= 0) {
preview.style.display = 'none';
return;
}
// Generate preview sequence
const previewSeq = Utils.generateSequence(target, parts, uniform, useIntegers);
const actualSum = previewSeq.reduce((a, b) => a + b, 0);
const deviation = Math.abs(actualSum - target);
const deviationPercent = ((deviation / target) * 100).toFixed(2);
// Worst-case bet projection: how high the bet climbs on a losing
// streak (each loss appends the lost bet → next bet = first + last).
const mult = this.savedSettings.multVal || 1;
const proj = (vals, n) => {
let s = vals.slice();
let bet = s.length > 1 ? s[0] + s[s.length - 1] : (s[0] || 0);
for (let k = 0; k < n; k++) { s.push(bet); bet = s.length > 1 ? s[0] + s[s.length - 1] : s[0]; }
return bet;
};
const betNow = proj(previewSeq, 0) * mult;
const bet5 = proj(previewSeq, 5) * mult;
// Total risked across a 5-loss streak (rounds 0..4) — what you'd actually lose.
let risked5 = 0; for (let n = 0; n < 5; n++) risked5 += proj(previewSeq, n) * mult;
const realBankroll = this.genMode === 'bankroll'
? (parseFloat(document.getElementById('lt-inp-bankroll')?.value) || 0) * mult : 0;
const overBankroll = realBankroll > 0 && risked5 > realBankroll;
const bet5Color = overBankroll ? '#f87171' : '#fbbf24';
// Show stats
statsEl.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
<div><span style="color:#94a3b8;">Target:</span> <b style="color:#fff;">${Utils.formatNumber(target)}</b></div>
<div><span style="color:#94a3b8;">Parts:</span> <b style="color:#fff;">${parts}</b></div>
<div><span style="color:#94a3b8;">Actual Sum:</span> <b style="color:${deviation < 1 ? '#4ade80' : '#fbbf24'};">${Utils.formatNumber(actualSum)}</b></div>
<div><span style="color:#94a3b8;">Deviation:</span> <b style="color:${deviation < 1 ? '#4ade80' : deviation < 10 ? '#fbbf24' : '#f87171'};">${deviationPercent}%</b></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px;padding-top:8px;border-top:1px solid rgba(124,58,237,0.2);">
<div><span style="color:#94a3b8;">Start bet:</span> <b style="color:#fff;">${Utils.formatNumber(betNow)}</b></div>
<div><span style="color:#94a3b8;">Bet @ 5 losses:</span> <b style="color:${bet5Color};">${Utils.formatNumber(bet5)}</b></div>
<div style="grid-column:1 / -1;"><span style="color:#94a3b8;">Risked over 5-loss streak:</span> <b style="color:${bet5Color};">${Utils.formatNumber(risked5)}</b>${overBankroll ? ' <span style="color:#f87171;">⚠️ exceeds bankroll</span>' : ''}</div>
</div>
`;
// Show sequence badges
seqEl.innerHTML = previewSeq.map(val =>
`<span style="background:#1e293b;border:1px solid rgba(124,58,237,0.3);padding:4px 8px;border-radius:4px;font-size:10px;color:#cbd5e1;font-family:monospace;">${val}</span>`
).join('');
preview.style.display = 'block';
} catch(e) {
Logger.error('Preview', `Failed: ${e.message}`);
preview.style.display = 'none';
}
}
handleGenerate() {
try {
// Make sure we're in the right unit before generating (prevents deviation).
this.autoFitMultiplier();
const partsInput = document.getElementById('lt-inp-parts');
const integersInput = document.getElementById('lt-chk-integers');
const uniformInput = document.getElementById('lt-chk-uniform');
const bankrollInput = document.getElementById('lt-inp-bankroll');
const riskInput = document.getElementById('lt-inp-risk');
const targetInput = document.getElementById('lt-inp-target');
const parts = parseInt(partsInput ? partsInput.value : 10) || 10;
const useIntegers = integersInput ? integersInput.checked : false;
const uniform = uniformInput ? uniformInput.checked : false;
// V6.29: Using short numbers for generation logic
let target = 0;
if (this.genMode === 'bankroll') {
const bankroll = parseFloat(bankrollInput ? bankrollInput.value : 0);
const risk = parseFloat(riskInput ? riskInput.value : 0);
if (bankroll > 0 && risk > 0) target = bankroll * (risk / 100);
else {
Utils.showToast("Enter Bankroll & Risk");
if(bankrollInput) bankrollInput.classList.add('error');
setTimeout(()=>bankrollInput?.classList.remove('error'), 1000);
return;
}
} else {
target = parseFloat(targetInput ? targetInput.value : 0);
if (!target || target <= 0) {
Utils.showToast("Enter Target");
if(targetInput) targetInput.classList.add('error');
setTimeout(()=>targetInput?.classList.remove('error'), 1000);
return;
}
}
if(target > 0) {
engine.generateNew(target, parts, uniform, useIntegers);
document.getElementById('lt-gen-panel').classList.add('lt-hidden');
}
} catch(e) { console.error("Gen Error:", e); Utils.showToast("Gen Error"); }
}
renderHistory() { this.renderHistoryInto(document.getElementById('lt-history-list')); }
renderHistoryInto(l) {
if (!l) return; l.innerHTML='';
const h = engine.state.roundHistory || [];
// Reserve 10 visual slots — empty placeholders sit where future games will
// land, so the list stays the same size from round 0 onwards. Anything past
// the 10th still appears (the panel scrolls). No layout shift on add/remove.
const SLOTS = 10;
h.forEach(r => {
const div = document.createElement('div');
div.className = `lt-history-item ${r.result==='WIN'?'lt-history-win':'lt-history-loss'}`;
div.innerHTML = `<span style="color:#888">${new Date(r.time).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span><span class="lt-hist-badge ${r.result==='WIN'?'lt-hist-win-bg':'lt-hist-loss-bg'}">${r.result}</span><b>$${Utils.formatNumber(r.bet)}</b><span style="color:${r.profit>=0?'#4ade80':'#f87171'}">${r.profit>0?'+':''}${Utils.formatNumber(r.profit)}</span>`;
l.appendChild(div);
});
const empties = Math.max(0, SLOTS - h.length);
for (let i = 0; i < empties; i++) {
const div = document.createElement('div');
div.className = 'lt-history-item lt-history-empty';
div.innerHTML = `<span class="lt-hist-empty-line">— slot ${h.length + i + 1} —</span>`;
l.appendChild(div);
}
}
// Labouchere discipline: warn (don't force) when the line has grown too long
// or the running loss has reached a multiple of the target. Experts advise
// capping the sequence (~initial + 5 entries) and abandoning at ~2× target
// loss, then restarting a fresh standard line instead of chasing.
// Dashboard border stays the default calm purple — only Hospital flips it
// to a pulsing red so an "I can't play" state is unmistakable at a glance.
syncBorderState() {
const db = document.getElementById('lt-dashboard');
if (!db) return;
const inHospital = !!document.querySelector('[aria-label^="Hospital:" i]');
db.classList.toggle('lt-state-danger', inHospital);
db.classList.remove('lt-state-hot', 'lt-state-cold');
}
// Tween a number element from its previous value to `to` over `dur` ms with
// ease-out-cubic. Keeps the cinematic count-up feel without bloating updates.
// Cancels any in-flight tween on the same element so rapid value changes don't
// queue up. `signed` adds a leading '+' for positive values (used by profit pills).
animateNumber(el, to, opts) {
if (!el) return;
opts = opts || {};
const dur = opts.duration || 380;
const signed = !!opts.signed;
const suffix = opts.suffix || '';
const fmt = (v) => {
const sign = signed && v >= 0 ? '+' : '';
return sign + Utils.formatNumber(v) + suffix;
};
if (el._ltAnim) cancelAnimationFrame(el._ltAnim);
const from = (el._ltAnimTo != null) ? el._ltAnimTo : to; // first time → no tween
// If the change is tiny relative to the value, skip the tween (avoids
// micro-jitter on the bet display each tick during a game).
if (Math.abs(to - from) < 0.5) { el.textContent = fmt(to); el._ltAnimTo = to; return; }
el._ltAnimTo = to;
const start = performance.now();
const tick = (now) => {
const t = Math.min(1, (now - start) / dur);
const e = 1 - Math.pow(1 - t, 3);
el.textContent = fmt(from + (to - from) * e);
if (t < 1) el._ltAnim = requestAnimationFrame(tick);
else { el._ltAnim = null; el.textContent = fmt(to); }
};
el._ltAnim = requestAnimationFrame(tick);
}
// Big profit chart — clean stock-style: one dot per round, segments coloured
// by direction (green = rising/WIN, red = falling/LOSS). Y-axis on the left,
// X-axis at the bottom (every ~5 rounds), dashed zero line, hover tooltips.
updateProfitChart(s, mult) {
const svg = document.getElementById('lt-profit-chart');
const segG = document.getElementById('lt-chart-segments');
const dotsG = document.getElementById('lt-chart-dots');
const gridG = document.getElementById('lt-chart-grid');
const axisY = document.getElementById('lt-chart-axis-y');
const axisX = document.getElementById('lt-chart-axis-x');
const zeroL = document.getElementById('lt-chart-zero');
const empty = document.getElementById('lt-chart-empty');
const meta = document.getElementById('lt-chart-meta');
const tip = document.getElementById('lt-chart-tooltip');
if (!svg || !segG || !dotsG) return;
const hist = (s.roundHistory || []).slice().reverse(); // oldest → newest
if (meta) meta.textContent = hist.length ? `${hist.length} round${hist.length === 1 ? '' : 's'}` : '— rounds';
if (empty) empty.classList.toggle('hidden', hist.length >= 2);
if (hist.length < 2) {
segG.innerHTML = ''; dotsG.innerHTML = '';
gridG.innerHTML = ''; axisY.innerHTML = ''; axisX.innerHTML = '';
return;
}
const series = [];
let acc = 0;
for (const r of hist) {
acc += (r.profit || 0);
series.push({ cum: acc, profit: r.profit || 0, result: r.result, bet: r.bet || 0, time: r.time });
}
const W = 600, H = 240, PAD_L = 52, PAD_R = 10, PAD_T = 12, PAD_B = 22;
const min = Math.min(0, ...series.map(p => p.cum));
const max = Math.max(0, ...series.map(p => p.cum));
const range = (max - min) || 1;
const xStep = (series.length === 1) ? 0 : (W - PAD_L - PAD_R) / (series.length - 1);
const xFor = i => PAD_L + i * xStep;
const yFor = v => H - PAD_B - ((v - min) / range) * (H - PAD_T - PAD_B);
// ── Y-axis: 5 ticks evenly distributed between min and max ──
const yTicks = [];
for (let i = 0; i <= 4; i++) yTicks.push(min + (max - min) * i / 4);
gridG.innerHTML = yTicks.map(t => `<line x1="${PAD_L}" x2="${W - PAD_R}" y1="${yFor(t)}" y2="${yFor(t)}"/>`).join('');
axisY.innerHTML = yTicks.map(t => `<text x="${PAD_L - 6}" y="${yFor(t) + 3}" text-anchor="end">${Utils.formatNumber(t)}</text>`).join('');
// ── X-axis: round number every ~5 rounds (always include first + last) ──
const xStepSize = series.length <= 10 ? 1 : series.length <= 30 ? 5 : series.length <= 80 ? 10 : 20;
let xLabels = '';
for (let i = 0; i < series.length; i++) {
const round = i + 1;
if (i === 0 || i === series.length - 1 || round % xStepSize === 0) {
xLabels += `<text x="${xFor(i)}" y="${H - PAD_B + 12}" text-anchor="middle">${round}</text>`;
}
}
axisX.innerHTML = xLabels;
// ── Zero baseline ──
const baseY = yFor(0);
zeroL.setAttribute('x1', PAD_L); zeroL.setAttribute('x2', W - PAD_R);
zeroL.setAttribute('y1', baseY); zeroL.setAttribute('y2', baseY);
// ── Segments: one <line> per pair, coloured by whether the running total
// is in profit (green) or loss (red). When a segment crosses the zero line
// it's split exactly at the crossing so each half gets its true colour. ──
const GREEN = '#4ade80', RED = '#ef4444';
const colorFor = v => v >= 0 ? GREEN : RED;
let segHtml = '';
for (let i = 0; i < series.length - 1; i++) {
const a = series[i], b = series[i + 1];
const ax = xFor(i), ay = yFor(a.cum);
const bx = xFor(i+1), by = yFor(b.cum);
const crosses = (a.cum > 0 && b.cum < 0) || (a.cum < 0 && b.cum > 0);
if (crosses) {
const t = a.cum / (a.cum - b.cum); // fraction along segment where cum = 0
const cx = ax + (bx - ax) * t;
const cy = baseY; // y of cum = 0
segHtml += `<line x1="${ax}" y1="${ay}" x2="${cx}" y2="${cy}" stroke="${colorFor(a.cum)}"/>`;
segHtml += `<line x1="${cx}" y1="${cy}" x2="${bx}" y2="${by}" stroke="${colorFor(b.cum)}"/>`;
} else {
// Use destination's sign (0 counts as green).
segHtml += `<line x1="${ax}" y1="${ay}" x2="${bx}" y2="${by}" stroke="${colorFor(b.cum)}"/>`;
}
}
segG.innerHTML = segHtml;
// ── Dots (one per round), coloured by result ──
dotsG.innerHTML = series.map((p, i) =>
`<circle class="lt-chart-dot ${p.result === 'WIN' ? 'win' : 'loss'}" cx="${xFor(i)}" cy="${yFor(p.cum)}" r="3.5" data-i="${i}"/>`
).join('');
// ── Tooltips ── clamp inside the chart wrap so edge dots stay readable
if (tip) {
const wrap = svg.parentElement;
dotsG.querySelectorAll('.lt-chart-dot').forEach(c => {
c.onmouseenter = () => {
const i = parseInt(c.dataset.i);
const p = series[i];
tip.innerHTML = `<b>Round ${i + 1}</b> · <span style="color:${p.result === 'WIN' ? '#4ade80' : '#f87171'}">${p.result}</span><br>
Bet ${Utils.formatNumber(p.bet)} · ${p.profit >= 0 ? '+' : ''}${Utils.formatNumber(p.profit)}<br>
Total: <b>${p.cum >= 0 ? '+' : ''}${Utils.formatNumber(p.cum)}</b>`;
// Show first so we can measure the rendered tooltip size
tip.style.transform = 'none';
tip.style.left = '0px'; tip.style.top = '0px';
tip.classList.add('show');
const wrapRect = wrap.getBoundingClientRect();
const dotRect = c.getBoundingClientRect();
const tipRect = tip.getBoundingClientRect();
const pad = 6;
// Horizontal: centre on dot, then clamp inside wrap bounds
let cx = dotRect.left - wrapRect.left + dotRect.width / 2;
let left = cx - tipRect.width / 2;
left = Math.max(pad, Math.min(left, wrapRect.width - tipRect.width - pad));
// Vertical: above the dot by tipHeight + 8; if not enough room, place below
let top = (dotRect.top - wrapRect.top) - tipRect.height - 8;
if (top < pad) top = (dotRect.top - wrapRect.top) + dotRect.height + 8;
tip.style.left = left + 'px';
tip.style.top = top + 'px';
};
c.onmouseleave = () => tip.classList.remove('show');
});
}
}
// Risk ring around the bet display — same idea as the old meter, but as a
// circular SVG arc hugging the bet number. Tweens green → yellow → red as
// you approach 2× target loss, pulses red once you cross.
updateRiskRing(lossUnits, targetUnits, mult) {
const fill = document.getElementById('lt-bet-ring-fill');
const valEl = document.getElementById('lt-bet-ring-val');
if (!fill || !valEl) return;
const C = parseFloat(fill.dataset.circumference) || 0;
const threshold = targetUnits * 2;
if (!(threshold > 0) || !C) {
fill.style.strokeDashoffset = C;
fill.style.stroke = 'rgba(124,58,237,0.35)';
fill.classList.remove('danger');
valEl.textContent = '';
return;
}
const rawPct = (lossUnits / threshold) * 100;
const pct = Math.min(100, Math.max(0, rawPct));
// Lerp green (34,197,94) → yellow (251,191,36) → red (239,68,68)
const lerp = (a, b, t) => Math.round(a + (b - a) * t);
let r, g, b;
if (pct <= 50) {
const t = pct / 50;
r = lerp(34, 251, t); g = lerp(197, 191, t); b = lerp(94, 36, t);
} else {
const t = (pct - 50) / 50;
r = lerp(251, 239, t); g = lerp(191, 68, t); b = lerp(36, 68, t);
}
const color = `rgb(${r},${g},${b})`;
fill.style.strokeDashoffset = C - (pct / 100) * C;
fill.style.stroke = color;
fill.style.filter = pct > 50 ? `drop-shadow(0 0 ${6 + (pct - 50) / 6}px rgba(${r},${g},${b},0.7))` : 'none';
fill.classList.toggle('danger', rawPct >= 100);
valEl.style.color = color;
valEl.textContent = `${Math.round(rawPct)}%`;
}
updateStopWarning(s, mult) {
const banner = document.getElementById('lt-stop-warning');
if (!banner) return;
const reasonEl = document.getElementById('lt-stop-warning-reason');
const initialLen = (s.initialSequence && s.initialSequence.length) || 10;
const lengthThreshold = initialLen + 5;
const targetUnits = (s.initialSequence || []).reduce((a, b) => a + (b.value || 0), 0);
const lossUnits = s.totalProfit < 0 ? -s.totalProfit : 0;
const lossThreshold = targetUnits * 2; // 2× target
// Update the risk ring (around the bet display) — single source of truth.
this.updateRiskRing(lossUnits, targetUnits, mult);
const lenHit = s.sequence.length >= lengthThreshold;
const lossHit = targetUnits > 0 && lossUnits >= lossThreshold;
if (!lenHit && !lossHit) {
// Conditions clear → also reset any dismissal so the next trigger shows again.
this._stopWarnDismissed = null;
banner.style.display = 'none';
return;
}
// Respect a user dismissal until the situation gets noticeably worse
// (line grew further OR loss grew by ≥10% of target). Otherwise it'd just
// pop right back the next tick and the close button would be useless.
if (this._stopWarnDismissed) {
const dis = this._stopWarnDismissed;
const worsened = s.sequence.length > dis.length
|| (targetUnits > 0 && lossUnits >= dis.lossUnits + targetUnits * 0.1);
if (!worsened) { banner.style.display = 'none'; return; }
this._stopWarnDismissed = null; // re-show with a fresh state
}
const reasons = [];
if (lossHit) reasons.push(`Loss ${Utils.formatNumber(lossUnits * mult)} ≥ 2× target`);
if (lenHit) reasons.push(`Line grew to ${s.sequence.length} numbers`);
if (reasonEl) reasonEl.innerText = reasons.join(' · ') + ' — abandon & restart, don\'t chase.';
banner.style.display = 'flex';
}
update() {
try {
if(!document.getElementById('lt-bet-display')) { this.updateExternalButtons(); return; }
const s = engine.state; const bet = engine.getEffectiveBet();
// Calculate internal profit with multiplier
const mult = this.savedSettings.multVal || 1;
const totalProfitVal = s.totalProfit * mult;
const bd = document.getElementById('lt-bet-display'); const bb = document.getElementById('lt-bet-box');
this.animateNumber(bd, bet, { duration: 320 });
if(s.customBet!==null) { bd.classList.add('override'); bb.classList.add('override'); } else { bd.classList.remove('override'); bb.classList.remove('override'); }
const ab = document.getElementById('lt-btn-auto');
if(s.autoDetect) { ab.innerText="Auto: ON"; ab.classList.add('active'); } else { ab.innerText="Auto: OFF"; ab.classList.remove('active'); }
this.syncManualButtons(s.autoDetect);
// V6.33 FIX: Correctly sum up sequence values (objects)
const rawSum = s.sequence.reduce((a,b) => a + (b.value || 0), 0);
this.animateNumber(document.getElementById('lt-rem-val'), rawSum * mult);
const profEl = document.getElementById('lt-prof-val');
this.animateNumber(profEl, totalProfitVal);
profEl.style.color = totalProfitVal>=0?'#4ade80':'#f87171';
// V8: Header live-profit pill
const hp = document.getElementById('lt-header-profit');
if (hp) {
this.animateNumber(hp, totalProfitVal, { signed: true });
hp.classList.remove('pos', 'neg');
if (totalProfitVal > 0) hp.classList.add('pos');
else if (totalProfitVal < 0) hp.classList.add('neg');
}
this.updateProfitChart(s, mult);
// Trigger the round-result celebration when a NEW history entry
// appears (manual click OR auto-detect — single unified path).
const histLen = (s.roundHistory || []).length;
if (this._lastHistLen != null && histLen > this._lastHistLen) {
const newest = s.roundHistory[0];
if (newest) this.celebrate(newest.result === 'WIN' ? 'win' : 'loss');
}
this._lastHistLen = histLen;
this.updateStopWarning(s, mult);
this.syncHospitalWarning(); // also poll-driven via checkAndMount
// V6.33: Calc Streak & Winrate
let streak = 0; let streakType = '';
if(s.roundHistory.length > 0) {
streakType = s.roundHistory[0].result;
for(let i=0; i<s.roundHistory.length; i++) {
if(s.roundHistory[i].result === streakType) streak++;
else break;
}
}
const wins = s.roundHistory.filter(r=>r.result==='WIN').length;
const rate = s.roundHistory.length > 0 ? Math.round((wins / s.roundHistory.length)*100) : 0;
this.syncBorderState();
// Update Rounds Counter
const rv = document.getElementById('lt-rounds-val');
if(rv) this.animateNumber(rv, s.roundCount || 0, { duration: 280 });
const sv = document.getElementById('lt-streak-val');
// Streak visualisation: flames for WIN streaks (size+glow grows with
// streak length), ❄ for LOSS streaks of 2+, plain number otherwise.
sv.classList.remove('lt-streak-fire-1','lt-streak-fire-2','lt-streak-fire-3','lt-streak-cold');
if (streakType === 'WIN' && streak >= 1) {
const lvl = streak >= 6 ? 3 : streak >= 3 ? 2 : 1;
sv.classList.add(`lt-streak-fire-${lvl}`);
sv.style.color = '';
sv.innerHTML = `<span class="lt-streak-icon">🔥</span><span class="lt-streak-num">${streak}</span>`;
} else if (streakType === 'LOSS' && streak >= 2) {
sv.classList.add('lt-streak-cold');
sv.style.color = '';
sv.innerHTML = `<span class="lt-streak-icon">❄</span><span class="lt-streak-num">${streak}</span>`;
} else {
sv.innerText = streak > 0 ? `${streak} ${streakType}` : '0';
sv.style.color = streakType === 'WIN' ? '#4ade80' : (streakType === 'LOSS' ? '#f87171' : '#e2e8f0');
}
const wr = document.getElementById('lt-winrate-val');
this.animateNumber(wr, rate, { duration: 280, suffix: '%' });
wr.style.color = rate >= 50 ? '#4ade80' : '#e2e8f0';
const mb = document.getElementById('lt-btn-confirm-merge');
const mt = document.getElementById('lt-btn-merge');
if(this.mergeMode) mt.classList.add('lt-btn-active'); else mt.classList.remove('lt-btn-active');
if(this.mergeMode && this.selectedForMerge.length>=2) { mb.classList.remove('lt-hidden'); mb.innerText=`✓ Confirm Merge (${this.selectedForMerge.length})`; } else mb.classList.add('lt-hidden');
// V8: Sequence meta (count + sum)
const seqMeta = document.getElementById('lt-seq-meta');
if (seqMeta) {
seqMeta.innerText = s.sequence.length
? `${s.sequence.length} numbers · Σ ${Utils.formatNumber(rawSum * mult)}`
: 'empty';
}
const cont = document.getElementById('lt-sequence'); const pa = document.getElementById('lt-play-again-container');
if(s.sequence.length===0 && s.roundCount>0) { cont.style.display='none'; pa.style.display='flex'; }
else {
cont.style.display='flex'; pa.style.display='none'; cont.innerHTML='';
if(s.sequence.length===0) cont.innerHTML='<div style="color:#666;font-size:11px;width:100%;text-align:center;">⊘ Empty sequence — generate a new one or paste</div>';
else s.sequence.forEach((item,idx) => {
const el=document.createElement('div'); el.className='lt-seq-card';
if(!this.mergeMode && (idx===0||idx===s.sequence.length-1)) el.classList.add('edge');
if(this.mergeMode && this.selectedForMerge.includes(idx)) el.classList.add('selected');
// V6.29: Display SHORT number in sequence
el.innerText = Utils.formatNumber(item.value);
// V8.2: Delete (×) badge — remove this number entirely (not in merge mode)
if (!this.mergeMode) {
const del = document.createElement('span');
del.className = 'lt-seq-card-del';
del.textContent = '×';
del.title = 'Remove this number';
del.onclick = (e) => { e.stopPropagation(); engine.removeItem(idx); };
el.appendChild(del);
}
// CLICK HANDLER
if(!this.mergeMode) {
el.onclick=()=>{ const v=prompt("Split value:", item.value/2); if(v){ const n=parseFloat(v); if(n>0 && n<item.value) engine.splitItem(idx,n); } };
} else {
el.onclick=()=>{ if(this.selectedForMerge.includes(idx)) this.selectedForMerge=this.selectedForMerge.filter(i=>i!==idx); else this.selectedForMerge.push(idx); this.update(); };
}
// SMART DRAG & DROP LOGIC (V6.26)
if (!this.mergeMode) {
el.draggable = true;
el.addEventListener('dragstart', (e) => {
this.dragSrcIndex = idx;
e.dataTransfer.effectAllowed = 'move';
el.classList.add('dragging');
});
el.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = el.getBoundingClientRect();
const mid = rect.left + (rect.width / 2);
el.classList.remove('drop-left', 'drop-right');
if (e.clientX < mid) {
el.classList.add('drop-left');
} else {
el.classList.add('drop-right');
}
});
el.addEventListener('dragleave', () => {
el.classList.remove('drop-left', 'drop-right');
});
el.addEventListener('dragend', () => {
el.classList.remove('dragging');
document.querySelectorAll('.lt-seq-card').forEach(b => b.classList.remove('drop-left', 'drop-right'));
});
el.addEventListener('drop', (e) => {
e.stopPropagation();
el.classList.remove('drop-left', 'drop-right');
const rect = el.getBoundingClientRect();
const mid = rect.left + (rect.width / 2);
let targetIdx = idx;
if (e.clientX >= mid) {
targetIdx++;
}
if (this.dragSrcIndex !== null && this.dragSrcIndex !== targetIdx) {
engine.reorderSequence(this.dragSrcIndex, targetIdx);
}
return false;
});
}
cont.appendChild(el);
});
// V8.2: Add (+) card — append a new number to the sequence
if (!this.mergeMode) {
const addCard = document.createElement('div');
addCard.className = 'lt-seq-card lt-seq-add';
addCard.textContent = '+';
addCard.title = 'Add a number';
addCard.onclick = () => {
const v = prompt('Add number (value):', '');
if (v !== null) { const n = parseFloat(v); if (n > 0) engine.addItem(n); }
};
cont.appendChild(addCard);
}
}
if(!document.getElementById('lt-history-panel').classList.contains('lt-hidden')) this.renderHistory();
// V8.4: Always keep the fixed right-side history panel up to date
this.renderHistoryInto(document.getElementById('lt-hist-side-list'));
this.updateExternalButtons();
} catch(e) { console.error("Update Err", e); }
}
// V8.3: Mini-games as floating, draggable popups over the Torn window
toggleGamePopup(type) {
const popupId = type === 'flappy' ? 'lt-popup-flappy' : 'lt-popup-snake';
const gameKey = type === 'flappy' ? '_flappyGame' : '_snakeGame';
const existing = document.getElementById(popupId);
if (existing) {
if (this[gameKey]) { this[gameKey].stop(); this[gameKey] = null; }
existing.remove();
return;
}
const W = 290, H = type === 'flappy' ? 370 : 290;
const popup = document.createElement('div');
popup.className = 'lt-popup';
popup.id = popupId;
popup.innerHTML = `
<div class="lt-popup-header">
<span class="lt-popup-title">${type === 'flappy' ? '🐦 Flappy Bird' : '🐍 Snake'}</span>
<span class="lt-popup-close" title="Close">✕</span>
</div>
<div class="lt-popup-body"><canvas width="${W}" height="${H}"></canvas></div>`;
document.body.appendChild(popup);
// Insert centered near the top
popup.style.left = `${Math.max(10, (window.innerWidth - popup.offsetWidth) / 2)}px`;
popup.style.top = `${Math.max(10, window.innerHeight * 0.12)}px`;
const close = () => {
if (this[gameKey]) { this[gameKey].stop(); this[gameKey] = null; }
popup.remove();
};
popup.querySelector('.lt-popup-close').onclick = close;
this.makeDraggable(popup, popup.querySelector('.lt-popup-header'));
const canvas = popup.querySelector('canvas');
if (type === 'flappy') {
this._flappyGame = new FlappyGame(canvas);
this._flappyGame.start();
canvas.onclick = () => this._flappyGame && this._flappyGame.flap();
} else {
this._snakeGame = new SnakeGame(canvas);
this._snakeGame.start();
}
}
makeDraggable(el, handle) {
let ox = 0, oy = 0, dragging = false;
handle.addEventListener('mousedown', (e) => {
dragging = true;
ox = e.clientX - el.offsetLeft;
oy = e.clientY - el.offsetTop;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const nx = Math.max(0, Math.min(window.innerWidth - el.offsetWidth, e.clientX - ox));
const ny = Math.max(0, Math.min(window.innerHeight - el.offsetHeight, e.clientY - oy));
el.style.left = `${nx}px`;
el.style.top = `${ny}px`;
});
document.addEventListener('mouseup', () => { dragging = false; });
}
updateStatus(text, color) {
const el = document.getElementById('lt-status-bar'); if(el) { el.innerText = text; el.style.color = color; }
// V8: Hero mini status (short label)
const mini = document.getElementById('lt-status-mini');
if (mini) {
const short = text.toUpperCase().replace(/\s*\(.*\)/, '').trim();
mini.innerText = short.length > 20 ? short.slice(0, 18) + '…' : short;
mini.style.color = color || '#a78bfa';
}
}
updateExternalButtons() {
const amt = engine.getEffectiveBet();
const setLabel = (b, t) => { if (b.tagName === 'INPUT') b.value = t; else b.innerText = t; };
const b1 = document.getElementById('lt-trade-btn-1'); const b2 = document.getElementById('lt-trade-btn-2');
if(b1 && b2) {
if(amt>0) { setLabel(b1, `- ${Utils.formatNumber(amt)}`); setLabel(b2, `-${Utils.formatNumber(amt+20)} +20`); b1.disabled=false; b2.disabled=false; }
else { setLabel(b1, "Done"); setLabel(b2, "Done"); b1.disabled=true; b2.disabled=true; }
}
const fb = document.getElementById('lt-fill-btn'); if(fb) fb.innerText=amt>0?`📋 ${Utils.formatNumber(amt)}`:"✅";
}
}
// --- INTEGRATION (V6.26 HYBRID & ASYNC SCANNER) ---
class TornIntegration {
constructor() {
this.isRR = false; this.myId = this.getMyId();
this.isArmed = false; this.lastManualBet = null;
this.isProcessing = false;
this.lockResults = false;
// V7.01 FIX: Duplicate detection
this.lastProcessedWinner = null;
this.lastProcessedTime = 0;
// V7.04 FIX: Text deduplication for DOM messages
this.lastProcessedDomText = null;
this.lastProcessedDomTime = 0;
this.pendingDomProcess = false;
// Make available globally so DevTool can trigger it
window.LabTrackIntegration = this;
this.installNetworkHooks();
this.installAudioHook();
console.log(`[LabTrack] Init - User ID: ${this.myId}`);
auditLog.record('NET', 'INIT', `UserID: ${this.myId}`);
}
getMyId() { const m = document.cookie.match(/uid=(\d+)/); return m ? parseInt(m[1], 10) : null; }
// V8.2: Detect RR sounds so our revolver can animate in sync with Torn's sound.
// Torn plays sounds via the Web Audio API (AudioBufferSourceNode), NOT <audio>,
// so we hook AudioBufferSourceNode.start. We also tag decoded buffers with their
// sound type (bang/blank/join) where possible. Plus an <audio> fallback.
installAudioHook() {
if (window._ltAudioHooked) return;
window._ltAudioHooked = true;
const fire = (t) => { try { window.dispatchEvent(new CustomEvent('lt-rr-sound', { detail: t })); } catch(e) {} };
// <audio> fallback (with type from src)
const origPlay = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function() {
try {
const m = (this.src || this.currentSrc || '').match(/russianRoulette\/audio\/(bang|blank|join)/);
if (m) fire(m[1]);
} catch(e) {}
return origPlay.apply(this, arguments);
};
// Web Audio: tag decoded buffers with their type, fire on playback start
try {
const AC = window.AudioContext || window.webkitAudioContext;
if (AC && AC.prototype.decodeAudioData) {
const origDecode = AC.prototype.decodeAudioData;
AC.prototype.decodeAudioData = function(buf, success, error) {
const type = buf && buf._ltSound;
const tag = (ab) => { if (type && ab) { try { ab._ltSound = type; } catch(e) {} } return ab; };
if (typeof success === 'function') return origDecode.call(this, buf, (ab) => success(tag(ab)), error);
const p = origDecode.call(this, buf);
return (p && p.then) ? p.then(tag) : p;
};
}
const ABSN = window.AudioBufferSourceNode;
if (ABSN && ABSN.prototype.start) {
const origStart = ABSN.prototype.start;
ABSN.prototype.start = function() {
try { fire((this.buffer && this.buffer._ltSound) || 'shot'); } catch(e) {}
return origStart.apply(this, arguments);
};
}
} catch(e) {}
}
setUI(ui) { this.ui = ui; }
start() {
setInterval(() => {
const href = window.location.href;
this.checkRoute();
this.scanPotMoney();
if (href.includes('sid=russianRoulette') && !href.includes('/game')) {
if (!this.isArmed) { this.isArmed = true; }
}
if(href.includes('russianRoulette')) { this.isRR=true; this.initDOMButtons(); this.embedGameWindow(); } else this.isRR=false;
if(href.includes('trade.php')) this.initTrade();
this.applyWideLayout(); // RR-only: widen the centered container for more room
}, CONFIG.POLL_MS);
}
// V8.4: On the RR page, widen Torn's centered layout container so the sidebar sits
// flush-left and the content (RR + our dashboard) gets more horizontal room.
// Auto-detects the constraining/centered ancestors. Reversible when leaving RR.
applyWideLayout() {
const onRR = window.location.href.includes('sid=russianRoulette');
if (!onRR) { this._restoreWideLayout(); return; }
if (this._wideApplied) return;
const sb = document.getElementById('sidebar');
if (!sb) return;
const touched = [];
let el = sb.parentElement;
while (el && el !== document.body && el !== document.documentElement) {
const cs = getComputedStyle(el);
const mw = parseFloat(cs.maxWidth);
const ml = parseFloat(cs.marginLeft) || 0;
const constrained = !isNaN(mw) && mw < window.innerWidth - 50;
const bigLeftMargin = ml > 20; // e.g. Torn's .container with margin-left:194px
if ((constrained || bigLeftMargin) && el.offsetWidth > 500) {
touched.push({ el, mw: el.style.maxWidth, ml: el.style.marginLeft, mr: el.style.marginRight, w: el.style.width });
el.style.setProperty('max-width', 'none', 'important');
el.style.setProperty('margin-left', '0', 'important');
el.style.setProperty('margin-right', '0', 'important');
el.style.setProperty('width', 'auto', 'important');
}
el = el.parentElement;
}
if (touched.length) {
this._wideTouched = touched; this._wideApplied = true;
document.body.classList.add('lt-rr-wide'); // reserve room for the history panel
Logger.info('Layout', `Widened ${touched.length} container(s) for RR`);
}
}
_restoreWideLayout() {
if (!this._wideApplied) return;
(this._wideTouched || []).forEach(t => {
t.el.style.maxWidth = t.mw; t.el.style.marginLeft = t.ml;
t.el.style.marginRight = t.mr; t.el.style.width = t.w;
});
document.body.classList.remove('lt-rr-wide');
this._wideApplied = false; this._wideTouched = null;
}
// V8.2: Custom appContainer rebuild via GameUIController
embedGameWindow() {
if (!this.isRR) return;
if (!this._gameUI) this._gameUI = new GameUIController();
this._gameUI.update();
}
// V6.31: Smart Pot Money Scanner
scanPotMoney() {
// Only scan if we are not locked/processing and in RR
if (this.lockResults || !this.isRR) return;
// Try to find the element by TEXT content to be robust against class changes
// Searching for "POT MONEY:" label
try {
const spans = document.querySelectorAll('span');
let potLabel = null;
for(let i=0; i<spans.length; i++) {
if(spans[i]?.innerText === "POT MONEY:") {
potLabel = spans[i];
break;
}
}
if (potLabel && potLabel.nextElementSibling) {
const valText = potLabel.nextElementSibling.innerText; // e.g. "$20"
const val = parseFloat(valText.replace(/[^0-9.]/g, ''));
if (!isNaN(val) && val > 0) {
// V6.32 FIX: If pendingBet is not set, use HALF of Pot.
if (engine.pendingBet === null) {
engine.setPendingBet(val / 2);
}
}
}
} catch(e) {}
}
checkRoute() {
const hash = window.location.hash;
if ((hash === '' || hash === '#/') && window.location.href.includes('sid=russianRoulette')) {
if (this.lockResults) {
this.lockResults = false;
// V7.06 FIX: Reset all duplicate detection on lobby return
this.lastProcessedWinner = null;
this.lastProcessedTime = 0;
this.lastSeenWinnerId = null;
this.lastSeenWinnerTime = 0;
this._winnerLock = null;
this.lastProcessedDomText = null;
this.lastProcessedDomTime = 0;
this.pendingDomProcess = false;
auditLog.record('NET', 'RESET', 'Lobby detected. Unlocked.');
if (this.ui) this.ui.updateStatus("READY", "#4ade80");
}
} else if (hash.includes('/game')) {
if (this.lockResults && this.ui) {
this.ui.updateStatus("LOCKED (RETURN TO LOBBY)", "#f87171");
} else if (this.ui) {
this.ui.updateStatus("GAME IN PROGRESS", "#fbbf24");
}
}
}
triggerResult(outcome, stake) {
// V7.01 FIX: Check if already being processed
// Locks should have been set by caller (DOM or Network handler)
// If somehow we get here without locks, something is wrong
if (!this.lockResults || !this.isProcessing) {
auditLog.record('NET', 'ERROR', 'triggerResult called without locks set!');
this.isProcessing = true;
this.lockResults = true;
}
// Get stake from engine if missing
let finalStake = stake;
if (!finalStake) finalStake = engine.pendingBet || engine.getEffectiveBet();
if (outcome === 'win') {
engine.processWin();
if(this.ui) {
this.ui.flash('win');
Utils.showToast(`WIN (Verified): ${Utils.formatNumber(finalStake)}`);
}
auditLog.record('NET', 'RESULT', 'WIN (Hybrid) processed. Locked.');
} else if (outcome === 'loss') {
engine.processLoss(finalStake);
if(this.ui) {
this.ui.flash('loss');
Utils.showToast(`LOSS (Verified): ${Utils.formatNumber(finalStake)}`);
}
auditLog.record('NET', 'RESULT', 'LOSS (Hybrid) processed. Locked.');
}
this.isArmed = false;
this.lastManualBet = null;
if(this.ui) this.ui.update();
setTimeout(() => { this.isProcessing = false; }, 500);
}
installNetworkHooks() {
const win = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); const self = this;
const origSend = win.XMLHttpRequest.prototype.send;
win.XMLHttpRequest.prototype.send = function(body) {
// V6.30: Enhanced hook for debugging custom bets
if(typeof body==='string' && (body.includes('sid=russianRouletteData')||window.location.href.includes('russianRoulette'))) {
console.log("[LabTrack] XHR POST:", body); // Debug log
auditLog.record('NET', 'POST', body.substring(0, 50));
const m = body.match(/amount=(\d+)/);
if(m) engine.setPendingBet(parseInt(m[1]));
}
this.addEventListener('load', function() {
if((this.responseType===''||this.responseType==='text') && this.responseText) {
if(self.isRR) self.scanTextForResults(this.responseText);
}
});
return origSend.apply(this, arguments);
};
const origFetch = win.fetch;
win.fetch = async function(...args) {
const url = args[0]?args[0].toString():"";
if(self.isRR && url.includes('russianRouletteData') && args[1]?.body) {
const bodyStr = args[1].body.toString();
console.log("[LabTrack] Fetch POST:", bodyStr);
auditLog.record('NET', 'FETCH', bodyStr.substring(0, 50));
const m = bodyStr.match(/amount=(\d+)/);
if(m) engine.setPendingBet(parseInt(m[1]));
}
const res = await origFetch.apply(this, args);
const c = res.clone(); c.text().then(t => {
if(url.includes('russianRouletteData')) self.scanTextForResults(t);
}).catch(()=>{});
return res;
};
const OrigWS = win.WebSocket;
win.WebSocket = function(...args) {
const ws = new OrigWS(...args);
ws.addEventListener('message', e => { if(typeof e.data==='string') self.scanTextForResults(e.data); });
return ws;
};
win.WebSocket.prototype = OrigWS.prototype; Object.assign(win.WebSocket, OrigWS);
}
scanTextForResults(text) {
if (!engine.state.autoDetect) return;
if (this.lockResults || this.isProcessing) return;
// V7.05 FIX: Quick pre-check - does this text even contain a winner?
if(!text || text.length<10 || text.indexOf('winner')===-1) return;
// V7.05 FIX: Extract winnerId FIRST, then do atomic check
const winnerMatch = text.match(/"winner"\s*:\s*"?(\d+)"?/);
if(!winnerMatch) return;
const winnerId = parseInt(winnerMatch[1], 10);
if(winnerId===0) return;
// V7.06 FIX: TRUE ATOMIC CHECK - Set FIRST, then check previous value
// This prevents TOCTOU race condition where two calls both pass the check
// before either sets the lock
const prevLock = this._winnerLock;
this._winnerLock = winnerId; // SET IMMEDIATELY before any check
if (prevLock === winnerId) {
return; // Another call is already processing this exact winner
}
// Additional time-based check for safety (different game rounds with same winner)
const now = Date.now();
if (this.lastSeenWinnerId === winnerId && (now - this.lastSeenWinnerTime < 5000)) {
this._winnerLock = null; // Release lock
return;
}
this.lastSeenWinnerId = winnerId;
this.lastSeenWinnerTime = now;
if(!this.myId) this.myId = this.getMyId();
if(!this.myId) { this._winnerLock = null; return; }
try {
auditLog.record('NET', 'NET', `Winner found: ${winnerId}`);
let outcome = 'none';
if(winnerId === this.myId) {
outcome = 'win';
} else {
const participationRegex = new RegExp(`"userID"\\s*:\\s*"?${this.myId}"?`);
const regexMatch = participationRegex.test(text);
const hasPendingBet = engine.pendingBet !== null;
const hasRecentManualBet = this.lastManualBet && (Date.now() - this.lastManualBet.time < 30000);
auditLog.record('NET', 'CHECK', `Regex: ${regexMatch}, Pending: ${hasPendingBet}, Manual: ${hasRecentManualBet}`);
if (regexMatch || hasPendingBet || hasRecentManualBet) {
outcome = 'loss';
}
}
if (outcome !== 'none') {
// V7.01 FIX: DOUBLE CHECK - locks might have been set by DOM already
if (this.lockResults || this.isProcessing) {
auditLog.record('NET', 'SKIP', 'Already locked by DOM detection');
return;
}
// V7.01 FIX: Set locks IMMEDIATELY before triggerResult
this.isProcessing = true;
this.lockResults = true;
// V7.01 FIX: Mark this winnerId as processed
this.lastProcessedWinner = winnerId;
this.lastProcessedTime = Date.now();
let val=0, isPot=false;
const mm = text.match(/"(pot_amount|betAmount|amount|money)"\s*:\s*"?(\d+(\.\d+)?)"?/);
if(mm) { val=parseFloat(mm[2]); if(mm[1]==='pot_amount') isPot=true; }
let netStake = isPot ? val/2 : val;
this.triggerResult(outcome, netStake);
} else {
auditLog.record('NET', 'IGNORE', 'Spectator / Not Involved');
this._winnerLock = null; // Release lock for spectator
}
} catch(e) {
console.error("[LabTrack] Scan Err", e);
auditLog.record('NET', 'ERR', e.message);
this.isProcessing = false;
this._winnerLock = null; // Release lock on error
}
}
initDOMButtons() {
const inp = document.querySelector('input[aria-label="Money value"]');
if(inp && !inp.hasAttribute('data-lt')) {
inp.setAttribute('data-lt','1');
// V7.02: Auto-detect Custom Bets
const capture = (e) => {
// V8.x: Skip synthetic events from our own writeNativeBet (would create loop)
const syntheticMark = e.target._ltSyntheticBetWrite;
if (syntheticMark && Date.now() - syntheticMark < 200) return;
const v = parseFloat(e.target.value.replace(/[^0-9.]/g,''));
if(!isNaN(v) && v > 0) {
this.lastManualBet = { amount: v, time: Date.now() };
// V7.02: Auto-detect if bet differs from expected
const expectedBet = engine.getEffectiveBet();
const tolerance = 1; // Allow 1$ difference for rounding
if (Math.abs(v - expectedBet) > tolerance) {
// User entered a different bet - set as custom bet
Logger.info('CustomBet', `Auto-detected: ${v} (Expected: ${expectedBet})`);
engine.setCustomBet(v);
Utils.showToast(`Custom Bet: ${Utils.formatNumber(v)}`);
if(this.ui) this.ui.update();
}
}
};
inp.addEventListener('input', capture);
inp.addEventListener('keyup', capture);
inp.addEventListener('change', capture);
}
if(inp && !document.getElementById('lt-fill-btn')) {
const btn = document.createElement("button"); btn.id="lt-fill-btn"; btn.innerText="📋";
btn.style.cssText="margin-left:5px;background:#6d28d9;color:#fff;border:none;border-radius:4px;cursor:pointer;padding:0 10px;height:34px;font-weight:bold;";
btn.onclick = (e) => { e.preventDefault(); const v=engine.getEffectiveBet(); if(v>0){ Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,"value").set.call(inp,Math.floor(v)); inp.dispatchEvent(new Event('input',{bubbles:true})); } };
if(inp.parentNode) inp.parentNode.appendChild(btn);
if(this.ui) this.ui.update();
}
}
initTrade() {
const inp = document.querySelector(".user-id.input-money");
if (!inp) return;
const moneyGroup = inp.closest('.input-money-group');
const li = moneyGroup?.closest('li');
const form = inp.closest('form');
if (!moneyGroup || !li || !form) return;
// Bereits aufgebaut?
if (document.getElementById('lt-trade-dashboard')) return;
// ── Migration: clean up old layouts ──
// If the Change button was stuck in an old container → move it back to the form end
const oldActions = document.getElementById('lt-trade-actions');
if (oldActions) {
const oldSubmitWrap = oldActions.querySelector('.btn-wrap');
if (oldSubmitWrap) form.appendChild(oldSubmitWrap);
oldActions.remove();
}
document.getElementById('lt-trade-pnl')?.remove();
moneyGroup.querySelector('#lt-max-minus20')?.remove();
// Reference the native Max button (for −$20 logic)
const maxSpan = inp.closest('.input-money-group')?.querySelector('.input-money-symbol');
const nativeMaxBtn = maxSpan?.querySelector('.wai-btn');
// React-friendly value setting
const setReactValue = (newVal) => {
const cur = parseInt(inp.value.replace(/[^0-9]/g, '')) || 0;
const tracker = inp._valueTracker;
if (tracker) tracker.setValue(String(cur));
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set.call(inp, String(newVal));
inp.dispatchEvent(new Event('input', { bubbles: true }));
inp.dispatchEvent(new Event('change', { bubbles: true }));
};
const subtractV = v => {
const c = parseInt(inp.value.replace(/[^0-9]/g, '')) || 0;
setReactValue(Math.max(0, c - v));
};
// ── "MAX − $20" button (with XHR wait via MutationObserver) ──
const m20Btn = document.createElement('input');
m20Btn.type = 'button';
m20Btn.className = 'torn-btn';
m20Btn.id = 'lt-max-minus20';
m20Btn.value = 'MAX − $20';
m20Btn.title = 'Fills max minus $20 — leaves $20 in your wallet';
m20Btn.onclick = (e) => {
e.preventDefault();
if (!nativeMaxBtn || m20Btn._pending) return;
m20Btn._pending = true;
let applied = false;
let observer = null;
let fallbackTimer = null;
const cleanup = () => {
if (observer) { observer.disconnect(); observer = null; }
if (fallbackTimer) { clearTimeout(fallbackTimer); fallbackTimer = null; }
m20Btn._pending = false;
};
const applySubtract = () => {
if (applied) return;
applied = true;
const fromAttr = parseInt(inp.getAttribute('data-money')) || 0;
const fromVal = parseInt(inp.value.replace(/[^0-9]/g, '')) || 0;
const max = Math.max(fromAttr, fromVal);
if (max <= 20) { cleanup(); return; }
const target = max - 20;
setReactValue(target);
setTimeout(() => {
const verify = parseInt(inp.value.replace(/[^0-9]/g, '')) || 0;
if (verify !== target) setReactValue(target);
cleanup();
}, 200);
};
observer = new MutationObserver(applySubtract);
observer.observe(inp, { attributes: true, attributeFilter: ['data-money'] });
fallbackTimer = setTimeout(applySubtract, 2000);
nativeMaxBtn.click();
};
// ── Bet buttons (subtract effective bet [+20]) ──
const mkBetBtn = (id, getAmount) => {
const b = document.createElement('input');
b.type = 'button';
b.className = 'torn-btn';
b.id = id;
b.value = 'Wait...';
b.onclick = e => { e.preventDefault(); subtractV(getAmount()); };
return b;
};
const betBtn1 = mkBetBtn('lt-trade-btn-1', () => engine.getEffectiveBet());
const betBtn2 = mkBetBtn('lt-trade-btn-2', () => engine.getEffectiveBet() + 20);
// ── LabTrack dashboard at the top of the <li> ──
const dash = document.createElement('div');
dash.id = 'lt-trade-dashboard';
dash.style.cssText = 'margin-bottom:10px;padding:8px 10px;background:rgba(124,58,237,0.06);border-left:3px solid #7c3aed;border-radius:4px;display:flex;flex-direction:column;gap:8px;';
const dashLabel = document.createElement('div');
dashLabel.style.cssText = 'font-size:10px;color:#a78bfa;font-weight:bold;text-transform:uppercase;letter-spacing:0.6px;';
dashLabel.innerText = '🎯 LabTrack';
dash.appendChild(dashLabel);
// ── Change submit button (left of MAX − $20) ──
const changeBtn = document.createElement('input');
changeBtn.type = 'submit';
changeBtn.className = 'torn-btn';
changeBtn.id = 'lt-change-btn';
changeBtn.value = 'Change';
changeBtn.title = 'Submit trade change';
// No onclick needed — type="submit" inside the <form> triggers native submission
const topRow = document.createElement('div');
topRow.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;align-items:center;';
topRow.appendChild(changeBtn);
topRow.appendChild(m20Btn);
dash.appendChild(topRow);
const betRow = document.createElement('div');
betRow.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;align-items:center;';
betRow.appendChild(betBtn1);
betRow.appendChild(betBtn2);
dash.appendChild(betRow);
// Insert at the very top of the <li> (before "Money:")
li.insertBefore(dash, li.firstChild);
if (this.ui) this.ui.updateExternalButtons();
}
}
// =============================================================================
// FLAPPY BIRD MINI-GAME
// =============================================================================
class FlappyGame {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = canvas.width;
this.H = canvas.height;
this.highScore = parseInt(localStorage.getItem('lt_flappy_hs') || '0');
this.animId = null;
this.state = 'idle';
this._keyHandler = null;
this._reset();
}
_reset() {
this.bird = { x: 65, y: this.H / 2, vy: 0, r: 11 };
this.pipes = [];
this.score = 0;
this.frame = 0;
this.speed = 2.2;
this.gap = 135;
this.pipeW = 44;
this.interval = 95;
this._lastTs = undefined;
this.paused = false;
}
flap() {
if (this.state === 'dead') this._reset();
this.state = 'playing';
this.paused = false;
this.bird.vy = -7;
}
_update() {
if (this.state !== 'playing' || this.paused) return;
this.frame++;
// Physics
this.bird.vy += 0.4;
this.bird.y += this.bird.vy;
// Spawn pipes
if (this.frame % this.interval === 0) {
const minY = 55, maxY = this.H - this.gap - 55;
const gapY = minY + Math.random() * (maxY - minY);
this.pipes.push({ x: this.W + 10, gapY, passed: false });
}
// Move & score pipes
for (const p of this.pipes) {
p.x -= this.speed;
if (!p.passed && p.x + this.pipeW < this.bird.x) {
p.passed = true;
this.score++;
this.speed = Math.min(4.8, 2.2 + this.score * 0.06);
}
}
this.pipes = this.pipes.filter(p => p.x + this.pipeW > -10);
// Floor / ceiling
if (this.bird.y + this.bird.r > this.H - 20 || this.bird.y - this.bird.r < 0) {
this._die(); return;
}
// Pipe collision
for (const p of this.pipes) {
const inX = this.bird.x + this.bird.r > p.x && this.bird.x - this.bird.r < p.x + this.pipeW;
if (inX) {
const inGap = this.bird.y - this.bird.r > p.gapY && this.bird.y + this.bird.r < p.gapY + this.gap;
if (!inGap) { this._die(); return; }
}
}
}
_die() {
this.state = 'dead';
if (this.score > this.highScore) {
this.highScore = this.score;
localStorage.setItem('lt_flappy_hs', String(this.highScore));
}
}
_draw() {
const { ctx, W, H, bird, pipes, score, highScore, state, gap, pipeW } = this;
// Background
const sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, '#050810');
sky.addColorStop(1, '#0f172a');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
// Static stars
ctx.fillStyle = 'rgba(255,255,255,0.45)';
for (let i = 0; i < 22; i++) {
ctx.fillRect((i * 137 + 17) % W, (i * 97 + 31) % (H - 40), 1, 1);
}
// Ground
ctx.fillStyle = '#1e3a2f';
ctx.fillRect(0, H - 20, W, 20);
ctx.fillStyle = '#15803d';
ctx.fillRect(0, H - 20, W, 3);
// Pipes
for (const p of pipes) {
const g = ctx.createLinearGradient(p.x, 0, p.x + pipeW, 0);
g.addColorStop(0, '#14532d');
g.addColorStop(0.5, '#22c55e');
g.addColorStop(1, '#15803d');
ctx.fillStyle = g;
// Top pipe body
ctx.fillRect(p.x, 0, pipeW, p.gapY - 14);
// Top cap
ctx.fillStyle = '#16a34a';
ctx.fillRect(p.x - 5, p.gapY - 14, pipeW + 10, 14);
// Bottom body
ctx.fillStyle = g;
ctx.fillRect(p.x, p.gapY + gap + 14, pipeW, H - p.gapY - gap - 14 - 20);
// Bottom cap
ctx.fillStyle = '#16a34a';
ctx.fillRect(p.x - 5, p.gapY + gap, pipeW + 10, 14);
}
// Bird
const tilt = Math.min(Math.max(bird.vy * 4, -28), 55) * Math.PI / 180;
ctx.save();
ctx.translate(bird.x, bird.y);
ctx.rotate(tilt);
// Body
const bodyG = ctx.createRadialGradient(-2, -2, 1, 0, 0, bird.r);
bodyG.addColorStop(0, '#c084fc');
bodyG.addColorStop(1, '#7c3aed');
ctx.beginPath();
ctx.ellipse(0, 0, bird.r, bird.r * 0.88, 0, 0, Math.PI * 2);
ctx.fillStyle = bodyG;
ctx.fill();
// Wing
ctx.beginPath();
ctx.ellipse(-1, 4, bird.r * 0.6, bird.r * 0.32, -0.25, 0, Math.PI * 2);
ctx.fillStyle = '#6d28d9';
ctx.fill();
// Eye white
ctx.beginPath();
ctx.arc(bird.r * 0.38, -bird.r * 0.22, 3.5, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
// Pupil
ctx.beginPath();
ctx.arc(bird.r * 0.38 + 1, -bird.r * 0.22, 1.8, 0, Math.PI * 2);
ctx.fillStyle = '#1e293b';
ctx.fill();
// Beak
ctx.beginPath();
ctx.moveTo(bird.r * 0.72, 0);
ctx.lineTo(bird.r * 1.25, 2.5);
ctx.lineTo(bird.r * 0.72, 5);
ctx.closePath();
ctx.fillStyle = '#f97316';
ctx.fill();
ctx.restore();
// HUD
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(0,0,0,0.9)';
ctx.shadowBlur = 6;
ctx.fillStyle = '#fff';
ctx.font = 'bold 24px monospace';
ctx.fillText(score, W / 2, 38);
ctx.font = '10px monospace';
ctx.fillStyle = '#64748b';
ctx.fillText(`BEST: ${highScore}`, W / 2, 54);
ctx.shadowBlur = 0;
// Idle overlay
if (state === 'idle') {
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#a78bfa';
ctx.font = 'bold 20px monospace';
ctx.fillText('FLAPPY BIRD', W / 2, H / 2 - 22);
ctx.fillStyle = '#94a3b8';
ctx.font = '12px monospace';
ctx.fillText('SPACE or CLICK', W / 2, H / 2 + 8);
if (highScore > 0) {
ctx.fillStyle = '#fbbf24';
ctx.font = '11px monospace';
ctx.fillText(`🏆 Best: ${highScore}`, W / 2, H / 2 + 30);
}
}
// Dead overlay
if (state === 'dead') {
ctx.fillStyle = 'rgba(0,0,0,0.65)';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#ef4444';
ctx.font = 'bold 20px monospace';
ctx.fillText('GAME OVER', W / 2, H / 2 - 34);
ctx.fillStyle = '#fff';
ctx.font = 'bold 16px monospace';
ctx.fillText(`Score: ${score}`, W / 2, H / 2 - 8);
if (score > 0 && score >= highScore) {
ctx.fillStyle = '#fbbf24';
ctx.font = 'bold 13px monospace';
ctx.fillText('🏆 NEW RECORD!', W / 2, H / 2 + 14);
}
ctx.fillStyle = '#a78bfa';
ctx.font = '12px monospace';
ctx.fillText('SPACE or CLICK', W / 2, H / 2 + 38);
}
// Paused overlay
if (this.paused && state === 'playing') {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center';
ctx.fillStyle = '#a78bfa';
ctx.font = 'bold 22px monospace';
ctx.fillText('⏸ PAUSED', W / 2, H / 2 - 8);
ctx.fillStyle = '#94a3b8';
ctx.font = '12px monospace';
ctx.fillText('Press SPACE to resume', W / 2, H / 2 + 18);
}
ctx.textAlign = 'left';
}
_loop(ts) {
// Cap at 60fps regardless of monitor refresh rate
if (this._lastTs === undefined) this._lastTs = ts;
const dt = ts - this._lastTs;
if (dt >= 16) {
this._lastTs = ts;
this._update();
this._draw();
}
this.animId = requestAnimationFrame((t) => this._loop(t));
}
start() {
if (this.animId) cancelAnimationFrame(this.animId);
this._lastTs = undefined;
this.state = 'idle';
this._reset();
this.animId = requestAnimationFrame((t) => this._loop(t));
this._keyHandler = (e) => {
if (e.code !== 'Space') return;
if (!document.getElementById('lt-popup-flappy')) return; // popup closed → let Torn handle Space
// SAFETY: stop Space from reaching Torn's RR handlers (could trigger shoot/leave → mug risk)
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
if (this.paused) { this.paused = false; this._lastTs = undefined; return; } // resume, no flap
this.flap();
};
document.addEventListener('keydown', this._keyHandler, true); // capture: runs before Torn
// Pause when tabbing out (window blur / tab switch)
this._pauseHandler = () => { if (this.state === 'playing') this.paused = true; };
this._visHandler = () => { if (document.hidden && this.state === 'playing') this.paused = true; };
window.addEventListener('blur', this._pauseHandler);
document.addEventListener('visibilitychange', this._visHandler);
}
stop() {
if (this.animId) { cancelAnimationFrame(this.animId); this.animId = null; }
if (this._keyHandler) { document.removeEventListener('keydown', this._keyHandler, true); this._keyHandler = null; }
if (this._pauseHandler) { window.removeEventListener('blur', this._pauseHandler); this._pauseHandler = null; }
if (this._visHandler) { document.removeEventListener('visibilitychange', this._visHandler); this._visHandler = null; }
}
}
// =============================================================================
// SNAKE MINI-GAME
// =============================================================================
class SnakeGame {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = canvas.width; // 290
this.H = canvas.height; // 290
this.cell = 10;
this.cols = this.W / this.cell; // 29
this.rows = this.H / this.cell; // 29
this.highScore = parseInt(localStorage.getItem('lt_snake_hs') || '0');
this.animId = null;
this.state = 'idle';
this._keyHandler = null;
this._reset();
}
_reset() {
const cx = Math.floor(this.cols / 2);
const cy = Math.floor(this.rows / 2);
this.snake = [{ x: cx, y: cy }, { x: cx-1, y: cy }, { x: cx-2, y: cy }];
this.dir = { x: 1, y: 0 };
this.nextDir = { x: 1, y: 0 };
this.score = 0;
this.speed = 120; // ms per tick
this._lastTick = undefined;
this.paused = false;
this._spawnFood();
}
_spawnFood() {
let pos;
do {
pos = { x: Math.floor(Math.random() * this.cols), y: Math.floor(Math.random() * this.rows) };
} while (this.snake.some(s => s.x === pos.x && s.y === pos.y));
this.food = pos;
}
_tick() {
this.dir = { ...this.nextDir };
const head = { x: this.snake[0].x + this.dir.x, y: this.snake[0].y + this.dir.y };
// Wall collision
if (head.x < 0 || head.x >= this.cols || head.y < 0 || head.y >= this.rows) { this._die(); return; }
// Self collision
if (this.snake.some(s => s.x === head.x && s.y === head.y)) { this._die(); return; }
this.snake.unshift(head);
if (head.x === this.food.x && head.y === this.food.y) {
this.score++;
this.speed = Math.max(60, 120 - Math.floor(this.score / 5) * 10);
this._spawnFood();
} else {
this.snake.pop();
}
}
_die() {
this.state = 'dead';
if (this.score > this.highScore) {
this.highScore = this.score;
localStorage.setItem('lt_snake_hs', String(this.highScore));
}
}
_draw() {
const { ctx, W, H, cell, snake, food, score, highScore, state } = this;
// Background
ctx.fillStyle = '#080d1a';
ctx.fillRect(0, 0, W, H);
// Subtle grid
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 0.5;
for (let x = 0; x <= this.cols; x++) { ctx.beginPath(); ctx.moveTo(x*cell,0); ctx.lineTo(x*cell,H); ctx.stroke(); }
for (let y = 0; y <= this.rows; y++) { ctx.beginPath(); ctx.moveTo(0,y*cell); ctx.lineTo(W,y*cell); ctx.stroke(); }
// Food
const fx = food.x*cell + cell/2, fy = food.y*cell + cell/2;
ctx.beginPath(); ctx.arc(fx, fy, cell*0.42, 0, Math.PI*2); ctx.fillStyle = '#ef4444'; ctx.fill();
ctx.beginPath(); ctx.arc(fx-1, fy-1, cell*0.18, 0, Math.PI*2); ctx.fillStyle = '#fca5a5'; ctx.fill();
// Snake body (head bright purple → tail dark)
const len = snake.length;
snake.forEach((seg, i) => {
const t = i / Math.max(len - 1, 1);
const r = Math.round(192 - t * 130);
const g = Math.round(132 - t * 103);
const b = Math.round(252 - t * 104);
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect(seg.x*cell + 1, seg.y*cell + 1, cell-2, cell-2);
});
// Head eyes
if (snake.length > 0) {
const { x: dx, y: dy } = this.dir;
const hx = snake[0].x*cell + cell/2, hy = snake[0].y*cell + cell/2;
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(hx + dy*2.5 + dx*2, hy - dx*2.5 + dy*2, 1.5, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(hx - dy*2.5 + dx*2, hy + dx*2.5 + dy*2, 1.5, 0, Math.PI*2); ctx.fill();
}
// HUD bar
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(0, 0, W, 20);
ctx.textAlign = 'center';
ctx.fillStyle = '#e2e8f0';
ctx.font = 'bold 11px monospace';
ctx.fillText(`Score: ${score}`, W/2, 13);
ctx.textAlign = 'right';
ctx.fillStyle = '#64748b';
ctx.font = '9px monospace';
ctx.fillText(`BEST: ${highScore}`, W - 6, 13);
ctx.textAlign = 'left';
ctx.fillStyle = '#64748b';
ctx.fillText(`SPD: ${Math.round((120 - this.speed) / 10) + 1}`, 6, 13);
// Idle overlay
if (state === 'idle') {
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center';
ctx.fillStyle = '#4ade80';
ctx.font = 'bold 22px monospace';
ctx.fillText('SNAKE', W/2, H/2 - 24);
ctx.fillStyle = '#94a3b8';
ctx.font = '12px monospace';
ctx.fillText('Arrow keys / WASD', W/2, H/2 + 4);
ctx.fillText('Press ENTER to start', W/2, H/2 + 22);
if (highScore > 0) { ctx.fillStyle = '#fbbf24'; ctx.font = '11px monospace'; ctx.fillText(`🏆 Best: ${highScore}`, W/2, H/2 + 46); }
}
// Dead overlay
if (state === 'dead') {
ctx.fillStyle = 'rgba(0,0,0,0.65)';
ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center';
ctx.fillStyle = '#ef4444';
ctx.font = 'bold 20px monospace';
ctx.fillText('GAME OVER', W/2, H/2 - 34);
ctx.fillStyle = '#fff';
ctx.font = 'bold 16px monospace';
ctx.fillText(`Score: ${score}`, W/2, H/2 - 8);
if (score > 0 && score >= highScore) { ctx.fillStyle = '#fbbf24'; ctx.font = 'bold 13px monospace'; ctx.fillText('🏆 NEW RECORD!', W/2, H/2 + 14); }
ctx.fillStyle = '#a78bfa';
ctx.font = '12px monospace';
ctx.fillText('Press ENTER to restart', W/2, H/2 + 38);
}
// Paused overlay
if (this.paused && state === 'playing') {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0, 0, W, H);
ctx.textAlign = 'center';
ctx.fillStyle = '#a78bfa';
ctx.font = 'bold 20px monospace';
ctx.fillText('⏸ PAUSED', W/2, H/2 - 8);
ctx.fillStyle = '#94a3b8';
ctx.font = '12px monospace';
ctx.fillText('Press SPACE to resume', W/2, H/2 + 18);
}
ctx.textAlign = 'left';
}
_loop(ts) {
this._draw();
if (this.state === 'playing' && !this.paused) {
if (this._lastTick === undefined) this._lastTick = ts;
if (ts - this._lastTick >= this.speed) {
this._lastTick = ts;
this._tick();
}
}
this.animId = requestAnimationFrame((t) => this._loop(t));
}
start() {
if (this.animId) cancelAnimationFrame(this.animId);
this._reset();
this.state = 'idle';
this.animId = requestAnimationFrame((t) => this._loop(t));
this._keyHandler = (e) => {
const panel = document.getElementById('lt-popup-snake');
if (!panel) return;
const dirs = {
ArrowUp: { x: 0, y: -1 }, KeyW: { x: 0, y: -1 },
ArrowDown: { x: 0, y: 1 }, KeyS: { x: 0, y: 1 },
ArrowLeft: { x:-1, y: 0 }, KeyA: { x:-1, y: 0 },
ArrowRight: { x: 1, y: 0 }, KeyD: { x: 1, y: 0 },
};
if (e.code === 'Enter' || e.code === 'Space') {
// SAFETY: block from Torn's RR handlers (mug risk)
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
if (this.paused) { this.paused = false; this._lastTick = undefined; return; } // resume
if (this.state === 'idle' || this.state === 'dead') { this._reset(); this.state = 'playing'; }
return;
}
if (!dirs[e.code]) return;
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
const d = dirs[e.code];
if (d.x !== -this.dir.x || d.y !== -this.dir.y) this.nextDir = d;
};
document.addEventListener('keydown', this._keyHandler, true); // capture: runs before Torn
// Pause when tabbing out (window blur / tab switch)
this._pauseHandler = () => { if (this.state === 'playing') this.paused = true; };
this._visHandler = () => { if (document.hidden && this.state === 'playing') this.paused = true; };
window.addEventListener('blur', this._pauseHandler);
document.addEventListener('visibilitychange', this._visHandler);
}
stop() {
if (this.animId) { cancelAnimationFrame(this.animId); this.animId = null; }
if (this._keyHandler) { document.removeEventListener('keydown', this._keyHandler, true); this._keyHandler = null; }
if (this._pauseHandler) { window.removeEventListener('blur', this._pauseHandler); this._pauseHandler = null; }
if (this._visHandler) { document.removeEventListener('visibilitychange', this._visHandler); this._visHandler = null; }
}
}
// =============================================================================
// V8.2 — CUSTOM APPCONTAINER REBUILD (with confirm-step handling)
// Hides Torn's appContainer off-screen, renders our own UI, syncs state from the
// hidden DOM, forwards user-initiated clicks to native buttons. States: lobby,
// confirm (game create/join confirmation), game.
// =============================================================================
class GameUIController {
constructor() {
this.hiddenApp = null;
this.observer = null;
this.state = null;
this._shellBuilt = false;
this._lastBetSet = null;
this._revRot = 0; // revolver cylinder rotation
this._deadXid = null; // XID of the player marked dead (red cross)
// Quick Create: persist across sessions; default OFF (safer).
this._quickCreate = localStorage.getItem('lt_quick_create') === '1';
// Listen for RR sound events (dispatched by the audio hook) → animate revolver
window.addEventListener('lt-rr-sound', (e) => this.onGameSound(e.detail));
}
toggleQuickCreate() {
this._quickCreate = !this._quickCreate;
localStorage.setItem('lt_quick_create', this._quickCreate ? '1' : '0');
const btn = document.getElementById('lt-quick-create');
if (btn) {
btn.classList.toggle('on', this._quickCreate);
btn.innerHTML = `⚡ Quick Create: <b>${this._quickCreate ? 'ON' : 'OFF'}</b>`;
}
Utils.showToast(`Quick Create ${this._quickCreate ? 'enabled' : 'disabled'}`);
}
update() {
const app = document.querySelector('div[class^="appContainer"]');
if (!app) return;
const slot = document.getElementById('lt-game-frame-slot');
const frame = document.getElementById('lt-game-frame');
if (!slot || !frame) return;
if (!this._shellBuilt) {
try { this.renderShell(slot); this._shellBuilt = true; }
catch (e) { Logger.error('GameUI', `Shell render failed: ${e.message}`); return; }
}
if (this.hiddenApp !== app) { this.hiddenApp = app; this.startObserver(); }
frame.style.display = 'block';
this.sync();
}
renderShell(slot) {
slot.innerHTML = `
<div id="lt-app">
<div class="lt-app-header">
<div class="lt-app-title">Russian Roulette</div>
<div class="lt-app-nav" id="lt-app-nav"></div>
</div>
<div id="lt-app-body"></div>
</div>`;
}
sync() {
// Re-discover appContainer if stale (Torn replaces it on game start)
if (!this.hiddenApp || !document.contains(this.hiddenApp)) {
const app = document.querySelector('div[class^="appContainer"]');
if (app) { this.hiddenApp = app; this.startObserver(); Logger.info('GameUI', 'Re-discovered appContainer'); }
}
if (!this.hiddenApp) return;
this.syncNav();
const hasConfirm = !!this.hiddenApp.querySelector('[class*="confirmWrap"]');
const hasTimer = !!this.hiddenApp.querySelector('[class*="timer"]');
const hasCards = !!this.hiddenApp.querySelector('[class*="userCardBlock"]');
const hasPot = !!this.hiddenApp.querySelector('[class*="potWrap"]');
const hasLeave = !!this.hiddenApp.querySelector('[class*="leaveWrap"]');
const hasList = !!this.hiddenApp.querySelector('[class*="rowsWrap"], [class*="createWrap"]');
let newState;
if (hasConfirm) newState = 'confirm';
else if (hasTimer || hasCards || hasPot || hasLeave) newState = 'game';
else if (hasList) newState = 'lobby';
else newState = this.state || 'lobby';
if (newState !== this.state) {
Logger.info('GameUI', `State: ${this.state || '(init)'} → ${newState}`);
// When entering 'confirm', snapshot the hospital status. If the dialog
// appeared while hospitalized, Quick Create is permanently disabled FOR
// THIS DIALOG — even if the user heals later. This avoids the bad-optics
// pattern of "auto-confirm fires the moment a condition (heal) is met",
// which would look like the script is playing on a trigger.
if (newState === 'confirm') this._confirmStartedHospitalized = this.isHospitalized();
else if (this.state === 'confirm') this._confirmStartedHospitalized = false;
this.state = newState;
this.renderBody();
}
if (this.state === 'game') this.syncGameState();
else if (this.state === 'confirm') this.syncConfirmState();
else this.syncLobbyState();
}
syncNav() {
const nav = document.getElementById('lt-app-nav');
if (!nav) return;
const links = this.hiddenApp.querySelectorAll('[class*="linksContainer"] a');
// Re-render when the link LABELS change (lobby has "Back to Casino", game has
// "Back to Lobby" — same count, so a count check would miss the switch).
const sig = Array.from(links).map(l => (l.querySelector('[class*="linkTitle"]')?.textContent || l.textContent).trim()).join('|');
if (links.length && nav.dataset.sig === sig && nav.children.length) return;
nav.dataset.sig = sig;
nav.innerHTML = '';
links.forEach(link => {
const btn = document.createElement('button');
btn.className = 'lt-nav-btn'; btn.type = 'button';
btn.textContent = link.querySelector('[class*="linkTitle"]')?.textContent || link.textContent.trim();
btn.onclick = (e) => { e.preventDefault(); this.fireFullClick(link); };
nav.appendChild(btn);
});
}
renderBody() {
const body = document.getElementById('lt-app-body');
if (!body) return;
if (this.state === 'game') this.renderGame(body);
else if (this.state === 'confirm') this.renderConfirm(body);
else this.renderLobby(body);
}
// ── GAME ──
renderGame(body) {
body.innerHTML = `
<div class="lt-igs">
<div class="lt-igs-top">
<div class="lt-igs-block">
<span class="lt-igs-label">⏱ Waiting</span>
<span class="lt-igs-timer" id="lt-igs-timer">--:--</span>
</div>
<button class="lt-igs-sound" id="lt-igs-sound">🔊 Sound</button>
<div class="lt-igs-block right">
<span class="lt-igs-label">💰 Pot Money</span>
<span class="lt-igs-pot" id="lt-igs-pot">$0</span>
</div>
</div>
<div class="lt-igs-msg" id="lt-igs-msg"></div>
<div class="lt-igs-players" id="lt-igs-players"></div>
<div class="lt-revolver" id="lt-revolver">
<div class="lt-rev-cyl" id="lt-rev-cyl"></div>
</div>
<div class="lt-igs-shots" id="lt-igs-shots"></div>
<div class="lt-igs-controls">
<button class="lt-leave-btn" id="lt-igs-leave">🚪 Leave Game</button>
</div>
</div>`;
// Fresh game UI → reset revolver + death state.
// Mark any stale winner from the previous game as already handled.
this._revRot = 0;
this._deadXid = null;
this._lastHandledWinner = (window.LabTrackIntegration && window.LabTrackIntegration.lastSeenWinnerId) || null;
this.buildRevolverChambers();
document.getElementById('lt-igs-leave').onclick = () => {
const btn = this._findNativeLeave();
if (btn) this.fireFullClick(btn);
};
document.getElementById('lt-igs-sound').onclick = () => {
const btn = this.hiddenApp.querySelector('[class*="soundToggler"]');
if (btn) this.fireFullClick(btn);
};
}
// Build 6 chambers in a circle + center hub + muzzle flash overlay
buildRevolverChambers() {
const cyl = document.getElementById('lt-rev-cyl');
if (!cyl) return;
cyl.innerHTML = '';
const R = 33; // radius from center (px)
for (let i = 0; i < 6; i++) {
const ang = (i * 60 - 90) * Math.PI / 180;
const x = Math.cos(ang) * R, y = Math.sin(ang) * R;
const ch = document.createElement('div');
ch.className = 'lt-rev-chamber';
ch.style.transform = `translate(calc(-50% + ${x}px), calc(-50% + ${y}px))`;
cyl.appendChild(ch);
}
const hub = document.createElement('div'); hub.className = 'lt-rev-hub'; cyl.appendChild(hub);
const flash = document.createElement('div'); flash.className = 'lt-rev-flash'; flash.id = 'lt-rev-flash'; cyl.appendChild(flash);
cyl.style.transform = `rotate(${this._revRot}deg)`;
}
// Triggered by the audio hook. type: 'blank' | 'bang' | 'join' | 'shot' (generic)
onGameSound(type) {
const cyl = document.getElementById('lt-rev-cyl');
if (!cyl) return; // not in our game view
if (type === 'join') return; // a player joined — not a trigger pull
// Rotate the cylinder on every shot sound (synced with Torn's audio)
this._revRot += 60;
cyl.style.transform = `rotate(${this._revRot}deg)`;
// Mechanical "click" brightness pulse on each rotation
cyl.classList.remove('click'); void cyl.offsetWidth; cyl.classList.add('click');
if (type === 'bang') {
this.fireRevolver();
// Sharp horizontal jolt on the whole dashboard — gives the bang impact.
const db = document.getElementById('lt-dashboard');
if (db) {
db.classList.remove('lt-shake'); void db.offsetWidth; db.classList.add('lt-shake');
setTimeout(() => db.classList.remove('lt-shake'), 450);
}
}
}
// Muzzle flash + recoil (called on bang sound OR on game-end fallback)
fireRevolver() {
const rev = document.getElementById('lt-revolver');
if (rev) { rev.classList.remove('fire'); void rev.offsetWidth; rev.classList.add('fire'); }
const flash = document.getElementById('lt-rev-flash');
if (flash) { flash.classList.remove('flash'); void flash.offsetWidth; flash.classList.add('flash'); }
}
// The native LEAVE button — lives in leaveWrap, but sometimes Torn renders
// it inside buttonsWrap. Find it wherever it is so we forward to ONE button.
_findNativeLeave() {
if (!this.hiddenApp) return null;
return this.hiddenApp.querySelector('[class*="leaveWrap"] button')
|| [...this.hiddenApp.querySelectorAll('[class*="buttonsWrap"] button')]
.find(b => /leave/i.test(b.textContent || ''));
}
_findLoserXid(winnerId) {
if (!this.hiddenApp) return null;
const cards = this.hiddenApp.querySelectorAll('[class*="userCardBlock"]');
for (const card of cards) {
const link = card.querySelector('a[href*="XID"]');
const m = link && link.href.match(/XID=(\d+)/);
if (m && parseInt(m[1]) !== winnerId) return m[1];
}
return null;
}
syncGameState() {
// Game-end detection: once a winner is known → fire revolver + red cross on loser.
// (Fallback for the bang sound, which can't always be distinguished via Web Audio.)
const integ = window.LabTrackIntegration;
const winnerId = integ && integ.lastSeenWinnerId;
if (winnerId && this._lastHandledWinner !== winnerId) {
this._lastHandledWinner = winnerId;
this.fireRevolver();
const loser = this._findLoserXid(winnerId);
if (loser) this._deadXid = loser;
}
const text = (el) => (el?.textContent || '').trim();
const setIf = (id, val) => { const el = document.getElementById(id); if (el && el.textContent !== val) el.textContent = val; };
// Timer — urgent pulse under 10s, critical pulse under 5s
const timerEl = document.getElementById('lt-igs-timer');
const timerText = text(this.hiddenApp.querySelector('[class*="timer"] [class*="counter"]')) || '--:--';
setIf('lt-igs-timer', timerText);
if (timerEl) {
const m = timerText.match(/(\d+):(\d+)/);
const totalSec = m ? (parseInt(m[1]) * 60 + parseInt(m[2])) : -1;
timerEl.classList.toggle('critical', totalSec >= 0 && totalSec < 5);
timerEl.classList.toggle('urgent', totalSec >= 5 && totalSec < 10);
}
// Pot — flash gold + scale on every increase
const potEl = document.getElementById('lt-igs-pot');
const potText = text(this.hiddenApp.querySelector('[class*="potWrap"] [class*="count"]')) || '$0';
if (potEl && potEl.textContent !== potText) {
const prev = parseInt((potEl.textContent || '').replace(/[^\d]/g, '')) || 0;
const curr = parseInt(potText.replace(/[^\d]/g, '')) || 0;
potEl.textContent = potText;
if (curr > prev && prev > 0) {
potEl.classList.remove('lt-pot-flash'); void potEl.offsetWidth;
potEl.classList.add('lt-pot-flash');
}
}
setIf('lt-igs-msg', text(this.hiddenApp.querySelector('[class*="messageWrap"] [class*="message"]')) || '');
const soundEl = this.hiddenApp.querySelector('[class*="soundToggler"]');
const ourSound = document.getElementById('lt-igs-sound');
if (soundEl && ourSound) ourSound.textContent = /on/i.test(soundEl.textContent) ? '🔊 Sound ON' : '🔇 Sound OFF';
// LEAVE only shows when Torn actually offers it (waiting / after death) —
// never during the active fight, where leaving isn't possible.
const nativeLeave = this._findNativeLeave();
const ourLeave = document.getElementById('lt-igs-leave');
if (ourLeave) ourLeave.style.display = nativeLeave ? '' : 'none';
// Action buttons in buttonsWrap: shoot/x2/x3 (commitButton, your turn) OR
// "take action" (no commitButton, appears when opponent is AFK).
const shotsEl = document.getElementById('lt-igs-shots');
if (shotsEl) {
// Exclude the LEAVE button — it lives in buttonsWrap too, but we render
// it separately as our dedicated #lt-igs-leave. (Avoids a 2nd leave button.)
const nativeBtns = [...this.hiddenApp.querySelectorAll('[class*="buttonsWrap"] button')]
.filter(b => !/leave/i.test(b.textContent || ''));
const sig = nativeBtns.map(b => `${b.dataset.id || ''}:${b.textContent.trim()}`).join('|');
if (shotsEl.dataset.sig !== sig) {
shotsEl.dataset.sig = sig;
shotsEl.innerHTML = '';
nativeBtns.forEach(nb => {
const txt = nb.textContent.trim();
const isCommit = /commitButton/i.test(nb.className);
const b = document.createElement('button');
b.type = 'button';
b.textContent = txt;
if (isCommit) {
const id = nb.dataset.id;
b.className = 'lt-shot-btn ' + (id === '1' ? 'primary' : 'mult');
const originalTxt = txt;
b.dataset.origTxt = originalTxt;
// Restore armed visual after a sync-rebuild (state lives on
// the controller so it survives DOM wipes).
if (this._armedShot && this._armedShot.id === id && Date.now() - this._armedShot.at < 3000) {
b.classList.add('armed');
b.textContent = `⚠ Confirm ${originalTxt}?`;
}
b.onclick = () => {
const fireNative = () => {
const fresh = this.hiddenApp.querySelector(`button[class*="commitButton"][data-id="${id}"]`);
if (fresh) this.fireFullClick(fresh);
};
// Single shot fires immediately. Multi-shots (x2/x3) need
// a second click within 3 s to confirm — prevents the
// accidental double/triple shots that can blow up a sequence.
if (id === '1') { fireNative(); return; }
const now = Date.now();
const armedFor = this._armedShot && this._armedShot.id === id && (now - this._armedShot.at < 3000);
if (armedFor) {
this._armedShot = null;
clearTimeout(this._shotArmTimer);
b.classList.remove('armed');
b.textContent = originalTxt;
fireNative();
return;
}
this._armedShot = { id, at: now };
// Clear any previously-armed sibling visuals.
shotsEl.querySelectorAll('.lt-shot-btn.armed').forEach(el => {
el.classList.remove('armed');
if (el.dataset.origTxt) el.textContent = el.dataset.origTxt;
});
b.classList.add('armed');
b.textContent = `⚠ Confirm ${originalTxt}?`;
clearTimeout(this._shotArmTimer);
this._shotArmTimer = setTimeout(() => {
this._armedShot = null;
const live = document.querySelectorAll('#lt-igs-shots .lt-shot-btn.armed');
live.forEach(el => {
el.classList.remove('armed');
if (el.dataset.origTxt) el.textContent = el.dataset.origTxt;
});
}, 3000);
};
} else {
// "take action" (claim win vs AFK opponent) or other non-commit button
b.className = 'lt-shot-btn take-action';
b.onclick = () => {
const fresh = [...this.hiddenApp.querySelectorAll('[class*="buttonsWrap"] button')]
.find(x => !/commitButton/i.test(x.className) && x.textContent.trim().toLowerCase() === txt.toLowerCase());
if (fresh) this.fireFullClick(fresh);
};
}
shotsEl.appendChild(b);
});
}
}
const players = document.getElementById('lt-igs-players');
if (!players) return;
const cards = this.hiddenApp.querySelectorAll('[class*="userCardBlock"]');
// Only rebuild structure when the count changes (no flicker)
if (players.dataset.cardCount !== String(cards.length)) {
players.innerHTML = '';
players.dataset.cardCount = String(cards.length);
cards.forEach((card, idx) => {
if (idx > 0) { const vs = document.createElement('div'); vs.className = 'lt-vs'; vs.textContent = 'VS'; players.appendChild(vs); }
const wrap = document.createElement('div');
wrap.className = 'lt-player';
wrap.innerHTML = `<div class="lt-player-avatar-slot"></div><div class="lt-player-name"></div>`;
players.appendChild(wrap);
});
}
// Update content (name + avatar) on EVERY sync → image appears as soon as it loads
const playerEls = players.querySelectorAll('.lt-player');
cards.forEach((card, idx) => {
const pl = playerEls[idx];
if (!pl) return;
const link = card.querySelector('a');
const rawName = link?.textContent.replace(/^User name:\s*/i, '').trim() || 'Unknown';
const isWaiting = /waiting/i.test(rawName);
pl.classList.toggle('waiting', isWaiting);
// Player XID (for death-cross matching + clickable profile link)
const xidLink = card.querySelector('a[href*="XID"]');
const xidM = xidLink && xidLink.href.match(/XID=(\d+)/);
const xid = xidM ? xidM[1] : '';
pl.dataset.xid = xid;
// Name → clickable profile link (like original RR), or plain text if no XID
const nameEl = pl.querySelector('.lt-player-name');
if (nameEl) {
nameEl.classList.toggle('waiting', isWaiting);
if (xid && !isWaiting) {
const desired = `<a href="/profiles.php?XID=${xid}" target="_blank" rel="noreferrer" class="lt-player-link">${rawName}</a>`;
if (nameEl.innerHTML !== desired) nameEl.innerHTML = desired;
} else if (nameEl.textContent !== rawName) {
nameEl.textContent = rawName;
}
}
const slot = pl.querySelector('.lt-player-avatar-slot');
if (slot) {
const img = card.querySelector('img[class*="userImage"]') || card.querySelector('img');
const realSrc = (img && img.src && !img.src.startsWith('data:')) ? img.src : null;
if (realSrc) {
let our = slot.querySelector('img.lt-player-avatar');
if (!our) { slot.innerHTML = '<img class="lt-player-avatar" alt="">'; our = slot.querySelector('img.lt-player-avatar'); }
if (our.src !== realSrc) our.src = realSrc;
} else if (!slot.querySelector('.lt-player-avatar-ph')) {
slot.innerHTML = '<div class="lt-player-avatar-ph">?</div>';
}
}
// Death cross overlay (red ✕) on the player who lost
const isDead = this._deadXid && xid === this._deadXid;
pl.classList.toggle('dead', !!isDead);
let cross = pl.querySelector('.lt-player-cross');
if (isDead && !cross) { cross = document.createElement('div'); cross.className = 'lt-player-cross'; pl.appendChild(cross); }
else if (!isDead && cross) cross.remove();
});
}
// ── CONFIRM (game create/join) ──
renderConfirm(body) {
body.innerHTML = `
<div class="lt-confirm">
<div class="lt-confirm-text" id="lt-confirm-text">Confirm?</div>
<div class="lt-confirm-btns">
<button class="lt-confirm-yes" id="lt-confirm-yes">Yes</button>
<button class="lt-confirm-no" id="lt-confirm-no">No</button>
</div>
</div>`;
document.getElementById('lt-confirm-yes').onclick = () => {
const btn = this.hiddenApp.querySelector('[class*="confirmWrap"] button[data-type="confirm"]');
if (btn) this.fireFullClick(btn);
};
document.getElementById('lt-confirm-no').onclick = () => {
const btn = this.hiddenApp.querySelector('[class*="confirmWrap"] button[data-type="cancel"]');
if (btn) this.fireFullClick(btn);
};
}
syncConfirmState() {
const txtEl = document.getElementById('lt-confirm-text');
const nativeText = this.hiddenApp.querySelector('[class*="confirmWrap"] [class*="text"]');
if (txtEl && nativeText && txtEl.innerHTML !== nativeText.innerHTML) txtEl.innerHTML = nativeText.innerHTML;
// QUICK CREATE: auto-click YES once per confirm dialog. Safety gates:
// 1) blocked while currently hospitalized
// 2) blocked if the dialog was *opened* while hospitalized — even if the
// user heals out later, this specific dialog stays manual-only. (Auto-
// clicking on heal-out would look like a "play on condition" bot.)
// 3) blocked if the bet in the dialog doesn't match our current next-bet
// (typo / stale value / wrong native input → no silent wrong-stake game)
if (this._quickCreate && !this.isHospitalized() && !this._confirmStartedHospitalized) {
const yesBtn = this.hiddenApp.querySelector('[class*="confirmWrap"] button[data-type="confirm"]');
if (yesBtn && Date.now() - (this._lastQuickConfirm || 0) > 800) {
// Parse the bet amount out of the dialog text ("create a game for $5,000,000")
const dlgTxt = (nativeText && nativeText.textContent) || '';
const m = dlgTxt.match(/\$\s*([\d,]+)/);
const dlgBet = m ? parseInt(m[1].replace(/,/g, '')) : null;
const expected = Math.floor(engine.getEffectiveBet() || 0);
if (dlgBet === null || expected <= 0 || dlgBet !== expected) {
// Mismatch / can't verify → leave the dialog open for the user to decide.
// Throttle the warning toast so it doesn't spam on every poll tick.
if (Date.now() - (this._lastQuickMismatchToast || 0) > 2000) {
this._lastQuickMismatchToast = Date.now();
Utils.showToast(dlgBet !== null
? `Quick Create blocked — dialog ${Utils.formatNumber(dlgBet)} ≠ Next ${Utils.formatNumber(expected)}`
: `Quick Create blocked — couldn't read bet from dialog`);
}
} else {
this._lastQuickConfirm = Date.now();
this.fireFullClick(yesBtn);
}
}
}
}
// Hospital status: the active indicator has aria-label "Hospital: <reason>" (with colon).
// The channel button only has title="Hospital" (no colon) → not matched.
isHospitalized() {
return !!document.querySelector('[aria-label^="Hospital:" i]');
}
// ── LOBBY ──
renderLobby(body) {
const qc = this._quickCreate ? ' on' : '';
body.innerHTML = `
<div class="lt-lobby">
<div class="lt-ingame-banner" id="lt-ingame-banner" style="display:none;"></div>
<div class="lt-lobby-section">
<div class="lt-lobby-section-title">
<span><span class="lt-glyph-chamber"></span> Start a new game</span>
<button id="lt-quick-create" class="lt-quick-toggle${qc}" type="button" title="When ON, the confirmation dialog after Start is clicked automatically (blocked while hospitalized)">⚡ Quick Create: <b>${this._quickCreate ? 'ON' : 'OFF'}</b></button>
</div>
<div class="lt-start-form">
<span class="lt-start-label">Bet:</span>
<input type="text" class="lt-start-input" id="lt-lobby-bet" placeholder="Amount">
<button class="lt-start-prefill" id="lt-lobby-prefill" title="Insert next bet">📋 Next Bet</button>
<span class="lt-start-label">🔒</span>
<input type="text" class="lt-start-input" id="lt-lobby-pw" placeholder="Password (optional)" style="width:150px;">
<button class="lt-start-go" id="lt-lobby-start">▶ Start</button>
</div>
</div>
<div class="lt-lobby-section">
<div class="lt-coll" id="lt-coll-games">
<div class="lt-coll-head" id="lt-coll-toggle">
<span class="lt-coll-title">⚔ Available Games <span class="lt-coll-count" id="lt-coll-count">(0)</span></span>
<span class="lt-coll-icon">▼</span>
</div>
<div class="lt-coll-body" id="lt-coll-body"><div class="lt-coll-empty">Loading…</div></div>
</div>
</div>
</div>`;
document.getElementById('lt-coll-toggle').onclick = () => {
document.getElementById('lt-coll-games').classList.toggle('collapsed');
};
const qcBtn = document.getElementById('lt-quick-create');
if (qcBtn) qcBtn.onclick = () => this.toggleQuickCreate();
const ourBet = document.getElementById('lt-lobby-bet');
const ourPw = document.getElementById('lt-lobby-pw');
// Pre-fill with the current next-bet (still freely editable). Native bet
// wins if it's already set (e.g. you typed in Torn's own field first).
const nbv = this.getNativeBet();
const nextBet = Math.floor(engine.getEffectiveBet() || 0);
const initial = nbv || (nextBet > 0 ? String(nextBet) : '');
if (initial) { ourBet.value = initial; this.writeNativeBet(initial); }
this._lastAutoBet = initial; // remembered so syncLobbyState can refresh untouched values
ourBet.addEventListener('input', () => this.writeNativeBet(ourBet.value.replace(/[^0-9]/g, '')));
ourPw.addEventListener('input', () => this.writeNativePassword(ourPw.value));
document.getElementById('lt-lobby-prefill').onclick = () => {
const amt = engine.getEffectiveBet();
if (amt > 0) { ourBet.value = String(Math.floor(amt)); this.writeNativeBet(String(Math.floor(amt))); }
};
document.getElementById('lt-lobby-start').onclick = () => {
const startBtn = this.findNativeStartButton();
if (!startBtn) { Logger.warn('GameUI', 'START button not found'); Utils.showToast('Start button not found'); return; }
const betStr = ourBet.value.replace(/[^0-9]/g, '');
// SAFETY (Quick Create only): with auto-confirm enabled the dialog
// is skipped, so a typo or stale value would silently create a wrong
// -stake game. Refuse to start unless the bet exactly matches the
// current next-bet. (Normal mode still leaves the dialog as a manual
// safety net.)
if (this._quickCreate) {
const expected = Math.floor(engine.getEffectiveBet() || 0);
const typed = parseInt(betStr) || 0;
if (expected <= 0) { Utils.showToast('No next bet — generate a sequence first'); return; }
if (typed !== expected) {
Utils.showToast(`Bet ${Utils.formatNumber(typed)} ≠ Next ${Utils.formatNumber(expected)} — Quick Create blocked`);
ourBet.classList.add('error'); setTimeout(() => ourBet.classList.remove('error'), 1500);
return;
}
}
if (betStr) { this._lastBetSet = null; this.writeNativeBet(betStr); }
this.writeNativePassword(ourPw.value); // sync password (empty = no password)
setTimeout(() => this.fireFullClick(startBtn), 60);
};
}
syncLobbyState() {
this.syncInGameBanner(); // "Return to Game" banner if a game of ours is waiting
// Auto-refresh the bet field with the current next-bet, but only if the
// user hasn't typed a custom value. We detect "untouched" by comparing
// the field value to the last value we auto-wrote — if it still matches,
// it's safe to update (e.g. after a win/loss changes the next bet).
const betEl = document.getElementById('lt-lobby-bet');
if (betEl && (betEl.value === '' || betEl.value === this._lastAutoBet)) {
const next = Math.floor(engine.getEffectiveBet() || 0);
if (next > 0) {
const str = String(next);
if (betEl.value !== str) { betEl.value = str; this.writeNativeBet(str); }
this._lastAutoBet = str;
}
}
const body = document.getElementById('lt-coll-body');
const count = document.getElementById('lt-coll-count');
if (!body || !count) return;
const rows = document.querySelectorAll('div[class*="rowsWrap"] > div[class*="row"]');
const games = [];
for (const row of rows) {
const betBlock = row.querySelector('[class*="betBlock"]');
let bet = 0;
const aria = betBlock?.getAttribute('aria-label') || '';
const am = aria.match(/([\d,]+)/);
if (am) bet = parseInt(am[1].replace(/,/g, '')) || 0;
else if (betBlock) bet = parseInt(betBlock.textContent.replace(/[^\d]/g, '')) || 0;
if (bet <= 0) continue;
const nameLink = row.querySelector('a[aria-label*="View profile" i], a[href*="XID"]');
const name = nameLink?.getAttribute('aria-label')?.replace(/^View profile of\s*/i, '').trim()
|| row.querySelector('.honor-text:not(.honor-text-svg)')?.textContent.trim() || 'Player';
const xidM = nameLink && (nameLink.href || '').match(/XID=(\d+)/);
const xid = xidM ? xidM[1] : '';
const joinBtn = row.querySelector('[class*="joinBlock"] button');
const locked = !!joinBtn && (joinBtn.disabled || /locked/i.test(joinBtn.className));
const hasPassword = !!row.querySelector('[class*="withPassword"]');
games.push({ bet, name, xid, joinBtn, hasPassword, locked });
}
count.textContent = `(${games.length})`;
// RESERVED SLOTS — the Available Games list always reserves space for
// 10 rows. Real games fill from the top; empty slots sit beneath so the
// panel stays the same size whether 0 or 10+ games exist, and the
// dashboard never shifts when games come/go.
const SLOTS = 10;
// Game "Bet Amount" = stake per player → compare directly with our NEXT BET
const target = engine.getEffectiveBet();
const tolerance = Math.max(20, target * 0.05);
const closeRange = Math.max(target * 0.20, 1000);
// Three-tier sort: matching bets first, then close-to-bet, then the rest.
// Within each tier sort by absolute distance to our next bet (or by bet
// size when we have no target yet).
const tier = (bet) => {
if (target <= 0) return 2;
const diff = Math.abs(bet - target);
if (diff <= tolerance) return 0; // match
if (diff <= closeRange) return 1; // close
return 2; // far
};
games.sort((a, b) => {
const ta = tier(a.bet), tb = tier(b.bet);
if (ta !== tb) return ta - tb;
if (target > 0) return Math.abs(a.bet - target) - Math.abs(b.bet - target);
return a.bet - b.bet;
});
body.innerHTML = '';
for (const g of games) {
const diff = Math.abs(g.bet - target);
let mc = '', bc = '';
if (target > 0) { if (diff <= tolerance) { mc = 'match'; bc = 'match-bet'; } else if (diff <= closeRange) mc = 'close'; }
const row = document.createElement('div');
row.className = 'lt-game-row ' + mc;
const nameHtml = g.xid
? `<a href="/profiles.php?XID=${g.xid}" target="_blank" rel="noreferrer" class="lt-player-link">${g.name}</a>`
: g.name;
row.innerHTML = `
<span class="lt-game-row-status"></span>
<span class="lt-game-row-name">${nameHtml}${g.hasPassword ? ' 🔒' : ''}</span>
<span class="lt-game-row-bet ${bc}">$${g.bet.toLocaleString('de')}</span>`;
const jb = document.createElement('button');
jb.className = 'lt-game-row-join'; jb.type = 'button';
if (g.locked) {
jb.disabled = true; jb.textContent = '🔒';
jb.title = "Can't join — you're already in a game";
} else {
jb.textContent = mc === 'match' ? '🎯 JOIN' : 'JOIN';
jb.onclick = () => {
if (!g.joinBtn) { Utils.showToast('Join button not found'); return; }
// The joined game's stake = our stake this round.
// If it differs from the expected NEXT BET → set as custom bet (like when creating).
const expected = engine.getEffectiveBet();
if (g.bet > 0 && Math.abs(g.bet - expected) > 1) {
engine.setCustomBet(g.bet);
Utils.showToast(`Custom Bet (Join): ${Utils.formatNumber(g.bet)}`);
}
this.fireFullClick(g.joinBtn);
};
}
row.appendChild(jb);
body.appendChild(row);
}
// Pad with empty rows so the list height is constant (no dashboard jumping)
const empties = Math.max(0, SLOTS - games.length);
for (let i = 0; i < empties; i++) {
const row = document.createElement('div');
row.className = 'lt-game-row lt-game-row-empty';
row.innerHTML = `<span class="lt-game-row-empty-line">— slot ${games.length + i + 1} —</span>`;
body.appendChild(row);
}
}
// "Return to Game" / "Leave Game" banner — shown when browsing the lobby while
// one of your own games is still waiting (Torn shows a red alert with these).
syncInGameBanner() {
const banner = document.getElementById('lt-ingame-banner');
if (!banner) return;
const infoBox = this.hiddenApp.querySelector('[class*="infoBox"]');
const active = infoBox && /return to game/i.test(infoBox.textContent || '');
if (!active) {
if (banner.dataset.active === '1') { banner.dataset.active = '0'; banner.style.display = 'none'; banner.innerHTML = ''; }
return;
}
if (banner.dataset.active !== '1') {
banner.dataset.active = '1';
banner.style.display = 'flex';
banner.innerHTML = `
<span class="lt-ingame-msg" id="lt-ingame-msg"></span>
<div class="lt-ingame-btns">
<button class="lt-ingame-return" id="lt-ingame-return">↩ Return to Game</button>
<button class="lt-ingame-leave" id="lt-ingame-leave">Leave Game</button>
</div>`;
document.getElementById('lt-ingame-return').onclick = () => {
const box = this.hiddenApp.querySelector('[class*="infoBox"]');
const rl = box && [...box.querySelectorAll('a')].find(a => /return to game/i.test(a.textContent || ''));
if (rl) this.fireFullClick(rl);
};
document.getElementById('lt-ingame-leave').onclick = () => {
const box = this.hiddenApp.querySelector('[class*="infoBox"]');
const lb = box && [...box.querySelectorAll('button')].find(b => /leave game/i.test(b.textContent || ''));
if (lb) this.fireFullClick(lb);
};
}
// Live timeout message (keeps the countdown updated)
const msgEl = document.getElementById('lt-ingame-msg');
const span = infoBox.querySelector('p span');
if (msgEl && span && msgEl.innerHTML !== span.innerHTML) msgEl.innerHTML = span.innerHTML;
}
// ── Helpers ──
getNativeBet() {
const inp = this.hiddenApp?.querySelector('input.input-money[type="text"]')
|| document.querySelector('input.input-money[type="text"]:not(.lt-start-input)');
return inp ? (inp.value || '').replace(/[^0-9]/g, '') : '';
}
writeNativeBet(value) {
if (value === this._lastBetSet) return;
this._lastBetSet = value;
const inp = this.hiddenApp?.querySelector('input.input-money[type="text"]')
|| document.querySelector('input.input-money[type="text"]:not(.lt-start-input)');
if (!inp) return;
const tracker = inp._valueTracker;
if (tracker) tracker.setValue(String(parseInt(inp.value) || 0));
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set.call(inp, value);
inp._ltSyntheticBetWrite = Date.now();
inp.dispatchEvent(new Event('input', { bubbles: true }));
inp.dispatchEvent(new Event('change', { bubbles: true }));
}
// Write the optional password into Torn's native RR password input (React-friendly)
writeNativePassword(value) {
const inp = this.hiddenApp?.querySelector('input[class*="password"], input[aria-label*="password" i]');
if (!inp || inp === document.getElementById('lt-lobby-pw')) return;
if (inp.value === value) return;
const tracker = inp._valueTracker;
if (tracker) tracker.setValue(inp.value);
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set.call(inp, value);
inp.dispatchEvent(new Event('input', { bubbles: true }));
inp.dispatchEvent(new Event('change', { bubbles: true }));
}
findNativeStartButton() {
const precise = document.querySelector('[class*="createWrap"] [class*="startBlock"] button[class*="submit"], [class*="createWrap"] button.torn-btn');
if (precise) return precise;
const matches = (el) => { const t = (el.value || el.textContent || '').trim().toUpperCase(); return t === 'START' || t === 'START GAME'; };
for (const el of document.querySelectorAll('button, input[type="button"], input[type="submit"]')) {
if (el.closest('#lt-dashboard, #lt-app')) continue;
if (matches(el)) return el;
}
return null;
}
fireFullClick(el) {
const opts = { bubbles: true, cancelable: true, composed: true, view: window };
const ptr = { ...opts, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0 };
// preventScroll: otherwise the browser scrolls to the off-screen element (left:-99999px) → jump to top
try { el.focus({ preventScroll: true }); } catch (e) {}
try {
el.dispatchEvent(new PointerEvent('pointerover', ptr));
el.dispatchEvent(new PointerEvent('pointerenter', ptr));
el.dispatchEvent(new PointerEvent('pointerdown', ptr));
el.dispatchEvent(new MouseEvent('mousedown', { ...opts, button: 0 }));
el.dispatchEvent(new PointerEvent('pointerup', ptr));
el.dispatchEvent(new MouseEvent('mouseup', { ...opts, button: 0 }));
} catch (e) {}
el.click();
}
startObserver() {
if (this.observer) this.observer.disconnect();
if (!this.hiddenApp) return;
this.observer = new MutationObserver(() => this.sync());
this.observer.observe(this.hiddenApp, { subtree: true, childList: true, characterData: true, attributes: true });
}
}
// =============================================================================
// INITIALIZATION - V7.00
// =============================================================================
Logger.info('INIT', `LabTrack Enhanced v${CONFIG.VERSION} starting...`);
Logger.info('INIT', 'Enhancements: CONFIG constants, Logger, Validator, Performance utils, Error handling');
const integration = new TornIntegration();
const waitForBody = () => {
if (!document.body) {
setTimeout(waitForBody, CONFIG.DOM_DELAY_MS);
} else {
const ui = new OverlayUI();
ui.init();
integration.setUI(ui);
integration.start();
Logger.info('INIT', 'LabTrack Enhanced initialization complete ✓');
}
};
waitForBody();
})();