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>
1853 lines
98 KiB
HTML
1853 lines
98 KiB
HTML
<!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}"><${l.n}></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,'"')}" 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>
|