Sidebar widget that monitors your faction's chain — alerts on chain start, timeout warnings, and upcoming bonus milestones.
// ==UserScript==
// @name Torn Chain Watcher
// @namespace torn_chain_watcher
// @version 1.1.4
// @description Sidebar widget that monitors your faction's chain — alerts on chain start, timeout warnings, and upcoming bonus milestones.
// @author TheOddSod (2640064)
// @match https://www.torn.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// ==/UserScript==
(function () {
'use strict';
// ─── Duplicate injection guard ───────────────────────────────────────────────
if (window._tcwLoaded) return;
window._tcwLoaded = true;
// ─── Constants ───────────────────────────────────────────────────────────────
const NS = 'tcw';
const API_BASE = 'https://api.torn.com/v2';
const COMMENT = 'TornChainWatcher';
// Chain bonus milestones (standard Torn chain bonuses)
const BONUS_MILESTONES = [10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
// Polling intervals (ms)
const POLL_ACTIVE = 10_000; // 10s during active chain
const POLL_IDLE = 10_000; // 10s when idle — fast enough to catch chain starts
// ─── Changelog ───────────────────────────────────────────────────────────────
// v1.1.4 — Update API key requirement to Limited access (attacks endpoint
// requires more than Minimal).
// v1.1.3 — Fix last hit: remove faction ID derivation entirely.
// v1.1.2 — Simplify last hit: first outgoing faction attack in descending list.
// v1.1.1 — Fix last hit showing pre-chain attacks: restore chain.start filter.
// v1.1.0 — Fix last hit blank: derive faction ID by matching logged-in player.
// v1.0.9 — Simplify last hit logic: most recent outgoing faction attack.
// v1.0.8 — Fix last hit showing stale attacker from previous chain.
// v1.0.7 — Show last attacker name during active chain ("Last hit: Name").
// v1.0.6 — Perfect countdown: derive remaining time from API `end` timestamp.
// v1.0.5 — Fix countdown drift: subtract elapsed time since API response.
// v1.0.4 — Fix GM_xmlhttpRequest casing (capital H broke Tampermonkey grant).
// v1.0.3 — Idle poll 10s; visibilitychange re-poll on tab focus.
// v1.0.2 — Pulse animation on header when any chain is active (current > 0).
// Chain start notification threshold lowered from 10 to 1.
// v1.0.1 — Fix header cramping, checkbox rendering (Torn CSS override), volume
// slider width, redundant no-key body message.
// v1.0.0 — Initial release. Sidebar widget with chain tracking, timeout
// warnings, bonus milestone display, visual/browser/sound alerts,
// configurable thresholds, full theme support.
// ─── Theme system (mirrors OCM design system) ────────────────────────────────
const THEMES = {
default: {
'--ocm-bg-deep':'#0f1a30','--ocm-bg-dark':'#111827','--ocm-bg-base':'#16213e',
'--ocm-bg-card':'#1a1a2e','--ocm-bg-header':'#1a1a2e','--ocm-bg-hover':'#1e1e36',
'--ocm-bg-input':'#0f3460','--ocm-bg-dropdown':'#0f1a30','--ocm-bg-row':'#111827',
'--ocm-border-faint':'#111','--ocm-border-card':'#2a2a4a','--ocm-border-strip':'#1a2a4a',
'--ocm-border-input':'#2a4a7a','--ocm-border-accent':'#e05a00','--ocm-border-section':'#333',
'--ocm-accent':'#ff7700','--ocm-accent-hover':'#e05a00','--ocm-accent-dim':'#cc5500',
'--ocm-text-primary':'#e0e0e0','--ocm-text-card':'#ccc','--ocm-text-secondary':'#aaa',
'--ocm-text-label':'#888','--ocm-text-muted':'#666','--ocm-text-disabled':'#555','--ocm-text-dead':'#444',
'--ocm-status-ok':'#44ee88','--ocm-status-warn':'#ffaa00','--ocm-status-crit':'#ff4444',
'--ocm-status-ok-bg':'#003322','--ocm-status-warn-bg':'#2a1a00','--ocm-status-crit-bg':'#330a00',
'--ocm-status-ok-border':'#006644','--ocm-status-warn-border':'#664400','--ocm-status-crit-border':'#882200',
'--ocm-font-scale':'1',
},
torn: {
'--ocm-bg-deep':'#111','--ocm-bg-dark':'#1a1a1a','--ocm-bg-base':'#222',
'--ocm-bg-card':'#1c1c1c','--ocm-bg-header':'#1c1c1c','--ocm-bg-hover':'#2a2a2a',
'--ocm-bg-input':'#2a2a2a','--ocm-bg-dropdown':'#111','--ocm-bg-row':'#1a1a1a',
'--ocm-border-faint':'#2a2a2a','--ocm-border-card':'#3a3a3a','--ocm-border-strip':'#333',
'--ocm-border-input':'#555','--ocm-border-accent':'#c03020','--ocm-border-section':'#444',
'--ocm-accent':'#e04030','--ocm-accent-hover':'#c03020','--ocm-accent-dim':'#aa2010',
'--ocm-text-primary':'#ddd','--ocm-text-card':'#ccc','--ocm-text-secondary':'#aaa',
'--ocm-text-label':'#888','--ocm-text-muted':'#666','--ocm-text-disabled':'#555','--ocm-text-dead':'#333',
'--ocm-status-ok':'#44ee88','--ocm-status-warn':'#ffaa00','--ocm-status-crit':'#ff4444',
'--ocm-status-ok-bg':'#003322','--ocm-status-warn-bg':'#2a1a00','--ocm-status-crit-bg':'#330a00',
'--ocm-status-ok-border':'#006644','--ocm-status-warn-border':'#664400','--ocm-status-crit-border':'#882200',
'--ocm-font-scale':'1',
},
highcontrast: {
'--ocm-bg-deep':'#000','--ocm-bg-dark':'#000','--ocm-bg-base':'#000',
'--ocm-bg-card':'#0a0a0a','--ocm-bg-header':'#000','--ocm-bg-hover':'#1a1a1a',
'--ocm-bg-input':'#111','--ocm-bg-dropdown':'#000','--ocm-bg-row':'#000',
'--ocm-border-faint':'#444','--ocm-border-card':'#fff','--ocm-border-strip':'#888',
'--ocm-border-input':'#fff','--ocm-border-accent':'#ffff00','--ocm-border-section':'#888',
'--ocm-accent':'#ffff00','--ocm-accent-hover':'#ffee00','--ocm-accent-dim':'#cccc00',
'--ocm-text-primary':'#fff','--ocm-text-card':'#fff','--ocm-text-secondary':'#eee',
'--ocm-text-label':'#ddd','--ocm-text-muted':'#bbb','--ocm-text-disabled':'#888','--ocm-text-dead':'#666',
'--ocm-status-ok':'#00ff88','--ocm-status-warn':'#ffdd00','--ocm-status-crit':'#ff4444',
'--ocm-status-ok-bg':'#003318','--ocm-status-warn-bg':'#332200','--ocm-status-crit-bg':'#330000',
'--ocm-status-ok-border':'#00ff88','--ocm-status-warn-border':'#ffdd00','--ocm-status-crit-border':'#ff4444',
'--ocm-font-scale':'1',
},
lowvision: {
'--ocm-bg-deep':'#080d18','--ocm-bg-dark':'#0a1020','--ocm-bg-base':'#0d1628',
'--ocm-bg-card':'#111828','--ocm-bg-header':'#111828','--ocm-bg-hover':'#161c30',
'--ocm-bg-input':'#0a2a50','--ocm-bg-dropdown':'#080d18','--ocm-bg-row':'#0a1020',
'--ocm-border-faint':'#333','--ocm-border-card':'#4a4a7a','--ocm-border-strip':'#2a3a6a',
'--ocm-border-input':'#4a6a9a','--ocm-border-accent':'#ff8800','--ocm-border-section':'#555',
'--ocm-accent':'#ff9900','--ocm-accent-hover':'#ff7700','--ocm-accent-dim':'#dd6600',
'--ocm-text-primary':'#ffffff','--ocm-text-card':'#eee','--ocm-text-secondary':'#ccc',
'--ocm-text-label':'#aaa','--ocm-text-muted':'#888','--ocm-text-disabled':'#666','--ocm-text-dead':'#555',
'--ocm-status-ok':'#66ffaa','--ocm-status-warn':'#ffcc00','--ocm-status-crit':'#ff5555',
'--ocm-status-ok-bg':'#003322','--ocm-status-warn-bg':'#332a00','--ocm-status-crit-bg':'#330a00',
'--ocm-status-ok-border':'#33cc77','--ocm-status-warn-border':'#998800','--ocm-status-crit-border':'#cc2200',
'--ocm-font-scale':'1.1',
},
light: {
'--ocm-bg-deep':'#dde4f0','--ocm-bg-dark':'#e8eef8','--ocm-bg-base':'#eef2fa',
'--ocm-bg-card':'#f4f6fc','--ocm-bg-header':'#f4f6fc','--ocm-bg-hover':'#e8ecf8',
'--ocm-bg-input':'#dde4f0','--ocm-bg-dropdown':'#dde4f0','--ocm-bg-row':'#e8eef8',
'--ocm-border-faint':'#ccd4e8','--ocm-border-card':'#b8c4dc','--ocm-border-strip':'#c8d4e8',
'--ocm-border-input':'#9aaac8','--ocm-border-accent':'#cc5500','--ocm-border-section':'#b0bcd8',
'--ocm-accent':'#cc5500','--ocm-accent-hover':'#aa4400','--ocm-accent-dim':'#993300',
'--ocm-text-primary':'#1a1a2e','--ocm-text-card':'#222','--ocm-text-secondary':'#444',
'--ocm-text-label':'#666','--ocm-text-muted':'#777','--ocm-text-disabled':'#999','--ocm-text-dead':'#aaa',
'--ocm-status-ok':'#006622','--ocm-status-warn':'#885500','--ocm-status-crit':'#cc1111',
'--ocm-status-ok-bg':'#d4f0dd','--ocm-status-warn-bg':'#fff0cc','--ocm-status-crit-bg':'#ffe0dd',
'--ocm-status-ok-border':'#44aa66','--ocm-status-warn-border':'#cc8800','--ocm-status-crit-border':'#dd4444',
'--ocm-font-scale':'1',
},
};
// ─── Config helpers ───────────────────────────────────────────────────────────
function cfgGet(key, def) { return GM_getValue(`${NS}_${key}`, def); }
function cfgSet(key, val) { GM_setValue(`${NS}_${key}`, val); }
function loadConfig() {
return {
apiKey: cfgGet('api_key', ''),
theme: cfgGet('theme', 'default'),
warnSecs: cfgGet('warn_secs', 60), // seconds before timeout to warn
notifVisual: cfgGet('notif_visual', true), // show in-page banner
notifBrowser: cfgGet('notif_browser', true), // browser Notification API
soundEnabled: cfgGet('sound_enabled', true), // play beep alerts
soundVolume: cfgGet('sound_volume', 0.5), // 0–1
collapsed: cfgGet('collapsed', false),
};
}
// ─── Web Audio beep ───────────────────────────────────────────────────────────
/**
* Plays a short beep using the Web Audio API.
* @param {number} freq - Hz
* @param {number} dur - seconds
* @param {number} vol - 0–1
* @param {'sine'|'square'|'sawtooth'|'triangle'} type
*/
function beep(freq, dur, vol, type = 'sine') {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = type;
osc.frequency.value = freq;
gain.gain.value = vol;
osc.start();
osc.stop(ctx.currentTime + dur);
osc.onended = () => ctx.close();
} catch (e) {
// AudioContext not available — fail silently
}
}
/** Play chain-start jingle (ascending two-tone) */
function soundChainStart(vol) {
beep(440, 0.12, vol);
setTimeout(() => beep(660, 0.18, vol), 130);
}
/** Play timeout-warning alert (urgent triple beep) */
function soundWarn(vol) {
beep(880, 0.1, vol, 'square');
setTimeout(() => beep(880, 0.1, vol, 'square'), 160);
setTimeout(() => beep(880, 0.15, vol, 'square'), 320);
}
/** Play bonus milestone chime (happy ascending) */
function soundBonus(vol) {
beep(523, 0.1, vol);
setTimeout(() => beep(659, 0.1, vol), 110);
setTimeout(() => beep(784, 0.15, vol), 220);
}
// ─── Browser notifications ────────────────────────────────────────────────────
/** Request browser notification permission if needed. */
function requestNotifPermission() {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
}
/** Show a browser notification if permitted. */
function browserNotif(title, body, icon = '⚔') {
if (!('Notification' in window) || Notification.permission !== 'granted') return;
new Notification(title, { body, icon: 'https://www.torn.com/favicon.ico' });
}
// ─── API fetch (GM_xmlHttpRequest for external domain) ───────────────────────
/**
* Fetches a Torn API v2 endpoint.
* Always uses GM_xmlHttpRequest to avoid Torn's CSP blocking external fetches.
* @param {string} path - e.g. '/faction?selections=chain'
* @param {string} key - API key
* @returns {Promise<object>}
*/
function apiFetch(path, key) {
return new Promise((resolve, reject) => {
const sep = path.includes('?') ? '&' : '?';
const url = `${API_BASE}${path}${sep}key=${key}&comment=${COMMENT}`;
GM_xmlhttpRequest({
method: 'GET',
url,
onload(resp) {
try {
const data = JSON.parse(resp.responseText);
if (data.error) reject(new Error(`API ${data.error.code}: ${data.error.error}`));
else resolve(data);
} catch (e) {
reject(new Error('JSON parse error'));
}
},
onerror() { reject(new Error('Network error')); },
ontimeout() { reject(new Error('Timeout')); },
timeout: 10000,
});
});
}
// ─── Bonus milestone helpers ──────────────────────────────────────────────────
/**
* Returns the next bonus milestone above current hits.
* @param {number} current
* @returns {number|null}
*/
function nextBonus(current) {
return BONUS_MILESTONES.find(m => m > current) ?? null;
}
/**
* Returns the most recent bonus milestone at or below current hits.
* @param {number} current
* @returns {number|null}
*/
function prevBonus(current) {
const passed = BONUS_MILESTONES.filter(m => m <= current);
return passed.length ? passed[passed.length - 1] : null;
}
// ─── Format seconds as mm:ss ─────────────────────────────────────────────────
function fmtTime(s) {
if (s <= 0) return '00:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
// ─── CSS injection ────────────────────────────────────────────────────────────
GM_addStyle(`
/* ── Chain active pulse ──────────────────────────────────────────────────── */
@keyframes tcw-pulse {
0%, 100% { border-top-color: var(--ocm-border-accent); }
50% { border-top-color: var(--ocm-accent); box-shadow: 0 -1px 6px var(--ocm-accent); }
}
#tcw-root.tcw-chain-active {
animation: tcw-pulse 1.8s ease-in-out infinite;
}
/* ── Chain Watcher root ─────────────────────────────────────────────────── */
#tcw-root {
background: var(--ocm-bg-card);
border-top: 2px solid var(--ocm-border-accent);
border-bottom: 1px solid var(--ocm-border-card);
font-size: 11px;
font-family: Arial, sans-serif;
line-height: 1.5;
color: var(--ocm-text-primary);
}
/* ── Header bar ─────────────────────────────────────────────────────────── */
#tcw-header {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 8px;
cursor: pointer;
background: var(--ocm-bg-header);
border-bottom: 1px solid var(--ocm-border-strip);
user-select: none;
min-width: 0; /* allow flex children to shrink */
}
#tcw-header:hover { background: var(--ocm-bg-hover); }
#tcw-header-title {
color: var(--ocm-accent);
font-weight: bold;
font-size: 10px;
letter-spacing: .5px;
text-transform: uppercase;
flex-shrink: 0; /* never shrink the label */
}
#tcw-header-status {
flex: 1 1 0; /* grow to fill, shrink as needed */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10px;
color: var(--ocm-text-secondary);
}
#tcw-header-timer {
font-size: 10px;
font-weight: bold;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
min-width: 36px;
text-align: right;
}
#tcw-cfg-toggle {
flex-shrink: 0;
}
#tcw-collapse-btn {
color: var(--ocm-text-muted);
font-size: 10px;
flex-shrink: 0;
}
/* ── Body ───────────────────────────────────────────────────────────────── */
#tcw-body {
padding: 6px 8px;
}
/* ── Stat row ───────────────────────────────────────────────────────────── */
.tcw-stat-row {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 3px;
}
.tcw-stat-label {
font-size: 9px;
color: var(--ocm-text-muted);
text-transform: uppercase;
letter-spacing: .5px;
}
.tcw-stat-value {
font-size: 13px;
font-weight: bold;
color: var(--ocm-accent);
font-variant-numeric: tabular-nums;
}
.tcw-stat-value.ok { color: var(--ocm-status-ok); }
.tcw-stat-value.warn { color: var(--ocm-status-warn); }
.tcw-stat-value.crit { color: var(--ocm-status-crit); }
/* ── Progress bar ───────────────────────────────────────────────────────── */
.tcw-progress-wrap {
height: 4px;
background: var(--ocm-bg-deep);
border-radius: 3px;
overflow: hidden;
margin: 3px 0 6px;
}
.tcw-progress-fill {
height: 100%;
border-radius: 3px;
transition: width .4s ease, background-color .4s ease;
background: var(--ocm-status-ok);
}
.tcw-progress-fill.warn { background: var(--ocm-status-warn); }
.tcw-progress-fill.crit { background: var(--ocm-status-crit); }
/* ── Bonus row ──────────────────────────────────────────────────────────── */
#tcw-bonus-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
border-top: 1px solid var(--ocm-border-faint);
margin-top: 2px;
}
.tcw-bonus-label {
font-size: 9px;
color: var(--ocm-text-muted);
text-transform: uppercase;
letter-spacing: .5px;
}
.tcw-bonus-val {
font-size: 11px;
font-weight: bold;
color: var(--ocm-accent);
}
.tcw-bonus-val.none { color: var(--ocm-text-disabled); }
/* ── Modifier badge ─────────────────────────────────────────────────────── */
#tcw-modifier {
display: inline-block;
background: var(--ocm-bg-deep);
border: 1px solid var(--ocm-border-input);
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
color: var(--ocm-text-secondary);
}
/* ── Alert banner ───────────────────────────────────────────────────────── */
#tcw-alert {
display: none;
margin: 4px 0 2px;
padding: 3px 7px;
border-radius: 3px;
font-size: 10px;
font-weight: bold;
border: 1px solid;
}
#tcw-alert.ok { display:block; background:var(--ocm-status-ok-bg); border-color:var(--ocm-status-ok-border); color:var(--ocm-status-ok); }
#tcw-alert.warn { display:block; background:var(--ocm-status-warn-bg); border-color:var(--ocm-status-warn-border); color:var(--ocm-status-warn); }
#tcw-alert.crit { display:block; background:var(--ocm-status-crit-bg); border-color:var(--ocm-status-crit-border); color:var(--ocm-status-crit); }
/* ── No-chain placeholder ───────────────────────────────────────────────── */
#tcw-no-chain {
padding: 6px 0;
font-size: 10px;
color: var(--ocm-text-disabled);
text-align: center;
}
/* ── Config panel ───────────────────────────────────────────────────────── */
#tcw-config {
display: none;
padding: 8px;
background: var(--ocm-bg-dark);
border-top: 1px solid var(--ocm-border-strip);
font-size: 11px;
}
.tcw-cfg-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
gap: 6px;
}
.tcw-cfg-label {
font-size: 10px;
color: var(--ocm-text-secondary);
flex-shrink: 0;
}
.tcw-cfg-note {
font-size: 9px;
color: var(--ocm-text-muted);
margin-bottom: 6px;
line-height: 1.4;
}
.tcw-input {
background: var(--ocm-bg-input);
border: 1px solid var(--ocm-border-input);
border-radius: 4px;
color: var(--ocm-text-primary);
padding: 3px 6px;
font-size: 11px;
width: 100%;
box-sizing: border-box;
}
.tcw-input:focus { outline: none; border-color: var(--ocm-accent); }
.tcw-input[type="number"] { width: 64px !important; }
.tcw-input[type="range"] {
width: 90px !important;
padding: 0 !important;
background: none !important;
border: none !important;
height: 18px !important;
cursor: pointer;
}
select.tcw-input { cursor: pointer; }
/* Checkboxes — use !important to beat Torn's global input CSS resets */
.tcw-checkbox {
-webkit-appearance: checkbox !important;
appearance: checkbox !important;
accent-color: var(--ocm-accent);
width: 14px !important;
height: 14px !important;
min-width: 14px !important;
min-height: 14px !important;
cursor: pointer;
flex-shrink: 0;
padding: 0 !important;
border: none !important;
background: none !important;
}
.tcw-btn-row {
display: flex;
gap: 6px;
margin-top: 8px;
}
.tcw-btn-primary {
background: var(--ocm-accent-hover);
border: none;
border-radius: 4px;
color: #fff;
padding: 4px 10px;
cursor: pointer;
font-size: 11px;
flex: 1;
}
.tcw-btn-primary:hover { background: var(--ocm-accent); }
.tcw-btn-secondary {
background: var(--ocm-bg-input);
border: 1px solid var(--ocm-border-input);
border-radius: 4px;
color: var(--ocm-text-secondary);
padding: 3px 8px;
cursor: pointer;
font-size: 11px;
}
.tcw-btn-secondary:hover { background: var(--ocm-bg-hover); color: var(--ocm-text-primary); }
#tcw-save-status {
font-size: 10px;
color: var(--ocm-status-ok);
margin-left: 4px;
opacity: 0;
transition: opacity .3s;
}
#tcw-cfg-toggle {
background: none;
border: none;
color: var(--ocm-text-muted);
font-size: 10px;
cursor: pointer;
padding: 0;
}
#tcw-cfg-toggle:hover { color: var(--ocm-text-secondary); }
/* ── Config section title ────────────────────────────────────────────────── */
.tcw-section-title {
font-size: 9px;
color: var(--ocm-text-disabled);
text-transform: uppercase;
letter-spacing: .5px;
margin: 8px 0 4px;
border-bottom: 1px solid var(--ocm-border-section);
padding-bottom: 2px;
}
`);
// ─── State ────────────────────────────────────────────────────────────────────
let cfg = loadConfig();
let chainState = null; // last known chain data from API
let chainEndTs = 0; // Unix timestamp (s) when current chain window ends
let countdown = 0; // local countdown derived from chainEndTs
let lastAttacker = null; // name of last faction member to land a chain hit
let myFactionId = null; // derived from first attack response, used as filter
let countdownTimer = null; // setInterval handle
let pollTimer = null; // setTimeout handle
let lastNotifType = null; // tracks last notification sent to avoid spam
let lastChainCount = 0; // tracks last known hit count for start detection
// ─── Get player/faction data from DOM ────────────────────────────────────────
function getTornUser() {
try {
const el = document.getElementById('torn-user');
if (el) return JSON.parse(el.value);
} catch (e) { /* ignore */ }
return null;
}
function getPlayerId() {
return getTornUser()?.id || null;
}
// ─── Apply theme ──────────────────────────────────────────────────────────────
function applyTheme(key) {
const root = document.getElementById('tcw-root');
if (!root) return;
const theme = THEMES[key] || THEMES.default;
for (const [prop, val] of Object.entries(theme)) {
if (prop.startsWith('--')) root.style.setProperty(prop, val);
}
root.style.fontSize = `${11 * parseFloat(theme['--ocm-font-scale'] || '1')}px`;
}
// ─── UI ───────────────────────────────────────────────────────────────────────
function buildUI() {
const root = document.createElement('div');
root.id = 'tcw-root';
root.innerHTML = `
<!-- Header -->
<div id="tcw-header">
<span id="tcw-header-title">⚔ Chain</span>
<span id="tcw-header-status">No chain</span>
<span id="tcw-header-timer">--:--</span>
<button id="tcw-cfg-toggle" title="Settings">⚙</button>
<span id="tcw-collapse-btn">▼</span>
</div>
<!-- Config panel -->
<div id="tcw-config">
<div class="tcw-section-title">API Access</div>
<div class="tcw-cfg-row">
<label class="tcw-cfg-label" for="tcw-api-key">API Key</label>
</div>
<div class="tcw-cfg-note">Requires: <b>Limited</b> access (for chain + attacks data).</div>
<div class="tcw-cfg-row">
<input id="tcw-api-key" class="tcw-input" type="password" placeholder="Your Torn API key" autocomplete="off" />
</div>
<div class="tcw-section-title">Theme</div>
<div class="tcw-cfg-row">
<label class="tcw-cfg-label" for="tcw-theme">Theme</label>
<select id="tcw-theme" class="tcw-input" style="width:auto">
<option value="default">Default (Dark Blue)</option>
<option value="torn">Torn Classic</option>
<option value="highcontrast">High Contrast</option>
<option value="lowvision">Low Vision</option>
<option value="light">Light Mode</option>
</select>
</div>
<div class="tcw-section-title">Alerts</div>
<div class="tcw-cfg-row">
<label class="tcw-cfg-label" for="tcw-warn-secs">Warn at (secs left)</label>
<input id="tcw-warn-secs" class="tcw-input" type="number" min="10" max="300" step="5" />
</div>
<div class="tcw-cfg-row">
<label class="tcw-cfg-label" for="tcw-notif-visual">Visual banner</label>
<input id="tcw-notif-visual" class="tcw-checkbox" type="checkbox" />
</div>
<div class="tcw-cfg-row">
<label class="tcw-cfg-label" for="tcw-notif-browser">Browser notification</label>
<input id="tcw-notif-browser" class="tcw-checkbox" type="checkbox" />
</div>
<div class="tcw-cfg-row">
<label class="tcw-cfg-label" for="tcw-sound">Sound alerts</label>
<input id="tcw-sound" class="tcw-checkbox" type="checkbox" />
</div>
<div class="tcw-cfg-row">
<label class="tcw-cfg-label" for="tcw-volume">Volume</label>
<input id="tcw-volume" class="tcw-input" type="range" min="0" max="1" step="0.05" />
</div>
<div class="tcw-btn-row">
<button id="tcw-save-btn" class="tcw-btn-primary">Save</button>
<button id="tcw-reset-btn" class="tcw-btn-secondary">Reset</button>
<span id="tcw-save-status">Saved ✓</span>
</div>
</div>
<!-- Body (chain data) -->
<div id="tcw-body">
<div id="tcw-no-chain">No active chain</div>
<div id="tcw-chain-data" style="display:none">
<!-- Alert banner -->
<div id="tcw-alert"></div>
<!-- Hit counter -->
<div class="tcw-stat-row">
<span class="tcw-stat-label">Hits</span>
<span id="tcw-hits" class="tcw-stat-value ok">0</span>
</div>
<div class="tcw-progress-wrap">
<div id="tcw-hits-bar" class="tcw-progress-fill" style="width:0%"></div>
</div>
<!-- Timeout countdown -->
<div class="tcw-stat-row">
<span class="tcw-stat-label">Time left</span>
<span id="tcw-timeout" class="tcw-stat-value">--:--</span>
</div>
<div class="tcw-progress-wrap">
<div id="tcw-time-bar" class="tcw-progress-fill" style="width:100%"></div>
</div>
<!-- Bonus row -->
<div id="tcw-bonus-row">
<div>
<div class="tcw-bonus-label">Last bonus</div>
<div id="tcw-bonus-prev" class="tcw-bonus-val none">—</div>
</div>
<div style="text-align:right">
<div class="tcw-bonus-label">Next bonus</div>
<div id="tcw-bonus-next" class="tcw-bonus-val">—</div>
</div>
</div>
<!-- Chain modifier -->
<div class="tcw-stat-row" style="margin-top:4px">
<span class="tcw-stat-label">Modifier</span>
<span id="tcw-modifier">×1</span>
</div>
<!-- Last hit -->
<div class="tcw-stat-row" style="margin-top:2px">
<span class="tcw-stat-label">Last hit</span>
<span id="tcw-last-attacker" style="font-size:11px;color:var(--ocm-text-card)">—</span>
</div>
</div>
</div>
`;
return root;
}
// ─── Populate config panel with current values ────────────────────────────────
function populateConfig() {
document.getElementById('tcw-api-key').value = cfg.apiKey;
document.getElementById('tcw-theme').value = cfg.theme;
document.getElementById('tcw-warn-secs').value = cfg.warnSecs;
document.getElementById('tcw-notif-visual').checked = cfg.notifVisual;
document.getElementById('tcw-notif-browser').checked= cfg.notifBrowser;
document.getElementById('tcw-sound').checked = cfg.soundEnabled;
document.getElementById('tcw-volume').value = cfg.soundVolume;
}
// ─── Wire config panel events ─────────────────────────────────────────────────
function wireConfig() {
// Config toggle button
document.getElementById('tcw-cfg-toggle').addEventListener('click', (e) => {
e.stopPropagation();
const panel = document.getElementById('tcw-config');
const open = panel.style.display !== 'block';
panel.style.display = open ? 'block' : 'none';
if (open) populateConfig();
});
// Live theme preview
document.getElementById('tcw-theme').addEventListener('change', function () {
applyTheme(this.value);
});
// Save button
document.getElementById('tcw-save-btn').addEventListener('click', () => {
const newKey = document.getElementById('tcw-api-key').value.trim();
cfg.apiKey = newKey;
cfg.theme = document.getElementById('tcw-theme').value;
cfg.warnSecs = parseInt(document.getElementById('tcw-warn-secs').value, 10) || 60;
cfg.notifVisual = document.getElementById('tcw-notif-visual').checked;
cfg.notifBrowser = document.getElementById('tcw-notif-browser').checked;
cfg.soundEnabled = document.getElementById('tcw-sound').checked;
cfg.soundVolume = parseFloat(document.getElementById('tcw-volume').value);
cfgSet('api_key', cfg.apiKey);
cfgSet('theme', cfg.theme);
cfgSet('warn_secs', cfg.warnSecs);
cfgSet('notif_visual', cfg.notifVisual);
cfgSet('notif_browser', cfg.notifBrowser);
cfgSet('sound_enabled', cfg.soundEnabled);
cfgSet('sound_volume', cfg.soundVolume);
applyTheme(cfg.theme);
// Show save confirmation briefly
const status = document.getElementById('tcw-save-status');
status.style.opacity = '1';
setTimeout(() => { status.style.opacity = '0'; }, 1800);
// Request browser notification permission if enabled
if (cfg.notifBrowser) requestNotifPermission();
// Close panel & restore body, then restart poll with new key
document.getElementById('tcw-config').style.display = 'none';
document.getElementById('tcw-body').style.display = '';
restartPoll();
});
// Reset defaults
document.getElementById('tcw-reset-btn').addEventListener('click', () => {
if (!confirm('Reset Chain Watcher settings to defaults?')) return;
cfgSet('warn_secs', 60);
cfgSet('notif_visual', true);
cfgSet('notif_browser', true);
cfgSet('sound_enabled', true);
cfgSet('sound_volume', 0.5);
cfgSet('theme', 'default');
cfg = loadConfig();
populateConfig();
applyTheme(cfg.theme);
});
// Collapse toggle (click on header except cfg button)
document.getElementById('tcw-header').addEventListener('click', (e) => {
if (e.target.id === 'tcw-cfg-toggle') return;
const body = document.getElementById('tcw-body');
const config = document.getElementById('tcw-config');
const btn = document.getElementById('tcw-collapse-btn');
const collapsed = body.style.display === 'none';
body.style.display = collapsed ? '' : 'none';
config.style.display = 'none'; // always close config on collapse toggle
btn.textContent = collapsed ? '▼' : '▲';
cfg.collapsed = !collapsed;
cfgSet('collapsed', cfg.collapsed);
});
}
// ─── Update UI from chain data ────────────────────────────────────────────────
/**
* Renders chain data into the sidebar widget.
* @param {object|null} chain - chain object from API or null for no-chain
*/
function renderChain(chain) {
const noChain = document.getElementById('tcw-no-chain');
const dataBlock = document.getElementById('tcw-chain-data');
// Determine if chain is active: current > 0 OR timeout > 0
const active = chain && (chain.current > 0 || chain.timeout > 0);
// Toggle pulse animation on root
const root = document.getElementById('tcw-root');
if (root) root.classList.toggle('tcw-chain-active', !!active);
if (!active) {
noChain.style.display = '';
dataBlock.style.display = 'none';
document.getElementById('tcw-header-status').textContent = 'No chain';
document.getElementById('tcw-header-timer').textContent = '--:--';
document.getElementById('tcw-header-timer').style.color = 'var(--ocm-text-muted)';
lastAttacker = null;
setAlert(null);
return;
}
noChain.style.display = 'none';
dataBlock.style.display = '';
const hits = chain.current;
const timeout = countdown; // use local countdown for smoothness
const max = chain.max || 10;
const mod = chain.modifier || 1;
// ── Hits bar ──────────────────────────────────────────────────────────────
const nb = nextBonus(hits);
const pb = prevBonus(hits);
const base = pb || 0;
const segTotal = (nb || max) - base;
const segProg = Math.max(0, hits - base);
const hitsPct = segTotal > 0 ? Math.min(100, (segProg / segTotal) * 100) : 100;
document.getElementById('tcw-hits').textContent = hits.toLocaleString();
document.getElementById('tcw-hits-bar').style.width = `${hitsPct}%`;
// ── Timeout bar & colour ──────────────────────────────────────────────────
const maxTimeout = 300; // assume 5-minute max window; bar scales against this
const timePct = Math.min(100, (timeout / maxTimeout) * 100);
let timeClass = 'ok';
if (timeout <= cfg.warnSecs) timeClass = 'warn';
if (timeout <= Math.floor(cfg.warnSecs / 2)) timeClass = 'crit';
const timeEl = document.getElementById('tcw-timeout');
const timeBar = document.getElementById('tcw-time-bar');
timeEl.textContent = fmtTime(timeout);
timeEl.className = `tcw-stat-value ${timeClass}`;
timeBar.style.width = `${timePct}%`;
timeBar.className = `tcw-progress-fill ${timeClass}`;
// ── Header mirror ─────────────────────────────────────────────────────────
document.getElementById('tcw-header-status').textContent = `${hits.toLocaleString()} hits`;
document.getElementById('tcw-header-timer').textContent = fmtTime(timeout);
document.getElementById('tcw-header-timer').style.color =
timeClass === 'crit' ? 'var(--ocm-status-crit)' :
timeClass === 'warn' ? 'var(--ocm-status-warn)' : 'var(--ocm-status-ok)';
// ── Bonus milestones ──────────────────────────────────────────────────────
const prevEl = document.getElementById('tcw-bonus-prev');
const nextEl = document.getElementById('tcw-bonus-next');
if (pb) { prevEl.textContent = pb.toLocaleString(); prevEl.className = 'tcw-bonus-val ok'; }
else { prevEl.textContent = '—'; prevEl.className = 'tcw-bonus-val none'; }
if (nb) { nextEl.textContent = `${nb.toLocaleString()} (${(nb - hits)} to go)`; nextEl.className = 'tcw-bonus-val'; }
else { nextEl.textContent = 'Max!'; nextEl.className = 'tcw-bonus-val ok'; }
// ── Modifier ──────────────────────────────────────────────────────────────
document.getElementById('tcw-modifier').textContent = `×${mod}`;
// ── Last attacker ─────────────────────────────────────────────────────────
const lastEl = document.getElementById('tcw-last-attacker');
if (lastEl) lastEl.textContent = lastAttacker || '—';
// ── Alert logic ───────────────────────────────────────────────────────────
if (timeout <= cfg.warnSecs && timeout > 0) {
setAlert('crit', `⚠ Chain breaks in ${fmtTime(timeout)}!`);
} else {
setAlert(null);
}
}
// ─── Set visual alert banner ──────────────────────────────────────────────────
function setAlert(type, msg = '') {
if (!cfg.notifVisual) return;
const el = document.getElementById('tcw-alert');
if (!el) return;
el.className = type ? `${type}` : '';
el.textContent = msg;
el.style.display = type ? '' : 'none';
}
// ─── Notification + sound dispatcher ─────────────────────────────────────────
/**
* Fires notifications based on chain state transitions.
* Uses lastNotifType to suppress duplicate alerts.
* @param {object} chain - API chain data
* @param {boolean} isNew - true if chain just started (detected by hit count jump)
*/
function dispatchNotifs(chain, isNew) {
const hits = chain.current;
const timeout = chain.timeout;
// ── Chain start ───────────────────────────────────────────────────────────
if (isNew && lastNotifType !== 'start') {
lastNotifType = 'start';
if (cfg.soundEnabled) soundChainStart(cfg.soundVolume);
if (cfg.notifBrowser) browserNotif('⚔ Chain started!', `${hits} hits — chain is live`);
}
// ── Timeout warning ───────────────────────────────────────────────────────
if (timeout > 0 && timeout <= cfg.warnSecs && lastNotifType !== 'warn') {
lastNotifType = 'warn';
if (cfg.soundEnabled) soundWarn(cfg.soundVolume);
if (cfg.notifBrowser) browserNotif('⚠ Chain timing out!', `${fmtTime(timeout)} remaining`);
}
// ── Bonus milestone just passed ───────────────────────────────────────────
// Detect when current crosses a milestone (hits > prev count and milestone in range)
const justHit = BONUS_MILESTONES.filter(m => m > lastChainCount && m <= hits);
if (justHit.length > 0) {
if (cfg.soundEnabled) soundBonus(cfg.soundVolume);
if (cfg.notifBrowser) browserNotif('🎉 Chain bonus!', `Hit milestone: ${justHit[justHit.length - 1].toLocaleString()}`);
}
// ── Reset warn suppression when chain recovers (timeout > warn threshold) ─
if (timeout > cfg.warnSecs && lastNotifType === 'warn') {
lastNotifType = null;
}
}
// ─── Fetch last chain attacker ────────────────────────────────────────────────
// The faction attacks endpoint only returns attacks involving our faction.
// Attacks are descending by time. The first entry with a non-null attacker
// is the most recent outgoing hit by one of our members.
async function fetchLastAttacker(apiKey) {
try {
const data = await apiFetch('/faction?selections=attacks', apiKey);
if (!data.attacks || !data.attacks.length) return;
for (const attack of data.attacks) {
if (attack.attacker) {
lastAttacker = attack.attacker.name;
return;
}
}
} catch (e) {
// Non-critical — fail silently
}
}
// Derives remaining seconds from chainEndTs vs Date.now() on every tick —
// this means the countdown is always accurate regardless of poll interval,
// matching Torn's own chain bar which uses the same approach.
function startCountdown(endTimestamp) {
stopCountdown();
chainEndTs = endTimestamp;
countdownTimer = setInterval(() => {
const remaining = Math.max(0, chainEndTs - Math.floor(Date.now() / 1000));
countdown = remaining;
if (chainState && (chainState.current > 0 || chainState.timeout > 0)) {
renderChain(chainState);
}
if (remaining <= 0) stopCountdown();
}, 1000);
}
function stopCountdown() {
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
}
// ─── Poll the API ─────────────────────────────────────────────────────────────
async function pollChain() {
if (!cfg.apiKey) {
// No key — show config prompt
document.getElementById('tcw-no-chain').textContent = 'Set API key in ⚙ settings';
return;
}
let data;
try {
data = await apiFetch('/faction?selections=chain', cfg.apiKey);
} catch (e) {
console.warn('[TCW] API error:', e.message);
schedulePoll(POLL_IDLE);
return;
}
const chain = data.chain;
if (!chain) { schedulePoll(POLL_IDLE); return; }
const active = chain.current > 0 || chain.timeout > 0;
const wasActive = chainState && (chainState.current > 0 || chainState.timeout > 0);
const isNew = active && !wasActive && chain.current > 0;
// Track previous hit count for bonus milestone detection
const prevCount = lastChainCount;
lastChainCount = chain.current;
// Dispatch notifications
if (active) dispatchNotifs(chain, isNew);
// Fetch last attacker name only when chain is active (non-blocking)
if (active) fetchLastAttacker(cfg.apiKey);
// Derive remaining time directly from the end timestamp vs local clock.
// This is always accurate regardless of API latency or poll interval.
const remaining = Math.max(0, chain.end - Math.floor(Date.now() / 1000));
chainState = chain;
countdown = remaining;
if (active) startCountdown(chain.end);
// Render
renderChain(chain);
// Schedule next poll — faster when chain is active
schedulePoll(active ? POLL_ACTIVE : POLL_IDLE);
}
function schedulePoll(delay) {
if (pollTimer) { clearTimeout(pollTimer); }
pollTimer = setTimeout(pollChain, delay);
}
function restartPoll() {
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
stopCountdown();
pollChain();
}
// ─── Inject into sidebar ──────────────────────────────────────────────────────
function inject() {
// Guard: don't inject twice
if (document.getElementById('tcw-root')) return;
const sidebar = document.getElementById('sidebar');
if (!sidebar) return;
const widget = buildUI();
// Insert at top of sidebar, below any existing scripts' first widget
sidebar.insertBefore(widget, sidebar.firstChild);
// Apply saved theme
applyTheme(cfg.theme);
// Restore collapsed state
if (cfg.collapsed) {
document.getElementById('tcw-body').style.display = 'none';
document.getElementById('tcw-collapse-btn').textContent = '▲';
}
// Wire events
wireConfig();
// If no API key, open config panel immediately and hide body
if (!cfg.apiKey) {
document.getElementById('tcw-config').style.display = 'block';
document.getElementById('tcw-body').style.display = 'none';
}
// Request browser notification permission upfront if enabled
if (cfg.notifBrowser) requestNotifPermission();
// Start polling
pollChain();
}
// ─── Entry point with retry ───────────────────────────────────────────────────
let attempts = 0;
const tryInject = setInterval(() => {
attempts++;
const sidebar = document.getElementById('sidebar');
if (sidebar) {
clearInterval(tryInject);
inject();
} else if (attempts >= 20) {
// 10 seconds elapsed, give up
clearInterval(tryInject);
}
}, 500);
// Re-poll immediately when the tab becomes visible again (e.g. switching back
// to Torn from another tab) — ensures chain starts are never missed.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && cfg.apiKey) {
restartPoll();
}
});
// Also re-inject on hash changes (Torn is a SPA)
window.addEventListener('hashchange', () => {
if (!document.getElementById('tcw-root')) {
attempts = 0;
const retry = setInterval(() => {
attempts++;
if (document.getElementById('sidebar')) {
clearInterval(retry);
inject();
} else if (attempts >= 20) {
clearInterval(retry);
}
}, 500);
}
});
})();