Times your Torn Choco Jump cycle (Xanax / BBC / Ecstasy / Points refill) and tells you what to do now vs wait for.
// ==UserScript==
// @name Choco Jump Helper
// @namespace https://github.com/marcin/choco-jump-helper
// @version 0.2.0
// @description Times your Torn Choco Jump cycle (Xanax / BBC / Ecstasy / Points refill) and tells you what to do now vs wait for.
// @author Nicram
// @match https://www.torn.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// -------------------------------------------------------------------------
// Constants — Choco Jump timing (from HOU5E's schedule + the guide)
// -------------------------------------------------------------------------
// Source of truth: HOU5E's Single & Double Choco Jump schedule charts.
//
// Single jump (1×/day): Xanax → 3.5h Ecstasy CD → 48 BBC + E → train →
// refill → train. Booster CD 24h. Optional 2nd
// Xanax inside an ~6.5h window starting at the
// Ecstasy-CD end (practical: from when 1st Xanax
// CD ends, ~3h long).
// Double jump (2×/day): Same shape but only 24 BBC → 12h booster CD,
// so the cycle repeats once per ~12h. No optional
// Xanax (no time for it in the half cycle).
//
// HOU5E's chart assumes a 7h Xanax CD (e.g. with the Insomnia perk).
// Base Torn is 8h; we default to 8h and let the user adjust if they have
// the perk. The actual wait time comes from the API in any case — these
// constants only drive the optional-Xanax window planning.
const T = {
XANAX_CD_S: 8 * 3600, // default Xanax cooldown (base Torn)
ECSTASY_CD_S: 3.5 * 3600, // 3.5h drug CD after Xanax → BBC/Ecstasy
BOOSTER_CD_FULL_S: 24 * 3600, // Full (Single): 48 BBC → 24h booster CD
BOOSTER_CD_HALF_S: 12 * 3600, // Half (Double): 24 BBC → 12h booster CD
OPTIONAL_XAN_END_AFTER_XAN_S: 10 * 3600, // Per HOU5E's chart, the Optional
// Daytime Xanax Window closes at
// hour 10 of the cycle (i.e. 10h
// after the 1st Xanax was popped).
TRAIN_MIN_ENERGY: 10, // Each gym train costs ≥10 energy.
};
const DEFAULTS = {
apiKey: '',
mode: 'full', // 'full' | 'half'
optionalXanax: false,
lastSnapshot: null, // { fetchedAt, energy, drugCD, boosterCD, medicalCD, refills }
firstXanaxAt: 0, // unix s — tracked locally for optional Xanax window
availableFrom: 8, // local hour, inclusive (0–23). Player is "online-able" from this hour…
availableTo: 23, // …until this hour (exclusive). Wraps past midnight if To <= From.
};
// -------------------------------------------------------------------------
// Storage helpers
// -------------------------------------------------------------------------
function load(key) {
const v = GM_getValue(key, undefined);
return v === undefined ? DEFAULTS[key] : v;
}
function save(key, value) {
GM_setValue(key, value);
}
const settings = {
apiKey: load('apiKey'),
mode: load('mode'),
optionalXanax: load('optionalXanax'),
firstXanaxAt: load('firstXanaxAt'),
availableFrom: load('availableFrom'),
availableTo: load('availableTo'),
};
let snapshot = load('lastSnapshot');
// -------------------------------------------------------------------------
// Torn API client
// -------------------------------------------------------------------------
function fetchState() {
if (!settings.apiKey) return Promise.resolve(null);
// `bars` includes both energy and happy in one call. `cooldowns` +
// `refills` round out everything we need — all available on a Limited
// (or even Public) key.
const url = `https://api.torn.com/user/?selections=bars,cooldowns,refills&key=${encodeURIComponent(settings.apiKey)}`;
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url,
timeout: 10000,
onload: (res) => {
try {
const data = JSON.parse(res.responseText);
if (data.error) {
console.warn('[CJH] API error:', data.error);
resolve(null);
return;
}
const now = Math.floor(Date.now() / 1000);
const snap = {
fetchedAt: now,
energy: data.energy || { current: 0, maximum: 150, fulltime: 0, increment: 5, interval: 600 },
happy: data.happy || { current: 0, maximum: 5025, fulltime: 0, increment: 5, interval: 900 },
drugCD: data.cooldowns ? data.cooldowns.drug : 0,
boosterCD: data.cooldowns ? data.cooldowns.booster : 0,
medicalCD: data.cooldowns ? data.cooldowns.medical : 0,
refills: data.refills || {},
};
save('lastSnapshot', snap);
resolve(snap);
} catch (e) {
console.warn('[CJH] parse error', e);
resolve(null);
}
},
onerror: () => resolve(null),
ontimeout: () => resolve(null),
});
});
}
// Project the cached snapshot forward by `elapsed` seconds so the banner
// ticks smoothly between API polls.
function projectSnapshot(snap, nowS) {
if (!snap) return null;
const elapsed = Math.max(0, nowS - snap.fetchedAt);
const energyRegenPer600s = (snap.energy.increment || 5);
const interval = snap.energy.interval || 600;
const projectedCurrent = Math.min(
snap.energy.maximum,
snap.energy.current + Math.floor((elapsed / interval) * energyRegenPer600s)
);
const projectedFulltime = Math.max(0, snap.energy.fulltime - elapsed);
// Happy projection. After eating BBC + popping Ecstasy, happy is
// *over* its maximum (e.g. 30k vs 5k cap) — that overcap is the
// signal that the gym-gain "happy jump" boost is live. When over
// max, happy drains toward max at the same regen rate it would
// otherwise climb at.
const happy = snap.happy || { current: 0, maximum: 5025, increment: 5, interval: 900 };
const happySecsPerUnit = happy.increment > 0 ? (happy.interval / happy.increment) : 180;
const happyDelta = Math.floor(elapsed / happySecsPerUnit);
const projectedHappy = happy.current > happy.maximum
? Math.max(happy.maximum, happy.current - happyDelta)
: Math.min(happy.maximum, happy.current + happyDelta);
return {
...snap,
energy: {
...snap.energy,
current: projectedCurrent,
fulltime: projectedFulltime,
},
happy: {
...happy,
current: projectedHappy,
fulltime: Math.max(0, (happy.fulltime || 0) - elapsed),
},
drugCD: Math.max(0, snap.drugCD - elapsed),
boosterCD: Math.max(0, snap.boosterCD - elapsed),
medicalCD: Math.max(0, snap.medicalCD - elapsed),
};
}
// -------------------------------------------------------------------------
// Availability helpers
// -------------------------------------------------------------------------
// The player declares an "online window" (local hours). The engine uses it
// to decide when to recommend popping Xanax, because the cycle requires the
// player back online ~3.5h later to eat BBC + pop Ecstasy + train.
//
// Window is [from, to). If to <= from we treat it as wrapping past midnight
// (e.g. 22 → 06 means 22:00–06:00 next day).
function isInWindow(date, from, to) {
const h = date.getHours() + date.getMinutes() / 60;
if (from === to) return true; // 24h availability
if (from < to) return h >= from && h < to;
return h >= from || h < to; // wrap-around
}
// Returns Date of the next moment the window is "open" at-or-after `from`.
// If currently open, returns `from` itself.
function nextWindowOpen(from, opts) {
if (isInWindow(from, opts.availableFrom, opts.availableTo)) return new Date(from);
const d = new Date(from);
// Step forward in 15-minute increments — cheap and exact enough.
for (let i = 0; i < 24 * 4; i++) {
d.setMinutes(d.getMinutes() + 15);
if (isInWindow(d, opts.availableFrom, opts.availableTo)) {
// Snap back to the exact hour boundary if we just crossed it.
d.setMinutes(0, 0, 0);
return d;
}
}
return new Date(from); // fallback
}
function fmtClock(date) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
}
// Like fmtClock, but tags non-today dates with a weekday or "tmrw" suffix so
// "next jump at 04:41" is unambiguous when it's actually tomorrow morning.
function fmtFuture(date) {
const now = new Date();
if (date.toDateString() === now.toDateString()) return fmtClock(date);
const oneDay = 24 * 3600 * 1000;
const startToday = new Date(now); startToday.setHours(0, 0, 0, 0);
const startThat = new Date(date); startThat.setHours(0, 0, 0, 0);
const daysAhead = Math.round((startThat - startToday) / oneDay);
if (daysAhead === 1) return `${fmtClock(date)} (tmrw)`;
return `${date.toLocaleDateString(undefined, { weekday: 'short' })} ${fmtClock(date)}`;
}
// Returns the next Date strictly after `after` whose local clock hits `hour:00`.
function nextClockHour(hour, after) {
const d = new Date(after);
d.setHours(hour, 0, 0, 0);
if (d <= after) d.setDate(d.getDate() + 1);
return d;
}
// Sub-engine: we're mid-cycle with the booster active. Decide between training,
// popping the optional 2nd Xanax (Full method only), using the daily refill,
// or just resting until the booster expires.
function trainOrRestDecision(state, opts, details) {
const refillUsed = !!state.refills.energy_refill_used;
const canTrain = state.energy.current >= T.TRAIN_MIN_ENERGY;
const nowS = Math.floor(Date.now() / 1000);
// Happy overcap is the direct signal that the BBC + Ecstasy "happy
// jump" boost is currently live. Booster CD only tells us BBC was
// eaten *recently* — happy decays over time, so the boost can be
// gone well before the 24h booster CD ends.
const happyCur = state.happy ? state.happy.current : 0;
const happyMax = state.happy ? state.happy.maximum : 0;
const happyOverCap = happyCur > happyMax;
const happyDeltaOver = happyOverCap ? (happyCur - happyMax) : 0;
// Optional 2nd Xanax window — Full (Single) method only.
// We derive firstXanaxAt from the live booster CD so the user doesn't
// have to manually log when they popped Xanax. The chart's "Optional
// Daytime Xanax Window" closes at hour 10 of the cycle (firstXanax+10h).
// Opens once the 1st Xanax CD has actually ended (drug CD already 0).
if (opts.mode === 'full' && opts.optionalXanax && state.drugCD === 0) {
const derivedFirstXanaxAt = opts.firstXanaxAt
|| (nowS - (T.BOOSTER_CD_FULL_S - state.boosterCD) - T.ECSTASY_CD_S);
const opensAt = derivedFirstXanaxAt + T.XANAX_CD_S;
const closesAt = derivedFirstXanaxAt + T.OPTIONAL_XAN_END_AFTER_XAN_S;
if (nowS >= opensAt && nowS <= closesAt) {
return {
phase: 'OPTIONAL_XAN_WINDOW',
title: 'Bonus Xanax',
action: `Optional Daytime Xanax window is OPEN (closes ~${fmtClock(new Date(closesAt * 1000))}). Pop a 2nd Xanax for +250 extra energy to train through, then keep training.`,
color: 'go',
etaS: closesAt - nowS,
details,
};
}
}
// Compute the bedtime-Xanax stacking plan once. It applies to both the
// "still training" and "winding down" branches below — the player needs
// to know the stop-training time even while POST_REFILL_TRAIN is active.
const restSecs = Math.max(0, state.boosterCD);
const boosterEndAt = new Date((nowS + restSecs) * 1000);
const stack = buildStackPlan(state, opts, nowS, boosterEndAt);
if (canTrain) {
let action;
let etaS = restSecs;
let color = 'go';
if (stack && stack.applicable && refillUsed) {
// Wind-down training: tell the player when to stop training so
// energy can passively regen to cap before bedtime.
if (stack.deficit === 0) {
action = `Energy is at cap. Don't train — let it sit until ${fmtClock(stack.bedTime)}, then pop Xanax to stack to 400.${stack.bleedNote}`;
color = 'wait';
etaS = stack.bedLeadS;
} else if (stack.stopLeadS > 60) {
action = `Train any spare energy until ~${fmtClock(stack.stopTrainingAt)} (${fmtDur(stack.stopLeadS)} from now) — regular gains, BBC happy boost is long gone. Then STOP and let energy regen to ${state.energy.maximum} by ${fmtClock(stack.bedTime)}. Pop Xanax then to stack to 400.${stack.bleedNote}`;
etaS = stack.stopLeadS;
} else if (stack.stopLeadS > -30 * 60) {
action = `Stop training now and let energy fill to ${state.energy.maximum} by ${fmtClock(stack.bedTime)}. Pop Xanax then to stack to 400.${stack.bleedNote}`;
color = 'wait';
etaS = stack.bedLeadS;
} else {
action = `⚠ Past optimal stop point. Currently you'd reach ~${stack.energyAtBedIfStopNow}/${state.energy.maximum} by ${fmtClock(stack.bedTime)} (stack ~${stack.stackAtBedIfStopNow}, not the full 400). Stop training now to save what you can.${stack.bleedNote}`;
color = 'warn';
etaS = stack.bedLeadS;
}
} else if (refillUsed) {
if (happyOverCap) {
action = `Happy boost still live (+${happyDeltaOver} over cap). Train remaining energy now — cycle wraps once energy drops below 10.`;
} else {
action = '⚠ Happy boost has worn off — training now gives regular gym gains, not the happy-jump multiplier. Train remaining energy anyway, cycle wraps once energy drops below 10.';
}
} else {
// The big moment: post-BBC + Ecstasy, happy is doubled,
// gym gains are massive. Happy overcap is the live signal.
if (happyOverCap) {
action = `Happy jump LIVE — happy is at ${happyCur} (+${happyDeltaOver} over cap). Train ${state.energy.current} energy down NOW, then USE POINTS REFILL → train again.`;
} else if (state.happy && happyMax > 0) {
action = `⚠ Happy is back to baseline (${happyCur}/${happyMax}) — the happy-jump window is gone. Train + refill anyway to clear the cycle, but gym gains will be regular.`;
} else {
action = `Train your ${state.energy.current} energy down, then USE POINTS REFILL → train again.`;
}
}
return {
phase: refillUsed ? 'POST_REFILL_TRAIN' : 'READY_TO_TRAIN',
title: refillUsed ? 'Train' : 'Train + Refill',
action,
color,
etaS,
details,
};
}
// Energy under the train minimum.
if (!refillUsed) {
return {
phase: 'USE_REFILL',
title: 'Use Refill',
action: `Energy is at ${state.energy.current} (< 10, can’t train). Use your daily Points refill (+150 energy) then train the rest.`,
color: 'go',
etaS: state.boosterCD,
details,
};
}
// Energy spent, refill used. If a stack plan applies, route into the
// bedtime Xanax phases. Otherwise (half mode / drug CD active / tail),
// fall through to the plain rest message at the bottom.
if (stack && stack.applicable) {
if (stack.boosterEndsBeforeBed) {
return {
phase: 'AWAKE_WAIT_FOR_BOOSTER',
title: 'Train Regen',
action: `Booster CD ends at ${fmtClock(boosterEndAt)} — still inside your online hours. No bedtime Xanax needed; train regenerating energy as it comes (regular gains, BBC happy boost is long gone) and start the next cycle when booster clears.`,
color: 'idle',
etaS: restSecs,
details,
};
}
if (stack.bedLeadS <= 5 * 60) {
const stackNow = Math.min(state.energy.maximum, state.energy.current) + 250;
const stackNote = state.energy.current >= state.energy.maximum
? `Energy is full — stacks to ${stackNow}.`
: `⚠ Energy only at ${state.energy.current}/${state.energy.maximum} — stacks to ${stackNow} (not the full 400). Next time, stop training earlier so it regens to cap by bedtime.`;
return {
phase: 'STACK_XAN_NOW',
title: 'Stack Xanax',
action: `It's bedtime. ${stackNote} Drug CD ends ${fmtClock(stack.xanCdOver)}, booster ends ${fmtClock(boosterEndAt)} — both during sleep.${stack.bleedNote}`,
color: 'go',
etaS: 0,
details,
};
}
// Energy under train minimum but not bedtime yet — just wait,
// passive regen will fill the bar.
let action;
let color = 'wait';
if (stack.deficit === 0) {
action = `Energy is at cap. Let it sit until ${fmtClock(stack.bedTime)}, then pop Xanax to stack to 400.${stack.bleedNote}`;
} else if (stack.energyAtBedIfStopNow >= state.energy.maximum) {
action = `Wait for energy to regen to ${state.energy.maximum} by ${fmtClock(stack.bedTime)}, then pop Xanax to stack to 400. Booster ends ${fmtClock(boosterEndAt)} during sleep.${stack.bleedNote}`;
} else {
action = `⚠ Energy won't fully refill by ${fmtClock(stack.bedTime)} (will reach ~${stack.energyAtBedIfStopNow}/${state.energy.maximum}). Xanax will stack to ~${stack.stackAtBedIfStopNow} instead of 400.${stack.bleedNote}`;
color = 'warn';
}
return {
phase: 'WAIT_STACK_XAN',
title: 'Wind Down',
action,
color,
etaS: stack.bedLeadS,
details,
};
}
// Final tail (<30min, half mode, or drug CD still active) — pure rest.
return {
phase: 'CYCLE_DONE_REST',
title: 'Rest',
action: `Nothing left to do (energy ${state.energy.current}/${state.energy.maximum}). Next Choco Jump in ${fmtDur(restSecs)} when booster CD clears (${fmtClock(boosterEndAt)}).`,
color: 'idle',
etaS: restSecs,
details,
};
}
// Builds the bedtime-Xanax-stacking plan. Returns an object describing
// when the player should stop training, when bedtime is, energy projections,
// etc. — used by both the "still training" and "winding down" code paths.
// .applicable is false when the plan doesn't apply (wrong mode, drug CD,
// booster too close to expiry, or booster ends while player is still awake).
function buildStackPlan(state, opts, nowS, boosterEndAt) {
const nowMs = nowS * 1000;
const restSecs = Math.max(0, state.boosterCD);
if (opts.mode !== 'full' || state.drugCD !== 0 || restSecs < 30 * 60) {
return { applicable: false };
}
const bedTime = nextClockHour(opts.availableTo, new Date(nowMs));
const wakeTime = nextClockHour(opts.availableFrom, bedTime);
const boosterEndsBeforeBed = boosterEndAt <= bedTime;
const sleepDurS = (wakeTime - bedTime) / 1000;
const xanCdOver = new Date(bedTime.getTime() + T.XANAX_CD_S * 1000);
const drugBleedsIntoMorningS = Math.max(0, (xanCdOver - wakeTime) / 1000);
const bleedNote = drugBleedsIntoMorningS > 60
? ` ⚠ Sleep window is ${fmtDur(sleepDurS)}, shorter than the 8h Xanax CD — drug CD will overhang into your morning by ${fmtDur(drugBleedsIntoMorningS)}.`
: '';
const secsPerEnergy = state.energy.increment > 0
? state.energy.interval / state.energy.increment
: 120; // default Torn: +5 per 600s → 120s per +1
const deficit = Math.max(0, state.energy.maximum - state.energy.current);
const timeToFullS = deficit * secsPerEnergy;
const stopTrainingAt = new Date(bedTime.getTime() - timeToFullS * 1000);
const stopLeadS = Math.floor((stopTrainingAt.getTime() - nowMs) / 1000);
const bedLeadS = (bedTime.getTime() - nowMs) / 1000;
const passiveRegenS = Math.max(0, bedLeadS);
const energyAtBedIfStopNow = Math.min(
state.energy.maximum,
state.energy.current + Math.floor(passiveRegenS / secsPerEnergy)
);
const stackAtBedIfStopNow = energyAtBedIfStopNow + 250;
return {
applicable: true,
boosterEndsBeforeBed,
bedTime, wakeTime, xanCdOver, bleedNote,
bedLeadS, stopTrainingAt, stopLeadS,
deficit, energyAtBedIfStopNow, stackAtBedIfStopNow,
};
}
// -------------------------------------------------------------------------
// Phase engine — pure function
// -------------------------------------------------------------------------
// Returns { phase, title, action, color, etaS, details: [{label, secs}] }
// color: 'go' (green = act now) | 'wait' (amber) | 'idle' (grey) | 'warn' (red)
function decide(state, opts, nowS) {
if (!state) {
return {
phase: 'NO_DATA',
title: 'Setup',
action: 'Set your Torn API key to begin (click ⚙ on the right).',
color: 'warn',
etaS: 0,
details: [],
};
}
const boosterTarget = opts.mode === 'half' ? T.BOOSTER_CD_HALF_S : T.BOOSTER_CD_FULL_S;
const bbcCount = opts.mode === 'half' ? '24' : '48';
const energyFull = state.energy.current >= state.energy.maximum;
const hasDrugCD = state.drugCD > 0;
const hasBoosterCD = state.boosterCD > 0;
const refillUsed = !!state.refills.energy_refill_used;
// Happy: when overcap (post-BBC + Ecstasy), the value is the live
// happy-jump boost. Annotate with a lightning bolt so it's obvious.
const happyCur = state.happy ? state.happy.current : 0;
const happyMax = state.happy ? state.happy.maximum : 0;
const happyOverCap = happyCur > happyMax;
const happyText = happyOverCap
? `${happyCur} ⚡`
: `${happyCur}/${happyMax}`;
const details = [
{ label: 'Energy', secs: state.energy.fulltime, text: `${state.energy.current}/${state.energy.maximum}` },
{ label: 'Happy', text: happyText },
{ label: 'Drug CD', secs: state.drugCD },
{ label: 'Booster CD', secs: state.boosterCD },
];
// "Next jump" = earliest moment the next Choco Jump cycle can start
// (= when both booster and drug cooldowns will be 0). If currently 0,
// suppress the pill — the active phase already says what to do.
const nextJumpInS = Math.max(state.boosterCD, state.drugCD);
if (nextJumpInS > 0) {
const nextJumpAt = new Date((nowS + nextJumpInS) * 1000);
details.push({ label: 'Next jump', text: fmtFuture(nextJumpAt) });
}
// 1. Booster is cooling down — cycle is "done" until it expires (full method),
// or until it drops past the half-method window.
if (hasBoosterCD && state.boosterCD > boosterTarget) {
// Booster CD longer than the target — must wait either way.
return {
phase: 'CYCLE_COMPLETE',
title: 'Rest',
action: `Booster cooling down. Next Choco Jump available in ${fmtDur(state.boosterCD - (opts.mode === 'half' ? T.BOOSTER_CD_HALF_S : 0))}. Train spare energy, do other Torn stuff.`,
color: 'idle',
etaS: state.boosterCD,
details,
};
}
// 2. Drug CD still going and booster active → mid-cycle.
if (hasDrugCD && hasBoosterCD) {
return trainOrRestDecision(state, opts, details);
}
// 3. Drug CD active, no booster yet → we just popped Xanax, waiting for Ecstasy CD.
if (hasDrugCD && !hasBoosterCD) {
// Flag whether the BBC moment lands inside the player's online window.
const bbcAt = new Date(Date.now() + state.drugCD * 1000);
const bbcInWindow = isInWindow(bbcAt, opts.availableFrom, opts.availableTo);
const warning = bbcInWindow
? ''
: ` ⚠ Drug CD ends at ${fmtClock(bbcAt)} — OUTSIDE your set online hours. You'll miss the BBC moment.`;
return {
phase: 'XAN_ECSTASY_CD',
title: 'Ecstasy CD',
action: `Xanax popped. Travel / race / trade for ${fmtDur(state.drugCD)} until drug CD clears (~${fmtClock(bbcAt)}), then eat ${bbcCount} BBC + Ecstasy.${warning}`,
color: bbcInWindow ? 'wait' : 'warn',
etaS: state.drugCD,
details,
};
}
// 4. No drug CD, no booster → time to eat chocolate and pop Ecstasy.
if (!hasDrugCD && !hasBoosterCD) {
// Sub-case: energy is still not full → fill it before Xanax to not waste regen.
if (!energyFull) {
const fullAt = new Date(Date.now() + state.energy.fulltime * 1000);
const bbcIfPopAtFull = new Date(fullAt.getTime() + T.ECSTASY_CD_S * 1000);
const fullInWindow = isInWindow(fullAt, opts.availableFrom, opts.availableTo);
const bbcInWindow = isInWindow(bbcIfPopAtFull, opts.availableFrom, opts.availableTo);
const note = (fullInWindow && bbcInWindow)
? ` Energy caps at ${fmtClock(fullAt)} — both Xanax and BBC moments land inside your hours.`
: ` ⚠ Energy caps at ${fmtClock(fullAt)}; popping then would put the BBC moment at ${fmtClock(bbcIfPopAtFull)} (outside hours). You may need to skip a day or shift your hours.`;
return {
phase: 'FILL_ENERGY',
title: 'Fill Energy',
action: `Wait ${fmtDur(state.energy.fulltime)} for energy to cap (${state.energy.current}/${state.energy.maximum}), then pop Xanax.${note}`,
color: 'wait',
etaS: state.energy.fulltime,
details,
};
}
// Energy full. Should we pop Xanax NOW? Only if we'll still be in the
// online window 3.5h from now (when BBC needs to happen).
const now = new Date();
const bbcAt = new Date(now.getTime() + T.ECSTASY_CD_S * 1000);
const nowInWindow = isInWindow(now, opts.availableFrom, opts.availableTo);
const bbcInWindow = isInWindow(bbcAt, opts.availableFrom, opts.availableTo);
if (nowInWindow && bbcInWindow) {
return {
phase: 'READY_FOR_XANAX',
title: 'Pop Xanax',
action: `Energy full. Pop Xanax now → BBC moment at ~${fmtClock(bbcAt)} (inside your hours).`,
color: 'go',
etaS: 0,
details,
};
}
// Find the next clock time that satisfies BOTH constraints:
// 1. that time is inside the window (player online to pop)
// 2. that time + 3.5h is inside the window (player online for BBC)
let candidate = nextWindowOpen(now, opts);
let bbcCandidate = new Date(candidate.getTime() + T.ECSTASY_CD_S * 1000);
for (let i = 0; i < 96; i++) {
if (
isInWindow(candidate, opts.availableFrom, opts.availableTo) &&
isInWindow(bbcCandidate, opts.availableFrom, opts.availableTo)
) break;
candidate = new Date(candidate.getTime() + 15 * 60 * 1000);
bbcCandidate = new Date(candidate.getTime() + T.ECSTASY_CD_S * 1000);
}
const waitS = Math.max(0, Math.floor((candidate.getTime() - now.getTime()) / 1000));
return {
phase: 'HOLD_FOR_WINDOW',
title: 'Hold Off',
action: `Energy is full but popping now would put the BBC moment outside your online hours (${pad2(opts.availableFrom)}:00–${pad2(opts.availableTo)}:00). Best Xanax time: ${fmtClock(candidate)} (BBC at ${fmtClock(bbcCandidate)}). Train spare energy or save Xanax until then.`,
color: 'wait',
etaS: waitS,
details,
};
}
// 5. No drug CD but booster is active → mid-cycle.
if (!hasDrugCD && hasBoosterCD) {
return trainOrRestDecision(state, opts, details);
}
return {
phase: 'UNKNOWN',
title: 'Idle',
action: 'Unknown state.',
color: 'idle',
etaS: 0,
details,
};
}
// -------------------------------------------------------------------------
// Formatting
// -------------------------------------------------------------------------
function pad2(n) { return n.toString().padStart(2, '0'); }
function fmtDur(secs) {
secs = Math.max(0, Math.floor(secs));
if (secs === 0) return '0s';
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s.toString().padStart(2, '0')}s`;
return `${s}s`;
}
// -------------------------------------------------------------------------
// UI
// -------------------------------------------------------------------------
function injectStyles() {
if (document.getElementById('cjh-style')) return;
// Try to pull in display + mono web fonts. If Torn's CSP blocks
// fonts.googleapis.com, the system-font fallbacks in the CSS still
// give a coherent look (Arial Narrow / Menlo / system sans).
if (!document.getElementById('cjh-fontlink')) {
const link = document.createElement('link');
link.id = 'cjh-fontlink';
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/css2?family=Bebas+Neue&family=JetBrains+Mono:wght@500;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap';
document.head.appendChild(link);
}
const s = document.createElement('style');
s.id = 'cjh-style';
s.textContent = `
/* === Choco Jump Helper — Tactical HUD theme ============= */
#cjh-banner, #cjh-settings {
--cjh-bg-0: #0a0807;
--cjh-bg-1: #15110d;
--cjh-bg-2: #1f1812;
--cjh-bg-3: #2a2116;
--cjh-ink: #ead9bd;
--cjh-ink-dim: #998873;
--cjh-ink-faint: #5e5246;
--cjh-brass: #c79552;
--cjh-brass-dim: #8a6536;
--cjh-line: rgba(199,149,82,0.18);
--cjh-line-strong: rgba(199,149,82,0.42);
--cjh-state: #c79552;
--cjh-state-field: #1f1812;
--cjh-state-line: rgba(199,149,82,0.42);
--cjh-state-glow: rgba(199,149,82,0.20);
}
#cjh-banner.go, #cjh-settings.go { --cjh-state:#9bc46e; --cjh-state-field:#15220e; --cjh-state-line:rgba(155,196,110,0.50); --cjh-state-glow:rgba(155,196,110,0.22); }
#cjh-banner.wait, #cjh-settings.wait { --cjh-state:#e3a93f; --cjh-state-field:#2b1d0a; --cjh-state-line:rgba(227,169,63,0.45); --cjh-state-glow:rgba(227,169,63,0.22); }
#cjh-banner.idle, #cjh-settings.idle { --cjh-state:#7a6e5d; --cjh-state-field:#15120e; --cjh-state-line:rgba(122,110,93,0.45); --cjh-state-glow:rgba(122,110,93,0.18); }
#cjh-banner.warn, #cjh-settings.warn { --cjh-state:#d24a3e; --cjh-state-field:#2a0d0a; --cjh-state-line:rgba(210,74,62,0.60); --cjh-state-glow:rgba(210,74,62,0.28); }
/* === BANNER ===================================== */
#cjh-banner {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 999999;
min-height: 44px;
box-sizing: border-box;
padding: 6px 14px 6px 12px;
display: grid;
grid-template-columns: auto 1fr auto auto auto;
align-items: center;
gap: 12px;
color: var(--cjh-ink);
font-family: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.005em;
background:
/* state-tinted wash on the left edge */
radial-gradient(ellipse 32% 140% at 0% 50%,
var(--cjh-state-glow), transparent 70%),
/* CRT scan lines */
repeating-linear-gradient(0deg,
transparent 0 2px,
rgba(0,0,0,0.18) 2px 3px),
/* warm chocolate base */
linear-gradient(180deg, var(--cjh-bg-1) 0%, var(--cjh-bg-0) 100%);
border-bottom: 1px solid var(--cjh-line);
box-shadow:
inset 0 1px 0 rgba(199,149,82,0.06),
0 1px 0 rgba(0,0,0,0.6),
0 14px 28px -16px rgba(0,0,0,0.9);
transition: background 250ms ease;
}
/* state-colored hairline at the very top */
#cjh-banner::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: var(--cjh-state);
box-shadow: 0 0 14px var(--cjh-state-glow);
opacity: 0.92;
transition: background 250ms ease;
}
/* Title block — small tactical stencil tag with bracket corners */
#cjh-banner .cjh-title {
position: relative;
z-index: 1;
font-family: 'Bebas Neue', 'Oswald', 'Arial Narrow', sans-serif;
font-weight: 400;
font-size: 13px;
line-height: 1;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--cjh-state);
padding: 6px 10px 4px;
white-space: nowrap;
background:
linear-gradient(180deg,
rgba(255,255,255,0.02), transparent 60%),
var(--cjh-state-field);
border: 1px solid var(--cjh-state-line);
text-shadow: 0 0 14px var(--cjh-state-glow);
}
/* Corner brackets */
#cjh-banner .cjh-title::before,
#cjh-banner .cjh-title::after {
content: '';
position: absolute;
width: 7px; height: 7px;
border: 1px solid var(--cjh-state);
}
#cjh-banner .cjh-title::before {
top: -2px; left: -2px;
border-right: none; border-bottom: none;
}
#cjh-banner .cjh-title::after {
bottom: -2px; right: -2px;
border-left: none; border-top: none;
}
/* Action sentence — primary status text. Wraps to 2 lines on
narrower viewports so important detail isn't cut off. */
#cjh-banner .cjh-action {
position: relative;
z-index: 1;
line-height: 1.35;
font-size: 12.5px;
color: var(--cjh-ink);
padding-left: 12px;
border-left: 1px solid var(--cjh-line);
min-width: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Pulsing status dot ahead of action text */
#cjh-banner .cjh-action::before {
content: '';
display: inline-block;
vertical-align: 1px;
width: 7px; height: 7px;
background: var(--cjh-state);
margin-right: 10px;
box-shadow:
0 0 0 1px var(--cjh-bg-0),
0 0 12px var(--cjh-state);
animation: cjh-pulse 2.2s ease-in-out infinite;
}
@keyframes cjh-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(0.85); }
}
/* HUD readout pills */
#cjh-banner .cjh-details {
position: relative;
z-index: 1;
display: flex;
gap: 6px;
white-space: nowrap;
}
#cjh-banner .cjh-pill {
padding: 4px 10px;
background:
linear-gradient(180deg, rgba(255,255,255,0.02), transparent 60%),
var(--cjh-bg-2);
border: 1px solid var(--cjh-line);
font-family: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace;
font-size: 10px;
font-weight: 500;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--cjh-ink-dim);
line-height: 1.6;
}
#cjh-banner .cjh-pill b {
display: inline-block;
margin-left: 8px;
padding-left: 8px;
border-left: 1px solid var(--cjh-line);
font-weight: 700;
font-size: 12px;
letter-spacing: 0.02em;
text-transform: none;
color: var(--cjh-ink);
font-variant-numeric: tabular-nums;
}
/* Refresh + cog: square brass buttons */
#cjh-banner .cjh-refresh,
#cjh-banner .cjh-cog {
position: relative;
z-index: 1;
width: 28px; height: 28px;
background:
linear-gradient(180deg, rgba(255,255,255,0.03), transparent 60%),
var(--cjh-bg-2);
border: 1px solid var(--cjh-line);
color: var(--cjh-ink-dim);
cursor: pointer;
user-select: none;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.15s ease;
}
#cjh-banner .cjh-refresh:hover,
#cjh-banner .cjh-cog:hover {
color: var(--cjh-brass);
border-color: var(--cjh-line-strong);
background:
linear-gradient(180deg, rgba(255,255,255,0.04), transparent 60%),
var(--cjh-bg-3);
box-shadow: inset 0 0 14px rgba(199,149,82,0.10);
}
#cjh-banner .cjh-refresh:active,
#cjh-banner .cjh-cog:active { transform: translateY(1px); }
#cjh-banner .cjh-refresh.spinning {
color: var(--cjh-brass);
animation: cjh-spin 0.7s linear infinite;
}
@keyframes cjh-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Push Torn's page content down to clear the banner */
body { padding-top: 52px !important; }
/* === SETTINGS PANEL ============================ */
#cjh-settings {
position: fixed;
top: 70px;
right: 16px;
z-index: 999999;
width: 360px;
box-sizing: border-box;
padding: 18px 20px 22px;
color: var(--cjh-ink);
font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
font-size: 12.5px;
background:
repeating-linear-gradient(0deg,
transparent 0 2px,
rgba(0,0,0,0.14) 2px 3px),
linear-gradient(180deg, var(--cjh-bg-1) 0%, var(--cjh-bg-0) 100%);
border: 1px solid var(--cjh-line-strong);
box-shadow:
inset 0 1px 0 rgba(199,149,82,0.08),
0 28px 60px -16px rgba(0,0,0,0.9),
0 0 0 1px rgba(0,0,0,0.7);
animation: cjh-slide-in 220ms cubic-bezier(0.2, 0.85, 0.3, 1.1);
}
#cjh-settings.hidden { display: none; }
/* Brass hairline + corner bracket */
#cjh-settings::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: var(--cjh-brass);
opacity: 0.85;
}
#cjh-settings::after {
content: '';
position: absolute;
top: 6px; right: 6px;
width: 10px; height: 10px;
border-top: 1px solid var(--cjh-brass);
border-right: 1px solid var(--cjh-brass);
pointer-events: none;
opacity: 0.7;
}
@keyframes cjh-slide-in {
from { opacity: 0; transform: translateY(-8px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
#cjh-settings h3 {
margin: 0 0 16px;
padding: 0 0 12px;
font-family: 'Bebas Neue', 'Oswald', 'Arial Narrow', sans-serif;
font-weight: 400;
font-size: 18px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--cjh-brass);
border-bottom: 1px solid var(--cjh-line);
}
#cjh-settings label {
display: block;
margin: 14px 0 6px;
font-family: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
font-size: 9.5px;
font-weight: 500;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--cjh-ink-dim);
}
#cjh-settings input[type=text],
#cjh-settings select {
width: 100%;
box-sizing: border-box;
background: var(--cjh-bg-2);
color: var(--cjh-ink);
border: 1px solid var(--cjh-line);
padding: 8px 10px;
font-family: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace;
font-size: 12px;
transition: border-color 0.15s, background 0.15s;
}
#cjh-settings input[type=text]:focus,
#cjh-settings select:focus {
outline: none;
border-color: var(--cjh-brass-dim);
background: var(--cjh-bg-3);
box-shadow: 0 0 0 1px rgba(199,149,82,0.12), inset 0 0 12px rgba(199,149,82,0.06);
}
#cjh-settings .cjh-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
/* Inline labels in rows render plain, not as section heads */
#cjh-settings .cjh-row label {
margin: 0;
font-family: 'IBM Plex Sans', system-ui, sans-serif;
font-size: 12.5px;
letter-spacing: 0.01em;
text-transform: none;
color: var(--cjh-ink);
}
#cjh-settings input[type=checkbox] {
accent-color: var(--cjh-brass);
width: 14px; height: 14px;
}
#cjh-settings button {
background:
linear-gradient(180deg, rgba(255,255,255,0.04), transparent 60%),
var(--cjh-bg-3);
color: var(--cjh-brass);
border: 1px solid var(--cjh-line-strong);
padding: 8px 16px;
cursor: pointer;
font-family: 'Bebas Neue', 'Oswald', 'Arial Narrow', sans-serif;
font-size: 14px;
letter-spacing: 0.18em;
text-transform: uppercase;
transition: all 0.15s ease;
}
#cjh-settings button:hover {
background: var(--cjh-brass);
color: var(--cjh-bg-0);
border-color: var(--cjh-brass);
box-shadow: 0 0 18px rgba(199,149,82,0.30);
}
#cjh-settings button:active { transform: translateY(1px); }
#cjh-settings .cjh-hint {
font-size: 11px;
line-height: 1.55;
color: var(--cjh-ink-faint);
margin: 6px 0 2px;
padding-left: 10px;
border-left: 1px solid var(--cjh-line);
}
#cjh-settings a {
color: var(--cjh-brass);
text-decoration: none;
border-bottom: 1px dotted var(--cjh-brass-dim);
}
#cjh-settings a:hover {
color: var(--cjh-ink);
border-bottom-color: var(--cjh-ink);
}
#cjh-settings .cjh-credits {
margin: 18px -20px -22px;
padding: 10px 20px 12px;
border-top: 1px solid var(--cjh-line);
background: rgba(0,0,0,0.25);
font-family: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
font-size: 10.5px;
letter-spacing: 0.06em;
text-align: center;
color: var(--cjh-ink-faint);
}
#cjh-settings .cjh-credits a {
color: var(--cjh-brass);
border-bottom: none;
}
#cjh-settings .cjh-credits a:hover { color: var(--cjh-ink); }
#cjh-settings .cjh-heart {
color: var(--cjh-warn, #d24a3e);
display: inline-block;
animation: cjh-heartbeat 1.6s ease-in-out infinite;
}
@keyframes cjh-heartbeat {
0%, 100% { transform: scale(1); opacity: 0.85; }
50% { transform: scale(1.2); opacity: 1; }
}
/* Narrow Torn layouts (incl. TornPDA mobile app) */
@media (max-width: 1100px) {
/* Drop the "Next jump" pill — keep the three core readouts only */
#cjh-banner .cjh-pill:nth-child(n+4) { display: none; }
}
@media (max-width: 820px) {
/* Drop pills entirely; action text remains the focus */
#cjh-banner .cjh-details { display: none; }
#cjh-banner { gap: 10px; }
}
@media (max-width: 560px) {
/* TornPDA-style narrow: collapse title to brackets-only, drop padding */
#cjh-banner {
padding: 6px 10px 6px 8px;
gap: 8px;
}
#cjh-banner .cjh-title {
font-size: 0;
padding: 0;
width: 6px; height: 22px;
background: var(--cjh-state);
border: none;
text-shadow: none;
}
#cjh-banner .cjh-title::before,
#cjh-banner .cjh-title::after { display: none; }
#cjh-banner .cjh-action { padding-left: 8px; font-size: 12px; }
#cjh-banner .cjh-refresh,
#cjh-banner .cjh-cog { width: 26px; height: 26px; font-size: 13px; }
body { padding-top: 56px !important; }
}
`;
document.head.appendChild(s);
}
let bannerEl, settingsEl;
function buildBanner() {
if (document.getElementById('cjh-banner')) return;
const div = document.createElement('div');
div.id = 'cjh-banner';
div.className = 'idle';
div.innerHTML = `
<span class="cjh-title">Choco Jump Helper</span>
<span class="cjh-action">Loading…</span>
<span class="cjh-details"></span>
<span class="cjh-refresh" title="Refresh now">↻</span>
<span class="cjh-cog" title="Settings">⚙</span>
`;
document.body.appendChild(div);
div.querySelector('.cjh-cog').addEventListener('click', toggleSettings);
div.querySelector('.cjh-refresh').addEventListener('click', manualRefresh);
bannerEl = div;
}
function buildSettings() {
if (document.getElementById('cjh-settings')) return;
const div = document.createElement('div');
div.id = 'cjh-settings';
div.className = 'hidden';
div.innerHTML = `
<h3>Choco Jump Helper — Settings</h3>
<label for="cjh-apikey">Torn API key (Public scope is enough)</label>
<input type="text" id="cjh-apikey" placeholder="paste your API key" />
<div class="cjh-hint">Create one at <a href="https://www.torn.com/preferences.php#tab=api" target="_blank" rel="noopener">torn.com/preferences → API</a>. Only "Public" access is required.</div>
<label for="cjh-mode">Method</label>
<select id="cjh-mode">
<option value="full">Full Choco Jump (48 BBC, 1×/day)</option>
<option value="half">Half Choco Jump (~22 BBC, 2×/day)</option>
</select>
<div class="cjh-row">
<input type="checkbox" id="cjh-optxan" />
<label for="cjh-optxan" style="margin:0;">Suggest optional 2nd Xanax (Full method only)</label>
</div>
<div class="cjh-hint">Per HOU5E's Single Choco Jump chart: a ~3h window once the 1st Xanax's CD ends, during which a 2nd Xanax adds +250 energy to train through. Ignored in the Double (half) method.</div>
<label>Online hours (local time)</label>
<div class="cjh-row">
<select id="cjh-avail-from" style="flex:1;"></select>
<span>to</span>
<select id="cjh-avail-to" style="flex:1;"></select>
</div>
<div class="cjh-hint">The hours you're realistically able to be in-game. The helper only recommends popping Xanax if the BBC step (≈3.5h later) also falls inside this window.</div>
<div class="cjh-row" style="margin-top:12px;">
<button id="cjh-save">Save</button>
<button id="cjh-mark-xan" title="Record that you just popped Xanax — used for the optional 2nd Xanax window" style="background:#5a4a14;">I just popped Xanax</button>
</div>
<div class="cjh-credits">
Crafted by <a href="https://www.torn.com/profiles.php?XID=3702492" target="_blank" rel="noopener">Nicram [3702492]</a> · all tips welcome <span class="cjh-heart">♥</span>
</div>
`;
document.body.appendChild(div);
// Populate hour dropdowns 0–23.
const fromSel = div.querySelector('#cjh-avail-from');
const toSel = div.querySelector('#cjh-avail-to');
for (let h = 0; h < 24; h++) {
const labelText = `${pad2(h)}:00`;
fromSel.insertAdjacentHTML('beforeend', `<option value="${h}">${labelText}</option>`);
toSel.insertAdjacentHTML('beforeend', `<option value="${h}">${labelText}</option>`);
}
div.querySelector('#cjh-apikey').value = settings.apiKey || '';
div.querySelector('#cjh-mode').value = settings.mode;
div.querySelector('#cjh-optxan').checked = !!settings.optionalXanax;
fromSel.value = String(settings.availableFrom);
toSel.value = String(settings.availableTo);
div.querySelector('#cjh-save').addEventListener('click', onSave);
div.querySelector('#cjh-mark-xan').addEventListener('click', () => {
const now = Math.floor(Date.now() / 1000);
settings.firstXanaxAt = now;
save('firstXanaxAt', now);
div.querySelector('#cjh-mark-xan').textContent = '✓ Xanax timestamp saved';
setTimeout(() => {
div.querySelector('#cjh-mark-xan').textContent = 'I just popped Xanax';
}, 1500);
});
settingsEl = div;
}
function toggleSettings() {
if (!settingsEl) return;
settingsEl.classList.toggle('hidden');
}
function onSave() {
const newKey = settingsEl.querySelector('#cjh-apikey').value.trim();
settings.apiKey = newKey;
settings.mode = settingsEl.querySelector('#cjh-mode').value;
settings.optionalXanax = settingsEl.querySelector('#cjh-optxan').checked;
settings.availableFrom = parseInt(settingsEl.querySelector('#cjh-avail-from').value, 10);
settings.availableTo = parseInt(settingsEl.querySelector('#cjh-avail-to').value, 10);
save('apiKey', settings.apiKey);
save('mode', settings.mode);
save('optionalXanax', settings.optionalXanax);
save('availableFrom', settings.availableFrom);
save('availableTo', settings.availableTo);
settingsEl.classList.add('hidden');
// Force immediate refetch + render.
snapshot = null;
save('lastSnapshot', null);
refresh();
}
function renderBanner(decision) {
if (!bannerEl) return;
bannerEl.className = decision.color;
bannerEl.querySelector('.cjh-title').textContent = decision.title;
bannerEl.querySelector('.cjh-action').textContent = decision.action;
const detailsEl = bannerEl.querySelector('.cjh-details');
detailsEl.innerHTML = decision.details.map(d => {
const v = d.text !== undefined ? d.text : (d.secs > 0 ? fmtDur(d.secs) : '—');
return `<span class="cjh-pill">${d.label}: <b>${v}</b></span>`;
}).join('');
}
// -------------------------------------------------------------------------
// Tick + poll loop
// -------------------------------------------------------------------------
let lastFetchAt = 0;
function pollInterval() {
return document.hidden ? 60_000 : 15_000;
}
async function manualRefresh() {
const btn = bannerEl && bannerEl.querySelector('.cjh-refresh');
if (btn) btn.classList.add('spinning');
lastFetchAt = 0; // force a fetch on next refresh()
await refresh();
if (btn) btn.classList.remove('spinning');
}
async function refresh() {
const nowMs = Date.now();
if (settings.apiKey && nowMs - lastFetchAt > pollInterval()) {
lastFetchAt = nowMs;
const fresh = await fetchState();
if (fresh) snapshot = fresh;
}
const nowS = Math.floor(Date.now() / 1000);
const projected = projectSnapshot(snapshot, nowS);
const decision = decide(projected, settings, nowS);
renderBanner(decision);
}
function start() {
injectStyles();
buildBanner();
buildSettings();
refresh();
setInterval(refresh, 1000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();