bitmax/index.html
monster 59fd95b707 Initial commit: BORING SOFTWARE — an anthology of incremental games
Five incremental games disguised as boring 1998-era desktop utilities, launched from a Win98 shell (ENTROPY OS):

- Vol I  qBitTorrz       (index.html)        — torrent-client idler; seed, climb hardware tiers, migrate
- Vol II  DEFRAG.EXE     (defrag.html)       — steer a disk defragmenter; drag-select; outrun entropy
- Vol III MACRO_VIRUS.XLS(spreadsheet.html)  — build a compounding economy by drag-filling formulas
- Vol IV  UPLINK         (terminal.html)     — terminal + live htop; manage load/heat; escalate to root
- Vol V   INBOX ZERO     (inbox.html)        — keyboard-triage an exponential inbox; write regex rules
- ENTROPY OS             (desktop.html)      — the Win98 launcher that holds them all

Each is a single self-contained HTML file (vanilla JS, no deps, no build), with idle/offline
progress, scientific-scale numbers, diegetic upgrades, and a prestige that re-frames the fiction.

Also includes MANIFESTO.md (design thesis) and docs/ — the Collector's Edition companion:
a Dev Handbook and a Lore Bible (lore complete; 5 handbook chapters land in the next commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:44:36 +10:00

1853 lines
98 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>qBitTorrz 4.6.2</title>
<style>
/* ============================================================
qBittorrz — an incremental game dressed up as a torrent client
Faithful to the dense, gray, tabular qBittorrent (Fusion) look.
============================================================ */
:root{
--bg:#f0f0f0; --panel:#ececec; --panel2:#e4e4e4; --line:#c8c8c8; --line2:#b4b4b4;
--text:#1d1d1d; --muted:#5a5a5a; --sel:#cce8ff; --sel-line:#99d1ff;
--tbl-alt:#f6f7f9; --tbl-hover:#eef5fd;
--green:#4f9e3a; --green2:#6fc24f; --blue:#2f7fd0; --red:#c23b2e; --orange:#d98a1f;
--grad-top:#fbfbfb; --grad-bot:#e6e6e6;
--mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
--ui:"Segoe UI","Helvetica Neue",-apple-system,system-ui,Tahoma,sans-serif;
}
*{box-sizing:border-box;}
html,body{height:100%;margin:0;}
body{
font-family:var(--ui); font-size:12px; color:var(--text);
background:var(--bg); overflow:hidden; user-select:none; -webkit-user-select:none;
}
button{font-family:inherit;font-size:inherit;color:inherit;cursor:pointer;}
/* ---- App window shell ---- */
#app{display:flex;flex-direction:column;height:100vh;width:100vw;}
/* ---- Menu bar ---- */
#menubar{display:flex;align-items:center;background:var(--panel);border-bottom:1px solid var(--line);
padding:0 2px;height:24px;flex:0 0 auto;}
.menu{padding:3px 9px;border-radius:3px;position:relative;}
.menu:hover{background:var(--sel);}
.menu .dropdown{display:none;position:absolute;top:22px;left:0;background:#fafafa;border:1px solid var(--line2);
box-shadow:2px 2px 6px rgba(0,0,0,.18);min-width:190px;z-index:300;padding:3px 0;border-radius:0 0 4px 4px;}
.menu:hover .dropdown{display:block;}
.menu .dropdown div{padding:4px 14px;display:flex;justify-content:space-between;gap:18px;}
.menu .dropdown div:hover{background:var(--sel);}
.menu .dropdown .sep{height:1px;background:var(--line);margin:3px 0;padding:0;}
.menu .dropdown .kbd{color:var(--muted);font-size:11px;}
/* ---- Toolbar ---- */
#toolbar{display:flex;align-items:center;gap:2px;height:38px;flex:0 0 auto;
background:linear-gradient(var(--grad-top),var(--grad-bot));border-bottom:1px solid var(--line2);padding:0 4px;}
.tbtn{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;
min-width:46px;height:34px;border:1px solid transparent;border-radius:4px;background:transparent;padding:2px 6px;}
.tbtn:hover{border-color:var(--line2);background:rgba(255,255,255,.6);}
.tbtn:active{background:var(--panel2);}
.tbtn[disabled]{opacity:.38;pointer-events:none;}
.tbtn .ic{font-size:16px;line-height:16px;}
.tbtn .lb{font-size:10px;color:#333;white-space:nowrap;}
.tbtn.cta .lb{color:var(--green);font-weight:600;}
.tsep{width:1px;height:26px;background:var(--line);margin:0 4px;}
#tb-stats{margin-left:auto;display:flex;gap:14px;align-items:center;padding-right:8px;}
.stat{display:flex;flex-direction:column;align-items:flex-end;line-height:1.15;}
.stat .k{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;}
.stat .v{font-size:13px;font-weight:700;font-family:var(--mono);}
.stat .v.data{color:var(--green);} .stat .v.cred{color:#8a2fb0;}
/* ---- Body split ---- */
#body{display:flex;flex:1 1 auto;min-height:0;}
#sidebar{width:182px;flex:0 0 auto;background:var(--panel);border-right:1px solid var(--line);
overflow-y:auto;padding:4px 0;}
#splitter-v{width:4px;cursor:col-resize;background:transparent;flex:0 0 auto;}
#splitter-v:hover{background:var(--sel-line);}
#center{flex:1 1 auto;display:flex;flex-direction:column;min-width:0;min-height:0;}
.side-group{margin-bottom:2px;}
.side-head{display:flex;align-items:center;gap:5px;padding:4px 8px;font-weight:700;color:#333;
cursor:pointer;font-size:11px;}
.side-head .tw{font-size:9px;color:var(--muted);width:8px;}
.side-item{display:flex;align-items:center;gap:6px;padding:3px 8px 3px 22px;cursor:pointer;border:1px solid transparent;}
.side-item:hover{background:var(--tbl-hover);}
.side-item.active{background:var(--sel);border-color:var(--sel-line);}
.side-item .si-ic{width:14px;text-align:center;font-size:12px;}
.side-item .si-lb{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.side-item .si-ct{color:var(--muted);font-family:var(--mono);font-size:11px;}
.side-item.locked{opacity:.45;}
.side-item.shop .si-lb{color:#1551a8;}
.badge-new{background:var(--red);color:#fff;border-radius:8px;font-size:9px;padding:0 5px;margin-left:4px;}
/* ---- Transfer list ---- */
#list-wrap{flex:1 1 auto;overflow:auto;min-height:60px;background:#fff;}
table.tl{border-collapse:collapse;width:100%;font-size:12px;}
table.tl thead th{position:sticky;top:0;z-index:2;background:linear-gradient(var(--grad-top),var(--panel2));
border-right:1px solid var(--line);border-bottom:1px solid var(--line2);padding:4px 6px;text-align:left;
font-weight:600;white-space:nowrap;color:#333;}
table.tl thead th.num{text-align:right;}
table.tl tbody td{border-right:1px solid #eee;border-bottom:1px solid #f0f0f0;padding:3px 6px;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis;max-width:320px;}
table.tl tbody td.num{text-align:right;font-family:var(--mono);}
table.tl tbody tr:nth-child(even){background:var(--tbl-alt);}
table.tl tbody tr:hover{background:var(--tbl-hover);}
table.tl tbody tr.sel{background:var(--sel)!important;}
.name-cell{display:flex;align-items:center;gap:6px;}
.st-dot{width:10px;height:10px;border-radius:2px;flex:0 0 auto;}
.st-down{background:var(--blue);} .st-seed{background:var(--green);}
.st-pause{background:#9a9a9a;} .st-stall{background:var(--orange);}
.st-check{background:#b59b2f;}
/* progress bar cell */
.pbar{position:relative;height:14px;background:#e9e9e9;border:1px solid #c4c4c4;border-radius:2px;overflow:hidden;min-width:90px;}
.pbar .fill{position:absolute;left:0;top:0;bottom:0;background:
repeating-linear-gradient(90deg,var(--green2) 0,var(--green2) 6px,var(--green) 6px,var(--green) 7px);}
.pbar.seed .fill{background:repeating-linear-gradient(90deg,#3f86d6 0,#3f86d6 6px,#2f7fd0 6px,#2f7fd0 7px);}
.pbar.stall .fill{background:var(--orange);}
.pbar .txt{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:10px;
font-family:var(--mono);color:#1d1d1d;text-shadow:0 0 2px rgba(255,255,255,.7);}
.col-down{color:var(--blue);} .col-up{color:var(--green);}
.stall-txt{color:var(--orange);font-style:italic;}
#empty-hint{padding:40px;text-align:center;color:var(--muted);}
#empty-hint b{color:var(--green);}
/* ---- horizontal splitter ---- */
#splitter-h{height:5px;cursor:row-resize;background:var(--panel2);border-top:1px solid var(--line);
border-bottom:1px solid var(--line);flex:0 0 auto;}
#splitter-h:hover{background:var(--sel-line);}
/* ---- Detail pane ---- */
#detail{height:210px;flex:0 0 auto;background:var(--panel);display:flex;flex-direction:column;min-height:0;}
#detail-tabs{display:flex;border-bottom:1px solid var(--line2);background:var(--panel2);flex:0 0 auto;}
.dtab{padding:5px 13px;border-right:1px solid var(--line);cursor:pointer;font-size:11px;}
.dtab:hover{background:#fff;}
.dtab.active{background:#fff;border-bottom:2px solid var(--blue);margin-bottom:-1px;font-weight:600;}
#detail-body{flex:1 1 auto;overflow:auto;background:#fff;padding:8px 10px;font-size:12px;}
.kv{display:grid;grid-template-columns:max-content 1fr;gap:2px 16px;}
.kv .k{color:var(--muted);} .kv .v{font-family:var(--mono);}
.det-empty{color:var(--muted);padding:18px;text-align:center;}
/* peers tab */
table.peers{border-collapse:collapse;width:100%;font-size:11px;}
table.peers th,table.peers td{border:1px solid #eee;padding:2px 6px;text-align:left;font-family:var(--mono);}
table.peers th{background:var(--panel2);font-family:var(--ui);}
.mini-btn{border:1px solid var(--line2);background:linear-gradient(var(--grad-top),var(--grad-bot));
border-radius:3px;padding:1px 7px;font-size:11px;}
.mini-btn:hover{background:#fff;} .mini-btn:active{background:var(--panel2);}
.mini-btn.warn{color:var(--red);}
/* irc */
#irc-log{font-family:var(--mono);font-size:11px;line-height:1.5;}
#irc-log .nick{font-weight:700;} #irc-log .me{color:#1551a8;}
.irc-op{color:var(--green);} .irc-sys{color:var(--muted);font-style:italic;}
.stall-row{display:flex;align-items:center;gap:8px;padding:3px 0;border-bottom:1px dotted #ddd;}
/* speed graph */
#spd-canvas{width:100%;height:150px;min-height:60px;display:block;background:#fbfdff;border:1px solid var(--line);}
.spd-legend{display:flex;gap:18px;margin-top:5px;font-family:var(--mono);font-size:11px;}
.spd-legend .d{color:var(--blue);} .spd-legend .u{color:var(--green);}
/* ---- Status bar ---- */
#statusbar{display:flex;align-items:center;gap:0;height:24px;flex:0 0 auto;background:var(--panel);
border-top:1px solid var(--line);font-size:11px;}
.sb{display:flex;align-items:center;gap:5px;padding:0 10px;border-right:1px solid var(--line);height:100%;}
.sb .ic{font-size:12px;} .sb.grow{flex:1;border-right:none;}
.sb .dn{color:var(--blue);font-family:var(--mono);} .sb .up{color:var(--green);font-family:var(--mono);}
.conn-ok{color:var(--green);} .conn-warn{color:var(--orange);}
/* ---- Modals ---- */
#modal-back{position:fixed;inset:0;background:rgba(0,0,0,.32);display:none;z-index:500;
align-items:center;justify-content:center;}
#modal-back.show{display:flex;}
.modal{background:var(--bg);border:1px solid #888;border-radius:5px;box-shadow:0 8px 30px rgba(0,0,0,.4);
width:760px;max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden;}
.modal.sm{width:440px;}
.modal-title{display:flex;align-items:center;gap:8px;background:linear-gradient(var(--grad-top),var(--panel2));
border-bottom:1px solid var(--line2);padding:7px 12px;font-weight:700;}
.modal-title .x{margin-left:auto;border:1px solid var(--line2);background:#fff;border-radius:3px;width:22px;height:20px;
line-height:16px;text-align:center;color:#a33;}
.modal-title .x:hover{background:#fbdcd9;}
.modal-body{padding:0;overflow:auto;flex:1 1 auto;}
.modal-foot{padding:8px 12px;border-top:1px solid var(--line);display:flex;gap:8px;justify-content:flex-end;
align-items:center;background:var(--panel);}
.btn{border:1px solid var(--line2);background:linear-gradient(var(--grad-top),var(--grad-bot));border-radius:4px;
padding:5px 16px;font-weight:600;}
.btn:hover{background:#fff;} .btn:active{background:var(--panel2);}
.btn.primary{background:linear-gradient(#5fb046,#4f9e3a);color:#fff;border-color:#3d7d2c;}
.btn.primary:hover{background:linear-gradient(#6cc051,#56ab40);}
.btn.danger{background:linear-gradient(#d7574a,#c23b2e);color:#fff;border-color:#9c2b20;}
.btn[disabled]{opacity:.45;pointer-events:none;}
/* store / catalog tabs */
.mtabs{display:flex;border-bottom:1px solid var(--line2);background:var(--panel2);position:sticky;top:0;z-index:3;}
.mtab{padding:7px 14px;border-right:1px solid var(--line);cursor:pointer;}
.mtab:hover{background:#fff;} .mtab.active{background:#fff;border-bottom:2px solid var(--blue);font-weight:600;}
.mtab.locked{opacity:.5;}
/* Dense, utilitarian "config list" rows — no arcade cards, no bright pill buttons. */
.store-list{display:flex;flex-direction:column;gap:0;border-top:1px solid var(--line);}
.up-card{display:flex;align-items:center;gap:10px;border:0;border-bottom:1px solid var(--line);
background:#fff;padding:5px 10px;}
.store-list .up-card:nth-child(even){background:var(--tbl-alt);}
.up-card:hover{background:var(--tbl-hover);}
.up-card .up-ic{font-size:13px;width:16px;text-align:center;filter:grayscale(1);opacity:.6;flex:0 0 auto;}
.up-card .up-main{flex:1;min-width:0;}
.up-card .up-name{font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.up-card .up-name .lvl{color:var(--muted);font-weight:400;margin-left:8px;font-family:var(--mono);font-size:11px;}
.up-card .up-desc{color:var(--muted);font-size:11px;margin-top:0;}
.up-card .up-eff{font-size:11px;font-family:var(--mono);color:#2a5a8a;margin-top:1px;}
.up-card .up-buy{display:flex;flex-direction:row;align-items:center;justify-content:flex-end;gap:12px;min-width:168px;}
.up-cost{font-family:var(--mono);font-weight:700;min-width:62px;text-align:right;}
.up-cost.ok{color:var(--green);} .up-cost.no{color:#a23;}
/* flat 2003-style action buttons inside rows */
.up-card .btn{padding:2px 12px;font-size:11px;font-weight:600;border-radius:3px;min-width:70px;
background:linear-gradient(var(--grad-top),var(--grad-bot));color:#1d1d1d;border:1px solid var(--line2);}
.up-card .btn:hover{background:#fff;}
.up-card .btn.primary{color:#1d6b14;border-color:#9cbf90;
background:linear-gradient(var(--grad-top),var(--grad-bot));}
.up-card .btn.primary:hover{background:#f1fbed;}
.up-card.bought{background:#f5f8f5!important;}
.up-card.bought .up-name{color:#3d7d2c;}
.up-card.maxed .up-buy{opacity:.7;}
.up-card.lockreq{opacity:.5;}
.cat-meta{font-size:11px;color:var(--muted);font-family:var(--mono);margin-top:1px;}
.pill{display:inline-block;background:var(--panel2);border:1px solid var(--line);border-radius:2px;
padding:0 6px;margin-right:4px;font-size:10px;font-family:var(--mono);color:#555;}
.tier-note{padding:6px 10px;background:var(--panel);border-bottom:1px solid var(--line);font-size:11px;color:var(--muted);}
/* prestige */
.prestige-box{padding:16px 20px;}
.prestige-box h2{margin:0 0 8px;}
.prestige-box .big{font-size:26px;font-family:var(--mono);font-weight:800;color:#8a2fb0;}
.warn-box{background:#fff4f2;border:1px solid #e7b7af;border-radius:5px;padding:10px 12px;margin:10px 0;color:#7a2018;}
/* context menu */
#ctxmenu{position:fixed;z-index:600;background:#fafafa;border:1px solid var(--line2);
box-shadow:2px 2px 8px rgba(0,0,0,.25);border-radius:4px;padding:3px 0;display:none;min-width:180px;}
#ctxmenu div{padding:5px 16px;display:flex;gap:10px;align-items:center;}
#ctxmenu div:hover{background:var(--sel);}
#ctxmenu div.dis{opacity:.4;pointer-events:none;}
#ctxmenu .sep{height:1px;background:var(--line);padding:0;margin:3px 0;}
/* toasts */
#toasts{position:fixed;right:14px;bottom:34px;z-index:700;display:flex;flex-direction:column;gap:8px;align-items:flex-end;}
.toast{background:#2b2b2b;color:#fff;border-radius:6px;padding:9px 14px;box-shadow:0 4px 14px rgba(0,0,0,.35);
font-size:12px;max-width:330px;border-left:4px solid var(--green);animation:tin .25s ease;}
.toast.warn{border-left-color:var(--orange);} .toast.bad{border-left-color:var(--red);}
.toast.info{border-left-color:var(--blue);} .toast.prestige{border-left-color:#8a2fb0;}
.toast b{display:block;margin-bottom:2px;}
@keyframes tin{from{transform:translateX(40px);opacity:0;}to{transform:none;opacity:1;}}
/* event banner */
#event-banner{display:none;align-items:center;gap:10px;padding:4px 12px;font-weight:600;font-size:11px;flex:0 0 auto;}
#event-banner.burst{display:flex;background:linear-gradient(#dff5d0,#cdeeb6);color:#2c5e16;border-bottom:1px solid #9fce7e;}
#event-banner.throttle{display:flex;background:linear-gradient(#ffe0d6,#ffcbbb);color:#8a3115;border-bottom:1px solid #e7a78f;}
.scroller{font-family:var(--mono);}
::-webkit-scrollbar{width:14px;height:14px;}
::-webkit-scrollbar-thumb{background:#c3c3c3;border:3px solid var(--bg);border-radius:8px;}
::-webkit-scrollbar-thumb:hover{background:#a9a9a9;}
</style>
</head>
<body>
<div id="app">
<!-- MENU BAR -->
<div id="menubar">
<div class="menu">File
<div class="dropdown">
<div data-act="open-add">Add Torrent…<span class="kbd">Ctrl+O</span></div>
<div data-act="open-add">Add Torrent Link…<span class="kbd">Ctrl+U</span></div>
<div class="sep"></div>
<div data-act="save-now">Save Session<span class="kbd">Ctrl+S</span></div>
<div data-act="export-save">Export Save…</div>
<div data-act="import-save">Import Save…</div>
<div class="sep"></div>
<div data-act="hard-reset">Delete Everything…</div>
</div>
</div>
<div class="menu">Edit
<div class="dropdown">
<div data-act="resume-all">Resume All</div>
<div data-act="pause-all">Pause All</div>
<div data-act="remove-sel">Remove Selected<span class="kbd">Del</span></div>
</div>
</div>
<div class="menu">View
<div class="dropdown">
<div data-act="open-store">Upgrades / Options…</div>
<div data-act="toggle-sci">Toggle Number Format</div>
</div>
</div>
<div class="menu">Tools
<div class="dropdown">
<div data-act="open-store">Network Options…</div>
<div data-act="open-prestige">Client Migration (Seedbox Cluster)…</div>
</div>
</div>
<div class="menu">Help
<div class="dropdown">
<div data-act="open-help">How to Play…</div>
<div data-act="open-about">About qBitTorrz</div>
</div>
</div>
</div>
<!-- TOOLBAR -->
<div id="toolbar">
<button class="tbtn cta" data-act="open-add"><span class="ic"></span><span class="lb">Add Torrent</span></button>
<div class="tsep"></div>
<button class="tbtn" data-act="resume-all"><span class="ic">▶️</span><span class="lb">Resume All</span></button>
<button class="tbtn" data-act="pause-all"><span class="ic">⏸️</span><span class="lb">Pause All</span></button>
<button class="tbtn" id="tb-remove" data-act="remove-sel"><span class="ic">🗑️</span><span class="lb">Remove</span></button>
<div class="tsep"></div>
<button class="tbtn" data-act="open-store"><span class="ic">🛠️</span><span class="lb">Upgrades</span></button>
<button class="tbtn" id="tb-prestige" data-act="open-prestige"><span class="ic">🛰️</span><span class="lb">Migrate</span></button>
<div id="tb-stats">
<div class="stat"><span class="k">Data</span><span class="v data" id="hud-data">0 B</span></div>
<div class="stat"><span class="k">Up Total</span><span class="v" id="hud-total">0 B</span></div>
<div class="stat" id="hud-cred-wrap" style="display:none"><span class="k">Credits</span><span class="v cred" id="hud-cred">0</span></div>
</div>
</div>
<!-- EVENT BANNER -->
<div id="event-banner"></div>
<!-- BODY -->
<div id="body">
<div id="sidebar"></div>
<div id="splitter-v"></div>
<div id="center">
<div id="list-wrap">
<table class="tl">
<thead><tr id="tl-head"></tr></thead>
<tbody id="tl-body"></tbody>
</table>
<div id="empty-hint"></div>
</div>
<div id="splitter-h"></div>
<div id="detail">
<div id="detail-tabs">
<div class="dtab active" data-dtab="general">General</div>
<div class="dtab" data-dtab="trackers">Trackers</div>
<div class="dtab" data-dtab="peers">Peers</div>
<div class="dtab" data-dtab="irc">IRC / Swarm</div>
<div class="dtab" data-dtab="speed">Speed</div>
</div>
<div id="detail-body"></div>
</div>
</div>
</div>
<!-- STATUS BAR -->
<div id="statusbar">
<div class="sb" id="sb-conn"><span class="ic">🟢</span><span class="conn-ok">DHT: <b id="sb-dht">0</b> nodes</span></div>
<div class="sb"><span class="ic">💾</span>Slots: <b id="sb-slots">0/0</b></div>
<div class="sb grow" id="sb-status">Idle</div>
<div class="sb"><span class="ic"></span><span class="dn" id="sb-down">0 B/s</span></div>
<div class="sb"><span class="ic"></span><span class="up" id="sb-up">0 B/s</span></div>
<div class="sb"><span class="ic">⚖️</span>Ratio: <b id="sb-ratio"></b></div>
</div>
</div>
<!-- MODAL -->
<div id="modal-back"><div class="modal" id="modal"></div></div>
<!-- CONTEXT MENU -->
<div id="ctxmenu"></div>
<!-- TOASTS -->
<div id="toasts"></div>
<script>
"use strict";
/* ===========================================================================
qBitTorrz — incremental torrent client
=========================================================================== */
const SAVE_KEY = "qbittorrz_save_v1";
const TICK_MS = 100;
const MAX_OFFLINE_S = 8*3600; // cap offline progress at 8h
const SPD_SAMPLES = 160;
/* ---- number formatting ---------------------------------------------------- */
const SI = ['B','kB','MB','GB','TB','PB','EB','ZB','YB','RB','QB'];
function sciStr(n){ const e=Math.floor(Math.log10(n)); return (n/Math.pow(10,e)).toFixed(2)+'e'+e+' B'; }
function fmt(n){
if(n===Infinity) return '∞';
if(!isFinite(n)||isNaN(n)) return '0 B';
if(n<0) n=0;
if(n<1000) return Math.floor(n)+' B';
// Number-format toggle (View menu): pure scientific notation vs SI suffixes.
if(G&&G.sci) return sciStr(n);
let i=0,x=n;
while(x>=1000 && i<SI.length-1){x/=1000;i++;}
if(i===SI.length-1 && x>=1000) return sciStr(n); // past QB -> scientific
return x.toFixed(x<100?2:1)+' '+SI[i];
}
function fmtSpd(n){return fmt(n)+'/s';}
function fmtInt(n){
if(!isFinite(n)) return '∞';
if(n<1000) return Math.floor(n).toString();
return fmt(n).replace(' B','');
}
function fmtTime(s){
if(!isFinite(s)||s<0) return '∞';
if(s<1) return '< 1s';
s=Math.floor(s);
const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60),ss=s%60;
if(d>99) return '∞';
if(d>0) return d+'d '+h+'h';
if(h>0) return h+'h '+m+'m';
if(m>0) return m+'m '+ss+'s';
return ss+'s';
}
/* ---- catalog (purchasable torrents) -------------------------------------- */
// size = bytes to download. up = base seeding income B/s. seeds = base swarm.
// req = unlock id required to see it. rare = stalls at 99.9% (needs IRC).
const CATALOG = [
// Public Tier A — always available
{id:'t01',name:'tiny_release_notes.txt', size:4e3, up:9, seeds:5, req:null},
{id:'t02',name:'Ubuntoo-24.04-desktop.iso', size:5.2e4, up:55, seeds:6, req:null},
{id:'t03',name:'Lofi_Beats_To_Compile_To.flac', size:2.4e5, up:230, seeds:6, req:null},
{id:'t04',name:'Big_Buck_Rabbit_1080p.mp4', size:1.3e6, up:900, seeds:5, req:null},
{id:'t05',name:'Arch_Minimal_NetInstall.iso', size:6.5e6, up:3.2e3, seeds:5, req:null},
// Public Tier B — Cat6
{id:'t06',name:'PublicDomain_FilmNoir_Pack.mkv', size:4.4e7, up:1.6e4, seeds:5, req:'cat6'},
{id:'t07',name:'OpenStreetMap_Planet.osm.pbf', size:2.1e8, up:6.5e4, seeds:4, req:'cat6'},
{id:'t08',name:'Wikipedia_Full_Text_Dump.xml', size:9.0e8, up:2.4e5, seeds:4, req:'cat6'},
// Private Trackers — GB scene releases
{id:'t09',name:'SCENE-Nature.Docu.4K.HDR.mkv', size:4.2e9, up:1.1e6, seeds:4, req:'private'},
{id:'t10',name:'Lossless_Discography_Box.flac', size:1.9e10,up:4.4e6, seeds:3, req:'private'},
{id:'t11',name:'GameOfThorns.S01.REMUX.2160p', size:6.4e10,up:1.4e7, seeds:3, req:'private', rare:true},
{id:'t12',name:'Every_Linux_ISO_Ever.tar', size:2.4e11,up:4.0e7, seeds:3, req:'private'},
// Fiber-Optic — TB datasets
{id:'t13',name:'NASA_Raw_Telescope_Capture.fits', size:1.6e12,up:1.9e8, seeds:3, req:'fiber'},
{id:'t14',name:'Human_Genome_RefSet.tar.gz', size:7.5e12,up:8.0e8, seeds:2, req:'fiber'},
{id:'t15',name:'Internet_Archive_Partial_Mirror', size:4.2e13,up:4.4e9, seeds:2, req:'fiber', rare:true},
{id:'t16',name:'CERN_Collider_Event_Logs.root', size:1.9e14,up:2.0e10,seeds:2, req:'fiber'},
// Darknet Relays — PB (prestige)
{id:'t17',name:'Decentralized_Web_Snapshot.warc', size:1.3e15,up:1.7e11,seeds:2, req:'darknet'},
{id:'t18',name:'Quantum_Training_Corpus.bin', size:9.5e15,up:1.3e12,seeds:2, req:'darknet'},
{id:'t19',name:'The_Library_Of_Everything.iso', size:8.0e16,up:9.0e12,seeds:1, req:'darknet', rare:true},
{id:'t20',name:'Universe_Simulation_State.dump', size:6.0e17,up:7.0e13,seeds:1, req:'darknet'},
// Singularity — EB+ (deep prestige perk)
{id:'t21',name:'All_Human_Knowledge.7z', size:5.0e18,up:6.0e14,seeds:1, req:'singularity'},
{id:'t22',name:'Multiverse_Full_Backup.tar', size:4.0e19,up:5.0e15,seeds:1, req:'singularity', rare:true},
{id:'t23',name:'The_Final_Hash.dat', size:3.0e20,up:4.0e16,seeds:1, req:'singularity'},
];
const CAT_BY_ID = Object.fromEntries(CATALOG.map(c=>[c.id,c]));
/* ---- upgrades (repeatable & one-time) ------------------------------------ */
// kind: 'mult-down','mult-up','seeds','storage','ratio','flat-up' (repeatable, leveled)
// 'unlock' (one-time on/off)
const UPGRADES = [
// --- Public Trackers tab ---
{id:'bandwidth', tab:'public', ic:'🖧', name:'Network Hardware', kind:'hardware',
desc:'Upgrade your physical line. Each tier multiplies Max Line Speed ×2.2.', base:64, growth:1.7, mult:2.2, max:60},
{id:'perpeer', tab:'public', ic:'🔗', name:'Bandwidth Per Peer', kind:'perpeer',
desc:'Pull harder from each seed. ×1.3 per-peer bandwidth — saturate the line with fewer seeds.', base:320, growth:1.6, mult:1.3, max:30},
{id:'upslot', tab:'public', ic:'🔼', name:'Upload Slot Tuning', kind:'mult-up',
desc:'More upload slots per torrent. ×1.55 seeding income.', base:90, growth:1.55, mult:1.55, max:200},
{id:'upnp', tab:'public', ic:'🔌', name:'UPnP Port Forwarding', kind:'seeds',
desc:'Open your ports. +2 connected seeds on every torrent.', base:140, growth:1.7, add:2, max:60},
{id:'storage1', tab:'public', ic:'💽', name:'Add Hard Drive', kind:'storage',
desc:'+1 active torrent slot.', base:600, growth:1.9, add:1, max:40},
{id:'ratio', tab:'public', ic:'⚖️', name:'Share Ratio Boost', kind:'ratio',
desc:'Be a good citizen. +12% seeding income (multiplicative).', base:260, growth:1.5, mult:1.12, max:150},
{id:'cat6', tab:'public', ic:'🧵', name:'Cat6 Cable Upgrade', kind:'unlock',
desc:'Unlock Megabyte-class Public Tier B torrents.', base:1800},
{id:'dht', tab:'public', ic:'🌐', name:'DHT Network Enablement', kind:'unlock',
desc:'Distributed hash table. Random peer-swarm bursts give 10× download.', base:4200},
// --- Private Trackers tab ---
{id:'private', tab:'private', ic:'🔑', name:'Private Tracker Invite', kind:'unlock', reqUp:'cat6',
desc:'Unlock Gigabyte-class scene releases (private trackers).', base:9000},
{id:'magnet', tab:'private', ic:'🧲', name:'Magnet Link Optimization', kind:'unlock', reqUp:'private',
desc:'Skip the "downloading metadata" phase. +2 base seeds on all torrents.', base:24000},
{id:'rss', tab:'private', ic:'📡', name:'Automated RSS Filters', kind:'unlock', reqUp:'private',
desc:'Auto-add the best torrent you can fit whenever a slot is free.', base:60000},
{id:'seedbox', tab:'private', ic:'🖥️', name:'Seedbox Rental', kind:'flat-up', reqUp:'private',
desc:'Rent remote servers. +flat upload to every seeding torrent.', base:40000, growth:1.8, add:5e5, max:120},
{id:'storage2', tab:'private', ic:'🗄️', name:'RAID Array Expansion', kind:'storage', reqUp:'private',
desc:'+1 active torrent slot (cheaper scaling than HDDs once private).', base:5e4, growth:1.75, add:1, max:60},
// --- Darknet / Late tab ---
{id:'fiber', tab:'darknet', ic:'🛰️', name:'Fiber-Optic Trunk Line', kind:'unlock', reqUp:'private',
desc:'Unlock Terabyte-class datasets.', base:5e6},
{id:'quantum', tab:'darknet', ic:'🔐', name:'Quantum Encryption Protocol', kind:'unlock', reqUp:'fiber',
desc:'Hide from the virtual ISP. Immune to bandwidth-throttle debuffs.', base:2e7},
{id:'overclock', tab:'darknet', ic:'⚙️', name:'Kernel Bypass / Overclock', kind:'mult-down', reqUp:'fiber',
desc:'Bypass the kernel network stack. ×1.8 download bandwidth.', base:3e7, growth:1.7, mult:1.8, max:200},
{id:'compress', tab:'darknet', ic:'🗜️', name:'Real-Time Compression', kind:'mult-up', reqUp:'fiber',
desc:'Squeeze the pipe. ×1.8 seeding income.', base:3.5e7, growth:1.7, mult:1.8, max:200},
];
const UP_BY_ID = Object.fromEntries(UPGRADES.map(u=>[u.id,u]));
/* ---- prestige perks (persist across resets) ------------------------------ */
const PERKS = [
{id:'dpi', ic:'🚀', name:'Deep Packet Bypass', desc:'+25% global download per level.', base:1, growth:1.6, mult:1.25, max:50},
{id:'ghost',ic:'👻', name:'Ghost Protocol', desc:'+25% global seeding income per level.', base:1, growth:1.6, mult:1.25, max:50},
{id:'rep', ic:'🏅', name:'Reputation Carryover',desc:'+1 permanent active slot per level.', base:2, growth:1.8, add:1, max:25},
{id:'seed0',ic:'🌱', name:'Persistent Peers', desc:'+2 base seeds on every torrent per level.', base:2, growth:1.7, add:2, max:25},
{id:'darknet', ic:'🕳️', name:'Darknet Relay Access', desc:'Permanently unlock Petabyte-class Darknet torrents.', base:3, once:true},
{id:'singularity',ic:'🌌', name:'The Singularity', desc:'Permanently unlock Exabyte+ torrents.', base:40, once:true, reqPerk:'darknet'},
];
const PERK_BY_ID = Object.fromEntries(PERKS.map(p=>[p.id,p]));
/* ---- network model constants --------------------------------------------- */
// Download Speed = min( Max Line Speed , Connected Seeds × Bandwidth Per Peer )
// Line speed is your hardware tier; the peer term means a torrent with few seeds
// is peer-limited (slow) until you announce to more trackers / prioritise peers.
const HW_TIERS = ['56k Dial-up Modem','ISDN Line','ADSL Broadband','SDSL Line','Cable Broadband',
'VDSL2','Gigabit Fiber','10G Fiber','Metro Ethernet','OC-768 Backbone','Tier-1 Telecom Trunk',
'Dark Fiber Mesh','Quantum Backbone'];
const SAT_SEEDS = 12; // connected seeds that saturate the line (peer-limited below this)
const TRACKER_URLS = [
'udp://tracker.publicswarm.org:80','udp://open.demonii.si:1337','udp://tracker.opentrackr.org:1337',
'udp://exodus.desync.com:6969','udp://tracker.coppersurfer.tk:6969','http://retracker.local:80',
'udp://9.rarbg.to:2710','udp://tracker.internetwarriors.net:1337','udp://tracker.leechers-paradise.org:6969',
'udp://tracker.cyberia.is:6969','udp://valakas.darknet.onion:6969','udp://relay.tor-swarm.onion:1337',
];
function defaultTrackers(){
return [
{url:TRACKER_URLS[0], seeds:1, peers:3, status:'working'},
{url:TRACKER_URLS[1], seeds:1, peers:2, status:'working'},
];
}
/* ---- columns -------------------------------------------------------------- */
const COLS = [
{k:'#', w:'34px', num:true},
{k:'Name', w:'auto'},
{k:'Size', w:'92px', num:true},
{k:'Progress', w:'130px'},
{k:'Status',w:'128px'},
{k:'Seeds', w:'60px', num:true},
{k:'Peers', w:'58px', num:true},
{k:'Down Speed', w:'104px', num:true},
{k:'Up Speed', w:'104px', num:true},
{k:'ETA', w:'76px', num:true},
{k:'Ratio', w:'60px', num:true},
];
/* ===========================================================================
STATE
=========================================================================== */
let G = null;
function freshState(){
return {
v:1,
data:0, totalUp:0, totalDown:0,
runUp:0, // upload accrued this prestige run (for credits)
credits:0, prestiges:0, lifetimeUp:0,
torrents:[], // active torrents
upgrades:{}, // id -> level
perks:{}, // id -> level
seq:1, // torrent id sequence
sel:null, // selected torrent id
filter:'all', // sidebar status filter
dtab:'general',
sci:false,
seenCatalog:{}, // catalog ids the player has seen in the Add dialog
trackerList:defaultTrackers(), // announced trackers, each {url,seeds,peers,status}
rssFilter:'*', // glob filter for RSS auto-download
rssOn:true, // auto-download matching releases when rss is unlocked
// event state (timestamps in ms)
burstUntil:0, nextBurst:0, throttleUntil:0, nextThrottle:0,
irc:[], // chat log lines {n,t,c,cid,magnet}
nextIrc:0, nextMagnet:0,
lastSave:Date.now(),
started:Date.now(),
sidebarW:182, detailH:210,
};
}
/* ===========================================================================
DERIVED VALUES
=========================================================================== */
const lvl = id => G.upgrades[id]||0;
const plvl = id => G.perks[id]||0;
const hasUnlock = id => (G.upgrades[id]||0)>0;
const hasPerkUnlock = id => (G.perks[id]||0)>0;
function perkMult(id){ const p=PERK_BY_ID[id]; return Math.pow(p.mult, plvl(id)); }
function downMult(){
let m=1;
m*=Math.pow(UP_BY_ID.bandwidth.mult, lvl('bandwidth'));
m*=Math.pow(UP_BY_ID.overclock.mult, lvl('overclock'));
m*=perkMult('dpi');
if(now()<G.burstUntil) m*=10;
if(now()<G.throttleUntil && !hasUnlock('quantum')) m*=0.4;
return m;
}
function upMultGlobal(){
let m=1;
m*=Math.pow(UP_BY_ID.upslot.mult, lvl('upslot'));
m*=Math.pow(UP_BY_ID.compress.mult, lvl('compress'));
m*=Math.pow(UP_BY_ID.ratio.mult, lvl('ratio'));
m*=perkMult('ghost');
if(now()<G.throttleUntil && !hasUnlock('quantum')) m*=0.4;
return m;
}
// Line speed scales with the largest TIER you've unlocked, so unlocking a tier
// also grants the throughput to actually download it ("Cat6 unlocks MB-tier speeds").
// The sqrt keeps download times sane across 17 orders of magnitude of file size:
// it halves the exponent the player must cover with bandwidth upgrades, while still
// leaving big late-tier files as a real sink for the bandwidth/overclock upgrades.
const BASE_DOWN = 1000; // B/s anchor before scaling/multipliers
const MIN_SIZE = 4e3; // smallest catalog file (Tier A)
function maxUnlockedSize(){
let m = MIN_SIZE;
for(const c of CATALOG){ if(c.size>m && catalogUnlocked(c)) m=c.size; }
return m;
}
function clientDown(){ return BASE_DOWN * Math.sqrt(maxUnlockedSize()/MIN_SIZE) * downMult(); }
function seedboxFlat(){ return lvl('seedbox') * UP_BY_ID.seedbox.add; }
function baseSeedBonus(){
let s = 2*lvl('upnp');
if(hasUnlock('magnet')) s+=2;
s += 2*plvl('seed0');
return s;
}
function maxSlots(){
return 3 + lvl('storage1')*1 + lvl('storage2')*1 + plvl('rep');
}
function usedSlots(){ return G.torrents.length; }
// per-torrent effective values
function trackerSeedBonus(){ let s=0; for(const t of (G.trackerList||[])) s+=t.seeds; return s; }
function peerPrioBonus(t){ return (t&&t.dropLeechers?0.15:0)+(t&&t.prioSeeds?0.15:0); }
// Connected seeds = catalog swarm + seed upgrades + announced trackers, then a
// per-torrent peer-prioritisation multiplier.
function torrentSeeds(t){
const c=CAT_BY_ID[t.cid];
const base=(c?c.seeds:1)+baseSeedBonus()+trackerSeedBonus();
return Math.max(1, Math.round(base*(1+peerPrioBonus(t))));
}
function perPeerMult(){ return Math.pow(UP_BY_ID.perpeer.mult, lvl('perpeer')); }
// Fraction of Max Line Speed actually achieved: min(1, seeds × bwPerPeer / line).
// Below SAT_SEEDS you are peer-limited; announce trackers / prioritise peers to saturate.
function downFrac(seeds){ return Math.min(1, seeds/SAT_SEEDS*perPeerMult()); }
function lineSpeed(){ return clientDown(); } // clientDown() IS your Max Line Speed
function hwTierName(){
const l=lvl('bandwidth');
return HW_TIERS[Math.min(l,HW_TIERS.length-1)] + (l>=HW_TIERS.length?(' +'+(l-HW_TIERS.length+1)):'');
}
function torrentDownSpeed(t){
if(t.status!=='downloading') return 0;
return lineSpeed() * downFrac(torrentSeeds(t));
}
function torrentUpSpeed(t){
if(t.status!=='seeding') return 0;
const c=CAT_BY_ID[t.cid];
return (c.up * upMultGlobal()) + seedboxFlat();
}
function totalDownSpeed(){ let s=0; for(const t of G.torrents) s+=torrentDownSpeed(t); return s; }
function totalUpSpeed(){ let s=0; for(const t of G.torrents) s+=torrentUpSpeed(t); return s; }
/* ===========================================================================
TIME
=========================================================================== */
function now(){ return Date.now(); }
/* ===========================================================================
GAME LOGIC TICK
=========================================================================== */
function step(dt){ // dt seconds
// events
handleEvents();
// per-torrent
for(const t of G.torrents){
if(t.status==='downloading'){
const sp=torrentDownSpeed(t);
t.downloaded += sp*dt;
G.totalDown += sp*dt;
const c=CAT_BY_ID[t.cid];
if(c.rare && t.downloaded >= c.size*0.999 && t.downloaded < c.size){
t.downloaded = c.size*0.999; t.status='stalled';
toast('warn','Torrent stalled at 99.9%', t.name.slice(0,38)+'…<br>No seeders for the last piece. Open the <b>IRC / Swarm</b> tab.');
ircSys(c.name.slice(0,30)+' stalled — last piece has 0 seeds');
} else if(t.downloaded >= c.size){
t.downloaded = c.size; t.status='seeding'; t.completedAt=now();
toast('info','Download complete', t.name.slice(0,40)+'<br>Now <b>seeding</b> for '+fmtSpd(torrentUpSpeed(t)));
}
} else if(t.status==='seeding'){
const sp=torrentUpSpeed(t);
const gain=sp*dt;
t.uploaded += gain;
G.data += gain; G.totalUp += gain; G.runUp += gain; G.lifetimeUp += gain;
}
}
// RSS auto-add
if(hasUnlock('rss')) rssAutoAdd();
}
function handleEvents(){
const t=now();
// DHT bursts
if(hasUnlock('dht')){
if(G.nextBurst===0) G.nextBurst = t + rand(25000,55000);
if(t>=G.nextBurst && t>=G.burstUntil){
G.burstUntil = t + 8000;
G.nextBurst = t + rand(45000,90000);
toast('info','⚡ Peer swarm found!','DHT located a fresh swarm — 10× download for 8s.');
}
}
// ISP throttle (unless quantum)
if(!hasUnlock('quantum') && hasUnlock('private')){
if(G.nextThrottle===0) G.nextThrottle = t + rand(60000,120000);
if(t>=G.nextThrottle && t>=G.throttleUntil){
G.throttleUntil = t + rand(9000,16000);
G.nextThrottle = t + rand(80000,160000);
toast('bad','🐌 ISP Throttling','Your ISP throttled the connection (×0.4) for a few seconds.<br>Counter it with <b>Quantum Encryption</b>.');
}
}
// IRC ambient chatter
if(t>=G.nextIrc){
G.nextIrc = t + rand(12000,26000);
ircAmbient();
}
// IRC pre-release magnet drops
if(G.nextMagnet===0) G.nextMagnet = t + rand(18000,38000);
if(t>=G.nextMagnet){
G.nextMagnet = t + rand(40000,75000);
ircMagnetDrop();
}
}
function rand(a,b){ return a + Math.random()*(b-a); }
// glob like *.upg or *REPACK* -> case-insensitive regex
function globToRe(glob){
if(!glob||glob==='*'||glob.trim()==='') return /.*/i;
const esc=glob.trim().replace(/[.+^${}()|[\]\\]/g,'\\$&').replace(/\*/g,'.*').replace(/\?/g,'.');
try{ return new RegExp(esc,'i'); }catch(e){ return /.*/i; }
}
function rssMatches(c){ return globToRe(G.rssFilter).test(c.name); }
function rssAutoAdd(){
if(!G.rssOn) return;
if(usedSlots()>=maxSlots()) return;
// best (highest up) unlocked entry that matches the RSS filter
const avail = CATALOG.filter(c=>catalogUnlocked(c) && rssMatches(c));
if(!avail.length) return;
avail.sort((a,b)=>b.up-a.up);
addTorrent(avail[0].id, true);
}
/* ---- trackers: announce to add connected seeds ---------------------------- */
function announceCost(){
const added=Math.max(0, (G.trackerList||[]).length-2);
return Math.max(120, totalUpSpeed()*18) * Math.pow(1.85, added);
}
function announceTracker(){
const cost=announceCost();
if(G.data<cost){ toast('bad','Not enough Data','Announcing to a new tracker costs '+fmt(cost)+'.'); return; }
const used=new Set(G.trackerList.map(t=>t.url));
const pool=TRACKER_URLS.filter(u=>!used.has(u));
const url = pool.length? pool[Math.floor(Math.random()*pool.length)]
: ('udp://swarm-'+G.trackerList.length+'.relay.onion:'+(1000+Math.floor(Math.random()*8999)));
G.data-=cost;
const seeds=2+Math.floor(Math.random()*3);
G.trackerList.push({url, seeds, peers:seeds*2+Math.floor(Math.random()*6), status:'working'});
toast('info','Announced to new tracker', url+'<br>+'+seeds+' connected seeds on every torrent.');
renderAll();
}
/* ---- IRC pre-release magnet drops ----------------------------------------- */
function ircMagnetDrop(){
const avail=CATALOG.filter(c=>catalogUnlocked(c));
if(!avail.length) return;
const c=avail[Math.floor(Math.random()*avail.length)];
ircPush('announce-bot','pre-release magnet dropped — '+c.name+' (skips the public peer queue)', 'irc-op', c.id);
}
function grabMagnet(cid){
const c=CAT_BY_ID[cid]; if(!c) return;
if(usedSlots()>=maxSlots()){ toast('bad','No free slots','Free a slot before grabbing this magnet.'); return; }
if(addTorrent(cid, true, 0.25)){ // 25% head-start: bypasses the slow initial swarm
toast('info','🧲 Magnet injected', c.name.slice(0,40)+'<br>Pre-seeded to 25% via IRC.');
ircPush('announce-bot','you grabbed '+c.name.slice(0,30)+' — leeching from the pre-release pool','irc-op');
renderDetail();
}
}
/* ===========================================================================
ACTIONS
=========================================================================== */
function catalogUnlocked(c){
if(!c.req) return true;
if(c.req==='darknet') return hasPerkUnlock('darknet');
if(c.req==='singularity') return hasPerkUnlock('singularity');
return hasUnlock(c.req);
}
function addTorrent(cid, silent, headstart){
const c=CAT_BY_ID[cid]; if(!c) return false;
if(usedSlots()>=maxSlots()){
if(!silent) toast('bad','No free slots','All '+maxSlots()+' slots are full. Buy more storage or remove a torrent.');
return false;
}
const t={
uid:G.seq++, cid:cid, name:c.name,
downloaded: headstart? Math.min(c.size*headstart, c.size*0.999) : 0, uploaded:0,
status:'downloading', addedAt:now(), completedAt:0,
peerSeed:Math.floor(rand(20,400)),
dropLeechers:false, prioSeeds:false,
};
G.torrents.push(t);
if(!silent) toast('','Torrent added', c.name.slice(0,42));
renderAll();
return true;
}
function removeTorrent(uid){
const i=G.torrents.findIndex(t=>t.uid===uid);
if(i<0) return;
G.torrents.splice(i,1);
if(G.sel===uid) G.sel=null;
renderAll();
}
function pauseTorrent(uid){
const t=G.torrents.find(t=>t.uid===uid); if(!t) return;
if(t.status==='downloading'||t.status==='stalled') t.status='paused-dl';
else if(t.status==='seeding') t.status='paused-seed';
renderAll();
}
function resumeTorrent(uid){
const t=G.torrents.find(t=>t.uid===uid); if(!t) return;
if(t.status==='paused-dl') t.status = (CAT_BY_ID[t.cid].rare && t.downloaded>=CAT_BY_ID[t.cid].size*0.999) ? 'stalled':'downloading';
else if(t.status==='paused-seed') t.status='seeding';
renderAll();
}
function forceComplete(uid){ // bribe archivist for last piece
const t=G.torrents.find(t=>t.uid===uid); if(!t) return;
if(t.status!=='stalled') return;
const c=CAT_BY_ID[t.cid];
t.downloaded=c.size; t.status='seeding'; t.completedAt=now();
ircSys('an archivist seeded the last piece of '+c.name.slice(0,26)+' — thanks!');
toast('info','Last piece acquired','An old-school archivist seeded the final block.<br>'+t.name.slice(0,40)+' is now seeding.');
renderAll();
}
function pauseAll(){ for(const t of G.torrents){ if(t.status==='downloading'||t.status==='stalled') t.status='paused-dl'; else if(t.status==='seeding') t.status='paused-seed'; } renderAll(); }
function resumeAll(){ for(const t of G.torrents){ if(t.status==='paused-dl') resumeTorrent(t.uid); else if(t.status==='paused-seed') t.status='seeding'; } renderAll(); }
/* ---- upgrades ---- */
function upgradeCost(u){
if(u.kind==='unlock') return u.base;
return Math.floor(u.base * Math.pow(u.growth, lvl(u.id)));
}
function upgradeMaxed(u){
if(u.kind==='unlock') return hasUnlock(u.id);
return u.max!=null && lvl(u.id)>=u.max;
}
function upgradeReqMet(u){
if(u.reqUp) return hasUnlock(u.reqUp);
return true;
}
function buyUpgrade(id){
const u=UP_BY_ID[id]; if(!u) return;
if(!upgradeReqMet(u)) return;
if(upgradeMaxed(u)) return;
const cost=upgradeCost(u);
if(G.data<cost){ toast('bad','Not enough Data','Need '+fmt(cost)+', have '+fmt(G.data)); return; }
G.data-=cost;
G.upgrades[id]=(G.upgrades[id]||0)+1;
if(u.kind==='unlock'){
toast('info','Unlocked: '+u.name, u.desc);
if(id==='cat6') ircSys('cat6 cable installed — megabit speeds online');
if(id==='private') ircSys('you were invited to a private tracker. welcome. maintain ratio.');
if(id==='fiber') ircSys('fiber trunk lit. terabyte payloads available.');
}
refreshStoreModal();
renderAll();
}
/* ---- perks ---- */
function perkCost(p){
if(p.once) return p.base;
return Math.floor(p.base * Math.pow(p.growth, plvl(p.id)));
}
function perkMaxed(p){
if(p.once) return hasPerkUnlock(p.id);
return p.max!=null && plvl(p.id)>=p.max;
}
function perkReqMet(p){ return p.reqPerk? hasPerkUnlock(p.reqPerk):true; }
function buyPerk(id){
const p=PERK_BY_ID[id]; if(!p) return;
if(!perkReqMet(p)||perkMaxed(p)) return;
const cost=perkCost(p);
if(G.credits<cost){ toast('bad','Not enough Credits','Need '+cost+' credits, have '+G.credits); return; }
G.credits-=cost;
G.perks[id]=(G.perks[id]||0)+1;
toast('prestige','Perk acquired: '+p.name, p.desc);
refreshPrestigeModal();
renderAll();
}
/* ---- prestige ---- */
function creditsFor(runUp){
// sub-linear; first meaningful prestige around ~1e10 uploaded
if(runUp<1e9) return 0;
return Math.floor(Math.pow(runUp/1e9, 0.42));
}
function doPrestige(){
const gain=creditsFor(G.runUp);
if(gain<1){ toast('bad','Not ready to migrate','Move more data before the cluster is worth deploying. Need ~'+fmt(1e9)+' uploaded this run.'); return; }
G.credits+=gain; G.prestiges++;
// reset run state — abandon local hardware & trackers, keep credits/perks
G.data=0; G.runUp=0; G.torrents=[]; G.upgrades={}; G.sel=null;
G.trackerList=defaultTrackers();
G.burstUntil=0;G.nextBurst=0;G.throttleUntil=0;G.nextThrottle=0;G.nextMagnet=0;
toast('prestige','🛰️ Migration Complete','Local hardware scrubbed. Redeployed onto an encrypted Virtual Seedbox Cluster.<br>Earned <b>'+gain+' Crypto-Credits</b>.');
ircSys('--- ISP throttle evaded. migrated to decentralized seedbox cluster. new node online ---');
closeModal();
save();
renderAll();
}
/* ===========================================================================
IRC FLAVOR
=========================================================================== */
const IRC_NICKS=['xX_seedlord_Xx','archivist_42','warez_grandpa','m4gnet','ratio_god','leech_hunter','tracker_op','obi_wan_kenseedi','byte_me','packet_ghost','dht_dan','nyaa_cat'];
const IRC_LINES=[
'anyone got the new scene release?',
'maintain your ratio or get banned smh',
'my seedbox is pushing 40 streams rn',
'public trackers are dead, go private',
'who keeps leeching and not seeding 😤',
'just hit a 5.0 ratio lets goooo',
'magnet links > .torrent files change my mind',
'ISP throttled me again, time for a vpn',
'rare flac, 1 seeder, godspeed',
'dht found me a fat swarm nice',
'fiber install tomorrow, byte me later',
'remember to forward your ports kids',
];
function ircPush(n,c,cls,cid){ G.irc.push({n,c,cls,cid}); if(G.irc.length>120) G.irc.shift(); if(G.dtab==='irc') renderDetail(); }
function ircSys(c){ ircPush('*',c,'irc-sys'); }
function ircAmbient(){
const n=IRC_NICKS[Math.floor(Math.random()*IRC_NICKS.length)];
const c=IRC_LINES[Math.floor(Math.random()*IRC_LINES.length)];
ircPush(n,c, Math.random()<0.25?'irc-op':'');
}
/* ===========================================================================
TOASTS
=========================================================================== */
function toast(kind,title,body){
const wrap=document.getElementById('toasts');
const el=document.createElement('div');
el.className='toast'+(kind?(' '+kind):'');
el.innerHTML='<b>'+title+'</b>'+(body?('<span>'+body+'</span>'):'');
wrap.appendChild(el);
setTimeout(()=>{el.style.transition='opacity .4s';el.style.opacity='0';setTimeout(()=>el.remove(),400);}, kind==='prestige'?6000:3800);
while(wrap.children.length>5) wrap.firstChild.remove();
}
/* ===========================================================================
RENDERING
=========================================================================== */
let spdDownHist=new Array(SPD_SAMPLES).fill(0);
let spdUpHist=new Array(SPD_SAMPLES).fill(0);
function renderAll(){ renderHud(); renderSidebar(); renderTable(); renderDetail(); renderStatus(); }
function renderHud(){
document.getElementById('hud-data').textContent=fmt(G.data);
document.getElementById('hud-total').textContent=fmt(G.totalUp);
const cw=document.getElementById('hud-cred-wrap');
if(G.credits>0||G.prestiges>0){ cw.style.display='flex'; document.getElementById('hud-cred').textContent=fmtInt(G.credits); }
else cw.style.display='none';
// prestige button enable
const pb=document.getElementById('tb-prestige');
const pg=creditsFor(G.runUp);
pb.style.opacity = pg>=1 ? '1':'0.5';
pb.title = pg>=1
? 'Client Migration: redeploy onto a Seedbox Cluster for '+pg+' permanent Crypto-Credits (wipes torrents, Data & network upgrades; keeps perks)'
: 'Client Migration (prestige): upload ~'+fmt(1e9)+' this run to unlock. Earns permanent Crypto-Credits & perks.';
const rm=document.getElementById('tb-remove');
rm.toggleAttribute('disabled', G.sel==null);
}
function sideCount(filter){
return G.torrents.filter(t=>matchFilter(t,filter)).length;
}
function matchFilter(t,f){
if(f==='all') return true;
if(f==='downloading') return t.status==='downloading'||t.status==='stalled'||t.status==='paused-dl';
if(f==='seeding') return t.status==='seeding';
if(f==='paused') return t.status==='paused-dl'||t.status==='paused-seed';
if(f==='stalled') return t.status==='stalled';
return true;
}
function renderSidebar(){
const sb=document.getElementById('sidebar');
const newCat = anyNewCatalog();
const html = `
<div class="side-group">
<div class="side-head"><span class="tw">▾</span>STATUS</div>
${sideItem('all','📋','All ('+sideCount('all')+')')}
${sideItem('downloading','⬇️','Downloading ('+sideCount('downloading')+')')}
${sideItem('seeding','🌱','Seeding ('+sideCount('seeding')+')')}
${sideItem('stalled','⚠️','Stalled ('+sideCount('stalled')+')')}
${sideItem('paused','⏸️','Paused ('+sideCount('paused')+')')}
</div>
<div class="side-group">
<div class="side-head"><span class="tw">▾</span>TRACKERS</div>
<div class="side-item shop" data-act="open-store" data-storetab="public"><span class="si-ic">🏷️</span><span class="si-lb">Public Trackers</span></div>
<div class="side-item shop ${hasUnlock('cat6')?'':'locked'}" data-act="open-store" data-storetab="private"><span class="si-ic">🔑</span><span class="si-lb">Private Trackers ${hasUnlock('cat6')?'':'🔒'}</span></div>
<div class="side-item shop ${hasUnlock('private')?'':'locked'}" data-act="open-store" data-storetab="darknet"><span class="si-ic">🕳️</span><span class="si-lb">Darknet Relays ${hasUnlock('private')?'':'🔒'}</span></div>
</div>
<div class="side-group">
<div class="side-head"><span class="tw">▾</span>FEEDS</div>
<div class="side-item" data-act="open-rss"><span class="si-ic">📡</span><span class="si-lb">RSS Feeds ${hasUnlock('rss')?'(auto)':'🔒'}</span></div>
<div class="side-item shop" data-act="open-add"><span class="si-ic"></span><span class="si-lb">Add from Catalog ${newCat?'<span class="badge-new">new</span>':''}</span></div>
</div>
${G.prestiges>0||G.credits>0?`
<div class="side-group">
<div class="side-head"><span class="tw">▾</span>SEEDBOX CLUSTER</div>
<div class="side-item shop" data-act="open-prestige"><span class="si-ic">🛰️</span><span class="si-lb">Crypto-Credits (${fmtInt(G.credits)})</span></div>
</div>`:''}
`;
sb.innerHTML=html;
}
function sideItem(f,ic,lb){
return `<div class="side-item ${G.filter===f?'active':''}" data-filter="${f}"><span class="si-ic">${ic}</span><span class="si-lb">${lb}</span></div>`;
}
function anyNewCatalog(){
// any unlocked catalog entry the player hasn't yet seen in the Add dialog
return CATALOG.some(c=>catalogUnlocked(c) && !G.seenCatalog[c.id]);
}
function markCatalogSeen(){
let changed=false;
for(const c of CATALOG){ if(catalogUnlocked(c) && !G.seenCatalog[c.id]){ G.seenCatalog[c.id]=1; changed=true; } }
return changed;
}
function statusLabel(t){
const c=CAT_BY_ID[t.cid];
switch(t.status){
case 'downloading': return '<span class="st-dot st-down"></span>Downloading';
case 'seeding': return '<span class="st-dot st-seed"></span>Seeding';
case 'stalled': return '<span class="st-dot st-stall"></span><span class="stall-txt">Stalled [99.9%]</span>';
case 'paused-dl': return '<span class="st-dot st-pause"></span>Paused';
case 'paused-seed': return '<span class="st-dot st-pause"></span>Paused';
default: return t.status;
}
}
function renderTable(){
// header (once)
const head=document.getElementById('tl-head');
if(!head.dataset.built){
head.innerHTML=COLS.map(c=>`<th class="${c.num?'num':''}" style="width:${c.w}">${c.k}</th>`).join('');
head.dataset.built='1';
}
const body=document.getElementById('tl-body');
const hint=document.getElementById('empty-hint');
const rows=G.torrents.filter(t=>matchFilter(t,G.filter));
if(rows.length===0){
body.innerHTML='';
hint.style.display='block';
hint.innerHTML = G.torrents.length===0
? `No torrents. Click <b> Add Torrent</b> to download your first release.<br><span style="font-size:11px">Completed torrents <b>seed</b> and generate Data — your currency for upgrades.</span>`
: `No torrents match this filter.`;
return;
}
hint.style.display='none';
let html='';
rows.forEach((t,i)=>{
const c=CAT_BY_ID[t.cid];
const pct=Math.min(100, t.downloaded/c.size*100);
const seeding=t.status==='seeding';
const stall=t.status==='stalled';
const dsp=torrentDownSpeed(t), usp=torrentUpSpeed(t);
const seeds=torrentSeeds(t);
const eta = seeding? '∞' : (dsp>0? fmtTime((c.size-t.downloaded)/dsp): (stall?'stalled':'∞'));
const ratio = t.downloaded>0? (t.uploaded/c.size): 0;
html+=`<tr data-uid="${t.uid}" class="${G.sel===t.uid?'sel':''}">
<td class="num">${i+1}</td>
<td><div class="name-cell" title="${c.name}">${iconFor(t)} ${c.name}</div></td>
<td class="num">${fmt(c.size)}</td>
<td><div class="pbar ${seeding?'seed':''} ${stall?'stall':''}"><div class="fill" style="width:${pct.toFixed(2)}%"></div><div class="txt">${pct.toFixed(seeding?0:1)}%</div></div></td>
<td>${statusLabel(t)}</td>
<td class="num">${fmtInt(seeds)}</td>
<td class="num">${seeding?fmtInt(t.peerSeed):fmtInt(Math.max(1,Math.floor(seeds*1.7)))}</td>
<td class="num col-down">${dsp>0?fmtSpd(dsp):'—'}</td>
<td class="num col-up">${usp>0?fmtSpd(usp):'—'}</td>
<td class="num">${eta}</td>
<td class="num">${ratio>=10?'∞':ratio.toFixed(2)}</td>
</tr>`;
});
body.innerHTML=html;
}
function iconFor(t){
if(t.status==='seeding') return '🟢';
if(t.status==='stalled') return '🟠';
if(t.status.startsWith('paused')) return '⚪';
return '🔵';
}
function renderStatus(){
document.getElementById('sb-slots').textContent=usedSlots()+'/'+maxSlots();
document.getElementById('sb-down').textContent=fmtSpd(totalDownSpeed());
document.getElementById('sb-up').textContent=fmtSpd(totalUpSpeed());
const dht=hasUnlock('dht')? Math.floor(120+lvl('bandwidth')*37+G.totalUp%500) : 0;
const conn=document.getElementById('sb-conn');
if(now()<G.throttleUntil && !hasUnlock('quantum')){ conn.innerHTML='<span class="ic">🟠</span><span class="conn-warn">Throttled by ISP</span>'; }
else if(now()<G.burstUntil){ conn.innerHTML='<span class="ic">🟢</span><span class="conn-ok">Swarm burst ×10</span>'; }
else { conn.innerHTML='<span class="ic">🟢</span><span class="conn-ok">DHT: <b>'+fmtInt(dht)+'</b> nodes</span>'; }
const ratio = G.totalDown>0? (G.totalUp/G.totalDown):Infinity;
document.getElementById('sb-ratio').textContent= ratio===Infinity?'∞':ratio.toFixed(2);
const dl=G.torrents.filter(t=>t.status==='downloading').length;
const sd=G.torrents.filter(t=>t.status==='seeding').length;
document.getElementById('sb-status').textContent =
`${dl} downloading · ${sd} seeding · earning ${fmtSpd(totalUpSpeed())} Data`;
// event banner
const eb=document.getElementById('event-banner');
if(now()<G.burstUntil){ eb.className='burst'; eb.innerHTML='⚡ PEER SWARM BURST — download ×10 ('+Math.ceil((G.burstUntil-now())/1000)+'s)'; }
else if(now()<G.throttleUntil && !hasUnlock('quantum')){ eb.className='throttle'; eb.innerHTML='🐌 ISP BANDWIDTH THROTTLING — speeds ×0.4 ('+Math.ceil((G.throttleUntil-now())/1000)+'s) — buy Quantum Encryption to immunize'; }
else { eb.className=''; eb.style.display='none'; }
}
/* ---- detail pane ---- */
function renderDetail(){
document.querySelectorAll('.dtab').forEach(d=>d.classList.toggle('active',d.dataset.dtab===G.dtab));
const body=document.getElementById('detail-body');
const t=G.torrents.find(x=>x.uid===G.sel);
if(!t && G.dtab!=='irc' && G.dtab!=='trackers'){
body.innerHTML='<div class="det-empty">Select a torrent to see its details.</div>';
if(G.dtab==='speed') drawSpeed();
return;
}
if(G.dtab==='general'){ body.innerHTML=detGeneral(t); }
else if(G.dtab==='trackers'){ body.innerHTML=detTrackers(t); }
else if(G.dtab==='peers'){ body.innerHTML=detPeers(t); }
else if(G.dtab==='irc'){ body.innerHTML=detIrc(t); const log=document.getElementById('irc-log'); if(log) log.scrollTop=log.scrollHeight; }
else if(G.dtab==='speed'){ body.innerHTML=detSpeed(t); drawSpeed(); }
}
function detTrackers(t){
const totSeeds=trackerSeedBonus();
const cost=announceCost();
const can=G.data>=cost;
const rows=G.trackerList.map(tr=>`<tr>
<td>${tr.url}</td><td>${tr.status}</td><td class="num">${fmtInt(tr.seeds)}</td>
<td class="num">${fmtInt(tr.peers)}</td></tr>`).join('');
const dhtRow = hasUnlock('dht')?`<tr><td>** [DHT] **</td><td>working</td><td class="num">${fmtInt(2+lvl('bandwidth'))}</td><td class="num">—</td></tr>`:'';
const pexRow = `<tr><td>** [PeX] **</td><td>working</td><td class="num">1</td><td class="num">—</td></tr>`;
return `<div style="margin-bottom:6px;color:var(--muted)">
Trackers announce your client to the swarm. Each adds <b>connected seeds</b> to <u>every</u> torrent —
raising the <b>Seeds × Bandwidth/Peer</b> term toward your Max Line Speed.
${t?`Selected torrent: <b>${fmtInt(torrentSeeds(t))}</b> seeds (${(downFrac(torrentSeeds(t))*100).toFixed(0)}% of line).`:''}
</div>
<table class="peers"><thead><tr><th>Tracker URL</th><th>Status</th><th>Seeds</th><th>Peers</th></tr></thead>
<tbody>${rows}${dhtRow}${pexRow}</tbody></table>
<div style="display:flex;align-items:center;gap:12px;margin-top:8px">
<button class="btn ${can?'primary':''}" data-act="announce" ${can?'':'disabled'} style="padding:4px 14px">Announce to New Tracker</button>
<span class="up-cost ${can?'ok':'no'}">${fmt(cost)}</span>
<span style="color:var(--muted)">total tracker seeds: <b>+${fmtInt(totSeeds)}</b> · ${G.trackerList.length} trackers</span>
</div>`;
}
function detGeneral(t){
const c=CAT_BY_ID[t.cid];
const pct=Math.min(100,t.downloaded/c.size*100);
const pieces=Math.max(8,Math.round(Math.log10(c.size)*64));
const havePieces=Math.floor(pieces*pct/100);
const ratio=t.downloaded>0?(t.uploaded/c.size):0;
return `<div class="kv">
<div class="k">Name</div><div class="v">${c.name}</div>
<div class="k">Total Size</div><div class="v">${fmt(c.size)}</div>
<div class="k">Downloaded</div><div class="v">${fmt(Math.min(t.downloaded,c.size))} (${pct.toFixed(2)}%)</div>
<div class="k">Uploaded (this torrent)</div><div class="v">${fmt(t.uploaded)}</div>
<div class="k">Share Ratio</div><div class="v">${ratio>=10?'∞ (∞)':ratio.toFixed(3)}</div>
<div class="k">Pieces</div><div class="v">${havePieces} / ${pieces} (${Math.round(c.size/pieces)} each)</div>
<div class="k">Max Line Speed</div><div class="v col-down">${fmtSpd(lineSpeed())} <span style="color:var(--muted)">(${hwTierName()})</span></div>
<div class="k">Down Speed</div><div class="v col-down">${fmtSpd(torrentDownSpeed(t))} ${downFrac(torrentSeeds(t))>=1?'<span style="color:var(--muted)">(line-limited)</span>':'<span style="color:var(--orange)">(peer-limited)</span>'}</div>
<div class="k">Up Speed</div><div class="v col-up">${fmtSpd(torrentUpSpeed(t))}</div>
<div class="k">Connected seeds</div><div class="v">${fmtInt(torrentSeeds(t))} / ${SAT_SEEDS} to saturate (${(downFrac(torrentSeeds(t))*100).toFixed(0)}% of line)</div>
<div class="k">Status</div><div class="v">${t.status}</div>
<div class="k">Info Hash</div><div class="v">${fakeHash(t.uid+c.id)}</div>
<div class="k">Save Path</div><div class="v">/mnt/seedbox/downloads/${c.name}</div>
</div>`;
}
function fakeHash(seed){
const s=String(seed), hex='0123456789abcdef';
let x=0x811c9dc5;
for(let i=0;i<s.length;i++){ x=(Math.imul(x,16777619)^s.charCodeAt(i))>>>0; }
if(x===0) x=0x9e3779b9;
let h='';
for(let i=0;i<40;i++){
x^=x<<13; x>>>=0; x^=x>>>17; x^=x<<5; x>>>=0; // xorshift32
h+=hex[(x>>>(4*(i%7)))&15];
}
return h;
}
function detPeers(t){
// deterministic-ish peer list
const c=CAT_BY_ID[t.cid];
const flags=['🇺🇸','🇸🇪','🇳🇱','🇩🇪','🇯🇵','🇷🇺','🇫🇷','🇨🇦','🇧🇷','🇦🇺','🇰🇷','🇨🇭'];
const clients=['qBitTorrz 4.6','µTorrent 2.2','Transmission 4.0','Deluge 2.1','rTorrent 0.9','libtorrent 2.x','BiglyBT'];
const seeds=Math.min(14, Math.max(3, torrentSeeds(t)));
let rows='';
for(let i=0;i<seeds;i++){
const r=fakeHash(t.uid*100+i);
const ip=(parseInt(r.slice(0,2),16))+'.'+(parseInt(r.slice(2,4),16))+'.'+(parseInt(r.slice(4,6),16))+'.'+(parseInt(r.slice(6,8),16));
const fl=flags[parseInt(r.slice(8,10),16)%flags.length];
const cl=clients[parseInt(r.slice(10,12),16)%clients.length];
const dn=t.status==='seeding'?0:(torrentDownSpeed(t)/seeds)*(0.6+(parseInt(r.slice(12,13),16)/15));
rows+=`<tr><td>${fl} ${ip}</td><td>${cl}</td><td>${(parseInt(r.slice(13,14),16)*7)}%</td><td class="col-down">${dn>0?fmtSpd(dn):'—'}</td>
<td><button class="mini-btn" data-act="peer-boost" data-uid="${t.uid}">Prioritize</button>
<button class="mini-btn warn" data-act="peer-ban" data-uid="${t.uid}">Ban</button></td></tr>`;
}
const bonus=peerPrioBonus(t);
return `<div style="margin-bottom:7px;display:flex;gap:18px;align-items:center;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:5px;cursor:pointer">
<input type="checkbox" data-act="peer-toggle" data-field="dropLeechers" data-uid="${t.uid}" ${t.dropLeechers?'checked':''}>
Drop Low-Speed Leechers <span style="color:var(--muted)">(+15% seeds)</span></label>
<label style="display:flex;align-items:center;gap:5px;cursor:pointer">
<input type="checkbox" data-act="peer-toggle" data-field="prioSeeds" data-uid="${t.uid}" ${t.prioSeeds?'checked':''}>
Prioritize High-Bandwidth Seeds <span style="color:var(--muted)">(+15% seeds)</span></label>
<span style="font-family:var(--mono)" class="${bonus>0?'col-up':''}">efficiency +${Math.round(bonus*100)}%</span>
</div>
<div style="margin-bottom:4px;color:var(--muted)">Connected peers for <b>${c.name}</b>:</div>
<table class="peers"><thead><tr><th>IP</th><th>Client</th><th>Progress</th><th>Down</th><th>Actions</th></tr></thead><tbody>${rows}</tbody></table>`;
}
function detIrc(t){
// stalled torrents needing help
const stalled=G.torrents.filter(x=>x.status==='stalled');
let stallHtml='';
if(stalled.length){
stallHtml='<div style="margin-bottom:8px"><b>🆘 Torrents stuck at 99.9% — ask an archivist for the last piece:</b>'+
stalled.map(s=>`<div class="stall-row"><span style="flex:1">${CAT_BY_ID[s.cid].name.slice(0,40)}</span>
<button class="mini-btn" data-act="bribe" data-uid="${s.uid}">📨 Request last piece</button></div>`).join('')+'</div>';
}
const log=G.irc.slice(-60).map(l=>{
if(l.cls==='irc-sys') return `<div class="irc-sys">* ${l.c}</div>`;
// pre-release magnet drop with a clickable grab link
const grab = (l.cid && CAT_BY_ID[l.cid])
? ` <a href="#" data-act="grab-magnet" data-cid="${l.cid}" style="color:#1551a8;font-weight:700;text-decoration:underline">[🧲 grab]</a>` : '';
return `<div><span class="nick ${l.cls}">&lt;${l.n}&gt;</span> ${l.c}${grab}</div>`;
}).join('');
return `${stallHtml}<div style="font-weight:600;margin-bottom:4px">#torrentz-chat — ${IRC_NICKS.length} users <span style="color:var(--muted);font-weight:400">· announce-bot drops pre-release magnets here</span></div>
<div id="irc-log" style="max-height:118px;overflow:auto;border:1px solid var(--line);background:#fcfcfc;padding:6px">${log||'<span class="irc-sys">* connecting to IRC…</span>'}</div>`;
}
function detSpeed(t){
return `<canvas id="spd-canvas"></canvas>
<div class="spd-legend"><span class="d">↓ ${fmtSpd(totalDownSpeed())}</span><span class="u">↑ ${fmtSpd(totalUpSpeed())}</span>
<span style="color:var(--muted)">peak window — last ${Math.round(SPD_SAMPLES*TICK_MS/1000)}s</span></div>`;
}
function drawSpeed(){
const cv=document.getElementById('spd-canvas'); if(!cv) return;
const dpr=window.devicePixelRatio||1;
// size the canvas to the available detail-pane height (legend ~ 46px) so it
// never clips or overflows when the splitter is dragged short.
const body=document.getElementById('detail-body');
const h=Math.max(60, Math.min(260, (body?body.clientHeight:196)-46));
cv.style.height=h+'px';
const w=cv.clientWidth||600;
cv.width=w*dpr; cv.height=h*dpr;
const ctx=cv.getContext('2d'); ctx.scale(dpr,dpr);
ctx.clearRect(0,0,w,h);
const maxV=Math.max(1, ...spdDownHist, ...spdUpHist);
// grid
ctx.strokeStyle='#e8eef5'; ctx.lineWidth=1;
for(let i=0;i<=4;i++){ const y=h*i/4; ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(w,y);ctx.stroke(); }
const plot=(arr,color,fill)=>{
ctx.beginPath();
for(let i=0;i<arr.length;i++){ const x=w*i/(arr.length-1); const y=h-(arr[i]/maxV)*(h-6)-3; if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y); }
ctx.strokeStyle=color; ctx.lineWidth=1.6; ctx.stroke();
ctx.lineTo(w,h); ctx.lineTo(0,h); ctx.closePath(); ctx.fillStyle=fill; ctx.fill();
};
plot(spdDownHist,'#2f7fd0','rgba(47,127,208,.10)');
plot(spdUpHist,'#4f9e3a','rgba(79,158,58,.12)');
ctx.fillStyle='#888'; ctx.font='10px monospace'; ctx.fillText(fmtSpd(maxV),4,11);
}
/* ===========================================================================
MODALS
=========================================================================== */
let modalKind=null, storeTab='public', catTab='all';
const back=()=>document.getElementById('modal-back');
const modalEl=()=>document.getElementById('modal');
function openModal(html){ modalEl().innerHTML=html; back().classList.add('show'); }
function closeModal(){ back().classList.remove('show'); modalKind=null; }
/* ---- Add Torrent catalog ---- */
function openAdd(){
modalKind='add';
if(markCatalogSeen()) renderSidebar(); // clear the "new" badge
renderAddModal();
back().classList.add('show');
}
function renderAddModal(){
const tiers=[
{id:'all',name:'All'},
{id:'public',name:'Public',reqs:[null,'cat6']},
{id:'private',name:'Private',reqs:['private'],lock:!hasUnlock('private')},
{id:'fiber',name:'Fiber',reqs:['fiber'],lock:!hasUnlock('fiber')},
{id:'darknet',name:'Darknet',reqs:['darknet'],lock:!hasPerkUnlock('darknet')},
{id:'singularity',name:'Singularity',reqs:['singularity'],lock:!hasPerkUnlock('singularity')},
];
const tabHtml=tiers.map(t=>`<div class="mtab ${catTab===t.id?'active':''} ${t.lock?'locked':''}" data-cattab="${t.id}">${t.name}</div>`).join('');
const visible=CATALOG.filter(c=>{
if(catTab==='all') return true;
if(catTab==='public') return (c.req===null||c.req==='cat6');
if(catTab==='private') return c.req==='private';
if(catTab==='fiber') return c.req==='fiber';
if(catTab==='darknet') return c.req==='darknet';
if(catTab==='singularity') return c.req==='singularity';
});
const slotsFull=usedSlots()>=maxSlots();
const cards=visible.map(c=>{
const unlocked=catalogUnlocked(c);
const upNow=c.up*upMultGlobal()+seedboxFlat();
const seeds=c.seeds+baseSeedBonus()+trackerSeedBonus();
const dlSpeed=lineSpeed()*downFrac(seeds);
const eta=fmtTime(c.size/dlSpeed);
return `<div class="up-card ${unlocked?'':'lockreq'}">
<div class="up-ic">${c.rare?'💎':'📦'}</div>
<div class="up-main">
<div class="up-name">${c.name} ${c.rare?'<span class="pill">rare</span>':''}</div>
<div class="cat-meta"><span class="pill">${fmt(c.size)}</span><span class="pill">seeds ${fmtInt(seeds)}</span>
<span class="pill">~ETA ${eta}</span></div>
<div class="up-eff">Seeds at ${fmtSpd(upNow)} Data once complete${c.rare?' · needs IRC for last piece':''}</div>
</div>
<div class="up-buy">
${unlocked
? `<button class="btn primary" data-act="add-torrent" data-cid="${c.id}" ${slotsFull?'disabled':''}>${slotsFull?'Slots full':'Download'}</button>`
: `<span class="up-cost no">🔒 ${reqLabel(c.req)}</span>`}
</div>
</div>`;
}).join('');
openModal(`
<div class="modal-title">📥 Add Torrent — Catalog<span class="x" data-act="close-modal">✕</span></div>
<div class="mtabs">${tabHtml}</div>
<div class="tier-note">Slots: <b>${usedSlots()}/${maxSlots()}</b> used. Downloading is free — you're limited by storage slots and bandwidth. Bigger files take longer but seed for far more Data.</div>
<div class="modal-body"><div class="store-list">${cards||'<div class="det-empty">Nothing here yet.</div>'}</div></div>
<div class="modal-foot"><span style="margin-right:auto;color:var(--muted)">Data: <b>${fmt(G.data)}</b></span>
<button class="btn" data-act="close-modal">Close</button></div>`);
}
function reqLabel(req){
if(req==='cat6') return 'Buy Cat6 Cable';
if(req==='private') return 'Need Private Tracker';
if(req==='fiber') return 'Need Fiber-Optic';
if(req==='darknet') return 'Prestige: Darknet';
if(req==='singularity') return 'Prestige: Singularity';
return 'Locked';
}
/* ---- RSS feed filters ---- */
function rssItemsHtml(){
const unlockedC=CATALOG.filter(c=>catalogUnlocked(c));
return unlockedC.map(c=>{
const m=rssMatches(c);
return `<div class="up-card" style="opacity:${m?1:0.4}">
<div class="up-ic">📄</div>
<div class="up-main"><div class="up-name">${c.name}</div>
<div class="cat-meta"><span class="pill">${fmt(c.size)}</span><span class="pill">seeds at ${fmtSpd(c.up*upMultGlobal()+seedboxFlat())}</span></div></div>
<div class="up-buy"><span class="up-cost ${m?'ok':'no'}">${m?'✓ matches':'filtered out'}</span></div>
</div>`;
}).join('') || '<div class="det-empty">No unlocked releases yet — unlock trackers to populate the feed.</div>';
}
function rssMatchSummary(){
const u=CATALOG.filter(c=>catalogUnlocked(c));
return u.filter(c=>rssMatches(c)).length+' of '+u.length+' unlocked releases match';
}
function openRss(){ modalKind='rss'; renderRssModal(); back().classList.add('show'); }
function renderRssModal(){
const unlocked=hasUnlock('rss');
openModal(`
<div class="modal-title">📡 RSS Feed Filters<span class="x" data-act="close-modal">✕</span></div>
${unlocked?'':'<div class="tier-note" style="color:#a23">RSS automation is locked — buy <b>Automated RSS Filters</b> in the Private Trackers upgrades. You can still set the filter now.</div>'}
<div style="padding:10px 12px;display:flex;gap:14px;align-items:center;flex-wrap:wrap;border-bottom:1px solid var(--line)">
<label>Filter (glob): <input id="rss-input" type="text" value="${(G.rssFilter||'').replace(/"/g,'&quot;')}" placeholder="* or Linux or *.iso" style="font-family:var(--mono);padding:3px 6px;width:210px;border:1px solid var(--line2)"></label>
<label style="display:flex;align-items:center;gap:5px;cursor:pointer"><input type="checkbox" data-act="rss-toggle" ${G.rssOn?'checked':''} ${unlocked?'':'disabled'}> Auto-download matching releases</label>
<span id="rss-count" style="color:var(--muted)">${rssMatchSummary()}</span>
</div>
<div class="modal-body"><div class="store-list" id="rss-list">${rssItemsHtml()}</div></div>
<div class="modal-foot"><span style="margin-right:auto;color:var(--muted)">When a slot frees up, the client auto-adds the highest-value matching release.</span>
<button class="btn" data-act="close-modal">Close</button></div>`);
const inp=document.getElementById('rss-input');
if(inp) inp.oninput=()=>{ G.rssFilter=inp.value;
const l=document.getElementById('rss-list'); if(l) l.innerHTML=rssItemsHtml();
const cnt=document.getElementById('rss-count'); if(cnt) cnt.textContent=rssMatchSummary(); };
}
/* ---- Store / upgrades ---- */
function openStore(tab){
modalKind='store'; if(tab) storeTab=tab;
renderStoreModal();
back().classList.add('show');
}
function renderStoreModal(){
const tabs=[
{id:'public',name:'🏷️ Public Trackers'},
{id:'private',name:'🔑 Private Trackers',lock:!hasUnlock('cat6')},
{id:'darknet',name:'🕳️ Darknet / Late',lock:!hasUnlock('private')},
];
const tabHtml=tabs.map(t=>`<div class="mtab ${storeTab===t.id?'active':''} ${t.lock?'locked':''}" data-storetab="${t.id}">${t.name}</div>`).join('');
const ups=UPGRADES.filter(u=>u.tab===storeTab);
const cards=ups.map(u=>upgradeCard(u)).join('');
openModal(`
<div class="modal-title">🛠️ Network Options & Upgrades<span class="x" data-act="close-modal">✕</span></div>
<div class="mtabs">${tabHtml}</div>
<div class="modal-body"><div class="store-list" id="store-list">${cards}</div></div>
<div class="modal-foot"><span style="margin-right:auto;color:var(--muted)">Data available: <b class="col-up" id="store-data">${fmt(G.data)}</b></span>
<button class="btn" data-act="close-modal">Close</button></div>`);
}
function upgradeCard(u){
const maxed=upgradeMaxed(u);
const reqMet=upgradeReqMet(u);
const cost=upgradeCost(u);
const can=reqMet && !maxed && G.data>=cost;
const level = u.kind==='unlock' ? (hasUnlock(u.id)?'OWNED':'') : ('Lv '+lvl(u.id)+(u.max?(' / '+u.max):''));
let eff='';
if(u.kind==='mult-down') eff=`now ×${(Math.pow(u.mult,lvl(u.id))).toFixed(2)} → next ×${(Math.pow(u.mult,lvl(u.id)+1)).toFixed(2)} download`;
else if(u.kind==='hardware'){ const nl=lvl(u.id)+1; const nm=HW_TIERS[Math.min(nl,HW_TIERS.length-1)]+(nl>=HW_TIERS.length?(' +'+(nl-HW_TIERS.length+1)):''); eff=`${hwTierName()}${nm} · line ${fmtSpd(lineSpeed())}${fmtSpd(lineSpeed()*u.mult)}`; }
else if(u.kind==='perpeer'){ const cur=perPeerMult(), nx=Math.pow(u.mult,lvl(u.id)+1); eff=`per-peer ×${cur.toFixed(2)}×${nx.toFixed(2)} · seeds to saturate: ${Math.ceil(SAT_SEEDS/cur)}${Math.ceil(SAT_SEEDS/nx)}`; }
else if(u.kind==='mult-up') eff=`now ×${(Math.pow(u.mult,lvl(u.id))).toFixed(2)} → next ×${(Math.pow(u.mult,lvl(u.id)+1)).toFixed(2)} income`;
else if(u.kind==='ratio') eff=`now ×${(Math.pow(u.mult,lvl(u.id))).toFixed(2)} income`;
else if(u.kind==='seeds') eff=`+${lvl(u.id)*u.add} seeds → +${(lvl(u.id)+1)*u.add} next`;
else if(u.kind==='storage') eff=`current slots from this: +${lvl(u.id)*u.add}`;
else if(u.kind==='flat-up') eff=`+${fmtSpd(lvl(u.id)*u.add)} flat → +${fmtSpd((lvl(u.id)+1)*u.add)} next`;
let cls='up-card';
if(u.kind==='unlock'&&hasUnlock(u.id)) cls+=' bought';
if(maxed) cls+=' maxed';
if(!reqMet) cls+=' lockreq';
const btn = (u.kind==='unlock'&&hasUnlock(u.id))
? `<span class="up-cost ok">✓ installed</span>`
: maxed ? `<span class="up-cost ok">MAX</span>`
: !reqMet ? `<span class="up-cost no">🔒 needs ${UP_BY_ID[u.reqUp]?UP_BY_ID[u.reqUp].name:u.reqUp}</span>`
: `<button class="btn ${can?'primary':''}" data-act="buy-up" data-id="${u.id}" ${can?'':'disabled'}>${u.kind==='unlock'?'Unlock':'Upgrade'}</button>`;
return `<div class="${cls}" id="upcard-${u.id}">
<div class="up-ic">${u.ic}</div>
<div class="up-main">
<div class="up-name">${u.name} <span class="lvl">${level}</span></div>
<div class="up-desc">${u.desc}</div>
<div class="up-eff">${eff}</div>
</div>
<div class="up-buy">
${(u.kind==='unlock'&&hasUnlock(u.id))||maxed||!reqMet?'':`<span class="up-cost ${G.data>=cost?'ok':'no'}" id="upcost-${u.id}">${fmt(cost)}</span>`}
${btn}
</div>
</div>`;
}
function refreshStoreModal(){ if(modalKind==='store') renderStoreModal(); }
/* ---- Prestige ---- */
function openPrestige(){
modalKind='prestige';
renderPrestigeModal();
back().classList.add('show');
}
function renderPrestigeModal(){
const gain=creditsFor(G.runUp);
const perkCards=PERKS.filter(p=>perkReqMet(p)||hasPerkUnlock(p.reqPerk)).map(p=>perkCard(p)).join('');
const lockedPerks=PERKS.filter(p=>!perkReqMet(p)&&!hasPerkUnlock(p.id)).map(p=>perkCard(p)).join('');
openModal(`
<div class="modal-title">🛰️ Client Migration — Deploy Seedbox Cluster<span class="x" data-act="close-modal">✕</span></div>
<div class="modal-body">
<div class="prestige-box">
<h2>Crypto-Credits: <span class="big">${fmtInt(G.credits)}</span></h2>
<div class="warn-box">
Your ISP is throttling you for moving too much data. <b>Migrate</b>: abandon your local hardware and
redeploy onto a decentralized, encrypted <b>Virtual Seedbox Cluster</b>. This wipes your
<b>torrents, Data, and all network upgrades</b> — but you keep your <b>Credits</b> and <b>perks</b>,
and gain a permanent boost to Global Protocol Efficiency.
</div>
<div style="margin:8px 0 14px">
Uploaded this run: <b>${fmt(G.runUp)}</b><br>
Migrate now to earn: <b class="big" style="font-size:18px">${fmtInt(gain)} Credits</b>
${gain<1?'<br><span style="color:var(--red)">Need ~'+fmt(1e9)+' uploaded this run to earn at least 1 credit.</span>':''}
</div>
<button class="btn danger" data-act="do-prestige" ${gain<1?'disabled':''}>🛰️ Migrate to Seedbox Cluster (+${fmtInt(gain)})</button>
</div>
<div style="padding:0 8px"><div style="font-weight:700;padding:6px 12px">Permanent Perks (spend Credits)</div>
<div class="store-list">${perkCards}${lockedPerks}</div></div>
</div>
<div class="modal-foot"><span style="margin-right:auto;color:var(--muted)">Prestiges: <b>${G.prestiges}</b> · Lifetime uploaded: <b>${fmt(G.lifetimeUp)}</b></span>
<button class="btn" data-act="close-modal">Close</button></div>`);
}
function perkCard(p){
const maxed=perkMaxed(p);
const reqMet=perkReqMet(p);
const cost=perkCost(p);
const can=reqMet&&!maxed&&G.credits>=cost;
const level=p.once?(hasPerkUnlock(p.id)?'OWNED':''):('Lv '+plvl(p.id)+(p.max?(' / '+p.max):''));
let cls='up-card'; if(p.once&&hasPerkUnlock(p.id)) cls+=' bought'; if(maxed)cls+=' maxed'; if(!reqMet)cls+=' lockreq';
const btn=(p.once&&hasPerkUnlock(p.id))?`<span class="up-cost ok">✓ owned</span>`
: maxed?`<span class="up-cost ok">MAX</span>`
: !reqMet?`<span class="up-cost no">🔒 needs ${PERK_BY_ID[p.reqPerk].name}</span>`
: `<button class="btn ${can?'primary':''}" data-act="buy-perk" data-id="${p.id}" ${can?'':'disabled'}>Buy</button>`;
return `<div class="${cls}">
<div class="up-ic">${p.ic}</div>
<div class="up-main"><div class="up-name">${p.name} <span class="lvl">${level}</span></div>
<div class="up-desc">${p.desc}</div></div>
<div class="up-buy">
${(p.once&&hasPerkUnlock(p.id))||maxed||!reqMet?'':`<span class="up-cost ${G.credits>=cost?'ok':'no'}">${fmtInt(cost)} cr</span>`}
${btn}</div></div>`;
}
function refreshPrestigeModal(){ if(modalKind==='prestige') renderPrestigeModal(); }
/* ---- help / about ---- */
function openHelp(){
modalKind='help';
openModal(`
<div class="modal-title">❓ How to Play<span class="x" data-act="close-modal">✕</span></div>
<div class="modal-body" style="padding:16px 22px;line-height:1.6;font-size:13px;max-width:none">
<h3 style="margin:0 0 6px">qBitTorrz — the idle torrent client</h3>
<ol style="padding-left:20px">
<li><b> Add Torrent</b> → pick a release. It downloads at <code>min(Max Line Speed, Seeds × Bandwidth/Peer)</code>.</li>
<li>At 100% a torrent flips to <b>🌱 Seeding</b> and generates <b>Data</b> — your currency — forever.</li>
<li>Spend Data in <b>🛠️ Upgrades</b>: advance your <b>Network Hardware</b> tier (56k → Fiber → Telecom Trunk) to raise your line ceiling, plus storage slots and unlocks (Cat6 → Private → Fiber).</li>
<li><b>Peer-limited?</b> Open the <b>Trackers</b> tab and <b>Announce to New Trackers</b> — each adds connected seeds to every torrent, pushing your speed up toward the line cap. In <b>Peers</b>, tick <i>Drop Leechers</i> / <i>Prioritize Seeds</i> for +15% each on a specific file.</li>
<li><b>📡 RSS</b> auto-downloads releases matching a glob filter; <b>announce-bot</b> drops pre-release <b>🧲 magnets</b> in the IRC tab — grab them to skip the slow start.</li>
<li><b>💎 Rare</b> torrents stall at 99.9% — open <b>IRC / Swarm</b> and request the last piece from an archivist.</li>
<li><b>🛰️ Migrate</b> (prestige): redeploy onto a <b>Virtual Seedbox Cluster</b> — wipes your run for permanent <b>Crypto-Credits</b> & perks, including the Darknet tier.</li>
</ol>
<p><b>The model:</b> Max Line Speed is your hardware. A torrent with few seeds is <span style="color:var(--orange)">peer-limited</span> (slow) until you announce trackers / prioritize peers to <b>saturate the line</b> (${SAT_SEEDS} seeds).</p>
<p><b>Events:</b> 🌐 DHT gives random ×10 line bursts; moving lots of data attracts 🐌 ISP throttling — neutralize it with Quantum Encryption.</p>
<p style="color:var(--muted)">Saves automatically. Offline & backgrounded-tab progress (up to 8h) is applied when you return.</p>
</div>
<div class="modal-foot"><button class="btn primary" data-act="close-modal">Got it</button></div>`);
}
function openAbout(){
modalKind='about';
openModal(`
<div class="modal-title">About<span class="x" data-act="close-modal">✕</span></div>
<div class="modal-body" style="padding:22px;text-align:center">
<div style="font-size:42px">🧲</div>
<h2 style="margin:6px 0">qBitTorrz <span style="color:var(--muted);font-weight:400">v4.6.2</span></h2>
<p style="color:var(--muted)">An incremental game disguised as a BitTorrent client.<br>
All torrents are fictional / public-domain parodies. No actual data is transferred.</p>
<p style="font-family:var(--mono);font-size:11px;color:var(--muted)">Lifetime uploaded: ${fmt(G.lifetimeUp)} · Prestiges: ${G.prestiges}</p>
</div>
<div class="modal-foot"><button class="btn" data-act="close-modal">Close</button></div>`);
}
/* ---- export/import ---- */
function exportSave(){
save();
const raw=localStorage.getItem(SAVE_KEY)||'';
const b64=btoa(unescape(encodeURIComponent(raw)));
modalKind='io';
openModal(`<div class="modal-title">Export Save<span class="x" data-act="close-modal">✕</span></div>
<div class="modal-body" style="padding:14px"><p>Copy this string to back up your progress:</p>
<textarea id="io-text" style="width:100%;height:120px;font-family:var(--mono);font-size:11px" readonly>${b64}</textarea></div>
<div class="modal-foot"><button class="btn" data-act="copy-save">Copy</button><button class="btn" data-act="close-modal">Close</button></div>`);
}
function importSave(){
modalKind='io';
openModal(`<div class="modal-title">Import Save<span class="x" data-act="close-modal">✕</span></div>
<div class="modal-body" style="padding:14px"><p>Paste a previously exported save string:</p>
<textarea id="io-text" style="width:100%;height:120px;font-family:var(--mono);font-size:11px"></textarea></div>
<div class="modal-foot"><button class="btn danger" data-act="do-import">Import & Reload</button><button class="btn" data-act="close-modal">Close</button></div>`);
}
/* ===========================================================================
CONTEXT MENU
=========================================================================== */
function showCtx(x,y,uid){
const t=G.torrents.find(t=>t.uid===uid); if(!t) return;
const m=document.getElementById('ctxmenu');
const paused=t.status.startsWith('paused');
const stalled=t.status==='stalled';
m.innerHTML=`
${paused?`<div data-ctx="resume" data-uid="${uid}">▶️ Resume</div>`:`<div data-ctx="pause" data-uid="${uid}">⏸️ Pause</div>`}
${stalled?`<div data-ctx="bribe" data-uid="${uid}">📨 Request last piece (IRC)</div>`:''}
<div data-ctx="recheck" data-uid="${uid}">🔁 Force Recheck</div>
<div class="sep"></div>
<div data-ctx="copyhash" data-uid="${uid}">📋 Copy Info Hash</div>
<div class="sep"></div>
<div data-ctx="remove" data-uid="${uid}" style="color:#c23b2e">🗑️ Remove</div>`;
m.style.display='block';
const mw=m.offsetWidth||200, mh=m.offsetHeight||160;
m.style.left=Math.max(2,Math.min(x,window.innerWidth-mw-2))+'px';
m.style.top=Math.max(2,Math.min(y,window.innerHeight-mh-2))+'px';
}
function hideCtx(){ document.getElementById('ctxmenu').style.display='none'; }
/* ===========================================================================
SAVE / LOAD
=========================================================================== */
let saveFailed=false;
function save(){
G.lastSave=Date.now();
try{ localStorage.setItem(SAVE_KEY, JSON.stringify(G)); return true; }
catch(e){
if(!saveFailed){ saveFailed=true;
toast('bad','⚠️ Could not save','Browser storage is unavailable (private mode / quota). Progress will be lost on reload.'); }
return false;
}
}
// Repair anything a partial / hand-edited / imported save could have corrupted, so a
// bad field can never brick the app (e.g. upgrades:null -> crash) or inject NaN into Data.
function sanitizeState(){
const num=(v,d)=> (typeof v==='number'&&isFinite(v)&&v>=0)?v:d;
G.data=num(G.data,0); G.totalUp=num(G.totalUp,0); G.totalDown=num(G.totalDown,0);
G.runUp=num(G.runUp,0); G.credits=num(G.credits,0); G.prestiges=num(G.prestiges,0);
G.lifetimeUp=num(G.lifetimeUp,0); G.seq=Math.max(1,Math.floor(num(G.seq,1)));
if(!G.upgrades||typeof G.upgrades!=='object'||Array.isArray(G.upgrades)) G.upgrades={};
if(!G.perks||typeof G.perks!=='object'||Array.isArray(G.perks)) G.perks={};
if(!G.seenCatalog||typeof G.seenCatalog!=='object'||Array.isArray(G.seenCatalog)) G.seenCatalog={};
if(!Array.isArray(G.trackerList)||!G.trackerList.length) G.trackerList=defaultTrackers();
G.trackerList=G.trackerList.filter(t=>t&&typeof t.url==='string').map(t=>({
url:t.url, seeds:num(t.seeds,1), peers:num(t.peers,1), status:t.status||'working'}));
if(typeof G.rssFilter!=='string') G.rssFilter='*';
G.rssOn=G.rssOn!==false;
if(!Array.isArray(G.torrents)) G.torrents=[];
G.torrents=G.torrents.filter(t=>t&&CAT_BY_ID[t.cid]);
for(const t of G.torrents){
const c=CAT_BY_ID[t.cid];
t.downloaded=Math.min(c.size,num(t.downloaded,0));
t.uploaded=num(t.uploaded,0);
t.dropLeechers=!!t.dropLeechers; t.prioSeeds=!!t.prioSeeds;
if(typeof t.uid!=='number'||!isFinite(t.uid)) t.uid=G.seq++;
}
if(!Array.isArray(G.irc)) G.irc=[];
if(typeof G.lastSave!=='number'||!isFinite(G.lastSave)) G.lastSave=Date.now();
if(G.lastSave>Date.now()) G.lastSave=Date.now(); // a future-dated save self-heals
if(G.sel!=null && !G.torrents.some(t=>t.uid===G.sel)) G.sel=null;
}
function load(){
let raw=null;
try{ raw=localStorage.getItem(SAVE_KEY); }catch(e){}
if(!raw){ G=freshState(); seedIrc(); return false; }
try{
const s=JSON.parse(raw);
if(!s||typeof s!=='object'||Array.isArray(s)) throw new Error('bad shape');
G=Object.assign(freshState(), s);
sanitizeState();
if(!G.irc.length) seedIrc();
return true;
}catch(e){ G=freshState(); seedIrc(); return false; }
}
function seedIrc(){
G.irc=[];
ircSys('connected to irc.torrentz.net');
ircSys('topic: maintain ratio · no leeching · seed your rares');
ircAmbient(); ircAmbient();
}
// Coarse fixed-step catch-up simulation, shared by load-time offline progress and
// the visibilitychange refocus handler. Step count scales with dt (capped) so a
// torrent that completes mid-gap still seeds for most of the remaining time.
function simulateAway(dt){
if(!(dt>0)) return;
const steps=Math.min(240, Math.max(20, Math.ceil(dt/30))), sdt=dt/steps;
for(let i=0;i<steps;i++){
for(const t of G.torrents){
if(t.status==='downloading'){
const sp=lineSpeed()*downFrac(torrentSeeds(t));
t.downloaded+=sp*sdt; G.totalDown+=sp*sdt;
const c=CAT_BY_ID[t.cid];
if(c.rare && t.downloaded>=c.size*0.999 && t.downloaded<c.size){ t.downloaded=c.size*0.999; t.status='stalled'; }
else if(t.downloaded>=c.size){ t.downloaded=c.size; t.status='seeding'; t.completedAt=Date.now(); }
} else if(t.status==='seeding'){
const c=CAT_BY_ID[t.cid];
const sp=c.up*upMultGlobal()+seedboxFlat();
const g=sp*sdt; t.uploaded+=g; G.data+=g; G.totalUp+=g; G.runUp+=g; G.lifetimeUp+=g;
}
}
}
}
function applyOffline(){
const dt=Math.min(MAX_OFFLINE_S, Math.max(0,(Date.now()-(G.lastSave||Date.now()))/1000));
if(dt<2) return;
const beforeData=G.data;
simulateAway(dt);
const earned=G.data-beforeData;
if(earned>0){
setTimeout(()=>toast('info','💤 Welcome back','While away ('+fmtTime(dt)+') your seeds earned <b>'+fmt(earned)+'</b> Data.'),400);
}
}
/* ===========================================================================
EVENT WIRING
=========================================================================== */
function selectTorrent(uid){ G.sel=uid; renderTable(); renderDetail(); renderHud(); }
document.addEventListener('click',e=>{
hideCtx();
const actEl=e.target.closest('[data-act]');
const filterEl=e.target.closest('[data-filter]');
const storeTabEl=e.target.closest('[data-storetab]');
const catTabEl=e.target.closest('[data-cattab]');
const dtabEl=e.target.closest('[data-dtab]');
const rowEl=e.target.closest('tr[data-uid]');
if(actEl){ if(actEl.tagName==='A') e.preventDefault(); handleAct(actEl.dataset.act, actEl); return; }
if(filterEl && filterEl.closest('#sidebar')){
G.filter=filterEl.dataset.filter;
// keep selection scoped to the visible list (so Remove/Del can't hit a hidden torrent)
const selT=G.torrents.find(t=>t.uid===G.sel);
if(selT && !matchFilter(selT,G.filter)) G.sel=null;
renderSidebar(); renderTable(); renderDetail(); renderHud(); return;
}
if(storeTabEl && storeTabEl.closest('.mtabs')){ if(!storeTabEl.classList.contains('locked')){ storeTab=storeTabEl.dataset.storetab; renderStoreModal(); } return; }
if(catTabEl){ if(!catTabEl.classList.contains('locked')){ catTab=catTabEl.dataset.cattab; renderAddModal(); } return; }
if(dtabEl){ G.dtab=dtabEl.dataset.dtab; renderDetail(); return; }
if(rowEl){ selectTorrent(parseInt(rowEl.dataset.uid)); return; }
});
document.addEventListener('contextmenu',e=>{
const row=e.target.closest('tr[data-uid]');
if(row){ e.preventDefault(); const uid=parseInt(row.dataset.uid); selectTorrent(uid); showCtx(e.clientX,e.clientY,uid); }
});
document.addEventListener('click',e=>{
const ctx=e.target.closest('[data-ctx]');
if(ctx){ handleCtx(ctx.dataset.ctx, parseInt(ctx.dataset.uid)); hideCtx(); }
});
document.addEventListener('dblclick',e=>{
const row=e.target.closest('tr[data-uid]'); if(row){ G.dtab='general'; renderDetail(); }
});
function handleAct(act,el){
switch(act){
case 'open-add': openAdd(); break;
case 'open-store': openStore(el.dataset.storetab||storeTab); break;
case 'open-rss': openRss(); break;
case 'open-prestige': openPrestige(); break;
case 'announce': announceTracker(); break;
case 'grab-magnet': grabMagnet(el.dataset.cid); break;
case 'peer-toggle': { const tt=G.torrents.find(x=>x.uid===parseInt(el.dataset.uid)); if(tt){ tt[el.dataset.field]=!tt[el.dataset.field]; renderDetail(); renderTable(); renderStatus(); } break; }
case 'rss-toggle': G.rssOn=!G.rssOn; renderRssModal(); break;
case 'open-help': openHelp(); break;
case 'open-about': openAbout(); break;
case 'close-modal': closeModal(); break;
case 'add-torrent': if(addTorrent(el.dataset.cid)) renderAddModal(); break;
case 'buy-up': buyUpgrade(el.dataset.id); break;
case 'buy-perk': buyPerk(el.dataset.id); break;
case 'do-prestige': doPrestige(); break;
case 'resume-all': resumeAll(); break;
case 'pause-all': pauseAll(); break;
case 'remove-sel': if(G.sel!=null) removeTorrent(G.sel); break;
case 'save-now': if(save()) toast('','Saved','Session written to local storage.'); break;
case 'toggle-sci': G.sci=!G.sci; renderAll(); break;
case 'export-save': exportSave(); break;
case 'import-save': importSave(); break;
case 'copy-save': { const ta=document.getElementById('io-text'); ta.select(); document.execCommand&&document.execCommand('copy'); toast('','Copied','Save string copied.'); break; }
case 'do-import': doImport(); break;
case 'hard-reset': hardReset(); break;
case 'peer-boost': { G.burstUntil=Math.max(G.burstUntil, now()+2500); toast('info','Peer prioritized','Brief download nudge applied.'); break; }
case 'peer-ban': { toast('','Peer banned','Leecher disconnected. The swarm thanks you.'); break; }
case 'bribe': forceComplete(parseInt(el.dataset.uid)); renderDetail(); break;
}
}
function handleCtx(ctx,uid){
switch(ctx){
case 'pause': pauseTorrent(uid); break;
case 'resume': resumeTorrent(uid); break;
case 'remove': removeTorrent(uid); break;
case 'bribe': forceComplete(uid); break;
case 'recheck': toast('','Rechecking…','Pieces verified. Everything checks out.'); break;
case 'copyhash': { const t=G.torrents.find(t=>t.uid===uid); if(t){ const h=fakeHash(t.uid+CAT_BY_ID[t.cid].id); navigator.clipboard&&navigator.clipboard.writeText(h); toast('','Hash copied',h.slice(0,20)+'…'); } break; }
}
}
function doImport(){
const ta=document.getElementById('io-text'); if(!ta) return;
try{
const raw=decodeURIComponent(escape(atob(ta.value.trim())));
JSON.parse(raw); // validate
localStorage.setItem(SAVE_KEY, raw);
location.reload();
}catch(e){ toast('bad','Import failed','That doesn\'t look like a valid save string.'); }
}
function hardReset(){
modalKind='confirm';
openModal(`<div class="modal-title">⚠️ Delete Everything<span class="x" data-act="close-modal">✕</span></div>
<div class="modal-body" style="padding:18px"><p>This permanently wipes <b>all progress</b>, including prestige credits and perks. This cannot be undone.</p></div>
<div class="modal-foot"><button class="btn" data-act="close-modal">Cancel</button>
<button class="btn danger" onclick="localStorage.removeItem('${SAVE_KEY}');location.reload();">Delete everything</button></div>`);
}
/* keyboard */
document.addEventListener('keydown',e=>{
if(e.target.tagName==='TEXTAREA'||e.target.tagName==='INPUT') return;
if(e.key==='Delete' && G.sel!=null){ removeTorrent(G.sel); }
if((e.ctrlKey||e.metaKey) && e.key==='s'){ e.preventDefault(); if(save()) toast('','Saved','Session saved.'); }
if((e.ctrlKey||e.metaKey) && (e.key==='o'||e.key==='u')){ e.preventDefault(); openAdd(); }
if(e.key==='Escape'){ closeModal(); hideCtx(); }
});
// close modal on backdrop click
document.getElementById('modal-back').addEventListener('mousedown',e=>{ if(e.target.id==='modal-back') closeModal(); });
/* ---- splitters ---- */
(function(){
const sv=document.getElementById('splitter-v'), sh=document.getElementById('splitter-h');
const sidebar=document.getElementById('sidebar'), detail=document.getElementById('detail');
let drag=null;
sv.addEventListener('mousedown',e=>{drag={type:'v',x:e.clientX,w:sidebar.offsetWidth};e.preventDefault();});
sh.addEventListener('mousedown',e=>{drag={type:'h',y:e.clientY,h:detail.offsetHeight};e.preventDefault();});
window.addEventListener('mousemove',e=>{
if(!drag) return;
if(drag.type==='v'){ let w=Math.max(140,Math.min(360,drag.w+(e.clientX-drag.x))); sidebar.style.width=w+'px'; G.sidebarW=w; }
else { let h=Math.max(90,Math.min(window.innerHeight-220,drag.h-(e.clientY-drag.y))); detail.style.height=h+'px'; G.detailH=h; if(G.dtab==='speed')drawSpeed(); }
});
window.addEventListener('mouseup',()=>{drag=null;});
})();
/* ===========================================================================
MAIN LOOP
=========================================================================== */
let lastTick=Date.now(), saveAccum=0;
function tick(){
const t=Date.now();
let dt=(t-lastTick)/1000; lastTick=t;
if(dt<0) dt=0; if(dt>5) dt=5; // cap big jumps (throttled tab)
step(dt);
// sample speed
spdDownHist.push(totalDownSpeed()); spdDownHist.shift();
spdUpHist.push(totalUpSpeed()); spdUpHist.shift();
renderHud(); renderTable(); renderStatus();
// sidebar counts change -> cheap re-render occasionally
if(Math.floor(t/700)!==Math.floor((t-TICK_MS)/700)) renderSidebar();
if(G.dtab!=='speed') { if(G.sel!=null && (G.dtab==='general')) renderDetail(); }
else renderDetail();
if(G.dtab==='irc') { const log=document.getElementById('irc-log'); }
// live modal numbers
if(modalKind==='store'){ const d=document.getElementById('store-data'); if(d) d.textContent=fmt(G.data); updateStoreAfford(); }
if(modalKind==='add'){ updateAddAfford(); }
// autosave
saveAccum+=dt;
if(saveAccum>=10){ saveAccum=0; save(); }
}
function updateStoreAfford(){
UPGRADES.filter(u=>u.tab===storeTab).forEach(u=>{
if(upgradeMaxed(u)||!upgradeReqMet(u)||(u.kind==='unlock'&&hasUnlock(u.id))) return;
const cost=upgradeCost(u);
const costEl=document.getElementById('upcost-'+u.id);
if(costEl){ costEl.className='up-cost '+(G.data>=cost?'ok':'no'); }
const card=document.getElementById('upcard-'+u.id);
if(card){ const btn=card.querySelector('button[data-act="buy-up"]');
if(btn){ const can=G.data>=cost; btn.toggleAttribute('disabled',!can); btn.classList.toggle('primary',can); } }
});
}
function updateAddAfford(){
const full=usedSlots()>=maxSlots();
document.querySelectorAll('button[data-act="add-torrent"]').forEach(b=>{
b.toggleAttribute('disabled',full); b.textContent=full?'Slots full':'Download';
});
}
/* ===========================================================================
BOOT
=========================================================================== */
function boot(){
load();
applyOffline();
renderAll();
// periodic detail refresh for peers/irc handled in tick for general/speed
setInterval(tick,TICK_MS);
window.addEventListener('beforeunload',save);
// Background tabs get setInterval throttled, so tick's 5s dt-cap would silently
// discard most elapsed time. On refocus, credit the gap via the offline sim and
// reset lastTick so the next tick doesn't double-count it.
document.addEventListener('visibilitychange',()=>{
if(document.visibilityState!=='visible') return;
const gap=(Date.now()-lastTick)/1000;
if(gap>10){ simulateAway(Math.min(MAX_OFFLINE_S,gap)); save(); renderAll(); }
lastTick=Date.now();
});
// first-time hint
if(G.torrents.length===0 && G.totalUp===0 && G.prestiges===0){
setTimeout(()=>toast('info','Welcome to qBitTorrz','Click Add Torrent to grab your first release. Help is in the menu.'),700);
}
}
boot();
</script>
</body>
</html>