feat: expand drop stats + pre-rolled mystery (?) downloads

Builds on the download-drops system:
- More drop stats: added Per-Peer Bandwidth, Drop Luck (raises future drop chance & rarity),
  and Migration Credits (boosts the next prestige payout) alongside Download Speed / Seed
  Income / Connected Seeds — six % stats, weighted. Plus a rare flat STORAGE drive drop (+1 slot).
- The '?' mystery: loot is now PRE-ROLLED when a torrent is added and stored on the torrent, so a
  download that contains something shows a pulsing purple ? badge while it downloads, revealed on
  completion. Drop Luck (evaluated at pre-roll) scales drop chance (capped) and biases rarity.
- Bonuses tab shows all six stats + storage; new drop kinds in the log; help updated.
- Integrated: perPeerMult, maxSlots (bonusSlots), doPrestige payout (credits). Robust sanitize for
  the new fields, and hardened torrent sanitization (default status/peerSeed/loot) so a corrupt
  save with a torrent missing 'status' no longer crashes render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
monster 2026-06-17 16:35:28 +10:00
parent 7ec834944b
commit 7718f5bb6b

View File

@ -96,6 +96,10 @@
table.tl tbody tr:hover{background:var(--tbl-hover);} table.tl tbody tr:hover{background:var(--tbl-hover);}
table.tl tbody tr.sel{background:var(--sel)!important;} table.tl tbody tr.sel{background:var(--sel)!important;}
.name-cell{display:flex;align-items:center;gap:6px;} .name-cell{display:flex;align-items:center;gap:6px;}
.myst{display:inline-flex;align-items:center;justify-content:center;min-width:14px;height:14px;
border-radius:3px;background:#8a2fb0;color:#fff;font-weight:800;font-size:10px;flex:0 0 auto;
animation:mystpulse 1.6s ease-in-out infinite;}
@keyframes mystpulse{0%,100%{opacity:.55;}50%{opacity:1;}}
.st-dot{width:10px;height:10px;border-radius:2px;flex:0 0 auto;} .st-dot{width:10px;height:10px;border-radius:2px;flex:0 0 auto;}
.st-down{background:var(--blue);} .st-seed{background:var(--green);} .st-down{background:var(--blue);} .st-seed{background:var(--green);}
.st-pause{background:#9a9a9a;} .st-stall{background:var(--orange);} .st-pause{background:#9a9a9a;} .st-stall{background:var(--orange);}
@ -548,7 +552,8 @@ function freshState(){
sci:false, sci:false,
seenCatalog:{}, // catalog ids the player has seen in the Add dialog seenCatalog:{}, // catalog ids the player has seen in the Add dialog
// download loot — completed downloads occasionally "contain" bonuses (reset on prestige) // download loot — completed downloads occasionally "contain" bonuses (reset on prestige)
bonusPct:{down:0, income:0, seeds:0}, // accumulated permanent % bonuses per stat bonusPct:{down:0, income:0, seeds:0, perpeer:0, luck:0, credits:0}, // permanent % per stat
bonusSlots:0, // flat extra storage slots from rare Storage drops
buffs:[], // active timed buffs {stat,mult,until,name,rarity} buffs:[], // active timed buffs {stat,mult,until,name,rarity}
dropLog:[], // recent drop history dropLog:[], // recent drop history
drops:0, // lifetime-this-run drop count drops:0, // lifetime-this-run drop count
@ -622,7 +627,7 @@ function baseSeedBonus(){
return s; return s;
} }
function maxSlots(){ function maxSlots(){
return 3 + lvl('storage1')*1 + lvl('storage2')*1 + plvl('rep'); return 3 + lvl('storage1')*1 + lvl('storage2')*1 + plvl('rep') + (G.bonusSlots||0);
} }
function usedSlots(){ return G.torrents.length; } function usedSlots(){ return G.torrents.length; }
@ -636,7 +641,7 @@ function torrentSeeds(t){
const base=(c?c.seeds:1)+baseSeedBonus()+trackerSeedBonus(); const base=(c?c.seeds:1)+baseSeedBonus()+trackerSeedBonus();
return Math.max(1, Math.round(base*(1+peerPrioBonus(t))*bonusMult('seeds'))); // ×seeds loot bonus return Math.max(1, Math.round(base*(1+peerPrioBonus(t))*bonusMult('seeds'))); // ×seeds loot bonus
} }
function perPeerMult(){ return Math.pow(UP_BY_ID.perpeer.mult, lvl('perpeer')); } function perPeerMult(){ return Math.pow(UP_BY_ID.perpeer.mult, lvl('perpeer')) * bonusMult('perpeer'); }
// Fraction of Max Line Speed actually achieved: min(1, seeds × bwPerPeer / line). // 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. // 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 downFrac(seeds){ return Math.min(1, seeds/SAT_SEEDS*perPeerMult()); }
@ -683,7 +688,7 @@ function step(dt){ // dt seconds
} else if(t.downloaded >= c.size){ } else if(t.downloaded >= c.size){
t.downloaded = c.size; t.status='seeding'; t.completedAt=now(); 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))); toast('info','Download complete', t.name.slice(0,40)+'<br>Now <b>seeding</b> for '+fmtSpd(torrentUpSpeed(t)));
rollLoot(t); // the completed download may have "contained" a bonus revealLoot(t); // reveal whatever the download "contained" (pre-rolled on add)
} }
} else if(t.status==='seeding'){ } else if(t.status==='seeding'){
const sp=torrentUpSpeed(t); const sp=torrentUpSpeed(t);
@ -732,11 +737,13 @@ function handleEvents(){
function rand(a,b){ return a + Math.random()*(b-a); } function rand(a,b){ return a + Math.random()*(b-a); }
/* ---- download loot / drops ------------------------------------------------ /* ---- download loot / drops ------------------------------------------------
Every COMPLETED download rolls for a bonus it "contained": Loot is PRE-ROLLED the moment you add a torrent (stored on t.loot), so a download
- occasional PERMANENT % bonus to a stat (rarer = bigger), or that "contains" something shows a ? while it downloads and is revealed on completion.
- much rarer, a TIMED multiplier buff ("better server access"). Bigger torrents Kinds: a PERMANENT +% bonus to a stat (rarer = bigger), a rarer TIMED multiplier
drop bigger bonuses (tierFactor). All loot resets on prestige. */ buff, or a rare flat STORAGE drive (+1 slot). Drop Luck raises chance & rarity.
const DROP = { timedChance:0.05, permChance:0.34 }; // per completion Stats: down/income/seeds (live), perpeer (saturate with fewer seeds), luck (better
future drops), credits (bigger next migration). All loot resets on prestige. */
const DROP = { storageChance:0.015, timedChance:0.05, permChance:0.34 }; // per completion (×luck, capped)
const RARITY = { const RARITY = {
Common: {c:'#8a8a8a', g:'•'}, Common: {c:'#8a8a8a', g:'•'},
Uncommon: {c:'#4f9e3a', g:'◆'}, Uncommon: {c:'#4f9e3a', g:'◆'},
@ -744,43 +751,72 @@ const RARITY = {
Epic: {c:'#8a2fb0', g:'✦'}, Epic: {c:'#8a2fb0', g:'✦'},
Legendary: {c:'#d98a1f', g:'✸'}, Legendary: {c:'#d98a1f', g:'✸'},
}; };
const STAT_LABEL = { down:'Download Speed', income:'Seed Income', seeds:'Connected Seeds' }; const STAT_LABEL = { down:'Download Speed', income:'Seed Income', seeds:'Connected Seeds',
const STAT_ICON = { down:'⬇️', income:'⬆️', seeds:'🌱' }; perpeer:'Per-Peer Bandwidth', luck:'Drop Luck', credits:'Migration Credits' };
const STAT_ICON = { down:'⬇️', income:'⬆️', seeds:'🌱', perpeer:'🔗', luck:'🍀', credits:'🛰️' };
const DROP_STATS = ['down','income','seeds','perpeer','luck','credits'];
function tierFactor(c){ function tierFactor(c){
switch(c.req){ switch(c.req){
case 'singularity': return 2.4; case 'darknet': return 2.0; case 'fiber': return 1.6; case 'singularity': return 2.4; case 'darknet': return 2.0; case 'fiber': return 1.6;
case 'private': return 1.3; case 'cat6': return 1.1; default: return 1.0; case 'private': return 1.3; case 'cat6': return 1.1; default: return 1.0;
} }
} }
// permanent % bonus — small common, big rare (the rarity curve) function pickDropStat(){
function rollPermBonus(c){ const w=[['income',26],['down',22],['seeds',20],['perpeer',12],['luck',10],['credits',10]];
const r=Math.random(); let pct, rarity; let tot=0; for(const x of w) tot+=x[1]; let r=Math.random()*tot;
for(const [s,wt] of w){ if(r<wt) return s; r-=wt; }
return 'income';
}
// permanent % bonus — small common, big rare; Drop Luck biases toward higher rarity
function rollPermBonus(c, luckMult){
let r=Math.random(); if(luckMult>1) r=Math.pow(r, 1/luckMult);
let pct, rarity;
if(r<0.56){ pct=rand(1,3); rarity='Common'; } if(r<0.56){ pct=rand(1,3); rarity='Common'; }
else if(r<0.83){ pct=rand(4,7); rarity='Uncommon'; } else if(r<0.83){ pct=rand(4,7); rarity='Uncommon'; }
else if(r<0.95){ pct=rand(8,15); rarity='Rare'; } else if(r<0.95){ pct=rand(8,15); rarity='Rare'; }
else if(r<0.99){ pct=rand(16,28); rarity='Epic'; } else if(r<0.99){ pct=rand(16,28); rarity='Epic'; }
else { pct=rand(30,55); rarity='Legendary'; } else { pct=rand(30,55); rarity='Legendary'; }
pct *= tierFactor(c); pct *= tierFactor(c);
const sr=Math.random(); const stat = sr<0.40?'income' : sr<0.72?'down' : 'seeds'; return { stat:pickDropStat(), pct:+pct.toFixed(1), rarity };
return { stat, pct:+pct.toFixed(1), rarity };
} }
// timed buff — rarer, multipliers from better server access // timed buff — rarer, multipliers from better server access
const BUFF_TABLE = [ const BUFF_TABLE = [
{p:0.50, stat:'seeds', mult:2, dur:30, name:'Private Tracker Invite', rarity:'Uncommon'}, {p:0.34, stat:'seeds', mult:2, dur:30, name:'Private Tracker Invite', rarity:'Uncommon'},
{p:0.28, stat:'seeds', mult:3, dur:45, name:'Seedbox Trial Access', rarity:'Rare'}, {p:0.24, stat:'seeds', mult:3, dur:45, name:'Seedbox Trial Access', rarity:'Rare'},
{p:0.15, stat:'down', mult:5, dur:45, name:'Fiber Burst', rarity:'Epic'}, {p:0.18, stat:'perpeer',mult:3, dur:40, name:'Priority Routing', rarity:'Rare'},
{p:0.07, stat:'income', mult:10, dur:60, name:'0-Day Scene Access', rarity:'Legendary'}, {p:0.13, stat:'down', mult:5, dur:45, name:'Fiber Burst', rarity:'Epic'},
{p:0.07, stat:'income', mult:8, dur:60, name:'Mirror Server', rarity:'Epic'},
{p:0.04, stat:'income', mult:15, dur:60, name:'0-Day Scene Access', rarity:'Legendary'},
]; ];
function rollTimedBuff(){ let r=Math.random(),a=0; for(const b of BUFF_TABLE){ a+=b.p; if(r<a) return b; } return BUFF_TABLE[0]; } function rollTimedBuff(){ let r=Math.random(),a=0; for(const b of BUFF_TABLE){ a+=b.p; if(r<a) return b; } return BUFF_TABLE[BUFF_TABLE.length-1]; }
function rollLoot(t, silent){ // PRE-ROLL what a torrent contains, the moment it's added
const c=CAT_BY_ID[t.cid]; if(!c) return; function prerollLoot(t){
const c=CAT_BY_ID[t.cid]; if(!c){ t.loot=null; return; }
const luck=permMult('luck');
let sP=DROP.storageChance*luck, tP=DROP.timedChance*luck, pP=DROP.permChance*luck;
const tot=sP+tP+pP; if(tot>0.85){ const k=0.85/tot; sP*=k; tP*=k; pP*=k; }
const r=Math.random(); const r=Math.random();
if(r < DROP.timedChance){ if(r<sP) t.loot={kind:'storage', rarity:'Epic'};
if(silent) return; // timed buffs are live-only else if(r<sP+tP) t.loot=Object.assign({kind:'buff'}, rollTimedBuff());
grantTimedBuff(t, rollTimedBuff()); else if(r<sP+tP+pP) t.loot=Object.assign({kind:'perm'}, rollPermBonus(c, luck));
} else if(r < DROP.timedChance + DROP.permChance){ else t.loot=null;
grantPermBonus(t, rollPermBonus(c), silent); }
// REVEAL pre-rolled loot on completion (silent = offline; timed buffs skipped offline)
function revealLoot(t, silent){
if(t.loot===undefined) prerollLoot(t); // legacy torrent (saved before pre-roll existed)
const L=t.loot; t.loot=null;
if(!L) return;
if(L.kind==='storage'){
G.bonusSlots=(G.bonusSlots||0)+1; G.drops=(G.drops||0)+1;
G.dropLog.unshift({kind:'storage', name:CAT_BY_ID[t.cid].name, rarity:L.rarity});
if(G.dropLog.length>40) G.dropLog.pop();
if(!silent) toast('drop-epic','✦ Storage Drive Found — +1 Slot','contained in '+CAT_BY_ID[t.cid].name.slice(0,30));
} else if(L.kind==='buff' && STAT_LABEL[L.stat] && isFinite(L.mult) && isFinite(L.dur)){
if(silent) return;
grantTimedBuff(t, L);
} else if(L.kind==='perm' && STAT_LABEL[L.stat] && isFinite(L.pct)){
grantPermBonus(t, L, silent);
} }
} }
function grantPermBonus(t, b, silent){ function grantPermBonus(t, b, silent){
@ -876,6 +912,7 @@ function addTorrent(cid, silent, headstart){
peerSeed:Math.floor(rand(20,400)), peerSeed:Math.floor(rand(20,400)),
dropLeechers:false, prioSeeds:false, dropLeechers:false, prioSeeds:false,
}; };
prerollLoot(t); // decide now what this download "contains" (shows ? while downloading)
G.torrents.push(t); G.torrents.push(t);
if(!silent) toast('','Torrent added', c.name.slice(0,42)); if(!silent) toast('','Torrent added', c.name.slice(0,42));
renderAll(); renderAll();
@ -972,13 +1009,13 @@ function creditsFor(runUp){
return Math.floor(Math.pow(runUp/1e9, 0.42)); return Math.floor(Math.pow(runUp/1e9, 0.42));
} }
function doPrestige(){ function doPrestige(){
const gain=creditsFor(G.runUp); const gain=Math.floor(creditsFor(G.runUp)*permMult('credits')); // Migration Credits drops boost the payout
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; } 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++; G.credits+=gain; G.prestiges++;
// reset run state — abandon local hardware & trackers, keep credits/perks // reset run state — abandon local hardware & trackers, keep credits/perks
G.data=0; G.runUp=0; G.torrents=[]; G.upgrades={}; G.sel=null; G.data=0; G.runUp=0; G.torrents=[]; G.upgrades={}; G.sel=null;
G.trackerList=defaultTrackers(); G.trackerList=defaultTrackers();
G.bonusPct={down:0,income:0,seeds:0}; G.buffs=[]; G.dropLog=[]; G.drops=0; // loot resets on migration G.bonusPct={down:0,income:0,seeds:0,perpeer:0,luck:0,credits:0}; G.bonusSlots=0; G.buffs=[]; G.dropLog=[]; G.drops=0; // loot resets on migration
G.burstUntil=0;G.nextBurst=0;G.throttleUntil=0;G.nextThrottle=0;G.nextMagnet=0; 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>.'); 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 ---'); ircSys('--- ISP throttle evaded. migrated to decentralized seedbox cluster. new node online ---');
@ -1150,7 +1187,7 @@ function renderTable(){
const ratio = t.downloaded>0? (t.uploaded/c.size): 0; const ratio = t.downloaded>0? (t.uploaded/c.size): 0;
html+=`<tr data-uid="${t.uid}" class="${G.sel===t.uid?'sel':''}"> html+=`<tr data-uid="${t.uid}" class="${G.sel===t.uid?'sel':''}">
<td class="num">${i+1}</td> <td class="num">${i+1}</td>
<td><div class="name-cell" title="${c.name}">${iconFor(t)} ${c.name}</div></td> <td><div class="name-cell" title="${c.name}">${iconFor(t)}${t.loot&&t.status!=='seeding'?'<span class="myst" title="This download contains something…">?</span>':''} ${c.name}</div></td>
<td class="num">${fmt(c.size)}</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><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>${statusLabel(t)}</td>
@ -1214,10 +1251,14 @@ function renderDetail(){
} }
function detBonuses(){ function detBonuses(){
const n=now(); const n=now();
const rows=['down','income','seeds'].map(s=>{ const note={ luck:'drop chance & rarity', credits:'next migration payout' };
const rows=DROP_STATS.map(s=>{
const perm=G.bonusPct[s]||0, bf=buffMult(s), tot=permMult(s)*bf; const perm=G.bonusPct[s]||0, bf=buffMult(s), tot=permMult(s)*bf;
return `<div class="k">${STAT_ICON[s]} ${STAT_LABEL[s]}</div><div class="v">+${perm.toFixed(1)}% permanent${bf>1?` · <span class="col-up">×${bf.toFixed(2)} buff</span>`:''} → <b>×${tot.toFixed(2)}</b></div>`; const right=(s==='luck'||s==='credits')
}).join(''); ? `+${perm.toFixed(1)}% <span style="color:var(--muted)">${note[s]}</span>`
: `+${perm.toFixed(1)}%${bf>1?` · <span class="col-up">×${bf.toFixed(2)} buff</span>`:''} → <b>×${tot.toFixed(2)}</b>`;
return `<div class="k">${STAT_ICON[s]} ${STAT_LABEL[s]}</div><div class="v">${right}</div>`;
}).join('') + (G.bonusSlots>0?`<div class="k">💽 Storage Slots</div><div class="v">+${G.bonusSlots} from drives found</div>`:'');
const active=G.buffs.filter(b=>b.until>n); const active=G.buffs.filter(b=>b.until>n);
const buffHtml = active.length ? active.map(b=>{ const buffHtml = active.length ? active.map(b=>{
const left=Math.max(0,(b.until-n)/1000); const left=Math.max(0,(b.until-n)/1000);
@ -1225,14 +1266,15 @@ function detBonuses(){
}).join('') : '<div style="color:var(--muted)">None active. A rare few downloads contain temporary server-access multipliers.</div>'; }).join('') : '<div style="color:var(--muted)">None active. A rare few downloads contain temporary server-access multipliers.</div>';
const log = G.dropLog.slice(0,16).map(d=>{ const log = G.dropLog.slice(0,16).map(d=>{
const col=RARITY[d.rarity]?RARITY[d.rarity].c:'#888'; const col=RARITY[d.rarity]?RARITY[d.rarity].c:'#888';
if(d.kind==='buff') return `<div><b style="color:${col}">${d.label}</b> ×${d.mult} ${STAT_LABEL[d.stat]} <span style="color:var(--muted)">— ${d.name.slice(0,30)}</span></div>`; if(d.kind==='buff') return `<div><b style="color:${col}">${d.label}</b> ×${d.mult} ${STAT_LABEL[d.stat]} <span style="color:var(--muted)">— ${(d.name||'').slice(0,30)}</span></div>`;
return `<div><span style="color:${col}">${(RARITY[d.rarity]||{}).g||'•'} ${d.rarity}</span> +${d.pct}% ${STAT_LABEL[d.stat]} <span style="color:var(--muted)">— ${d.name.slice(0,30)}</span></div>`; if(d.kind==='storage') return `<div><span style="color:${col}">✦ Storage</span> +1 Slot <span style="color:var(--muted)">— ${(d.name||'').slice(0,30)}</span></div>`;
return `<div><span style="color:${col}">${(RARITY[d.rarity]||{}).g||'•'} ${d.rarity}</span> +${d.pct}% ${STAT_LABEL[d.stat]} <span style="color:var(--muted)">— ${(d.name||'').slice(0,30)}</span></div>`;
}).join('') || '<div style="color:var(--muted)">No drops yet — complete downloads to find bonuses.</div>'; }).join('') || '<div style="color:var(--muted)">No drops yet — complete downloads to find bonuses.</div>';
return `<div style="margin-bottom:6px;color:var(--muted)">Completed downloads occasionally <b>contain bonuses</b> — small % boosts are common, big ones rare; a rare few hold temporary multipliers. <b>${G.drops}</b> found this run (resets on Migration).</div> return `<div style="margin-bottom:6px;color:var(--muted)">Completed downloads occasionally <b>contain bonuses</b>a <span class="myst">?</span> in the list means this one holds something. Small % boosts are common, big ones rare; a rare few hold timed multipliers or a storage drive. <b>${G.drops}</b> found this run (resets on Migration).</div>
<div class="kv" style="margin-bottom:8px">${rows}</div> <div class="kv" style="margin-bottom:8px">${rows}</div>
<div style="font-weight:600;margin:4px 0 3px">⚡ Active Timed Buffs</div>${buffHtml} <div style="font-weight:600;margin:4px 0 3px">⚡ Active Timed Buffs</div>${buffHtml}
<div style="font-weight:600;margin:8px 0 3px">Recent Drops</div> <div style="font-weight:600;margin:8px 0 3px">Recent Drops</div>
<div style="max-height:84px;overflow:auto;border:1px solid var(--line);background:#fcfcfc;padding:5px;font-size:11px;line-height:1.5">${log}</div>`; <div style="max-height:80px;overflow:auto;border:1px solid var(--line);background:#fcfcfc;padding:5px;font-size:11px;line-height:1.5">${log}</div>`;
} }
function detTrackers(t){ function detTrackers(t){
const totSeeds=trackerSeedBonus(); const totSeeds=trackerSeedBonus();
@ -1608,7 +1650,7 @@ function openHelp(){
<li><b> Add Torrent</b> → pick a release. It downloads at <code>min(Max Line Speed, Seeds × Bandwidth/Peer)</code>.</li> <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>At 100% a torrent flips to <b>🌱 Seeding</b> and generates <b>Data</b> — your currency — forever.</li>
<li>Spend Data in the <b>🛒 Shop</b>: advance your <b>Network Hardware</b> tier (56k → Fiber → Telecom Trunk) to raise your line ceiling, plus storage slots and tier unlocks (Cat6 → Private → Fiber).</li> <li>Spend Data in the <b>🛒 Shop</b>: advance your <b>Network Hardware</b> tier (56k → Fiber → Telecom Trunk) to raise your line ceiling, plus storage slots and tier unlocks (Cat6 → Private → Fiber).</li>
<li><b>🎁 Drops:</b> completed downloads occasionally <b>contain bonuses</b> — small permanent % stat boosts are common, big ones rare; a rare few hold <b>timed multipliers</b> (e.g. ×3 seeds from server access). Track them in the <b>Bonuses</b> tab. (Drops reset on Migration.)</li> <li><b>🎁 Drops:</b> a download that "contains" something shows a <span class="myst">?</span> while it downloads, revealed when it completes. Small permanent <b>% stat boosts</b> (speed, income, seeds, per-peer, luck, credits) are common, big ones rare; a rare few hold <b>timed multipliers</b> (e.g. ×3 seeds) or a <b>storage drive</b> (+1 slot). Track it all in the <b>Bonuses</b> tab. (Drops reset on Migration.)</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>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>📡 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>💎 Rare</b> torrents stall at 99.9% — open <b>IRC / Swarm</b> and request the last piece from an archivist.</li>
@ -1699,10 +1741,11 @@ function sanitizeState(){
if(!G.upgrades||typeof G.upgrades!=='object'||Array.isArray(G.upgrades)) G.upgrades={}; 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.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(!G.seenCatalog||typeof G.seenCatalog!=='object'||Array.isArray(G.seenCatalog)) G.seenCatalog={};
if(!G.bonusPct||typeof G.bonusPct!=='object'||Array.isArray(G.bonusPct)) G.bonusPct={down:0,income:0,seeds:0}; if(!G.bonusPct||typeof G.bonusPct!=='object'||Array.isArray(G.bonusPct)) G.bonusPct={};
['down','income','seeds'].forEach(s=>{ G.bonusPct[s]=num(G.bonusPct[s],0); }); DROP_STATS.forEach(s=>{ G.bonusPct[s]=num(G.bonusPct[s],0); });
G.bonusSlots=Math.max(0,Math.floor(num(G.bonusSlots,0)));
if(!Array.isArray(G.buffs)) G.buffs=[]; if(!Array.isArray(G.buffs)) G.buffs=[];
G.buffs=G.buffs.filter(b=>b&&['down','income','seeds'].includes(b.stat)&&typeof b.until==='number'&&b.until>Date.now()&&num(b.mult,0)>0); G.buffs=G.buffs.filter(b=>b&&DROP_STATS.includes(b.stat)&&typeof b.until==='number'&&b.until>Date.now()&&num(b.mult,0)>0);
if(!Array.isArray(G.dropLog)) G.dropLog=[]; if(!Array.isArray(G.dropLog)) G.dropLog=[];
G.drops=Math.floor(num(G.drops,0)); G.drops=Math.floor(num(G.drops,0));
if(!Array.isArray(G.trackerList)||!G.trackerList.length) G.trackerList=defaultTrackers(); if(!Array.isArray(G.trackerList)||!G.trackerList.length) G.trackerList=defaultTrackers();
@ -1712,12 +1755,19 @@ function sanitizeState(){
G.rssOn=G.rssOn!==false; G.rssOn=G.rssOn!==false;
if(!Array.isArray(G.torrents)) G.torrents=[]; if(!Array.isArray(G.torrents)) G.torrents=[];
G.torrents=G.torrents.filter(t=>t&&CAT_BY_ID[t.cid]); G.torrents=G.torrents.filter(t=>t&&CAT_BY_ID[t.cid]);
const STATUSES=['downloading','seeding','stalled','paused-dl','paused-seed'];
for(const t of G.torrents){ for(const t of G.torrents){
const c=CAT_BY_ID[t.cid]; const c=CAT_BY_ID[t.cid];
t.downloaded=Math.min(c.size,num(t.downloaded,0)); t.downloaded=Math.min(c.size,num(t.downloaded,0));
t.uploaded=num(t.uploaded,0); t.uploaded=num(t.uploaded,0);
t.dropLeechers=!!t.dropLeechers; t.prioSeeds=!!t.prioSeeds; t.dropLeechers=!!t.dropLeechers; t.prioSeeds=!!t.prioSeeds;
if(typeof t.uid!=='number'||!isFinite(t.uid)) t.uid=G.seq++; if(typeof t.uid!=='number'||!isFinite(t.uid)) t.uid=G.seq++;
if(!STATUSES.includes(t.status)) t.status = t.downloaded>=c.size ? 'seeding' : 'downloading';
if(typeof t.peerSeed!=='number'||!isFinite(t.peerSeed)) t.peerSeed=Math.floor(rand(20,400));
if(typeof t.name!=='string') t.name=c.name;
if(typeof t.completedAt!=='number') t.completedAt=0;
// pre-rolled loot must be null or a well-formed descriptor
if(t.loot!=null && (typeof t.loot!=='object' || !['perm','buff','storage'].includes(t.loot.kind))) t.loot=null;
} }
if(!Array.isArray(G.irc)) G.irc=[]; if(!Array.isArray(G.irc)) G.irc=[];
if(typeof G.lastSave!=='number'||!isFinite(G.lastSave)) G.lastSave=Date.now(); if(typeof G.lastSave!=='number'||!isFinite(G.lastSave)) G.lastSave=Date.now();
@ -1757,7 +1807,7 @@ function simulateAway(dt){
t.downloaded+=sp*sdt; G.totalDown+=sp*sdt; t.downloaded+=sp*sdt; G.totalDown+=sp*sdt;
const c=CAT_BY_ID[t.cid]; 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'; } 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(); rollLoot(t, true); } else if(t.downloaded>=c.size){ t.downloaded=c.size; t.status='seeding'; t.completedAt=Date.now(); revealLoot(t, true); }
} else if(t.status==='seeding'){ } else if(t.status==='seeding'){
const c=CAT_BY_ID[t.cid]; const c=CAT_BY_ID[t.cid];
const sp=c.up*upMultGlobal()+seedboxFlat(); const sp=c.up*upMultGlobal()+seedboxFlat();