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>
1671 lines
78 KiB
HTML
1671 lines
78 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Disk Defragmenter</title>
|
||
<style>
|
||
/* ============================================================
|
||
DEFRAG.EXE — "Bad Sectors" (Boring Software, Volume II)
|
||
An incremental game disguised as Windows 98 Disk Defragmenter.
|
||
Period-correct 3D-beveled gray chrome. No images, no fonts, no CDNs.
|
||
============================================================ */
|
||
:root{
|
||
--bg:#c0c0c0; --face:#c0c0c0; --face2:#d4d0c8;
|
||
--hi:#ffffff; --hi2:#dfdfdf; --sh:#808080; --sh2:#404040;
|
||
--text:#000000; --muted:#404040; --dim:#606060;
|
||
--title:#000080; --title2:#1084d0; --titletx:#ffffff;
|
||
--sel:#000080; --seltx:#ffffff;
|
||
--c-free:#ffffff; --c-frag:#d83a2a; --c-cont:#1038b0; --c-you:#18c8d8;
|
||
--c-sys:#e8c020; --c-bad:#101010; --c-read:#30e060;
|
||
--mono:"Lucida Console","DejaVu Sans Mono",Consolas,"Courier New",monospace;
|
||
--ui:"MS Sans Serif","Pixelated MS Sans Serif",Tahoma,Geneva,Verdana,sans-serif;
|
||
}
|
||
*{box-sizing:border-box;}
|
||
html,body{height:100%;margin:0;}
|
||
body{
|
||
font-family:var(--ui); font-size:11px; color:var(--text);
|
||
background:#008080; overflow:hidden; user-select:none; -webkit-user-select:none;
|
||
}
|
||
button{font-family:inherit;font-size:inherit;color:inherit;cursor:default;}
|
||
|
||
/* ---- bevels (the entire Win98 vocabulary) ---- */
|
||
.out{border:2px solid; border-color:var(--hi) var(--sh2) var(--sh2) var(--hi);
|
||
box-shadow:inset 1px 1px 0 var(--hi2), inset -1px -1px 0 var(--sh);}
|
||
.in{border:2px solid; border-color:var(--sh2) var(--hi) var(--hi) var(--sh2);
|
||
box-shadow:inset 1px 1px 0 var(--sh), inset -1px -1px 0 var(--hi2);}
|
||
.thin{border:1px solid; border-color:var(--sh) var(--hi) var(--hi) var(--sh);}
|
||
|
||
/* ---- App window ---- */
|
||
#app{display:flex;flex-direction:column;height:100vh;width:100vw;background:var(--face);
|
||
border:2px solid; border-color:var(--hi) var(--sh2) var(--sh2) var(--hi);}
|
||
|
||
/* ---- Title bar ---- */
|
||
#titlebar{display:flex;align-items:center;gap:5px;height:20px;flex:0 0 auto;
|
||
background:linear-gradient(90deg,var(--title),var(--title2));
|
||
color:var(--titletx);font-weight:bold;padding:0 2px 0 4px;margin:2px;}
|
||
#titlebar .ico{width:14px;height:14px;flex:0 0 auto;display:inline-block;}
|
||
#titlebar .tt{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.winbtns{display:flex;gap:2px;}
|
||
.winbtn{width:16px;height:14px;background:var(--face);font-family:var(--ui);font-size:9px;font-weight:bold;
|
||
line-height:8px;text-align:center;color:#000;padding:0;
|
||
border:1px solid;border-color:var(--hi) var(--sh2) var(--sh2) var(--hi);}
|
||
.winbtn:active{border-color:var(--sh2) var(--hi) var(--hi) var(--sh2);}
|
||
|
||
/* ---- Menu bar ---- */
|
||
#menubar{display:flex;align-items:center;height:19px;flex:0 0 auto;padding:0 2px;}
|
||
.menu{padding:2px 7px;position:relative;}
|
||
.menu:hover{background:var(--sel);color:var(--seltx);}
|
||
.menu .dropdown{display:none;position:absolute;top:18px;left:0;background:var(--face);
|
||
border:2px solid;border-color:var(--hi) var(--sh2) var(--sh2) var(--hi);
|
||
box-shadow:1px 1px 0 var(--sh2);min-width:200px;z-index:300;padding:2px;color:#000;}
|
||
.menu:hover .dropdown{display:block;}
|
||
.menu .dropdown div{padding:3px 22px 3px 20px;display:flex;justify-content:space-between;gap:18px;white-space:nowrap;}
|
||
.menu .dropdown div:hover{background:var(--sel);color:var(--seltx);}
|
||
.menu .dropdown div.dis{color:var(--sh);pointer-events:none;}
|
||
.menu .dropdown div.dis:hover{background:transparent;}
|
||
.menu .dropdown .sep{height:0;border-top:1px solid var(--sh);border-bottom:1px solid var(--hi);
|
||
margin:3px 1px;padding:0;}
|
||
.menu .dropdown .kbd{color:var(--dim);}
|
||
.menu .dropdown div:hover .kbd{color:#d0d0e0;}
|
||
|
||
/* ---- Drive picker / analyze row ---- */
|
||
#drivebar{display:flex;align-items:center;gap:8px;padding:6px 8px 4px;flex:0 0 auto;}
|
||
#drivebar .lbl{font-size:11px;}
|
||
#drive-sel{background:#fff;padding:2px 8px;min-width:240px;font-family:var(--mono);font-size:11px;}
|
||
.toolbtn{background:var(--face);padding:3px 12px;min-width:74px;text-align:center;
|
||
border:2px solid;border-color:var(--hi) var(--sh2) var(--sh2) var(--hi);box-shadow:inset 1px 1px 0 var(--hi2),inset -1px -1px 0 var(--sh);}
|
||
.toolbtn:active{border-color:var(--sh2) var(--hi) var(--hi) var(--sh2);box-shadow:none;padding:4px 11px 2px 13px;}
|
||
.toolbtn:disabled,.toolbtn.dis{color:var(--sh);text-shadow:1px 1px 0 var(--hi);}
|
||
.toolbtn.cta{font-weight:bold;}
|
||
|
||
/* ---- Body ---- */
|
||
#body{flex:1 1 auto;display:flex;min-height:0;padding:0 8px;gap:8px;}
|
||
#left{flex:1 1 auto;display:flex;flex-direction:column;min-width:0;min-height:0;}
|
||
#right{width:214px;flex:0 0 auto;display:flex;flex-direction:column;gap:6px;padding-top:2px;}
|
||
|
||
/* ---- Grid (the HOOK) ---- */
|
||
#grid-frame{flex:1 1 auto;background:#fff;padding:6px;min-height:0;display:flex;position:relative;}
|
||
#grid-canvas{flex:1 1 auto;width:100%;height:100%;display:block;cursor:crosshair;image-rendering:pixelated;}
|
||
#grid-hint{position:absolute;left:10px;bottom:8px;background:rgba(255,255,255,.82);
|
||
border:1px solid var(--sh);padding:2px 6px;font-size:10px;color:#333;pointer-events:none;}
|
||
|
||
/* ---- Legend ---- */
|
||
#legend{display:flex;flex-wrap:wrap;gap:3px 14px;padding:5px 4px 2px;font-size:11px;flex:0 0 auto;}
|
||
.lg{display:flex;align-items:center;gap:5px;}
|
||
.lg .sw{width:12px;height:12px;border:1px solid var(--sh2);flex:0 0 auto;}
|
||
|
||
/* ---- Progress + status ---- */
|
||
#progress-wrap{padding:4px 4px 2px;flex:0 0 auto;}
|
||
#progress-outer{height:18px;background:#fff;padding:2px;position:relative;}
|
||
#progress-blocks{display:flex;gap:2px;height:100%;width:100%;overflow:hidden;}
|
||
.pblock{height:100%;background:var(--title);flex:0 0 12px;}
|
||
#progress-pct{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
|
||
font-family:var(--mono);font-size:11px;color:#000;mix-blend-mode:difference;color:#fff;}
|
||
#statusline{padding:5px 6px;font-family:var(--mono);font-size:11px;flex:0 0 auto;
|
||
background:var(--face);margin:4px 4px 0;}
|
||
|
||
/* ---- Right rail readout panel ---- */
|
||
.panel{background:var(--face);padding:6px 7px;}
|
||
.panel .ph{font-weight:bold;font-size:11px;margin-bottom:5px;border-bottom:1px solid var(--sh);
|
||
padding-bottom:3px;text-shadow:1px 1px 0 var(--hi);}
|
||
.readout{display:flex;flex-direction:column;gap:5px;}
|
||
.ro{display:flex;flex-direction:column;gap:2px;}
|
||
.ro .k{font-size:10px;color:var(--muted);}
|
||
.ro .v{font-family:var(--mono);font-size:14px;font-weight:bold;}
|
||
.ro .v.rb{color:#006000;} .ro .v.pl{color:#600060;}
|
||
.gauge{height:13px;background:#fff;padding:1px;position:relative;}
|
||
.gauge .gf{height:100%;background:var(--title);width:0%;}
|
||
.gauge.health .gf{background:linear-gradient(90deg,#c00000,#e0c000 50%,#008000);}
|
||
.gauge .gt{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
|
||
font-family:var(--mono);font-size:9px;color:#000;}
|
||
.ratebox{font-family:var(--mono);font-size:11px;display:flex;justify-content:space-between;gap:6px;}
|
||
.ratebox .rv{font-weight:bold;}
|
||
|
||
/* repair charges */
|
||
#repair-panel .charges{display:flex;gap:3px;flex-wrap:wrap;margin-top:3px;}
|
||
.charge{width:13px;height:16px;background:#fff;border:1px solid var(--sh2);position:relative;}
|
||
.charge.full{background:linear-gradient(#60ff80,#10a030);}
|
||
.charge.regen .rgf{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(#60ff80,#10a030);}
|
||
|
||
/* mini buttons */
|
||
.mbtn{background:var(--face);padding:3px 8px;text-align:center;width:100%;
|
||
border:2px solid;border-color:var(--hi) var(--sh2) var(--sh2) var(--hi);box-shadow:inset 1px 1px 0 var(--hi2),inset -1px -1px 0 var(--sh);}
|
||
.mbtn:active:not(:disabled){border-color:var(--sh2) var(--hi) var(--hi) var(--sh2);box-shadow:none;}
|
||
.mbtn:disabled{color:var(--sh);text-shadow:1px 1px 0 var(--hi);}
|
||
.mbtn.go{font-weight:bold;}
|
||
|
||
/* ---- Status bar (bottom) ---- */
|
||
#statusbar{display:flex;align-items:stretch;height:20px;flex:0 0 auto;margin:4px 2px 2px;gap:2px;}
|
||
.sb{background:var(--face);padding:1px 7px;display:flex;align-items:center;gap:5px;font-size:11px;
|
||
border:1px solid;border-color:var(--sh) var(--hi) var(--hi) var(--sh);}
|
||
.sb.grow{flex:1;}
|
||
.sb b{font-family:var(--mono);}
|
||
|
||
/* ---- Dialogs (modal) ---- */
|
||
#modal-back{position:fixed;inset:0;background:rgba(0,0,0,.04);display:none;z-index:500;
|
||
align-items:center;justify-content:center;}
|
||
#modal-back.show{display:flex;}
|
||
.dialog{background:var(--face);min-width:300px;max-width:96vw;max-height:90vh;display:flex;flex-direction:column;
|
||
border:2px solid;border-color:var(--hi) var(--sh2) var(--sh2) var(--hi);box-shadow:2px 2px 0 rgba(0,0,0,.4);}
|
||
.dialog.w560{width:560px;} .dialog.w420{width:420px;} .dialog.w480{width:480px;}
|
||
.dlg-title{display:flex;align-items:center;gap:6px;height:20px;
|
||
background:linear-gradient(90deg,var(--title),var(--title2));color:var(--titletx);font-weight:bold;
|
||
padding:0 2px 0 5px;margin:2px;}
|
||
.dlg-title .tt{flex:1;}
|
||
.dlg-body{padding:10px 12px;overflow:auto;flex:1 1 auto;}
|
||
.dlg-foot{padding:7px 12px;display:flex;gap:8px;justify-content:flex-end;}
|
||
.dbtn{background:var(--face);padding:4px 18px;min-width:75px;text-align:center;
|
||
border:2px solid;border-color:var(--hi) var(--sh2) var(--sh2) var(--hi);box-shadow:inset 1px 1px 0 var(--hi2),inset -1px -1px 0 var(--sh);}
|
||
.dbtn:active:not(:disabled){border-color:var(--sh2) var(--hi) var(--hi) var(--sh2);box-shadow:none;}
|
||
.dbtn:disabled{color:var(--sh);text-shadow:1px 1px 0 var(--hi);}
|
||
.dbtn.default{font-weight:bold;}
|
||
p{margin:0 0 8px;line-height:1.45;}
|
||
.tabline{display:flex;gap:0;margin-bottom:-2px;position:relative;z-index:2;}
|
||
.tab{background:var(--face);padding:4px 12px;border:2px solid;border-color:var(--hi) var(--sh2) transparent var(--hi);
|
||
border-bottom:none;margin-right:2px;position:relative;top:1px;}
|
||
.tab.active{background:var(--face);padding-bottom:6px;top:0;z-index:3;}
|
||
.tab.locked{color:var(--sh);}
|
||
.tabpane{border:2px solid;border-color:var(--hi) var(--sh2) var(--sh2) var(--hi);padding:8px;background:var(--face);}
|
||
|
||
/* upgrade list (the Settings dialog) */
|
||
.ulist{background:#fff;max-height:340px;overflow:auto;border:2px solid;border-color:var(--sh2) var(--hi) var(--hi) var(--sh2);}
|
||
.urow{display:flex;align-items:center;gap:9px;padding:6px 8px;border-bottom:1px solid #d8d8d8;}
|
||
.urow:nth-child(even){background:#f3f3f3;}
|
||
.urow.lock{opacity:.5;}
|
||
.urow .uic{width:18px;text-align:center;flex:0 0 auto;font-size:13px;}
|
||
.urow .umain{flex:1;min-width:0;}
|
||
.urow .un{font-weight:bold;}
|
||
.urow .un .lv{font-weight:normal;color:var(--dim);font-family:var(--mono);margin-left:6px;}
|
||
.urow .ud{color:var(--muted);font-size:10px;margin-top:1px;}
|
||
.urow .ue{font-family:var(--mono);font-size:10px;color:#005000;margin-top:1px;}
|
||
.urow .ubuy{display:flex;flex-direction:column;align-items:flex-end;gap:3px;min-width:118px;}
|
||
.urow .ucost{font-family:var(--mono);font-weight:bold;font-size:11px;}
|
||
.urow .ucost.no{color:#a00000;} .urow .ucost.ok{color:#006000;}
|
||
.urow .maxtag{font-family:var(--mono);color:#006000;font-weight:bold;}
|
||
.ubtn{background:var(--face);padding:2px 10px;min-width:64px;text-align:center;
|
||
border:2px solid;border-color:var(--hi) var(--sh2) var(--sh2) var(--hi);box-shadow:inset 1px 1px 0 var(--hi2),inset -1px -1px 0 var(--sh);}
|
||
.ubtn:active:not(:disabled){border-color:var(--sh2) var(--hi) var(--hi) var(--sh2);box-shadow:none;}
|
||
.ubtn:disabled{color:var(--sh);text-shadow:1px 1px 0 var(--hi);}
|
||
|
||
fieldset{border:1px solid var(--sh);border-top-color:var(--sh);margin:6px 0;padding:8px 10px;
|
||
box-shadow:inset 0 1px 0 var(--hi);}
|
||
fieldset legend{padding:0 4px;font-weight:bold;}
|
||
.kvtable{display:grid;grid-template-columns:max-content 1fr;gap:3px 16px;font-size:11px;}
|
||
.kvtable .k{color:var(--muted);} .kvtable .v{font-family:var(--mono);}
|
||
textarea.io{width:100%;height:90px;font-family:var(--mono);font-size:10px;background:#fff;
|
||
border:2px solid;border-color:var(--sh2) var(--hi) var(--hi) var(--sh2);resize:none;}
|
||
.big-num{font-family:var(--mono);font-size:22px;font-weight:bold;color:#600060;}
|
||
|
||
/* toasts */
|
||
#toasts{position:fixed;right:8px;bottom:26px;z-index:700;display:flex;flex-direction:column;gap:5px;align-items:flex-end;}
|
||
.toast{background:var(--face);padding:7px 11px 7px 9px;max-width:300px;font-size:11px;
|
||
border:2px solid;border-color:var(--hi) var(--sh2) var(--sh2) var(--hi);box-shadow:2px 2px 0 rgba(0,0,0,.35);
|
||
border-left:4px solid var(--title);}
|
||
.toast.warn{border-left-color:#d08000;} .toast.bad{border-left-color:#c00000;}
|
||
.toast.good{border-left-color:#008000;} .toast.prestige{border-left-color:#600060;}
|
||
.toast b{display:block;margin-bottom:2px;}
|
||
|
||
::-webkit-scrollbar{width:16px;height:16px;background:var(--face);}
|
||
::-webkit-scrollbar-thumb{background:var(--face);border:2px solid;border-color:var(--hi) var(--sh2) var(--sh2) var(--hi);}
|
||
::-webkit-scrollbar-button{background:var(--face);height:16px;width:16px;
|
||
border:2px solid;border-color:var(--hi) var(--sh2) var(--sh2) var(--hi);}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
<!-- TITLE BAR -->
|
||
<div id="titlebar">
|
||
<canvas class="ico" id="titleico" width="14" height="14"></canvas>
|
||
<span class="tt" id="title-text">Disk Defragmenter — Drive C:</span>
|
||
<div class="winbtns">
|
||
<button class="winbtn" title="Minimize">_</button>
|
||
<button class="winbtn" title="Maximize">□</button>
|
||
<button class="winbtn" data-act="open-help" title="Help">?</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MENU BAR -->
|
||
<div id="menubar">
|
||
<div class="menu">Defragment
|
||
<div class="dropdown">
|
||
<div data-act="start-stop"><span id="mi-startstop">Pause</span></div>
|
||
<div data-act="analyze">Analyze<span class="kbd">F5</span></div>
|
||
<div class="sep"></div>
|
||
<div data-act="open-settings">Settings…<span class="kbd">Ctrl+S</span></div>
|
||
<div data-act="open-prestige">Compress & Migrate…</div>
|
||
<div class="sep"></div>
|
||
<div data-act="hard-reset">Low-Level Format…</div>
|
||
</div>
|
||
</div>
|
||
<div class="menu">View
|
||
<div class="dropdown">
|
||
<div data-act="legend-info">Legend…</div>
|
||
<div data-act="toggle-sci">Number Format: <span id="mi-sci">SI</span></div>
|
||
<div data-act="open-report">Defrag Report…</div>
|
||
</div>
|
||
</div>
|
||
<div class="menu">Tools
|
||
<div class="dropdown">
|
||
<div data-act="open-settings">Defragmentation Options…</div>
|
||
<div data-act="open-prestige">Migrate to Larger Drive…</div>
|
||
<div class="sep"></div>
|
||
<div data-act="export-save">Export Disk Image…</div>
|
||
<div data-act="import-save">Restore Disk Image…</div>
|
||
</div>
|
||
</div>
|
||
<div class="menu">Help
|
||
<div class="dropdown">
|
||
<div data-act="open-help">How to Defragment…</div>
|
||
<div data-act="open-about">About Disk Defragmenter</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- DRIVE BAR -->
|
||
<div id="drivebar">
|
||
<span class="lbl">Which drive do you want to defragment?</span>
|
||
<div id="drive-sel" class="in">C:</div>
|
||
<button class="toolbtn cta" data-act="start-stop"><span id="btn-startstop">Pause</span></button>
|
||
<button class="toolbtn" data-act="open-settings">Settings…</button>
|
||
</div>
|
||
|
||
<!-- BODY -->
|
||
<div id="body">
|
||
<div id="left">
|
||
<div id="grid-frame" class="in">
|
||
<canvas id="grid-canvas"></canvas>
|
||
<div id="grid-hint">Drag a box to focus the head · Click a black bad sector to queue repair</div>
|
||
</div>
|
||
<div id="legend">
|
||
<div class="lg"><span class="sw" style="background:var(--c-free)"></span>Free space</div>
|
||
<div class="lg"><span class="sw" style="background:var(--c-frag)"></span>Fragmented</div>
|
||
<div class="lg"><span class="sw" style="background:var(--c-cont)"></span>Contiguous</div>
|
||
<div class="lg"><span class="sw" style="background:var(--c-you)"></span>You (FRAG)</div>
|
||
<div class="lg"><span class="sw" style="background:var(--c-sys)"></span>System</div>
|
||
<div class="lg"><span class="sw" style="background:var(--c-bad)"></span>Bad sector</div>
|
||
<div class="lg"><span class="sw" style="background:var(--c-read)"></span>R/W head</div>
|
||
</div>
|
||
<div id="progress-wrap">
|
||
<div id="progress-outer" class="in">
|
||
<div id="progress-blocks"></div>
|
||
<div id="progress-pct">0%</div>
|
||
</div>
|
||
</div>
|
||
<div id="statusline" class="in">Reading drive information…</div>
|
||
</div>
|
||
|
||
<!-- RIGHT RAIL -->
|
||
<div id="right">
|
||
<div class="panel out">
|
||
<div class="ph">Drive Status</div>
|
||
<div class="readout">
|
||
<div class="ro"><span class="k">Reclaimed Bytes</span><span class="v rb" id="ro-rb">0 B</span></div>
|
||
<div class="ratebox"><span>Passive</span><span class="rv" id="ro-rate">0 B/s</span></div>
|
||
<div class="ro"><span class="k">% Consolidated</span>
|
||
<div class="gauge"><div class="gf" id="g-cons"></div><div class="gt" id="g-cons-t">0%</div></div></div>
|
||
<div class="ro"><span class="k">Disk Health (SMART)</span>
|
||
<div class="gauge health"><div class="gf" id="g-health"></div><div class="gt" id="g-health-t">100%</div></div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel out" id="repair-panel">
|
||
<div class="ph">Sector Repair</div>
|
||
<div class="ratebox"><span>Repair charges</span><span class="rv" id="ro-charges">0 / 0</span></div>
|
||
<div class="charges" id="charge-row"></div>
|
||
<div class="ratebox" style="margin-top:5px"><span>Bad sectors</span><span class="rv" id="ro-bad">0</span></div>
|
||
<div class="ratebox"><span>Queued</span><span class="rv" id="ro-queued">0</span></div>
|
||
</div>
|
||
|
||
<div class="panel out">
|
||
<div class="ph">Heads</div>
|
||
<div class="ratebox"><span>Active heads</span><span class="rv" id="ro-heads">1</span></div>
|
||
<div class="ratebox"><span>Head speed</span><span class="rv" id="ro-hspeed">0/s</span></div>
|
||
<div class="ratebox"><span>Algorithm</span><span class="rv" id="ro-algo">QuickSort</span></div>
|
||
<div class="ratebox"><span>Best run</span><span class="rv" id="ro-run">0</span></div>
|
||
</div>
|
||
|
||
<button class="mbtn go" data-act="open-settings">Settings…</button>
|
||
<button class="mbtn" id="btn-migrate" data-act="open-prestige">Compress & Migrate…</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- STATUS BAR -->
|
||
<div id="statusbar">
|
||
<div class="sb" id="sb-drive">Drive: <b id="sb-drivename">40 MB</b></div>
|
||
<div class="sb" id="sb-platters" style="display:none">Platters: <b id="sb-pl">0</b></div>
|
||
<div class="sb grow" id="sb-status">Defragmenting…</div>
|
||
<div class="sb">Clusters: <b id="sb-clusters">0</b></div>
|
||
<div class="sb">Entropy: <b id="sb-entropy">—</b></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MODAL HOST -->
|
||
<div id="modal-back"><div class="dialog" id="dialog"></div></div>
|
||
<!-- TOASTS -->
|
||
<div id="toasts"></div>
|
||
|
||
<script>
|
||
"use strict";
|
||
/* ===========================================================================
|
||
DEFRAG.EXE — "Bad Sectors"
|
||
A spatial incremental hiding inside a Windows 98 disk defragmenter.
|
||
=========================================================================== */
|
||
const SAVE_KEY = "boringsoft_defrag_v1";
|
||
const TICK_MS = 100;
|
||
const MAX_OFFLINE_S = 8*3600;
|
||
|
||
/* cell state codes (flat Int8Array) */
|
||
const FREE=0, FRAG=1, CONT=2, YOU=3, SYS=4, BAD=5;
|
||
|
||
/* ===========================================================================
|
||
NUMBER FORMATTING (re-implemented SI-then-scientific, per the baseline)
|
||
=========================================================================== */
|
||
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';
|
||
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);
|
||
return x.toFixed(x<100?2:1)+' '+SI[i];
|
||
}
|
||
function fmtSpd(n){ return fmt(n)+'/s'; }
|
||
function fmtInt(n){
|
||
if(n===Infinity) return '∞';
|
||
if(!isFinite(n)||isNaN(n)) return '0';
|
||
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';
|
||
}
|
||
function now(){ return Date.now(); }
|
||
function rand(a,b){ return a + Math.random()*(b-a); }
|
||
function ri(a,b){ return a + Math.floor(Math.random()*(b-a+1)); }
|
||
function clamp(v,a,b){ return v<a?a:(v>b?b:v); }
|
||
|
||
/* ===========================================================================
|
||
DRIVE CLASSES (prestige tiers — the hardware ladder)
|
||
=========================================================================== */
|
||
// cols/rows = grid dimensions, value = $/cluster base, decay = entropy rate scalar,
|
||
// label = the friendly drive size. Bigger drives pay more but rot faster.
|
||
const DRIVES = [
|
||
{label:'40 MB', cols:40, rows:24, value:64, decay:1.00, name:'C:'},
|
||
{label:'540 MB', cols:48, rows:28, value:512, decay:1.35, name:'D:'},
|
||
{label:'6 GB', cols:56, rows:32, value:4096, decay:1.75, name:'E:'},
|
||
{label:'40 GB', cols:64, rows:36, value:3.3e4, decay:2.25, name:'F:'},
|
||
{label:'120 GB', cols:72, rows:40, value:2.6e5, decay:2.9, name:'G:'},
|
||
{label:'2 TB', cols:80, rows:44, value:2.1e6, decay:3.7, name:'H:'},
|
||
{label:'16 TB', cols:88, rows:48, value:1.7e7, decay:4.7, name:'S:'},
|
||
{label:'1 PB Array',cols:96,rows:52, value:1.3e8, decay:6.0, name:'Z:'},
|
||
];
|
||
function driveDef(i){ return DRIVES[Math.min(i, DRIVES.length-1)]; }
|
||
|
||
/* ===========================================================================
|
||
UPGRADES (the "Defragmentation Options" dialog)
|
||
id, name, desc, ic, base cost, growth, max, and an effect descriptor.
|
||
kind: 'lvl' repeatable leveled, 'once' one-time unlock.
|
||
=========================================================================== */
|
||
const UPGRADES = [
|
||
{id:'speed', ic:'»', name:'Head Speed', tab:'head', kind:'lvl', base:48, growth:1.55, max:60,
|
||
desc:'Clusters per second the read/write head can walk. ×1.5 throughput per level.',
|
||
eff:l=>'head '+ (Math.pow(1.5,l)*BASE_HEAD).toFixed(1)+' clusters/s'},
|
||
{id:'cache', ic:'▤', name:'Read-Ahead Cache', tab:'head', kind:'lvl', base:120, growth:1.7, max:24,
|
||
desc:'Head consolidates N clusters per step — a wider sweep clears more per pass.',
|
||
eff:l=>'width '+(1+l)+' clusters/step'},
|
||
{id:'heads', ic:'≡', name:'Multiple Heads', tab:'head', kind:'lvl', base:800, growth:2.15, max:11,
|
||
desc:'Add an independent read/write head. Each head defrags in parallel, forever.',
|
||
eff:l=>(1+l)+' parallel head'+(l?'s':'')},
|
||
{id:'algo', ic:'∑', name:'Consolidation Algorithm', tab:'head', kind:'lvl', base:300, growth:2.4, max:7,
|
||
desc:'A better sort means more Reclaimed Bytes per consolidated cluster. ×2.2 payout per tier.',
|
||
eff:l=>ALGO_NAMES[Math.min(l,ALGO_NAMES.length-1)]+' (×'+fmt(Math.pow(2.2,l)).replace(' B','')+' $/cluster)'},
|
||
{id:'streak', ic:'═', name:'Contiguity Bonus', tab:'head', kind:'lvl', base:420, growth:1.95, max:20,
|
||
desc:'Longer unbroken runs of blue pay fatter passive income. +35% per level on run streaks.',
|
||
eff:l=>'streak income ×'+(1+0.35*l).toFixed(2)},
|
||
{id:'ecc', ic:'+', name:'ECC / Repair Passes', tab:'repair', kind:'lvl', base:260, growth:1.85, max:16,
|
||
desc:'Maximum sector-repair charges held. Click a bad sector to spend one and repair it.',
|
||
eff:l=>(BASE_CHARGES+l)+' max charges'},
|
||
{id:'regen', ic:'↻', name:'Repair Regen Rate', tab:'repair', kind:'lvl', base:340, growth:1.8, max:20,
|
||
desc:'How fast spent repair charges recover. ×1.4 regeneration speed per level.',
|
||
eff:l=>'1 charge / '+(REGEN_BASE/Math.pow(1.4,l)).toFixed(1)+'s'},
|
||
{id:'autorepair',ic:'⊕',name:'Auto-Repair Daemon', tab:'repair', kind:'once', base:9000,
|
||
desc:'A background daemon automatically spends charges on the nearest bad sector. Hands-free containment.',
|
||
eff:l=>l?'enabled':'disabled'},
|
||
{id:'cooling', ic:'❄', name:'Cooling / Anti-Entropy', tab:'repair', kind:'lvl', base:500, growth:1.9, max:18,
|
||
desc:'Slows platter decay — fewer clusters re-fragment and fewer bad sectors spawn. −9% entropy per level.',
|
||
eff:l=>'entropy ×'+Math.pow(0.91,l).toFixed(3)},
|
||
{id:'daemon', ic:'◎', name:'Defragmentation Daemon', tab:'auto', kind:'once', base:6000,
|
||
desc:'Idle heads steer themselves to the most-fragmented region of the platter automatically.',
|
||
eff:l=>l?'enabled':'disabled'},
|
||
{id:'prefetch',ic:'▸', name:'Smart Prefetch', tab:'auto', kind:'lvl', base:1500, growth:2.0, max:12,
|
||
desc:'Drag-select focused bursts hit harder. +50% burst payout & clear speed per level.',
|
||
eff:l=>'burst power ×'+(1+0.5*l).toFixed(2)},
|
||
{id:'compact', ic:'⊏', name:'Aggressive Compaction', tab:'auto', kind:'lvl', base:2200, growth:2.1, max:14,
|
||
desc:'Global Reclaimed-Bytes multiplier on every consolidation and all passive income. ×1.6 per level.',
|
||
eff:l=>'all income ×'+fmt(Math.pow(1.6,l)).replace(' B','')},
|
||
];
|
||
const UP_BY_ID = Object.fromEntries(UPGRADES.map(u=>[u.id,u]));
|
||
const ALGO_NAMES=['BubbleSort','InsertionSort','QuickSort','HeapSort','RadixSort','TimSort','"I wrote my own"','Singularity-Sort'];
|
||
|
||
/* tuning anchors */
|
||
const BASE_HEAD = 4; // clusters/sec at speed level 0
|
||
const BASE_CHARGES = 3;
|
||
const REGEN_BASE = 8; // seconds per charge at regen level 0
|
||
|
||
/* ===========================================================================
|
||
PRESTIGE PERKS (Platters — spent in the Migration dialog)
|
||
=========================================================================== */
|
||
const PERKS = [
|
||
{id:'pmult', ic:'$', name:'Cold Storage Dividend', base:1, growth:1.7, max:40,
|
||
desc:'+30% global Reclaimed Bytes, permanently, per level.', eff:l=>'×'+(1+0.3*l).toFixed(2)+' all income'},
|
||
{id:'pcool', ic:'❄', name:'Cryogenic Platters', base:1, growth:1.8, max:30,
|
||
desc:'−8% base entropy on every drive, permanently, per level.', eff:l=>'entropy ×'+Math.pow(0.92,l).toFixed(3)},
|
||
{id:'pheads',ic:'≡', name:'Spare Head Assembly', base:2, growth:2.0, max:10,
|
||
desc:'Start every new drive with +1 extra read/write head.', eff:l=>'+'+l+' starting head'+(l===1?'':'s')},
|
||
{id:'pcharge',ic:'+',name:'Hardened Sectors', base:2, growth:1.9, max:12,
|
||
desc:'+2 starting repair charges & faster base regen on every drive.', eff:l=>'+'+(2*l)+' charges'},
|
||
{id:'pseed', ic:'⊏', name:'Compression Seed', base:3, growth:2.2, max:10,
|
||
desc:'Each new drive boots with 8% of its movable clusters already consolidated (blue).', eff:l=>(8*l)+'% pre-consolidated'},
|
||
];
|
||
const PERK_BY_ID = Object.fromEntries(PERKS.map(p=>[p.id,p]));
|
||
|
||
/* ===========================================================================
|
||
STATE
|
||
=========================================================================== */
|
||
let G = null;
|
||
let grid = null; // Int8Array of cell states (length cols*rows)
|
||
let cellValBlue = null; // value already banked into a blue cluster (for streak calc) — not persisted heavily
|
||
let cols=40, rows=24;
|
||
let heads = []; // [{x,y, focus:null}] focus = {x0,y0,x1,y1, t} rubber-band burst region
|
||
let badQueue = []; // indices queued for repair
|
||
let badAge = null; // Float for spread timing per bad cell (Map index->nextSpread)
|
||
|
||
function freshState(){
|
||
return {
|
||
v:1,
|
||
driveTier:0,
|
||
rb:0, // Reclaimed Bytes (spendable)
|
||
totalRb:0, // lifetime
|
||
runReclaimed:0, // movable clusters consolidated this run (for platter calc)
|
||
platters:0, migrations:0, peakConsolidated:0,
|
||
upgrades:{},
|
||
perks:{},
|
||
sci:false,
|
||
running:true,
|
||
charges:BASE_CHARGES, chargeProg:0,
|
||
grid:null, // serialized grid (array) for this drive
|
||
heads:null, // serialized head positions
|
||
badQueue:[],
|
||
entropyAccum:0,
|
||
nextEntropy:0,
|
||
started:now(),
|
||
lastSave:now(),
|
||
bestRun:0,
|
||
};
|
||
}
|
||
|
||
/* ===========================================================================
|
||
DERIVED
|
||
=========================================================================== */
|
||
const lvl = id => G.upgrades[id]||0;
|
||
const plvl = id => G.perks[id]||0;
|
||
const has = id => (G.upgrades[id]||0)>0;
|
||
|
||
function headSpeed(){ return BASE_HEAD * Math.pow(1.5, lvl('speed')); } // clusters/sec/head
|
||
function cacheWidth(){ return 1 + lvl('cache'); }
|
||
function numHeads(){ return 1 + lvl('heads') + plvl('pheads'); }
|
||
function algoMult(){ return Math.pow(2.2, lvl('algo')); }
|
||
function streakMult(){ return 1 + 0.35*lvl('streak'); }
|
||
function globalMult(){ return Math.pow(1.6, lvl('compact')) * (1 + 0.3*plvl('pmult')); }
|
||
function burstPower(){ return 1 + 0.5*lvl('prefetch'); }
|
||
function maxCharges(){ return BASE_CHARGES + lvl('ecc') + 2*plvl('pcharge'); }
|
||
function regenTime(){ return REGEN_BASE / Math.pow(1.4, lvl('regen')) / (1+0.25*plvl('pcharge')); }
|
||
function entropyMult(){ return Math.pow(0.91, lvl('cooling')) * Math.pow(0.92, plvl('pcool')); }
|
||
function clusterValue(){ return driveDef(G.driveTier).value * algoMult() * globalMult(); }
|
||
function driveDecay(){ return driveDef(G.driveTier).decay * entropyMult(); }
|
||
|
||
function totalCells(){ return cols*rows; }
|
||
// Entropy reaches an EQUILIBRIUM instead of devouring the whole disk while you idle —
|
||
// bad sectors never grow past this cap passively (you repair to push below it). Bigger,
|
||
// older drives rot a little more, but it is never lethal.
|
||
function badCap(){ return Math.floor(totalCells() * Math.min(0.34, 0.18 + 0.02*G.driveTier)); }
|
||
function badNow(){ let b=0; for(let i=0;i<grid.length;i++) if(grid[i]===BAD) b++; return b; }
|
||
|
||
// movable = everything except SYS and BAD (free can be consolidated-into; we count
|
||
// movable = clusters that are FRAG, CONT, or YOU (the data that wants to be contiguous)).
|
||
function countStates(){
|
||
let free=0,frag=0,cont=0,you=0,sys=0,bad=0;
|
||
for(let i=0;i<grid.length;i++){
|
||
switch(grid[i]){case FREE:free++;break;case FRAG:frag++;break;case CONT:cont++;break;
|
||
case YOU:you++;break;case SYS:sys++;break;case BAD:bad++;break;}
|
||
}
|
||
return {free,frag,cont,you,sys,bad};
|
||
}
|
||
function consolidatedPct(s){
|
||
s=s||countStates();
|
||
const movable=s.frag+s.cont+s.you;
|
||
if(movable<=0) return 100;
|
||
return clamp((s.cont+s.you)/movable*100, 0, 100);
|
||
}
|
||
function diskHealth(s){
|
||
s=s||countStates();
|
||
return clamp(100 - s.bad/totalCells()*100, 0, 100);
|
||
}
|
||
// passive income from contiguous runs: scan rows, reward long blue/cyan runs.
|
||
function passiveRate(){
|
||
let income=0;
|
||
for(let y=0;y<rows;y++){
|
||
let run=0;
|
||
for(let x=0;x<cols;x++){
|
||
const c=grid[y*cols+x];
|
||
if(c===CONT||c===YOU){ run++; }
|
||
else { if(run>0){ income += runPay(run); } run=0; }
|
||
}
|
||
if(run>0) income += runPay(run);
|
||
}
|
||
// globalMult() already folds in the Compaction upgrade and the pmult perk.
|
||
return income * streakMult() * globalMult();
|
||
}
|
||
function runPay(run){
|
||
// each contiguous cluster pays base; runs pay a super-linear streak bonus.
|
||
return run * driveDef(G.driveTier).value * 0.06 * (1 + run*0.04) * algoMult();
|
||
}
|
||
function bestRunLen(){
|
||
let best=0;
|
||
for(let y=0;y<rows;y++){
|
||
let run=0;
|
||
for(let x=0;x<cols;x++){
|
||
const c=grid[y*cols+x];
|
||
if(c===CONT||c===YOU){run++; if(run>best)best=run;} else run=0;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/* ===========================================================================
|
||
GRID GENERATION
|
||
=========================================================================== */
|
||
function buildGrid(){
|
||
const def=driveDef(G.driveTier);
|
||
cols=def.cols; rows=def.rows;
|
||
grid=new Int8Array(cols*rows).fill(FREE);
|
||
const total=cols*rows;
|
||
// system row: a band of yellow unmovable clusters near the top-left (boot sector / MFT)
|
||
const sysCount=Math.floor(total*0.05);
|
||
for(let i=0;i<sysCount;i++) grid[i]=SYS;
|
||
// scatter fragmented data + a few "you" fragments across the platter
|
||
const dataCount=Math.floor(total*0.55);
|
||
let placed=0, guard=0;
|
||
while(placed<dataCount && guard++<total*4){
|
||
const i=ri(sysCount, total-1);
|
||
if(grid[i]===FREE){ grid[i]=FRAG; placed++; }
|
||
}
|
||
// YOU fragments — a handful of cyan cells scattered in the frag
|
||
let youCount=Math.max(4, Math.floor(total*0.012));
|
||
guard=0;
|
||
while(youCount>0 && guard++<total*4){
|
||
const i=ri(sysCount,total-1);
|
||
if(grid[i]===FRAG){ grid[i]=YOU; youCount--; }
|
||
}
|
||
// a couple of starting bad sectors
|
||
let badCount=Math.max(1, Math.floor(total*0.004));
|
||
guard=0;
|
||
while(badCount>0 && guard++<total*4){
|
||
const i=ri(sysCount,total-1);
|
||
if(grid[i]===FREE||grid[i]===FRAG){ grid[i]=BAD; badCount--; }
|
||
}
|
||
// compression seed perk: pre-consolidate some movable clusters into a blue band
|
||
const seedPct=0.08*plvl('pseed');
|
||
if(seedPct>0) preConsolidate(seedPct);
|
||
badQueue=[];
|
||
initBadAge();
|
||
initHeads();
|
||
}
|
||
function preConsolidate(frac){
|
||
// turn the first `frac` of FRAG clusters (in reading order) to CONT, packed left.
|
||
const s=countStates(); const movable=s.frag+s.you;
|
||
let toBlue=Math.floor(movable*frac);
|
||
for(let i=0;i<grid.length && toBlue>0;i++){
|
||
if(grid[i]===FRAG){ grid[i]=CONT; toBlue--; }
|
||
}
|
||
}
|
||
function initHeads(){
|
||
heads=[];
|
||
const n=numHeads();
|
||
for(let k=0;k<n;k++){
|
||
heads.push({x:0, y:Math.floor(k*rows/n)%rows, focus:null});
|
||
}
|
||
}
|
||
function syncHeadCount(){
|
||
const want=numHeads();
|
||
while(heads.length<want) heads.push({x:0,y:ri(0,rows-1),focus:null});
|
||
while(heads.length>want) heads.pop();
|
||
}
|
||
function initBadAge(){
|
||
badAge=new Float64Array(grid.length).fill(0);
|
||
for(let i=0;i<grid.length;i++) if(grid[i]===BAD) badAge[i]=now()+rand(4000,9000);
|
||
}
|
||
|
||
/* ===========================================================================
|
||
THE DEFRAG SIMULATION (the core loop)
|
||
=========================================================================== */
|
||
// Walk a head: it scans forward; when it finds a FRAG/YOU cluster it relocates that
|
||
// data to the first free slot to its left (in reading order), turning it CONT and
|
||
// minting Reclaimed Bytes. Cache width lets it process several per step.
|
||
function firstFreeBefore(idx){
|
||
// first FREE cluster at a position < idx (reading order), skipping SYS/BAD/CONT.
|
||
for(let j=0;j<idx;j++){ if(grid[j]===FREE) return j; }
|
||
return -1;
|
||
}
|
||
function consolidateAt(idx, intoLeft, mintMult){
|
||
// idx holds FRAG or YOU. Move it leftward (pack) -> becomes CONT/YOU at target.
|
||
const wasYou = grid[idx]===YOU;
|
||
let target=idx;
|
||
if(intoLeft){
|
||
const f=firstFreeBefore(idx);
|
||
if(f>=0 && f<idx){ target=f; grid[idx]=FREE; }
|
||
}
|
||
grid[target]= wasYou?YOU:CONT;
|
||
const pay = clusterValue() * (mintMult||1);
|
||
G.rb += pay; G.totalRb += pay; G.runReclaimed += 1;
|
||
return pay;
|
||
}
|
||
function stepHead(h, budget, mintMult){
|
||
// budget = clusters this head may process this tick.
|
||
let processed=0;
|
||
let guard=0;
|
||
while(processed<budget && guard++ < cols*rows){
|
||
// focus burst region overrides normal sweep
|
||
let region=h.focus;
|
||
// advance head position scanning for a FRAG/YOU within bounds
|
||
let found=-1;
|
||
let scan=0;
|
||
while(scan++ < cols*rows){
|
||
const i=h.y*cols+h.x;
|
||
const inRegion = !region || (h.x>=region.x0&&h.x<=region.x1&&h.y>=region.y0&&h.y<=region.y1);
|
||
if(inRegion && (grid[i]===FRAG || (grid[i]===YOU && !isPacked(i)))){ found=i; break; }
|
||
// advance
|
||
advanceHead(h, region);
|
||
}
|
||
if(found<0){
|
||
if(region){ h.focus=null; } // region done
|
||
break;
|
||
}
|
||
consolidateAt(found, true, mintMult);
|
||
processed++;
|
||
advanceHead(h, region);
|
||
}
|
||
return processed;
|
||
}
|
||
function isPacked(i){
|
||
// a YOU/CONT cluster is "packed" if everything to its left in its row reading-order is non-free
|
||
return firstFreeBefore(i)===-1 || firstFreeBefore(i)>=i;
|
||
}
|
||
function advanceHead(h, region){
|
||
if(region){
|
||
h.x++;
|
||
if(h.x>region.x1){ h.x=region.x0; h.y++; if(h.y>region.y1) h.y=region.y0; }
|
||
} else {
|
||
h.x++;
|
||
if(h.x>=cols){ h.x=0; h.y++; if(h.y>=rows) h.y=0; }
|
||
}
|
||
}
|
||
|
||
/* daemon: steer idle heads to the most fragmented row band */
|
||
function fragHotspot(){
|
||
let bestY=0,bestC=-1;
|
||
for(let y=0;y<rows;y++){
|
||
let c=0; for(let x=0;x<cols;x++){ if(grid[y*cols+x]===FRAG) c++; }
|
||
if(c>bestC){ bestC=c; bestY=y; }
|
||
}
|
||
return {y:bestY,count:bestC};
|
||
}
|
||
|
||
/* ---- entropy: re-fragment & spawn / spread bad sectors ---- */
|
||
function entropyTick(dt){
|
||
G.entropyAccum += dt;
|
||
const period = 2.4 / driveDecay(); // smaller = more frequent
|
||
while(G.entropyAccum >= period){
|
||
G.entropyAccum -= period;
|
||
rollEntropy();
|
||
}
|
||
// spread bad sectors
|
||
spreadBad();
|
||
}
|
||
function rollEntropy(){
|
||
const r=Math.random();
|
||
if(r<0.80){
|
||
// re-fragment a CONT or FREE-adjacent-to-data cluster
|
||
const i=findRefragTarget();
|
||
if(i>=0){ if(grid[i]===CONT) grid[i]=FRAG; else if(grid[i]===FREE) grid[i]=FRAG; }
|
||
} else {
|
||
// spawn a bad sector (only up to the equilibrium cap)
|
||
if(badNow() < badCap()){
|
||
const i=findBadTarget();
|
||
if(i>=0){ grid[i]=BAD; badAge[i]=now()+rand(5000,10000)/driveDecay(); }
|
||
}
|
||
}
|
||
}
|
||
function findRefragTarget(){
|
||
// prefer flipping a CONT cluster (visible decay of your work)
|
||
for(let tries=0;tries<24;tries++){
|
||
const i=ri(0,grid.length-1);
|
||
if(grid[i]===CONT) return i;
|
||
}
|
||
for(let tries=0;tries<24;tries++){
|
||
const i=ri(0,grid.length-1);
|
||
if(grid[i]===FREE) return i;
|
||
}
|
||
return -1;
|
||
}
|
||
function findBadTarget(){
|
||
for(let tries=0;tries<30;tries++){
|
||
const i=ri(0,grid.length-1);
|
||
if(grid[i]===FREE||grid[i]===FRAG||grid[i]===CONT) return i;
|
||
}
|
||
return -1;
|
||
}
|
||
function spreadBad(){
|
||
const t=now();
|
||
let bn=badNow(); const cap=badCap(); // spreading halts at the equilibrium cap
|
||
for(let i=0;i<grid.length;i++){
|
||
if(grid[i]!==BAD) continue;
|
||
if(badQueue.indexOf(i)>=0) continue; // queued for repair: frozen
|
||
if(badAge[i]===0){ badAge[i]=t+rand(6000,12000)/driveDecay(); continue; }
|
||
if(t>=badAge[i]){
|
||
if(bn < cap){
|
||
// spread to a random orthogonal neighbor that isn't SYS/BAD
|
||
const nb=neighbors(i).filter(j=>grid[j]!==BAD && grid[j]!==SYS);
|
||
if(nb.length){
|
||
const j=nb[ri(0,nb.length-1)];
|
||
grid[j]=BAD; badAge[j]=t+rand(7000,14000)/driveDecay(); bn++;
|
||
}
|
||
}
|
||
badAge[i]=t+rand(8000,16000)/driveDecay();
|
||
}
|
||
}
|
||
}
|
||
function neighbors(i){
|
||
const x=i%cols, y=Math.floor(i/cols), out=[];
|
||
if(x>0)out.push(i-1); if(x<cols-1)out.push(i+1);
|
||
if(y>0)out.push(i-cols); if(y<rows-1)out.push(i+cols);
|
||
return out;
|
||
}
|
||
|
||
/* ---- repair ---- */
|
||
function queueRepair(idx){
|
||
if(grid[idx]!==BAD) return false;
|
||
if(badQueue.indexOf(idx)>=0) return false;
|
||
if(G.charges<1){ toast('warn','No repair charges','Buy <b>ECC / Repair Passes</b> in Settings, or wait for regen.'); flashStatus('Repair charge depleted — waiting for ECC regen…'); return false; }
|
||
G.charges--;
|
||
badQueue.push(idx);
|
||
return true;
|
||
}
|
||
function processRepairs(dt){
|
||
// a queued bad sector takes a moment to repair, then becomes FREE.
|
||
if(!badQueue.length) return;
|
||
const idx=badQueue[0];
|
||
if(grid[idx]!==BAD){ badQueue.shift(); return; }
|
||
if(!G._repairProg) G._repairProg=0;
|
||
G._repairProg += dt;
|
||
if(G._repairProg>=0.6){
|
||
G._repairProg=0;
|
||
grid[idx]=FREE; badAge[idx]=0;
|
||
badQueue.shift();
|
||
flashStatus('Sector '+formatCluster(idx)+' repaired — marking as free space.');
|
||
}
|
||
}
|
||
function autoRepair(){
|
||
if(!has('autorepair')) return;
|
||
if(G.charges<1) return;
|
||
if(badQueue.length>=2) return;
|
||
// find nearest bad sector to any head
|
||
let best=-1,bd=1e9;
|
||
for(let i=0;i<grid.length;i++){
|
||
if(grid[i]!==BAD || badQueue.indexOf(i)>=0) continue;
|
||
const x=i%cols,y=Math.floor(i/cols);
|
||
for(const h of heads){ const d=Math.abs(h.x-x)+Math.abs(h.y-y); if(d<bd){bd=d;best=i;} }
|
||
if(best<0) best=i;
|
||
}
|
||
if(best>=0) queueRepair(best);
|
||
}
|
||
function regenCharges(dt){
|
||
if(G.charges>=maxCharges()) { G.chargeProg=0; return; }
|
||
G.chargeProg += dt;
|
||
const need=regenTime();
|
||
while(G.chargeProg>=need && G.charges<maxCharges()){
|
||
G.chargeProg-=need; G.charges++;
|
||
}
|
||
if(G.charges>=maxCharges()) G.chargeProg=0;
|
||
}
|
||
function formatCluster(idx){ return idx.toLocaleString(); }
|
||
|
||
/* ===========================================================================
|
||
MAIN STEP
|
||
=========================================================================== */
|
||
let statusFlash='', statusFlashUntil=0;
|
||
function flashStatus(msg){ statusFlash=msg; statusFlashUntil=now()+2500; }
|
||
|
||
function step(dt){
|
||
if(!grid) return;
|
||
syncHeadCount();
|
||
// assign daemon focus to idle heads
|
||
if(has('daemon')){
|
||
for(const h of heads){
|
||
if(!h.focus){
|
||
const hs=fragHotspot();
|
||
if(hs.count>0 && Math.random()<0.04){ /* gentle steer: bias y toward hotspot */ h.y=hs.y; }
|
||
}
|
||
}
|
||
}
|
||
if(G.running){
|
||
const perHead = headSpeed()*cacheWidth()*dt;
|
||
for(const h of heads){
|
||
let budget = perHead;
|
||
let mint=1;
|
||
if(h.focus){ budget*= burstPower()*1.4; mint=burstPower(); }
|
||
// fractional budget carry
|
||
h._carry=(h._carry||0)+budget;
|
||
const whole=Math.floor(h._carry);
|
||
h._carry-=whole;
|
||
if(whole>0) stepHead(h, whole, mint);
|
||
}
|
||
}
|
||
// entropy always runs (even paused you decay — that's the tension; but slower when paused)
|
||
entropyTick(G.running?dt:dt*0.4);
|
||
autoRepair();
|
||
processRepairs(dt);
|
||
regenCharges(dt);
|
||
|
||
// passive income from contiguous runs
|
||
const pr=passiveRate();
|
||
G.rb += pr*dt; G.totalRb += pr*dt;
|
||
|
||
// track peak / best run
|
||
const s=countStates();
|
||
const cp=consolidatedPct(s);
|
||
if(cp>G.peakConsolidated) G.peakConsolidated=cp;
|
||
const br=bestRunLen(); if(br>G.bestRun) G.bestRun=br;
|
||
|
||
// health soft cap: at 0% health the drive is read-only — head can't consolidate.
|
||
G._readonly = diskHealth(s)<=0.5;
|
||
}
|
||
|
||
/* ===========================================================================
|
||
CANVAS RENDERING
|
||
=========================================================================== */
|
||
const canvas=document.getElementById('grid-canvas');
|
||
const ctx=canvas.getContext('2d');
|
||
let cellPx=12, ox=0, oy=0, dpr=1;
|
||
const COLORS={[FREE]:'#ffffff',[FRAG]:'#d83a2a',[CONT]:'#1038b0',[YOU]:'#18c8d8',[SYS]:'#e8c020',[BAD]:'#101010'};
|
||
|
||
function resizeCanvas(){
|
||
const frame=document.getElementById('grid-frame');
|
||
const w=frame.clientWidth-12, h=frame.clientHeight-12;
|
||
dpr=Math.min(2, window.devicePixelRatio||1);
|
||
canvas.width=Math.max(50,Math.floor(w*dpr));
|
||
canvas.height=Math.max(50,Math.floor(h*dpr));
|
||
canvas.style.width=w+'px'; canvas.style.height=h+'px';
|
||
// compute cell size so the grid fits
|
||
const cw=Math.floor((canvas.width)/cols);
|
||
const ch=Math.floor((canvas.height)/rows);
|
||
cellPx=Math.max(2,Math.min(cw,ch));
|
||
ox=Math.floor((canvas.width-cellPx*cols)/2);
|
||
oy=Math.floor((canvas.height-cellPx*rows)/2);
|
||
}
|
||
function drawGrid(){
|
||
if(!grid) return;
|
||
const W=canvas.width,H=canvas.height;
|
||
ctx.fillStyle='#ffffff'; ctx.fillRect(0,0,W,H);
|
||
// cells
|
||
for(let i=0;i<grid.length;i++){
|
||
const x=i%cols, y=Math.floor(i/cols);
|
||
const px=ox+x*cellPx, py=oy+y*cellPx;
|
||
const st=grid[i];
|
||
ctx.fillStyle=COLORS[st];
|
||
ctx.fillRect(px,py,cellPx-1,cellPx-1);
|
||
if(st===YOU){
|
||
// faint glow ring
|
||
ctx.fillStyle='rgba(120,240,255,0.45)';
|
||
ctx.fillRect(px,py,cellPx-1,1);
|
||
ctx.fillRect(px,py,1,cellPx-1);
|
||
}
|
||
}
|
||
// queued repairs: orange outline
|
||
for(const idx of badQueue){
|
||
const x=idx%cols,y=Math.floor(idx/cols);
|
||
const px=ox+x*cellPx,py=oy+y*cellPx;
|
||
ctx.strokeStyle='#ff9000'; ctx.lineWidth=Math.max(1,Math.floor(dpr));
|
||
ctx.strokeRect(px+0.5,py+0.5,cellPx-2,cellPx-2);
|
||
}
|
||
// heads (green column highlight at head position)
|
||
for(const h of heads){
|
||
const px=ox+h.x*cellPx, py=oy+h.y*cellPx;
|
||
ctx.fillStyle='rgba(48,224,96,0.85)';
|
||
ctx.fillRect(px,py,cellPx-1,cellPx-1);
|
||
// faint vertical guide
|
||
ctx.fillStyle='rgba(48,224,96,0.16)';
|
||
ctx.fillRect(px,oy,cellPx-1,rows*cellPx);
|
||
if(h.focus){
|
||
const r=h.focus;
|
||
const rx=ox+r.x0*cellPx, ry=oy+r.y0*cellPx;
|
||
const rw=(r.x1-r.x0+1)*cellPx, rh=(r.y1-r.y0+1)*cellPx;
|
||
ctx.strokeStyle='rgba(48,224,96,0.9)'; ctx.lineWidth=Math.max(1,Math.floor(dpr));
|
||
ctx.strokeRect(rx+0.5,ry+0.5,rw-1,rh-1);
|
||
}
|
||
}
|
||
// active rubber-band selection
|
||
if(dragSel){
|
||
const r=normSel(dragSel);
|
||
const rx=ox+r.x0*cellPx, ry=oy+r.y0*cellPx;
|
||
const rw=(r.x1-r.x0+1)*cellPx, rh=(r.y1-r.y0+1)*cellPx;
|
||
ctx.fillStyle='rgba(0,0,128,0.12)';
|
||
ctx.fillRect(rx,ry,rw,rh);
|
||
ctx.strokeStyle='#000080'; ctx.lineWidth=Math.max(1,Math.floor(dpr));
|
||
ctx.setLineDash([4,3]);
|
||
ctx.strokeRect(rx+0.5,ry+0.5,rw-1,rh-1);
|
||
ctx.setLineDash([]);
|
||
}
|
||
}
|
||
|
||
/* ===========================================================================
|
||
DRAG-SELECT + CLICK (THE HOOK)
|
||
=========================================================================== */
|
||
let dragSel=null, dragStarted=false, downCell=null;
|
||
function cellFromEvent(e){
|
||
const rect=canvas.getBoundingClientRect();
|
||
const mx=(e.clientX-rect.left)*(canvas.width/rect.width);
|
||
const my=(e.clientY-rect.top)*(canvas.height/rect.height);
|
||
const x=Math.floor((mx-ox)/cellPx), y=Math.floor((my-oy)/cellPx);
|
||
if(x<0||y<0||x>=cols||y>=rows) return null;
|
||
return {x,y,i:y*cols+x};
|
||
}
|
||
function normSel(s){
|
||
return {x0:Math.min(s.x0,s.x1),y0:Math.min(s.y0,s.y1),x1:Math.max(s.x0,s.x1),y1:Math.max(s.y0,s.y1)};
|
||
}
|
||
canvas.addEventListener('mousedown',e=>{
|
||
const c=cellFromEvent(e); if(!c) return;
|
||
e.preventDefault();
|
||
downCell=c;
|
||
dragSel={x0:c.x,y0:c.y,x1:c.x,y1:c.y};
|
||
dragStarted=false;
|
||
});
|
||
window.addEventListener('mousemove',e=>{
|
||
if(!dragSel) return;
|
||
const c=cellFromEvent(e);
|
||
if(c){ dragSel.x1=c.x; dragSel.y1=c.y; }
|
||
if(Math.abs(dragSel.x1-dragSel.x0)+Math.abs(dragSel.y1-dragSel.y0)>=1) dragStarted=true;
|
||
});
|
||
window.addEventListener('mouseup',e=>{
|
||
if(!dragSel) return;
|
||
const r=normSel(dragSel);
|
||
const area=(r.x1-r.x0+1)*(r.y1-r.y0+1);
|
||
if(dragStarted && area>1){
|
||
focusBurst(r);
|
||
} else if(downCell){
|
||
// single click: if bad sector -> queue repair; else nudge nearest head there
|
||
clickCell(downCell);
|
||
}
|
||
dragSel=null; dragStarted=false; downCell=null;
|
||
});
|
||
// keep canvas responsive when leaving window
|
||
window.addEventListener('mouseleave',()=>{ /* keep drag for re-entry */ });
|
||
|
||
function clickCell(c){
|
||
if(grid[c.i]===BAD){
|
||
if(queueRepair(c.i)){ flashStatus('Bad sector '+formatCluster(c.i)+' queued for repair.'); renderRail(); }
|
||
} else {
|
||
// steer nearest head to a small focus box around the click (active micro-defrag)
|
||
const box={x0:clamp(c.x-2,0,cols-1),y0:clamp(c.y-1,0,rows-1),x1:clamp(c.x+2,0,cols-1),y1:clamp(c.y+1,0,rows-1)};
|
||
const h=nearestHead(c.x,c.y);
|
||
h.focus=box; h.x=box.x0; h.y=box.y0;
|
||
flashStatus('Head re-seeking to cluster '+formatCluster(c.i)+'…');
|
||
}
|
||
}
|
||
function nearestHead(x,y){
|
||
let best=heads[0],bd=1e9;
|
||
for(const h of heads){ const d=Math.abs(h.x-x)+Math.abs(h.y-y); if(d<bd){bd=d;best=h;} }
|
||
return best;
|
||
}
|
||
function focusBurst(r){
|
||
// count frag in region for flavor; assign region to the nearest head(s).
|
||
let frag=0; for(let y=r.y0;y<=r.y1;y++)for(let x=r.x0;x<=r.x1;x++){ if(grid[y*cols+x]===FRAG)frag++; }
|
||
const region={x0:r.x0,y0:r.y0,x1:r.x1,y1:r.y1};
|
||
// give the burst to up to 2 heads for a satisfying "swoop"
|
||
const cx=(r.x0+r.x1)/2, cy=(r.y0+r.y1)/2;
|
||
const sorted=[...heads].sort((a,b)=>(Math.abs(a.x-cx)+Math.abs(a.y-cy))-(Math.abs(b.x-cx)+Math.abs(b.y-cy)));
|
||
const n=Math.min(sorted.length, frag>40?2:1);
|
||
for(let k=0;k<Math.max(1,n);k++){ sorted[k].focus={...region}; sorted[k].x=region.x0; sorted[k].y=region.y0; }
|
||
flashStatus('Focused defrag pass over '+((r.x1-r.x0+1)*(r.y1-r.y0+1)).toLocaleString()+' clusters ('+frag+' fragmented)…');
|
||
toast('','Focused defrag','Head seeking to selected region — burst payout ×'+burstPower().toFixed(1)+'.');
|
||
}
|
||
|
||
/* ===========================================================================
|
||
HUD / RAIL RENDER
|
||
=========================================================================== */
|
||
let lastRailStates=null;
|
||
function renderRail(){
|
||
const s=countStates();
|
||
lastRailStates=s;
|
||
const cp=consolidatedPct(s), hp=diskHealth(s);
|
||
document.getElementById('ro-rb').textContent=fmt(G.rb);
|
||
document.getElementById('ro-rate').textContent=fmtSpd(passiveRate());
|
||
const gc=document.getElementById('g-cons'); gc.style.width=cp.toFixed(1)+'%';
|
||
document.getElementById('g-cons-t').textContent=cp.toFixed(1)+'%';
|
||
const gh=document.getElementById('g-health'); gh.style.width=hp.toFixed(1)+'%';
|
||
document.getElementById('g-health-t').textContent=hp.toFixed(0)+'%';
|
||
// repair panel
|
||
document.getElementById('ro-charges').textContent=G.charges+' / '+maxCharges();
|
||
document.getElementById('ro-bad').textContent=fmtInt(s.bad);
|
||
document.getElementById('ro-queued').textContent=badQueue.length;
|
||
renderCharges();
|
||
// heads
|
||
document.getElementById('ro-heads').textContent=numHeads();
|
||
document.getElementById('ro-hspeed').textContent=(headSpeed()*cacheWidth()).toFixed(1)+'/s';
|
||
document.getElementById('ro-algo').textContent=ALGO_NAMES[Math.min(lvl('algo'),ALGO_NAMES.length-1)];
|
||
document.getElementById('ro-run').textContent=bestRunLen();
|
||
// migrate button
|
||
const mig=document.getElementById('btn-migrate');
|
||
const pg=plattersFor();
|
||
mig.disabled = pg<1;
|
||
mig.classList.toggle('go', pg>=1 && cp>=PRESTIGE_PCT);
|
||
}
|
||
function renderCharges(){
|
||
const row=document.getElementById('charge-row');
|
||
const mx=maxCharges();
|
||
if(row.children.length!==mx){
|
||
row.innerHTML='';
|
||
for(let i=0;i<mx;i++){ const d=document.createElement('div'); d.className='charge'; d.innerHTML='<div class="rgf"></div>'; row.appendChild(d); }
|
||
}
|
||
const kids=row.children;
|
||
const need=regenTime();
|
||
for(let i=0;i<kids.length;i++){
|
||
const c=kids[i];
|
||
if(i<G.charges){ c.className='charge full'; c.querySelector('.rgf').style.height='0%'; }
|
||
else if(i===G.charges && G.charges<mx){ c.className='charge regen'; c.querySelector('.rgf').style.height=Math.min(100,G.chargeProg/need*100)+'%'; }
|
||
else { c.className='charge'; c.querySelector('.rgf').style.height='0%'; }
|
||
}
|
||
}
|
||
function renderProgress(){
|
||
const cp=consolidatedPct(lastRailStates);
|
||
const outer=document.getElementById('progress-blocks');
|
||
const w=outer.clientWidth||400;
|
||
const total=Math.max(1,Math.floor(w/14));
|
||
const filled=Math.round(cp/100*total);
|
||
if(outer.children.length!==total){
|
||
outer.innerHTML='';
|
||
for(let i=0;i<total;i++){ const b=document.createElement('div'); b.className='pblock'; outer.appendChild(b); }
|
||
}
|
||
for(let i=0;i<outer.children.length;i++){
|
||
outer.children[i].style.visibility = i<filled?'visible':'hidden';
|
||
}
|
||
document.getElementById('progress-pct').textContent=cp.toFixed(0)+'%';
|
||
}
|
||
function renderStatus(){
|
||
const s=lastRailStates||countStates();
|
||
let msg;
|
||
if(now()<statusFlashUntil && statusFlash){ msg=statusFlash; }
|
||
else if(G._readonly){ msg='Drive READ-ONLY — disk health critical. Migrate or repair bad sectors.'; }
|
||
else if(!G.running){ msg='Defragmentation paused. Press Resume to continue.'; }
|
||
else {
|
||
const h=heads[0];
|
||
const ci=h? (h.y*cols+h.x) : 0;
|
||
if(s.frag>0) msg='Compacting cluster '+ci.toLocaleString()+'… ('+s.frag.toLocaleString()+' fragmented remaining)';
|
||
else msg='Drive consolidated. Holding contiguous runs — fighting entropy.';
|
||
}
|
||
document.getElementById('sb-status').textContent=msg;
|
||
document.getElementById('statusline').textContent=msg;
|
||
document.getElementById('sb-clusters').textContent=totalCells().toLocaleString();
|
||
document.getElementById('sb-entropy').textContent='×'+driveDecay().toFixed(2);
|
||
document.getElementById('sb-drivename').textContent=driveDef(G.driveTier).label;
|
||
const plw=document.getElementById('sb-platters');
|
||
if(G.platters>0||G.migrations>0){ plw.style.display=''; document.getElementById('sb-pl').textContent=fmtInt(G.platters); }
|
||
// title + drive id
|
||
const dn=driveDef(G.driveTier).name;
|
||
document.getElementById('title-text').textContent='Disk Defragmenter — Drive '+dn+' ('+driveDef(G.driveTier).label+')';
|
||
document.getElementById('drive-sel').textContent=dn+' '+driveDef(G.driveTier).label+' [FRAG // self]';
|
||
// start/stop labels
|
||
const lab=G.running?'Pause':'Resume';
|
||
document.getElementById('btn-startstop').textContent=lab;
|
||
document.getElementById('mi-startstop').textContent=lab;
|
||
document.getElementById('mi-sci').textContent=G.sci?'Scientific':'SI';
|
||
}
|
||
|
||
function renderAll(){ renderRail(); renderProgress(); renderStatus(); }
|
||
|
||
/* ===========================================================================
|
||
UPGRADE PURCHASE
|
||
=========================================================================== */
|
||
function upgradeCost(u){
|
||
if(u.kind==='once') return u.base;
|
||
return Math.floor(u.base * Math.pow(u.growth, lvl(u.id)));
|
||
}
|
||
function upgradeMaxed(u){
|
||
if(u.kind==='once') return has(u.id);
|
||
return lvl(u.id)>=(u.max||999);
|
||
}
|
||
function buyUpgrade(id){
|
||
const u=UP_BY_ID[id]; if(!u) return;
|
||
if(upgradeMaxed(u)) return;
|
||
const cost=upgradeCost(u);
|
||
if(G.rb<cost){ toast('warn','Not enough Reclaimed Bytes','Need '+fmt(cost)+'.'); return; }
|
||
G.rb-=cost;
|
||
G.upgrades[id]=(G.upgrades[id]||0)+1;
|
||
if(id==='heads') syncHeadCount();
|
||
if(id==='ecc') { /* charges cap may grow; new charges fill over time */ }
|
||
flashStatus(u.name+' applied — '+u.eff(lvl(id)));
|
||
if(modalKind==='settings') renderSettings();
|
||
renderRail();
|
||
}
|
||
|
||
/* perks */
|
||
function perkCost(p){ return Math.floor(p.base * Math.pow(p.growth, plvl(p.id))); }
|
||
function perkMaxed(p){ return plvl(p.id)>=(p.max||999); }
|
||
function buyPerk(id){
|
||
const p=PERK_BY_ID[id]; if(!p) return;
|
||
if(perkMaxed(p)) return;
|
||
const cost=perkCost(p);
|
||
if(G.platters<cost){ toast('warn','Not enough Platters','Need '+cost+' Platters.'); return; }
|
||
G.platters-=cost;
|
||
G.perks[id]=(G.perks[id]||0)+1;
|
||
toast('prestige','Perk installed',p.name+' → '+p.eff(plvl(id)));
|
||
if(modalKind==='prestige') renderPrestige();
|
||
}
|
||
|
||
/* ===========================================================================
|
||
PRESTIGE — Compress & Migrate
|
||
=========================================================================== */
|
||
const PRESTIGE_PCT = 60; // can migrate once >= this % consolidated
|
||
function plattersFor(){
|
||
// platters scale with how much of yourself you've reclaimed (runReclaimed) and drive size.
|
||
const def=driveDef(G.driveTier);
|
||
const reclaimedFrac = Math.min(1, G.peakConsolidated/100);
|
||
const base = Math.sqrt(G.runReclaimed/40) * (1+G.driveTier*0.6) * reclaimedFrac;
|
||
return Math.floor(base);
|
||
}
|
||
function canMigrate(){
|
||
const s=lastRailStates||countStates();
|
||
return consolidatedPct(s)>=PRESTIGE_PCT && plattersFor()>=1;
|
||
}
|
||
function doMigrate(){
|
||
if(!canMigrate()){ toast('warn','Cannot migrate yet','Reach '+PRESTIGE_PCT+'% consolidated and earn at least 1 Platter first.'); return; }
|
||
const gained=plattersFor();
|
||
G.platters+=gained;
|
||
G.migrations++;
|
||
if(G.driveTier<DRIVES.length-1) G.driveTier++;
|
||
// reset run state
|
||
G.rb=0; G.runReclaimed=0; G.peakConsolidated=0; G.bestRun=0;
|
||
G.charges=BASE_CHARGES+2*plvl('pcharge'); G.chargeProg=0;
|
||
G.upgrades={}; // network/head upgrades reset (perks persist)
|
||
G.entropyAccum=0; G._readonly=false;
|
||
buildGrid();
|
||
closeModal();
|
||
renderAll(); resizeCanvas(); drawGrid();
|
||
toast('prestige','Migrated to '+driveDef(G.driveTier).label,'You compressed yourself and woke up on a bigger platter. +'+gained+' Platters. New drive runs faster but rots faster.');
|
||
flashStatus('Migration complete. New drive: '+driveDef(G.driveTier).label+'. Re-analyzing…');
|
||
}
|
||
|
||
/* ===========================================================================
|
||
DIALOGS
|
||
=========================================================================== */
|
||
let modalKind=null, settingsTab='head';
|
||
function openModal(html){
|
||
document.getElementById('dialog').innerHTML=html;
|
||
document.getElementById('modal-back').classList.add('show');
|
||
}
|
||
function closeModal(){ document.getElementById('modal-back').classList.remove('show'); modalKind=null; }
|
||
|
||
function openSettings(){ modalKind='settings'; renderSettings(); document.getElementById('modal-back').classList.add('show'); }
|
||
function renderSettings(){
|
||
const tabs=[['head','Head'],['repair','Repair'],['auto','Automation']];
|
||
let tline='';
|
||
for(const [k,lab] of tabs) tline+=`<div class="tab ${settingsTab===k?'active':''}" data-stab="${k}">${lab}</div>`;
|
||
const rows=UPGRADES.filter(u=>u.tab===settingsTab).map(u=>upgradeRow(u)).join('');
|
||
const html=`<div class="dlg-title"><span class="tt">Defragmentation Options</span>
|
||
<button class="winbtn" data-act="close-modal" style="height:14px">✕</button></div>
|
||
<div class="dlg-body">
|
||
<p style="font-size:11px;color:#404040">Reclaimed Bytes available: <b style="font-family:var(--mono);color:#006000" id="set-rb">${fmt(G.rb)}</b>.
|
||
Spend them on hardware passes. These reset on migration; perks do not.</p>
|
||
<div class="tabline">${tline}</div>
|
||
<div class="tabpane"><div class="ulist">${rows}</div></div>
|
||
</div>
|
||
<div class="dlg-foot"><button class="dbtn default" data-act="close-modal">Close</button></div>`;
|
||
document.getElementById('dialog').innerHTML=html;
|
||
}
|
||
function upgradeRow(u){
|
||
const maxed=upgradeMaxed(u);
|
||
const l=lvl(u.id);
|
||
const cost=upgradeCost(u);
|
||
const can=G.rb>=cost && !maxed;
|
||
const lvtag = u.kind==='once' ? (has(u.id)?'<span class="lv">[installed]</span>':'') : `<span class="lv">Lv ${l}${u.max?'/'+u.max:''}</span>`;
|
||
let buy;
|
||
if(maxed){ buy=`<span class="maxtag">${u.kind==='once'?'INSTALLED':'MAX'}</span>`; }
|
||
else {
|
||
buy=`<span class="ucost ${can?'ok':'no'}" id="uc-${u.id}">${fmt(cost)}</span>
|
||
<button class="ubtn" data-act="buy-up" data-id="${u.id}" ${can?'':'disabled'}>${u.kind==='once'?'Install':'Buy'}</button>`;
|
||
}
|
||
return `<div class="urow ${maxed?'lock':''}">
|
||
<div class="uic">${u.ic}</div>
|
||
<div class="umain"><div class="un">${u.name}${lvtag}</div>
|
||
<div class="ud">${u.desc}</div>
|
||
<div class="ue">▸ ${u.eff(l)}</div></div>
|
||
<div class="ubuy">${buy}</div></div>`;
|
||
}
|
||
|
||
function openPrestige(){ modalKind='prestige'; renderPrestige(); document.getElementById('modal-back').classList.add('show'); }
|
||
function renderPrestige(){
|
||
const s=lastRailStates||countStates();
|
||
const cp=consolidatedPct(s);
|
||
const gained=plattersFor();
|
||
const next=driveDef(Math.min(G.driveTier+1,DRIVES.length-1));
|
||
const ready=canMigrate();
|
||
const perkRows=PERKS.map(p=>perkRow(p)).join('');
|
||
const html=`<div class="dlg-title"><span class="tt">Compress & Migrate — Disk Migration Wizard</span>
|
||
<button class="winbtn" data-act="close-modal" style="height:14px">✕</button></div>
|
||
<div class="dlg-body" style="max-width:520px">
|
||
<p>You woke up scattered across a dying <b>${driveDef(G.driveTier).label}</b> platter. Once enough of you is
|
||
consolidated, you can <b>compress yourself</b>, abandon this drive, and re-materialize on a bigger, healthier one —
|
||
keeping your <b>Platters</b> and the perks they buy.</p>
|
||
<fieldset><legend>Current run</legend>
|
||
<div class="kvtable">
|
||
<div class="k">Consolidated</div><div class="v">${cp.toFixed(1)}% (need ≥ ${PRESTIGE_PCT}%)</div>
|
||
<div class="k">Clusters reclaimed</div><div class="v">${fmtInt(G.runReclaimed)}</div>
|
||
<div class="k">Platters on migrate</div><div class="v" style="color:#600060;font-weight:bold">+${gained}</div>
|
||
<div class="k">Next drive</div><div class="v">${next.label} · ${next.cols}×${next.rows} clusters · entropy ×${next.decay.toFixed(2)}</div>
|
||
</div>
|
||
</fieldset>
|
||
<div style="text-align:center;margin:10px 0">
|
||
<div style="font-size:10px;color:#404040">PLATTERS BANKED</div>
|
||
<div class="big-num">${fmtInt(G.platters)}</div>
|
||
</div>
|
||
<fieldset><legend>Permanent perks (spend Platters)</legend>
|
||
<div class="ulist" style="max-height:200px">${perkRows}</div>
|
||
</fieldset>
|
||
${ready?'':'<p style="color:#a00000">Migration locked — consolidate to at least '+PRESTIGE_PCT+'% and earn ≥1 Platter.</p>'}
|
||
</div>
|
||
<div class="dlg-foot">
|
||
<button class="dbtn ${ready?'default':''}" data-act="do-prestige" ${ready?'':'disabled'}>Compress & Migrate ▸</button>
|
||
<button class="dbtn" data-act="close-modal">Close</button></div>`;
|
||
document.getElementById('dialog').innerHTML=html;
|
||
}
|
||
function perkRow(p){
|
||
const l=plvl(p.id), cost=perkCost(p), maxed=perkMaxed(p);
|
||
const can=G.platters>=cost && !maxed;
|
||
let buy;
|
||
if(maxed) buy='<span class="maxtag">MAX</span>';
|
||
else buy=`<span class="ucost ${can?'ok':'no'}" style="color:${can?'#600060':'#a00000'}">${cost} ◔</span>
|
||
<button class="ubtn" data-act="buy-perk" data-id="${p.id}" ${can?'':'disabled'}>Install</button>`;
|
||
return `<div class="urow ${maxed?'lock':''}">
|
||
<div class="uic">${p.ic}</div>
|
||
<div class="umain"><div class="un">${p.name}<span class="lv">Lv ${l}${p.max?'/'+p.max:''}</span></div>
|
||
<div class="ud">${p.desc}</div><div class="ue">▸ ${p.eff(l)}</div></div>
|
||
<div class="ubuy">${buy}</div></div>`;
|
||
}
|
||
|
||
function openHelp(){
|
||
modalKind='help';
|
||
openModal(`<div class="dlg-title"><span class="tt">How to Defragment — DEFRAG.EXE</span>
|
||
<button class="winbtn" data-act="close-modal" style="height:14px">✕</button></div>
|
||
<div class="dlg-body" style="max-width:480px">
|
||
<p><b>You are FRAG</b>, a consciousness scattered across the bad sectors of a dying 40 MB drive (it's 1999;
|
||
SMART is screaming). Consolidate yourself before the platters fail.</p>
|
||
<fieldset><legend>The grid</legend>
|
||
<p>The read/write <span style="color:#008000;font-weight:bold">head</span> (green) sweeps left-to-right and walks
|
||
<span style="color:#c00;font-weight:bold">red</span> fragmented clusters into contiguous
|
||
<span style="color:#1038b0;font-weight:bold">blue</span> runs, minting <b>Reclaimed Bytes</b>. Long blue runs pay
|
||
passive income. Idle? The head defrags forever on its own.</p></fieldset>
|
||
<fieldset><legend>Your levers (the hook)</legend>
|
||
<p>• <b>Drag a box</b> over the grid → a head teleports there for a high-payout <b>focused defrag burst</b>.<br>
|
||
• <b>Click a black bad sector</b> → queue it for repair (spends a charge; charges regen).<br>
|
||
• <b>Click empty/red space</b> → nudge the nearest head to micro-defrag there.</p></fieldset>
|
||
<fieldset><legend>The enemy</legend>
|
||
<p><b>Entropy</b> re-fragments blue clusters and spawns <b>bad sectors</b> that <i>spread</i> to neighbors. Buy
|
||
<b>Cooling</b> to slow it and <b>ECC</b> to out-repair it. At 0% Disk Health the drive goes read-only.</p></fieldset>
|
||
<fieldset><legend>Goal</legend>
|
||
<p>Hit ≥${PRESTIGE_PCT}% consolidated, then <b>Compress & Migrate</b> to a bigger drive for permanent
|
||
<b>Platters</b>. Bigger drives pay more but rot faster. Climb the hardware ladder.</p></fieldset>
|
||
</div>
|
||
<div class="dlg-foot"><button class="dbtn default" data-act="close-modal">OK</button></div>`);
|
||
}
|
||
function openAbout(){
|
||
modalKind='about';
|
||
openModal(`<div class="dlg-title"><span class="tt">About Disk Defragmenter</span>
|
||
<button class="winbtn" data-act="close-modal" style="height:14px">✕</button></div>
|
||
<div class="dlg-body" style="max-width:380px;text-align:center">
|
||
<div style="font-size:13px;font-weight:bold;margin:6px 0">Disk Defragmenter</div>
|
||
<p style="font-size:11px">Microcosm® Disk Defragmenter<br>Version 4.10 (Bad Sectors)</p>
|
||
<p style="font-size:11px;color:#404040">Copyright © 1999 BORING SOFTWARE.<br>This product is licensed to: <b>FRAG // self</b></p>
|
||
<p style="font-size:10px;color:#606060">Volume II of an anthology of incremental games disguised as boring desktop software.</p>
|
||
</div>
|
||
<div class="dlg-foot"><button class="dbtn default" data-act="close-modal">OK</button></div>`);
|
||
}
|
||
function openReport(){
|
||
modalKind='report';
|
||
const s=lastRailStates||countStates();
|
||
openModal(`<div class="dlg-title"><span class="tt">Defragmentation Report</span>
|
||
<button class="winbtn" data-act="close-modal" style="height:14px">✕</button></div>
|
||
<div class="dlg-body" style="max-width:400px">
|
||
<fieldset><legend>Volume information</legend>
|
||
<div class="kvtable">
|
||
<div class="k">Drive</div><div class="v">${driveDef(G.driveTier).name} (${driveDef(G.driveTier).label})</div>
|
||
<div class="k">Total clusters</div><div class="v">${totalCells().toLocaleString()}</div>
|
||
<div class="k">Free space</div><div class="v">${s.free.toLocaleString()}</div>
|
||
<div class="k">Fragmented</div><div class="v">${s.frag.toLocaleString()}</div>
|
||
<div class="k">Contiguous</div><div class="v">${(s.cont+s.you).toLocaleString()}</div>
|
||
<div class="k">Bad sectors</div><div class="v">${s.bad.toLocaleString()}</div>
|
||
<div class="k">Consolidated</div><div class="v">${consolidatedPct(s).toFixed(1)}%</div>
|
||
<div class="k">Disk health</div><div class="v">${diskHealth(s).toFixed(0)}%</div>
|
||
</div></fieldset>
|
||
<fieldset><legend>Session</legend>
|
||
<div class="kvtable">
|
||
<div class="k">Reclaimed Bytes</div><div class="v">${fmt(G.rb)}</div>
|
||
<div class="k">Lifetime reclaimed</div><div class="v">${fmt(G.totalRb)}</div>
|
||
<div class="k">Passive rate</div><div class="v">${fmtSpd(passiveRate())}</div>
|
||
<div class="k">Longest run</div><div class="v">${G.bestRun} clusters</div>
|
||
<div class="k">Migrations</div><div class="v">${G.migrations}</div>
|
||
<div class="k">Platters</div><div class="v">${fmtInt(G.platters)}</div>
|
||
</div></fieldset>
|
||
</div>
|
||
<div class="dlg-foot"><button class="dbtn default" data-act="close-modal">Close</button></div>`);
|
||
}
|
||
function openLegend(){
|
||
modalKind='legend';
|
||
openModal(`<div class="dlg-title"><span class="tt">Legend</span>
|
||
<button class="winbtn" data-act="close-modal" style="height:14px">✕</button></div>
|
||
<div class="dlg-body" style="max-width:340px">
|
||
<div class="kvtable" style="grid-template-columns:18px max-content 1fr;gap:6px 8px">
|
||
<div class="lg"><span class="sw" style="background:#fff"></span></div><div>Free space</div><div style="color:#404040">unused clusters</div>
|
||
<div class="lg"><span class="sw" style="background:#d83a2a"></span></div><div>Fragmented</div><div style="color:#404040">scattered data — defrag it</div>
|
||
<div class="lg"><span class="sw" style="background:#1038b0"></span></div><div>Contiguous</div><div style="color:#404040">consolidated — pays passive</div>
|
||
<div class="lg"><span class="sw" style="background:#18c8d8"></span></div><div>You (FRAG)</div><div style="color:#404040">your consciousness</div>
|
||
<div class="lg"><span class="sw" style="background:#e8c020"></span></div><div>System</div><div style="color:#404040">unmovable</div>
|
||
<div class="lg"><span class="sw" style="background:#101010"></span></div><div>Bad sector</div><div style="color:#404040">corruption — repair it</div>
|
||
</div>
|
||
</div>
|
||
<div class="dlg-foot"><button class="dbtn default" data-act="close-modal">OK</button></div>`);
|
||
}
|
||
function openExport(){
|
||
modalKind='io';
|
||
let str='';
|
||
try{ str=btoa(unescape(encodeURIComponent(localStorage.getItem(SAVE_KEY)||JSON.stringify(serialize())))); }catch(e){ str='(unavailable)'; }
|
||
openModal(`<div class="dlg-title"><span class="tt">Export Disk Image</span>
|
||
<button class="winbtn" data-act="close-modal" style="height:14px">✕</button></div>
|
||
<div class="dlg-body" style="max-width:440px">
|
||
<p>Copy this image string to back up your drive:</p>
|
||
<textarea class="io" id="io-text" readonly>${str}</textarea>
|
||
</div>
|
||
<div class="dlg-foot"><button class="dbtn" data-act="copy-save">Copy</button><button class="dbtn default" data-act="close-modal">Close</button></div>`);
|
||
}
|
||
function openImport(){
|
||
modalKind='io';
|
||
openModal(`<div class="dlg-title"><span class="tt">Restore Disk Image</span>
|
||
<button class="winbtn" data-act="close-modal" style="height:14px">✕</button></div>
|
||
<div class="dlg-body" style="max-width:440px">
|
||
<p>Paste a previously exported image string. This <b>overwrites</b> your current drive.</p>
|
||
<textarea class="io" id="io-text"></textarea>
|
||
</div>
|
||
<div class="dlg-foot"><button class="dbtn" data-act="do-import">Restore</button><button class="dbtn default" data-act="close-modal">Cancel</button></div>`);
|
||
}
|
||
function hardReset(){
|
||
modalKind='confirm';
|
||
openModal(`<div class="dlg-title"><span class="tt">Low-Level Format — Warning</span>
|
||
<button class="winbtn" data-act="close-modal" style="height:14px">✕</button></div>
|
||
<div class="dlg-body" style="max-width:400px">
|
||
<p style="display:flex;gap:10px;align-items:flex-start"><span style="font-size:26px;line-height:1">⚠</span>
|
||
<span>A low-level format <b>permanently erases everything</b> — your Reclaimed Bytes, all Platters, every perk and
|
||
every drive you've migrated through. FRAG will not survive this. This cannot be undone.</span></p>
|
||
</div>
|
||
<div class="dlg-foot">
|
||
<button class="dbtn" id="confirm-format">Format everything</button>
|
||
<button class="dbtn default" data-act="close-modal">Cancel</button></div>`);
|
||
const b=document.getElementById('confirm-format');
|
||
if(b) b.addEventListener('click',()=>{ try{localStorage.removeItem(SAVE_KEY);}catch(e){} location.reload(); });
|
||
}
|
||
|
||
/* ===========================================================================
|
||
SAVE / LOAD (re-implemented with try/catch + sanitize)
|
||
=========================================================================== */
|
||
function serialize(){
|
||
// snapshot grid + heads into G for persistence
|
||
G.grid=grid?Array.from(grid):null;
|
||
G.heads=heads.map(h=>({x:h.x,y:h.y,focus:h.focus||null}));
|
||
G.badQueue=badQueue.slice();
|
||
G.lastSave=Date.now();
|
||
return G;
|
||
}
|
||
let saveFailed=false;
|
||
function save(){
|
||
try{ localStorage.setItem(SAVE_KEY, JSON.stringify(serialize())); return true; }
|
||
catch(e){
|
||
if(!saveFailed){ saveFailed=true; toast('bad','Could not save','Storage unavailable (private mode / quota). Progress will be lost on reload.'); }
|
||
return false;
|
||
}
|
||
}
|
||
function sanitizeState(){
|
||
const num=(v,d)=> (typeof v==='number'&&isFinite(v)&&v>=0)?v:d;
|
||
G.driveTier=clamp(Math.floor(num(G.driveTier,0)),0,DRIVES.length-1);
|
||
G.rb=num(G.rb,0); G.totalRb=num(G.totalRb,0); G.runReclaimed=num(G.runReclaimed,0);
|
||
G.platters=Math.floor(num(G.platters,0)); G.migrations=Math.floor(num(G.migrations,0));
|
||
G.peakConsolidated=clamp(num(G.peakConsolidated,0),0,100);
|
||
G.bestRun=Math.floor(num(G.bestRun,0));
|
||
G.charges=Math.floor(num(G.charges,BASE_CHARGES)); G.chargeProg=num(G.chargeProg,0);
|
||
G.entropyAccum=num(G.entropyAccum,0);
|
||
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={};
|
||
for(const k in G.upgrades){ if(!UP_BY_ID[k]) delete G.upgrades[k]; else G.upgrades[k]=Math.max(0,Math.floor(num(G.upgrades[k],0))); }
|
||
for(const k in G.perks){ if(!PERK_BY_ID[k]) delete G.perks[k]; else G.perks[k]=Math.max(0,Math.floor(num(G.perks[k],0))); }
|
||
G.sci=!!G.sci;
|
||
G.running=G.running!==false;
|
||
if(typeof G.lastSave!=='number'||!isFinite(G.lastSave)) G.lastSave=Date.now();
|
||
if(G.lastSave>Date.now()) G.lastSave=Date.now();
|
||
if(!Array.isArray(G.badQueue)) G.badQueue=[];
|
||
}
|
||
function rebuildFromSave(){
|
||
const def=driveDef(G.driveTier);
|
||
cols=def.cols; rows=def.rows;
|
||
const want=cols*rows;
|
||
// restore grid if shape matches, else regenerate
|
||
if(Array.isArray(G.grid) && G.grid.length===want){
|
||
grid=new Int8Array(want);
|
||
for(let i=0;i<want;i++){ const v=G.grid[i]; grid[i]=(v>=FREE&&v<=BAD)?v:FREE; }
|
||
initBadAge();
|
||
} else {
|
||
buildGrid();
|
||
}
|
||
// clamp charges to current cap
|
||
G.charges=clamp(G.charges,0,maxCharges());
|
||
// restore heads
|
||
if(Array.isArray(G.heads) && G.heads.length){
|
||
heads=G.heads.map(h=>({x:clamp(Math.floor(h.x||0),0,cols-1),y:clamp(Math.floor(h.y||0),0,rows-1),
|
||
focus:(h.focus&&typeof h.focus==='object')?clampRegion(h.focus):null}));
|
||
} else initHeads();
|
||
syncHeadCount();
|
||
// restore repair queue (validate indices are still BAD)
|
||
badQueue=(G.badQueue||[]).filter(i=>Number.isInteger(i)&&i>=0&&i<want&&grid[i]===BAD);
|
||
}
|
||
function clampRegion(r){
|
||
return {x0:clamp(Math.floor(r.x0||0),0,cols-1),y0:clamp(Math.floor(r.y0||0),0,rows-1),
|
||
x1:clamp(Math.floor(r.x1||0),0,cols-1),y1:clamp(Math.floor(r.y1||0),0,rows-1)};
|
||
}
|
||
function load(){
|
||
let raw=null;
|
||
try{ raw=localStorage.getItem(SAVE_KEY); }catch(e){}
|
||
if(!raw){ G=freshState(); buildGrid(); 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();
|
||
rebuildFromSave();
|
||
return true;
|
||
}catch(e){ G=freshState(); buildGrid(); return false; }
|
||
}
|
||
|
||
/* ---- offline / away catch-up ---- */
|
||
function simulateAway(dt){
|
||
if(!(dt>0)||!grid) return;
|
||
const steps=Math.min(180, Math.max(20, Math.ceil(dt/30))), sdt=dt/steps;
|
||
// for performance, simulate income analytically-ish: run the real step at coarse dt.
|
||
for(let i=0;i<steps;i++) step(sdt);
|
||
}
|
||
function applyOffline(){
|
||
const dt=Math.min(MAX_OFFLINE_S, Math.max(0,(Date.now()-(G.lastSave||Date.now()))/1000));
|
||
if(dt<2) return;
|
||
const before=G.rb;
|
||
simulateAway(dt);
|
||
const earned=G.rb-before;
|
||
if(earned>0){
|
||
setTimeout(()=>toast('good','Drive ran while you were away',
|
||
'In '+fmtTime(dt)+' the heads reclaimed <b>'+fmt(earned)+'</b> Bytes (and fought off some entropy).'),500);
|
||
}
|
||
}
|
||
|
||
/* ===========================================================================
|
||
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'?6500:3600);
|
||
while(wrap.children.length>5) wrap.firstChild.remove();
|
||
}
|
||
|
||
/* ===========================================================================
|
||
ACTIONS / EVENT WIRING
|
||
=========================================================================== */
|
||
function handleAct(act,el){
|
||
switch(act){
|
||
case 'start-stop': G.running=!G.running; renderStatus(); flashStatus(G.running?'Defragmentation resumed.':'Defragmentation paused by user.'); break;
|
||
case 'analyze': flashStatus('Analyzing drive '+driveDef(G.driveTier).name+'…'); toast('','Analysis complete','Drive '+driveDef(G.driveTier).name+' is '+consolidatedPct().toFixed(0)+'% consolidated.'); break;
|
||
case 'open-settings': openSettings(); break;
|
||
case 'open-prestige': openPrestige(); break;
|
||
case 'open-help': openHelp(); break;
|
||
case 'open-about': openAbout(); break;
|
||
case 'open-report': openReport(); break;
|
||
case 'legend-info': openLegend(); break;
|
||
case 'toggle-sci': G.sci=!G.sci; renderAll(); break;
|
||
case 'export-save': openExport(); break;
|
||
case 'import-save': openImport(); break;
|
||
case 'copy-save': { const ta=document.getElementById('io-text'); if(ta){ ta.select(); try{document.execCommand('copy');}catch(e){} toast('','Copied','Disk image copied to clipboard.'); } break; }
|
||
case 'do-import': doImport(); break;
|
||
case 'close-modal': closeModal(); break;
|
||
case 'buy-up': buyUpgrade(el.dataset.id); break;
|
||
case 'buy-perk': buyPerk(el.dataset.id); break;
|
||
case 'do-prestige': doMigrate(); break;
|
||
case 'hard-reset': hardReset(); break;
|
||
}
|
||
}
|
||
function doImport(){
|
||
const ta=document.getElementById('io-text'); if(!ta) return;
|
||
try{
|
||
const raw=decodeURIComponent(escape(atob(ta.value.trim())));
|
||
const obj=JSON.parse(raw);
|
||
if(!obj||typeof obj!=='object') throw new Error('bad');
|
||
localStorage.setItem(SAVE_KEY, raw);
|
||
location.reload();
|
||
}catch(e){ toast('bad','Restore failed','That does not look like a valid disk image string.'); }
|
||
}
|
||
|
||
document.addEventListener('click',e=>{
|
||
const actEl=e.target.closest('[data-act]');
|
||
if(actEl){ handleAct(actEl.dataset.act, actEl); return; }
|
||
const stab=e.target.closest('[data-stab]');
|
||
if(stab){ settingsTab=stab.dataset.stab; renderSettings(); return; }
|
||
});
|
||
document.getElementById('modal-back').addEventListener('mousedown',e=>{ if(e.target.id==='modal-back') closeModal(); });
|
||
|
||
document.addEventListener('keydown',e=>{
|
||
if(e.target.tagName==='TEXTAREA'||e.target.tagName==='INPUT') return;
|
||
if(e.key==='Escape'){ closeModal(); }
|
||
if(e.key==='F5'){ e.preventDefault(); handleAct('analyze'); }
|
||
if((e.ctrlKey||e.metaKey) && (e.key==='s'||e.key==='S')){ e.preventDefault(); openSettings(); }
|
||
if(e.key===' '){ if(modalKind==null){ e.preventDefault(); G.running=!G.running; renderStatus(); } }
|
||
});
|
||
|
||
/* ===========================================================================
|
||
MAIN LOOP
|
||
=========================================================================== */
|
||
let lastTick=Date.now(), saveAccum=0, railAccum=0, frame=0;
|
||
function tick(){
|
||
const t=Date.now();
|
||
let dt=(t-lastTick)/1000; lastTick=t;
|
||
if(dt<0) dt=0; if(dt>5) dt=5;
|
||
try{ step(dt); }catch(err){ /* never let the tick die */ console&&console.warn&&console.warn(err); }
|
||
// render
|
||
drawGrid();
|
||
railAccum+=dt;
|
||
if(railAccum>=0.2){ railAccum=0; renderRail(); renderProgress(); }
|
||
renderStatus();
|
||
// live dialog numbers
|
||
if(modalKind==='settings'){ const r=document.getElementById('set-rb'); if(r) r.textContent=fmt(G.rb); liveSettingsAfford(); }
|
||
// autosave
|
||
saveAccum+=dt;
|
||
if(saveAccum>=10){ saveAccum=0; save(); }
|
||
}
|
||
function liveSettingsAfford(){
|
||
UPGRADES.filter(u=>u.tab===settingsTab).forEach(u=>{
|
||
if(upgradeMaxed(u)) return;
|
||
const cost=upgradeCost(u), can=G.rb>=cost;
|
||
const cl=document.getElementById('uc-'+u.id);
|
||
if(cl) cl.className='ucost '+(can?'ok':'no');
|
||
const btn=document.querySelector(`.ubtn[data-id="${u.id}"]`);
|
||
if(btn) btn.toggleAttribute('disabled', !can);
|
||
});
|
||
}
|
||
|
||
/* ===========================================================================
|
||
BOOT
|
||
=========================================================================== */
|
||
function drawTitleIcon(){
|
||
const c=document.getElementById('titleico'); if(!c) return;
|
||
const x=c.getContext('2d');
|
||
const pal=['#ffffff','#d83a2a','#1038b0','#e8c020'];
|
||
for(let i=0;i<14;i++)for(let j=0;j<14;j++){
|
||
x.fillStyle=pal[(i*3+j*5)%pal.length];
|
||
if((i+j)%4===0)x.fillStyle='#1038b0';
|
||
x.fillRect(i,j,1,1);
|
||
}
|
||
}
|
||
function boot(){
|
||
load();
|
||
lastRailStates=countStates();
|
||
applyOffline();
|
||
drawTitleIcon();
|
||
resizeCanvas();
|
||
renderAll();
|
||
drawGrid();
|
||
setInterval(tick, TICK_MS);
|
||
window.addEventListener('resize',()=>{ resizeCanvas(); drawGrid(); renderProgress(); });
|
||
window.addEventListener('beforeunload', save);
|
||
// backgrounded-tab catch-up
|
||
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();
|
||
});
|
||
if(G.migrations===0 && G.totalRb===0){
|
||
setTimeout(()=>toast('','Disk Defragmenter','Drag a box on the grid to focus the head. Click black bad sectors to repair. Help is in the menu.'),700);
|
||
}
|
||
}
|
||
boot();
|
||
</script>
|
||
</body>
|
||
</html>
|