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

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

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

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

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

1671 lines
78 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>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 &amp; 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 &amp; 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 &amp; 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 &amp; 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&nbsp;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 &amp; 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&reg; Disk Defragmenter<br>Version 4.10 (Bad Sectors)</p>
<p style="font-size:11px;color:#404040">Copyright &copy; 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>