docs: complete the Dev Handbook

Adds the 5 chapters that hit a transient rate-limit on first pass:
- 01 Architecture & the Shared DNA
- 02 Building a New Volume (the Cookbook)
- 10 Vol I Internals — qBitTorrz
- 12 Vol III Internals — MACRO_VIRUS.XLS
- 13 Vol IV Internals — UPLINK

The Dev Handbook (Disc One) is now complete at 10 chapters; the Lore Bible (Disc Two) was already complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
monster 2026-06-14 16:21:31 +10:00
parent 59fd95b707
commit 87cfead4d3
5 changed files with 1312 additions and 0 deletions

View File

@ -0,0 +1,270 @@
# Architecture & the Shared DNA
*The reference architecture every volume re-implements — documented from `index.html`, the canonical Vol I implementation (qBitTorrz 4.6.2).*
This is the scaffolding chapter. Five volumes ship five hooks, but they all stand on the same skeleton, established here in `index.html` and **re-implemented, never imported**, by **DEFRAG.EXE**, **MACRO_VIRUS.XLS**, **UPLINK**, and **INBOX ZERO**. If you understand the qBitTorrz shell — its number system, its `G` global, its save round-trip, its tick, its offline catch-up, its render strategy — you understand the other four before you open them. What follows is that skeleton, with the real function names, the real constants, and the reasons each choice exists.
## The single-file app-shell anatomy
Every volume is one self-contained `.html`: an inline `<style>`, an inline `<script>`, vanilla JS, zero external resources, no build step, double-click to run. `index.html` opens with `"use strict";` and four module constants that the rest of the file hangs off of:
```js
const SAVE_KEY = "qbittorrz_save_v1";
const TICK_MS = 100;
const MAX_OFFLINE_S = 8*3600; // cap offline progress at 8h
const SPD_SAMPLES = 160;
```
The script is laid out in fixed sections, top to bottom, separated by banner comments: number formatting, the data tables (`CATALOG`, `UPGRADES`, `PERKS`), the network-model constants, `STATE`, the derived getters, the game-logic tick, rendering, modals, save/load, event wiring, the main loop, and finally `boot()`. The DOM is declared once in the body — a menu bar, a toolbar, a body split with a sidebar and a center column, a status bar, plus three always-present singletons: `#modal-back`, `#ctxmenu`, and `#toasts`. JS never builds the chrome; it only fills `innerHTML` into the containers that already exist.
> **DEV NOTE** — The section order is load-bearing. Number formatting and the data tables come *before* `freshState()` because `defaultTrackers()` is called inside the state seed, and `fmt()` is referenced everywhere. Keep that ordering when you port the shell — moving `freshState()` above the tables it depends on is the classic "works until you reset" bug.
## The number system
Numbers in an idle game cross twenty orders of magnitude, so the formatter is foundational. `index.html` defines one core function, `fmt(n)`, and three thin wrappers — `fmtSpd`, `fmtInt`, and `fmtTime` — plus a `sciStr(n)` helper for the scientific tail.
`fmt()` walks an SI suffix table, dividing by 1000 each step, and falls back to exponential notation once it runs out of suffixes:
```js
const SI = ['B','kB','MB','GB','TB','PB','EB','ZB','YB','RB','QB'];
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); // past QB -> scientific
return x.toFixed(x<100?2:1)+' '+SI[i];
}
```
Note the deliberate defenses: `Infinity` renders as `∞`, NaN/non-finite collapses to `0 B`, and negatives clamp to zero — so a garbage number can never reach the screen as `NaN B`. Precision is adaptive: two decimals under 100, one decimal otherwise. The `G&&G.sci` guard means `fmt()` is safe to call even before `G` is assigned.
| Range (bytes) | Suffix | Example output |
|---|---|---|
| `< 1000` | `B` (integer, no decimals) | `512 B` |
| `1e3 1e6` | `kB` | `5.20 kB` |
| `1e6 1e9` | `MB` | `1.30 MB` |
| `1e9 1e12` | `GB` | `4.20 GB` |
| `1e12 1e15` | `TB` | `1.60 TB` |
| `1e15 1e18` | `PB` | `1.30 PB` |
| `1e18 1e21` | `EB` | `5.00 EB` |
| `1e21 1e24` | `ZB` | — |
| `1e24 1e27` | `YB` | — |
| `1e27 1e30` | `RB` | — |
| `1e30 1e33` | `QB` (last suffix) | — |
| `≥ 1e33`, **or** `G.sci` on | scientific via `sciStr` | `1.23e34 B` |
`sciStr` is `(n/Math.pow(10,e)).toFixed(2)+'e'+e+' B'` with `e=Math.floor(Math.log10(n))` — a fixed two-significant-digit mantissa with the raw exponent. The `G.sci` toggle (View → Toggle Number Format, or the `toggle-sci` action) flips the whole UI into pure scientific even mid-SI-range, for players who would rather read `4.40e6 B` than `4.40 MB`. It is a single boolean on state, read inside `fmt()`, so one flag re-formats the entire app on the next render.
The wrappers are trivial and intentionally so. `fmtSpd(n)` is `fmt(n)+'/s'`. `fmtInt(n)` reuses `fmt` for large counts but strips the ` B` suffix (so seed counts read `1.20k`, not `1.20 kB`). `fmtTime(s)` formats seconds into `d/h/m/s` with two units shown, and — critically for an idle game — returns `∞` for anything over 99 days, so an un-seeded ETA never prints a meaningless nine-digit countdown.
## The `G` global and `freshState()`
There is exactly one mutable global: `G`, the entire save in a single object. It starts `null` and is populated only by `load()`. Everything the game knows — currencies, the torrent array, upgrade levels, perk levels, event timers, the IRC log, UI preferences — lives on `G`. Derived values are never stored; they are computed on demand by small pure getters (`downMult()`, `maxSlots()`, `lineSpeed()`, `creditsFor()`), so there is no cache to invalidate and no derived field to corrupt.
`freshState()` returns the canonical shape with every key seeded to a safe default:
```js
function freshState(){
return {
v:1,
data:0, totalUp:0, totalDown:0,
runUp:0, // upload accrued this prestige run (for credits)
credits:0, prestiges:0, lifetimeUp:0,
torrents:[], upgrades:{}, perks:{},
seq:1, sel:null, filter:'all', dtab:'general', sci:false,
seenCatalog:{},
trackerList:defaultTrackers(),
// ... event timers, irc:[], lastSave, started, sidebarW, detailH
};
}
```
Two distinctions matter for prestige later: `runUp` (uploaded *this run*, reset on migration) versus `totalUp` and `lifetimeUp` (which persist), and `upgrades`/`perks` as separate maps because upgrades are wiped on prestige and perks are not. Note that `freshState()` is also the schema authority for `load()`: every key that exists must appear here, or it won't survive a partial save.
## The save / load / sanitize round-trip
Persistence is three functions: `save()`, `load()`, and `sanitizeState()`. `save()` stamps `G.lastSave` and writes `JSON.stringify(G)` to `localStorage` under `SAVE_KEY`, wrapped in try/catch. On failure (private mode, quota) it toasts *once* — guarded by a module-level `saveFailed` flag — and returns `false`, so a storage-less browser degrades to a playable-but-unsaved session instead of throwing on every tick.
`load()` is the boot path and is paranoid by design:
```js
function load(){
let raw=null;
try{ raw=localStorage.getItem(SAVE_KEY); }catch(e){}
if(!raw){ G=freshState(); seedIrc(); return false; }
try{
const s=JSON.parse(raw);
if(!s||typeof s!=='object'||Array.isArray(s)) throw new Error('bad shape');
G=Object.assign(freshState(), s);
sanitizeState();
if(!G.irc.length) seedIrc();
return true;
}catch(e){ G=freshState(); seedIrc(); return false; }
}
```
The `Object.assign(freshState(), s)` is the migration mechanism: a save written by an older version that lacks a newer key still gets that key from `freshState()`, and any stray key from a tampered save is harmless because the getters only ever read the keys they know about. Anything that throws — bad JSON, an array at the top level, a `null` — falls through to a clean `freshState()`. A corrupt save costs your progress, never your boot.
`sanitizeState()` is the repair pass that runs *after* the merge, and it is the most defensively-written function in the file. It defines a local `num(v,d)` that accepts a value only if it is a finite, non-negative number and otherwise substitutes a default, then runs every scalar through it:
```js
const num=(v,d)=> (typeof v==='number'&&isFinite(v)&&v>=0)?v:d;
G.data=num(G.data,0); G.totalUp=num(G.totalUp,0); /* …all scalars… */
if(!G.upgrades||typeof G.upgrades!=='object'||Array.isArray(G.upgrades)) G.upgrades={};
if(!Array.isArray(G.trackerList)||!G.trackerList.length) G.trackerList=defaultTrackers();
if(!Array.isArray(G.torrents)) G.torrents=[];
G.torrents=G.torrents.filter(t=>t&&CAT_BY_ID[t.cid]);
```
The repairs, in order of paranoia:
| Hazard in the save | What `sanitizeState()` does |
|---|---|
| `data: NaN` or a negative / string scalar | `num()` substitutes the default — no NaN ever enters the economy |
| `upgrades` is `null`, an array, or missing | reset to `{}` so `G.upgrades[id]` can't crash |
| `trackerList` empty or not an array | restored to `defaultTrackers()` |
| a torrent referencing a deleted catalog id | filtered out (`CAT_BY_ID[t.cid]` is falsy) |
| `t.downloaded` exceeds the file size | clamped to `c.size` |
| duplicate / non-numeric `t.uid` | reassigned from `G.seq++` |
| `sel` points at a torrent that no longer exists | set to `null` |
| `lastSave` in the future (clock skew / edited save) | clamped to `Date.now()` — a future-dated save self-heals |
That last one matters more than it looks: offline catch-up trusts `lastSave`, so a future timestamp would otherwise yield a *negative* elapsed gap. Clamping it is what stops an edited save from breaking the welcome-back math.
> **DEV NOTE** — The pattern to copy is the three-layer funnel: `Object.assign` over `freshState()` guarantees every key *exists*; `num()` and the type guards in `sanitizeState()` guarantee every value is *sane*; the `CATALOG`/`CAT_BY_ID` cross-check guarantees every reference is *live*. Each volume's `sanitizeState()` differs only in which arrays it walks (clusters, cells, processes, mail) — the funnel shape is identical, and it is the single best defense against a community of save-editors.
## The tick loop
One `setInterval(tick, TICK_MS)` at 100 ms drives everything. The tick is delta-time based, not fixed-step, so it stays correct when the browser starves it:
```js
let lastTick=Date.now(), saveAccum=0;
function tick(){
const t=Date.now();
let dt=(t-lastTick)/1000; lastTick=t;
if(dt<0) dt=0; if(dt>5) dt=5; // cap big jumps (throttled tab)
step(dt);
// sample speed, render HUD/table/status…
saveAccum+=dt;
if(saveAccum>=10){ saveAccum=0; save(); }
}
```
The `dt` cap of 5 seconds is deliberate: a backgrounded or throttled tab can deliver a tick with several minutes of elapsed wall-clock, and feeding that straight into `step()` would credit a giant lump in one frame — and, worse, let download speeds overshoot. Capping `dt` keeps each *online* tick bounded; the real elapsed time is recovered separately by the offline/visibility path below, so nothing is lost, it's just credited through the more careful simulation. Autosave is an accumulator: `dt` sums into `saveAccum`, and every ~10 s of game time it flushes and resets. Plus `boot()` registers `window.addEventListener('beforeunload', save)` so closing the tab always writes one last time.
`tick()` also does the per-frame rendering — HUD, table, status bar every tick; the sidebar only when the 700 ms bucket rolls over (`Math.floor(t/700)!==Math.floor((t-TICK_MS)/700)`); the detail pane only if it's showing live data; and the open modal's affordance state via `updateStoreAfford()` / `updateAddAfford()`. The simulation (`step()`) and the presentation are cleanly separated — `step(dt)` mutates `G`, the render functions read it.
## Offline catch-up and the refocus handler
The idle contract is: it plays while you're gone and rewards your return. Two code paths honor it, and they share one simulator, `simulateAway(dt)`.
`simulateAway` is a coarse fixed-step replay: it chooses a step count scaled to `dt` and clamped to a sane band — `Math.min(240, Math.max(20, Math.ceil(dt/30)))` — and runs the download/seed economy that many times. The clamp is the point: enough sub-steps that a torrent which *finishes* mid-gap still spends the rest of the gap seeding (so you aren't cheated of completion income), but never so many that catching up eight hours hangs the boot.
`applyOffline()` runs at boot, after `load()`:
```js
function applyOffline(){
const dt=Math.min(MAX_OFFLINE_S, Math.max(0,(Date.now()-(G.lastSave||Date.now()))/1000));
if(dt<2) return;
const beforeData=G.data;
simulateAway(dt);
const earned=G.data-beforeData;
if(earned>0){
setTimeout(()=>toast('info','💤 Welcome back',
'While away ('+fmtTime(dt)+') your seeds earned <b>'+fmt(earned)+'</b> Data.'),400);
}
}
```
The gap is the difference between now and `lastSave`, clamped to `MAX_OFFLINE_S` (8 h) on the high end and `0` on the low — the latter being why `sanitizeState()` heals future-dated saves. The welcome-back toast is deferred 400 ms so it lands after the first render rather than fighting it.
The second path is the `visibilitychange` handler wired in `boot()`. Browsers throttle `setInterval` in background tabs, so the `dt` cap inside `tick()` would silently discard most of a long background stretch. On refocus, the handler measures the gap against `lastTick`, and if it exceeds 10 s, credits it through `simulateAway` and — crucially — resets `lastTick` so the very next `tick()` doesn't double-count the same span:
```js
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();
});
```
> **DEV NOTE** — There are two clocks, and conflating them is a real bug we want to avoid: `lastSave` (wall time, persisted, drives boot-time offline) and `lastTick` (live, in-memory, drives the visibility gap). The boot path reads `lastSave`; the refocus path reads `lastTick`. Reset `lastTick` after crediting the gap or the next online tick re-bills it. This is exactly the kind of double-credit that, in **DEFRAG.EXE**, would matter in the *opposite* direction — see the QA chapter's entropy death-spiral, where the lesson is that the away-sim must reach equilibrium, never runaway.
## The toast system
`toast(kind, title, body)` is the only notification primitive. It builds a `<div class="toast {kind}">`, appends it to the always-present `#toasts` container, and schedules its own removal — 6 s for `prestige`, 3.8 s for everything else — then caps the stack so only the five most recent remain (`while(wrap.children.length>5) wrap.firstChild.remove()`). `kind` is one of `''`, `info`, `warn`, `bad`, or `prestige`, each a left-border color via CSS. `title` and `body` are injected as HTML, which is what lets toasts carry `<b>` emphasis and the occasional inline link. Every player-facing event — a completed download, an unlocked tier, an ISP throttle, a migration — routes through this one function.
## The modal and event-delegation pattern
There is one modal host (`#modal-back` wrapping `#modal`) and one context-menu host (`#ctxmenu`). `openModal(html)` drops markup into `#modal` and shows the backdrop; `closeModal()` hides it and clears `modalKind`. A module-level `modalKind` string identifies which dialog is open, so the tick can live-update only the right one and the helpers `refreshStoreModal()` / `refreshPrestigeModal()` can no-op when their dialog isn't showing.
The wiring is **event delegation**, not per-element handlers. A single document-level `click` listener walks `e.target.closest(...)` for the relevant `data-*` attribute and dispatches:
```js
document.addEventListener('click',e=>{
hideCtx();
const actEl=e.target.closest('[data-act]');
// …filterEl, storeTabEl, catTabEl, dtabEl, rowEl…
if(actEl){ if(actEl.tagName==='A') e.preventDefault(); handleAct(actEl.dataset.act, actEl); return; }
if(rowEl){ selectTorrent(parseInt(rowEl.dataset.uid)); return; }
});
```
Every interactive element declares its intent in markup (`data-act="open-store"`, `data-cid`, `data-uid`), and `handleAct(act, el)` is one big switch over those verbs. Because the listener is on `document`, markup that `renderStoreModal()` regenerates wholesale on every purchase keeps working with zero re-binding — you can blow away and rebuild a modal's `innerHTML` and the buttons inside it still fire. `contextmenu`, `dblclick`, and `keydown` follow the same delegated shape. This is *the* reason the rebuild-heavy render strategy below is cheap to maintain.
## The render strategy: update-in-place vs. rebuild
`renderAll()` calls five renderers — `renderHud`, `renderSidebar`, `renderTable`, `renderDetail`, `renderStatus`. The split between *rebuild* and *update-in-place* is intentional and the policy is worth copying:
- **Update-in-place** for small, fixed sets of nodes that change every tick: `renderHud()` and `renderStatus()` set `.textContent` on a handful of known `id`s (`hud-data`, `sb-down`, `sb-ratio`, …). No DOM churn 10×/second.
- **Rebuild** for variable-length lists: `renderTable()`, `renderSidebar()`, and the modals assemble an HTML string and assign `innerHTML` once. The transfer table re-emits every visible row each frame. This is fine because the row count is small and event delegation means rebuilt rows need no handler re-attachment.
The one bespoke renderer is `drawSpeed()`, the Speed-tab graph: a `<canvas>` painted from two ring buffers, `spdDownHist` and `spdUpHist`, each `SPD_SAMPLES` (160) long. The tick `push`es the current totals and `shift`s the oldest every frame, so the canvas always shows the last ~16 s. The manifesto's note for the bigger grids in **DEFRAG.EXE** generalizes this: when the cell count gets large, canvas it — don't ask the DOM to hold 960 nodes.
## The save-key registry
Each volume owns a distinct `localStorage` key. They never collide, so all five can run side-by-side under **ENTROPY OS** with independent saves. This table is canon; do not reuse a key.
| Vol | App | `SAVE_KEY` constant |
|---|---|---|
| I | qBitTorrz | `qbittorrz_save_v1` |
| II | DEFRAG.EXE | `boringsoft_defrag_v1` |
| III | MACRO_VIRUS.XLS | `boringsoft_xls_v1` |
| IV | UPLINK | `boringsoft_uplink_v1` |
| V | INBOX ZERO | `boringsoft_inbox_v1` |
| — | ENTROPY OS (`desktop.html`) | *(none — stateless launcher)* |
The `_v1` suffix is the migration escape hatch: if a future save shape becomes genuinely incompatible with `Object.assign`-over-`freshState()` recovery, bump to `_v2` and the old key is simply ignored rather than mis-parsed.
## The prestige pattern
Prestige is the hard reset that grants a permanent currency and persistent perks, re-skinned per volume (migrate / compress / audit / ssh / ascend). In qBitTorrz it is **Client Migration** to a Seedbox Cluster, and the math lives in two functions. `creditsFor(runUp)` is the payout curve — deliberately sub-linear so each migration is worth less per byte than the last, which is what makes the *next* prestige a decision rather than a reflex:
```js
function creditsFor(runUp){
if(runUp<1e9) return 0; // gate: ~1e9 uploaded this run
return Math.floor(Math.pow(runUp/1e9, 0.42)); // sub-linear in run upload
}
```
`doPrestige()` performs the reset: it banks `gain` into `G.credits`, increments `G.prestiges`, then zeroes the *run* state while preserving the *meta* state. Specifically it wipes `data`, `runUp`, `torrents`, `upgrades`, `sel`, restores `trackerList` to `defaultTrackers()`, and clears every event timer — but keeps `credits`, `prestiges`, `perks`, `totalUp`, and `lifetimeUp`. The currency is spent in the prestige modal on `PERKS`, which carry across resets because they live in `G.perks`, never touched by `doPrestige()`. The two endgame perks — `darknet` and `singularity` — are `once:true` unlocks that gate the highest catalog tiers, so prestige is also the only door to the Exabyte-and-beyond content.
## The diegetic-upgrade pattern
The final law of the DNA: upgrades are bought through the app's own dialogs, never a cartoon shop. There is no "+1 click" button. In qBitTorrz the `UPGRADES` table is configuration — `Network Hardware`, `Cat6 Cable Upgrade`, `UPnP Port Forwarding`, `Automated RSS Filters`, `Quantum Encryption Protocol` — and each row's effect is *legible*: the store card computes and shows the before/after of the actual model term it touches. Buying `bandwidth` literally reads `Gigabit Fiber → 10G Fiber · line 4.40 MB/s → 9.68 MB/s`, because `upgradeCard()` renders the real `hwTierName()` and `lineSpeed()*u.mult`. Two kinds drive the curve: repeatable leveled upgrades (`kind:'mult-up'`, `'hardware'`, `'storage'`, etc., cost `base * growth^level`) and one-time `'unlock'`s (flat `base`, on/off). The player can always answer "where does my next number come from" by reading the dialog — which is Design Law 4, **legible numbers**, made concrete. Every sibling volume inherits this contract: the shop is the Options pane, the formula bar, the `apt install` list, the Rules editor — never a video-game menu.
· · ·
> **If you change the shared scaffolding**
>
> - **Bump `SAVE_KEY`'s `_v` suffix** if the new save shape can't survive `Object.assign` over `freshState()`. A silent shape change behind the same key mis-loads every existing save.
> - **Add every new state field to `freshState()` *and* `sanitizeState()`.** A field that exists in only one is either un-migratable (missing from `freshState`) or un-repairable (missing from `sanitize`).
> - **Keep two clocks straight.** `lastSave` drives `applyOffline()` at boot; `lastTick` drives the `visibilitychange` gap. Reset `lastTick` after crediting a gap, or the next tick double-bills it.
> - **Never widen the `dt` cap to "fix" idle.** The 5 s cap is the *online* safety valve; long gaps are the offline simulator's job. Loosening the cap reintroduces lump-credit and speed overshoot.
> - **Route new UI through event delegation.** Add a `data-act` verb and a `handleAct` case, not a fresh `addEventListener` — or rebuilt modals will quietly stop responding.
> - **Sub-linear prestige stays sub-linear.** `creditsFor`'s `0.42` exponent is what keeps migration a choice. Make the payout linear and the loop collapses into spam-prestige.

View File

@ -0,0 +1,306 @@
# Building a New Volume (the Cookbook)
*A step-by-step recipe for authoring Volume VI from an empty file: the DNA skeleton you paste first, the hard-requirements checklist you grade yourself against, how to pick the one hook, how to tune the balance the way `index.html` does, and how to wire the new program into ENTROPY OS.*
This is the build chapter. **Introduction — How This Machine Is Built** explains the shared DNA in the abstract; this one hands you the actual scaffolding and a procedure. The order matters: scaffold first, hook second, balance third, wiring last. Do not start with the fun part. The fun part is the easy part — Design Law 3 — and you will get there faster if the boring frame already breathes.
## 1. Paste the skeleton
Every volume re-implements the DNA; nothing is imported. So the honest starting point is a single self-contained `.html` file that already wires `fmt`, the global `G`, `freshState`/`save`/`load`/`sanitizeState`, the `dt`-based tick with autosave, `simulateAway`/`applyOffline`, and `toast`. Below is the minimum that boots, idles, survives a backgrounded tab, and never bricks on a bad save. The only zone you touch to make a *game* is marked.
```html
<!doctype html><html><head><meta charset="utf-8">
<title>NEW_VOLUME</title>
<style>
/* The clutter line (Design Law 2): system fonts, gray chrome, bevels.
No rounded cartoon buttons, no rarity colors. See ENTROPY OS — The
Desktop Shell for the bevel recipe. */
body{margin:0;background:#c0c0c0;font:12px Tahoma,"MS Sans Serif",sans-serif;color:#000}
.win{border:2px outset #fff;margin:8px}
.titlebar{background:#000080;color:#fff;font-weight:bold;padding:2px 4px}
.status{border-top:1px solid #808080;padding:2px 6px;font:11px "Courier New",monospace}
#toasts{position:fixed;right:8px;bottom:8px;display:flex;flex-direction:column;gap:6px}
.toast{background:#ffffe1;border:1px solid #000;padding:6px 9px;max-width:240px}
.toast.bad{background:#ffe1e1}.toast.prestige{background:#e1ffe1}
</style></head>
<body>
<div class="win">
<div class="titlebar">NEW_VOLUME — [untitled]</div>
<div id="app" style="padding:8px"></div>
<!-- The clutter line lives in real chrome. This status bar is one of your
honest gauges (Design Law 4): it shows where the next number comes from. -->
<div class="status" id="statusbar">Ready.</div>
</div>
<div id="toasts"></div>
<script>
"use strict";
/* ======================= SHARED DNA (do not delete) ======================= */
const SAVE_KEY = "boringsoft_newvol_v1"; // UNIQUE per volume — see save-key registry
const TICK_MS = 100;
const MAX_OFFLINE_S = 8*3600; // cap offline catch-up at 8h
/* ---- number formatting: SI then scientific, with a G.sci override -------- */
const SI = ['','k','M','G','T','P','E','Z','Y','R','Q'];
function sciStr(n){ const e=Math.floor(Math.log10(n)); return (n/Math.pow(10,e)).toFixed(2)+'e'+e; }
function fmt(n){
if(n===Infinity) return '∞';
if(!isFinite(n)||isNaN(n)) return '0';
if(n<0) n=0;
if(n<1000) return Math.floor(n).toString();
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 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';
}
/* ---- the single global state object -------------------------------------- */
let G = null;
function freshState(){
return {
v:1,
work:0, // the number you are — rename per fiction
runWork:0, // accrued this prestige run (for the prestige formula)
prestige:0, prestiges:0,
upgrades:{}, // id -> level
perks:{}, // id -> level (survives prestige)
sci:false,
lastSave:Date.now(), started:Date.now(),
};
}
/* ---- derived helpers ----------------------------------------------------- */
const lvl = id => G.upgrades[id]||0;
const plvl = id => G.perks[id]||0;
/* ---- save / load / sanitize: a bad save can never brick boot ------------- */
let saveFailed=false;
function save(){
G.lastSave=Date.now();
try{ localStorage.setItem(SAVE_KEY, JSON.stringify(G)); return true; }
catch(e){
if(!saveFailed){ saveFailed=true;
toast('bad','Could not save','Storage unavailable (private mode / quota).'); }
return false;
}
}
function sanitizeState(){
const num=(v,d)=> (typeof v==='number'&&isFinite(v)&&v>=0)?v:d;
G.work=num(G.work,0); G.runWork=num(G.runWork,0);
G.prestige=num(G.prestige,0); G.prestiges=num(G.prestiges,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={};
if(typeof G.lastSave!=='number'||!isFinite(G.lastSave)) G.lastSave=Date.now();
if(G.lastSave>Date.now()) G.lastSave=Date.now(); // a future-dated save self-heals
}
function load(){
let raw=null;
try{ raw=localStorage.getItem(SAVE_KEY); }catch(e){}
if(!raw){ G=freshState(); return false; }
try{
const s=JSON.parse(raw);
if(!s||typeof s!=='object'||Array.isArray(s)) throw 0;
G=Object.assign(freshState(), s); // missing keys default; extra keys survive
sanitizeState();
return true;
}catch(e){ G=freshState(); return false; }
}
/* ---- toast --------------------------------------------------------------- */
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?('<br>'+body):'');
wrap.appendChild(el);
setTimeout(()=>{el.style.opacity='0';setTimeout(()=>el.remove(),400);}, kind==='prestige'?6000:3800);
while(wrap.children.length>5) wrap.firstChild.remove();
}
/* ---- offline catch-up: shared by boot and visibilitychange --------------- */
function simulateAway(dt){
if(!(dt>0)) return;
const steps=Math.min(240, Math.max(20, Math.ceil(dt/30))), sdt=dt/steps;
for(let i=0;i<steps;i++){ step(sdt); } // reuse the SAME step() as live play
}
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.work;
simulateAway(dt);
const earned=G.work-before;
if(earned>0) setTimeout(()=>toast('info','Welcome back',
'While away ('+fmtTime(dt)+') you earned <b>'+fmt(earned)+'</b>.'),400);
}
/* ---- the tick: dt-based, capped, autosaves every ~10s -------------------- */
let lastTick=Date.now(), saveAccum=0;
function tick(){
const t=Date.now();
let dt=(t-lastTick)/1000; lastTick=t;
if(dt<0) dt=0; if(dt>5) dt=5; // cap big jumps (throttled tab)
step(dt);
render();
saveAccum+=dt; if(saveAccum>=10){ saveAccum=0; save(); }
}
/* ======================= // ==== YOUR GAME HERE ==== ======================= */
/* Everything above is DNA. Everything below is the one volume. Replace it. */
function step(dt){
// The whole economy advances here, dt seconds at a time. This same function
// runs live AND inside simulateAway() — that's what makes idle honest.
G.work += incomePerSec() * dt;
G.runWork += incomePerSec() * dt;
}
function incomePerSec(){
// Diegetic upgrades feed this. Make the formula legible (Design Law 4).
return 1 * (1 + lvl('throughput')*0.5) * Math.pow(1.25, plvl('perm'));
}
function render(){
document.getElementById('app').textContent = 'Output: ' + fmt(G.work);
document.getElementById('statusbar').textContent =
fmt(incomePerSec()) + '/s · run total ' + fmt(G.runWork);
}
/* ======================= boot (DNA) ======================= */
function boot(){
load();
applyOffline();
render();
setInterval(tick, TICK_MS);
window.addEventListener('beforeunload', save);
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(); render(); }
lastTick=Date.now();
});
}
boot();
</script>
</body></html>
```
> **DEV NOTE** — The skeleton's `simulateAway` calls the *same* `step()` as the live tick, and that is deliberate. `index.html` keeps a separate fixed-step loop in its `simulateAway` for performance over 8-hour gaps, but the principle is identical: there is exactly one economy, and offline is just that economy run faster with a bigger `dt`. The moment offline gets its own private math, the two diverge and you ship a bug where idle pays differently than active. If your `step()` is too heavy to run 240 times at boot, write a coarse closed-form that *provably* tracks it — don't fork the rules.
· · ·
## 2. The hard-requirements checklist
Grade Volume VI against this before you call it done. Each line is a law from `_STYLE.md §5` or a piece of the DNA, made concrete. A volume that misses any of these is not a sibling — it is a different game wearing the same wallpaper.
| # | Requirement | How to verify |
|---|-------------|---------------|
| 1 | **Self-contained file** — one `.html`, inline CSS+JS, zero external resources, double-click to run | Open it with the network tab on; zero requests after the document. |
| 2 | **Unique `localStorage` key** | Grep the save-key registry in **QA Playbook & Post-Mortems**; `boringsoft_<name>_v1`. A collision silently eats a sibling's save. |
| 3 | **The clutter line holds** | No mascot, no rarity gem, no reroll button, no confetti. If a screenshot looks like a game menu, it's wrong. |
| 4 | **Real-time `dt`-based tick**, capped (`if(dt>5) dt=5`) | Throttle the tab, return: no time-warp jackpot, no frozen sim. |
| 5 | **Offline + `visibilitychange` catch-up**, capped at `MAX_OFFLINE_S` | Set `lastSave` back an hour in devtools, reload: a welcome-back toast with sane earnings. |
| 6 | **Scientific numbers**`fmt` with SI then exponential, and a `G.sci` toggle | Force `G.work=1e40`; the HUD must not read `NaN` or overflow the cell. |
| 7 | **Prestige with ≥3 persistent perks** | A hard reset that grants a permanent currency and keeps `G.perks`. |
| 8 | **≥6 diegetic upgrades** | Bought through the app's own dialogs/panes, never a cartoon shop. |
| 9 | **One hook, fully committed** | A stranger should name the novel interaction in one sentence after 60 seconds. |
| 10 | **Robust `sanitizeState`** | Paste `{"work":"NaN","upgrades":null}` into the save key, reload: it boots clean, no `NaN` in `G.work`, no crash on `upgrades.foo`. |
| 11 | **Sane first two minutes** | First purchase affordable in ~2040s; first prestige reachable but distant. See §5. |
> **DEV NOTE** — Requirement 10 is the one people fake. They wrap `load()` in a try/catch, ship it, and call it robust. But `Object.assign(freshState(), s)` happily copies a hostile `upgrades:null` straight over the good default, and the *first* call to `lvl('foo')` throws. The try/catch in `load()` only protects `JSON.parse`; `sanitizeState` is what protects the *shape*. Note in `index.html` how every numeric field passes through `num(v,d)` and every collection is type-checked with `typeof ... !=='object' || Array.isArray(...)` before use. Copy that paranoia exactly. A save that bricks boot is the only bug a player cannot work around.
· · ·
## 3. Choosing a hook
Design Law 1 says every boring app is already an incremental game; Law 3 says you must commit to its one novel interaction. The trick is recognizing which *kind* of loop is hiding in the chrome. The five shipped volumes fall into a clean taxonomy, and a sixth should pick a quadrant that is not yet crowded.
| Genre | The interaction | Shipped example | The loop already in the app |
|-------|-----------------|-----------------|------------------------------|
| **Spatial** | steer / shape a 2-D field | **Vol II — DEFRAG.EXE** (drag-select a region, the head sweeps it) | A defrag *is* watching colored blocks consolidate. |
| **Constructive** | author the engine itself | **Vol III — MACRO_VIRUS.XLS** (drag the fill handle to stamp generators) | A spreadsheet *is* a dependency graph that recalculates. |
| **Command** | type at a control surface | **Vol IV — UPLINK** (`spawn`, `renice`, `apt install`, `sudo su`) | A terminal *is* issuing verbs against live processes. |
| **Triage** | sort an exponential queue, then automate it | **Vol V — INBOX ZERO** (`j/k/e/r/#`, then regex rules) | An inbox *is* a survival game against noise. |
Vol I — qBitTorrz is the baseline that sits across spatial and triage: a table you manage, slots you fill, a swarm you optimize. To find the loop in your candidate app, ask the manifesto's question literally — *what number already ticks up here, and what is the player already doing to make it tick faster?* A disk checker counts bad sectors; a screensaver routes flow; a BIOS counts memory; a sequencer fills a grid in time. The boring app has already chosen your genre. Your job is to make the subtext text, then build that one interaction until it feels good *before* you write a single upgrade. The incremental scaffolding — the skeleton in §1 — is genuinely the easy part.
· · ·
## 4. Balance-tuning methodology — the qBitTorrz worked example
The hardest balance problem in this anthology is spanning many orders of magnitude without either (a) the early game being unplayably slow or (b) the late game being trivially instant. qBitTorrz solves it across **17 orders of magnitude** of file size, and the technique generalizes. Read these real functions from `index.html`.
The catalog runs from `tiny_release_notes.txt` at `size:4e3` to `The_Final_Hash.dat` at `size:3e20`. Download time is bytes ÷ speed, so naively, line speed would also need to cover 17 orders of magnitude or the biggest file would take geological time. The constants:
```js
const BASE_DOWN = 1000; // B/s anchor before scaling/multipliers
const MIN_SIZE = 4e3; // smallest catalog file (Tier A)
const SAT_SEEDS = 12; // connected seeds that saturate the line
```
The key move is the **`sqrt` line-speed trick**. Line speed scales not with file size but with the *square root* of the largest tier you've unlocked:
```js
function maxUnlockedSize(){ let m=MIN_SIZE; for(const c of CATALOG){ if(c.size>m && catalogUnlocked(c)) m=c.size; } return m; }
function clientDown(){ return BASE_DOWN * Math.sqrt(maxUnlockedSize()/MIN_SIZE) * downMult(); }
function lineSpeed(){ return clientDown(); } // clientDown() IS your Max Line Speed
```
The `sqrt` halves the exponent the player must cover with bandwidth upgrades. Unlocking a bigger tier *also* grants the throughput to download it — "Cat6 unlocks MB-tier speeds" — but only half the throughput, so big late files stay a real sink for the repeatable `bandwidth` and `overclock` upgrades rather than completing instantly. Without the `sqrt`, every file takes the same wall-clock time and tier progression is cosmetic; with it, each tier is a little slower than the last, which is exactly the felt curve of a torrent client.
The second half of the model is the **peer term**, which gives the player something to *do* besides buy bandwidth:
```js
function downFrac(seeds){ return Math.min(1, seeds/SAT_SEEDS*perPeerMult()); }
function torrentDownSpeed(t){
if(t.status!=='downloading') return 0;
return lineSpeed() * downFrac(torrentSeeds(t));
}
```
Read together, `lineSpeed() * downFrac(seeds)` realizes the design comment's stated model: **Download Speed = min( Max Line Speed, Connected Seeds × Bandwidth Per Peer )**. Below `SAT_SEEDS = 12` you are *peer-limited*`downFrac < 1`, the line is starved — and you fix that by announcing trackers and prioritizing peers, not by spending money. Above saturation you are *line-limited*, and only hardware helps. Two orthogonal levers, each legible in the detail pane (`peer-limited` in orange, `line-limited` in muted gray).
**Verify pacing with a numeric sweep.** Don't trust intuition across 17 orders of magnitude; compute the table. For each tier, take the largest unlocked file, the resulting `lineSpeed()` assuming saturated seeds (`downFrac = 1`), and the download time:
| Tier | Largest size | `sqrt(size/MIN_SIZE)` | `lineSpeed` (no mults) | Time at saturation |
|------|--------------|------------------------|------------------------|--------------------|
| Public A | `6.5e6` | ≈ 40 | ≈ 40 kB/s | ≈ 160 s |
| Public B (`cat6`) | `9.0e8` | ≈ 474 | ≈ 474 kB/s | ≈ 1,900 s |
| Private | `2.4e11` | ≈ 7,746 | ≈ 7.7 MB/s | ≈ 31,000 s |
| Fiber | `1.9e14` | ≈ 218,000 | ≈ 218 MB/s | ≈ 870,000 s |
Raw times climb, which is the point — but they climb *sub-linearly* against the 10⁴-to-10⁸ jump in file size, and the player's repeatable `bandwidth` (`mult:2.2`) and `overclock` multipliers, plus prestige perks, are sized to close the gap. The sweep tells you whether a tier is a wall or a cakewalk before a player ever feels it. If the unmultiplied time at a tier is more than a few hours or less than a few seconds, retune `BASE_DOWN`, the `sqrt` exponent, or the multiplier growth — not the file sizes, which are fiction.
> **DEV NOTE** — Resist the urge to delete the `sqrt` "to make late game faster." It is load-bearing. The first time someone linearized it, Tier A files finished in one tick and the Fiber tier took a week — the same `BASE_DOWN` cannot serve both ends of a 17-order span without a compressing function. If you want a different curve, change the *exponent* (try `0.4``0.6`) and re-run the sweep; never remove the compression entirely.
· · ·
## 5. The first two minutes
The sweep covers the long game; the opening is judged by hand. Anchor your first upgrade so it is affordable in roughly 2040 seconds of the base loop, and make the *second* purchase cost enough that the player feels the upgrade working before they can afford it again. In qBitTorrz, the first torrent earns immediately and `bandwidth` (`base:64, growth:1.7`) is reachable within the first minute; the first meaningful prestige is gated far away by `creditsFor`, which returns `0` below `1e9` uploaded and grows sub-linearly (`Math.pow(runUp/1e9, 0.42)`). Mirror that shape: a fast first reward, a distant first migration.
## Future volumes — the seed list
The manifesto leaves Volumes VI+ as exercises. Each below names its genre (from §3) and a one-paragraph hook pitch. Pick one, paste the skeleton, and commit to the hook.
- **SCANDISK** *(triage)* — A full-surface scan crawls cluster by cluster while viruses replicate in real time. The hook is **quarantine triage under a moving scan head**: infected clusters surface as the scan reaches them, and you must route each to *Quarantine*, *Clean*, or *Delete* before it spreads to neighbors — a defrag's spatial cousin played as containment rather than consolidation. Prestige: *low-level format* to a larger, healthier volume.
- **PIPES.SCR** *(constructive)* — The Windows pipes screensaver, but you lay the pipe. The hook is **routing flow through a 3-D pipe network you build**: each joint and run is a generator, throughput compounds where pipes meet, and the screensaver "draws itself" as your idle production. The thing you couldn't stop watching is now the thing you're optimizing. Prestige: the maze fills, and you start a fresh, larger grid.
- **BIOS / POST** *(command)* — A boot sequence that never quite finishes. The hook is **the POST checklist as the tech tree**: you `DETECT` devices, `INIT` controllers, and grow the memory count, each line of the boot log a purchasable subsystem that raises throughput toward an OS that always needs one more megabyte. Prestige: a warm reboot into a faster machine with more slots.
- **MIDI Sequencer** *(constructive / spatial)* — A tracker-style step grid where notes are generators. The hook is a **rhythm-economy**: filling steps on the beat compounds output, and a denser, on-tempo pattern pays more per bar — an engine-builder you author in time instead of cells. Prestige: bounce the track and start a new, longer arrangement with more channels.
- **Dial-up BBS** *(command / triage)* — A text-adventure terminal dialed into a board at 2400 baud. The hook is **menu-tree navigation as the loop**: each `[D]ownload`, `[M]essage`, `[F]ile area` mines the board for credits while the connection degrades, and you upgrade your modem to reach deeper, slower boards. Prestige: hang up and dial a bigger system one area code further into the network.
· · ·
## If you change this
- **The save key.** Changing `SAVE_KEY` after launch orphans every existing save with no migration path. If you must rename, read the old key, write the new one, and delete the old one once — and register the new key in the save-key table in **QA Playbook & Post-Mortems** so a future sibling never collides with it.
- **The `step()` / `simulateAway()` contract.** They must compute the same economy. If you optimize offline into a closed form, it must provably track the live tick or idle pays differently than active play — Design Law 5, the idle contract.
- **The `dt` cap.** Removing `if(dt>5) dt=5` reintroduces the throttled-tab time-warp; lowering it too far starves a slow machine. Five seconds is the tested value.
- **The `sqrt` (or your volume's equivalent compressor).** Whatever maps your widest stat onto sane wall-clock time is load-bearing across the whole prestige ladder. Change the exponent, never delete the function, and re-run the numeric sweep in §4 afterward.
- **`sanitizeState`.** Every field you add to `freshState()` needs a matching guard here, or a malformed save will eventually find the one key you forgot. A new volume is not done until a garbage save boots clean.

View File

@ -0,0 +1,276 @@
# Vol I Internals — qBitTorrz
*The annotated internals of the baseline volume: state shape, the two-term download model, seeding income, the catalog tier ladder, the swarm systems, hardware tiers, the upgrade store, and Client Migration. Everything here is cited from `index.html`; the numbers are the real numbers.*
qBitTorrz is the reference implementation. Every later volume re-derives the scaffolding it establishes — `freshState`/`save`/`load`/`sanitizeState`, the `dt`-capped tick, `simulateAway` offline catch-up, `toast`. The novel hook is the **two-term download model**: a torrent downloads at `min(Max Line Speed, Seeds × Bandwidth/Peer)`, and almost every system exists to push one of those two terms.
## The `G` state shape
`G` is the single global, seeded by `freshState()` (line 530) and round-tripped through `localStorage` under `SAVE_KEY = "qbittorrz_save_v1"`. The annotated keys:
| Key | Type | Meaning |
|---|---|---|
| `v` | number | Save schema version (`1`). |
| `data` | number | **Data** — the spendable currency (bytes uploaded, banked). |
| `totalUp` / `totalDown` | number | Lifetime-of-run upload / download counters (HUD + ratio). |
| `runUp` | number | Upload accrued *this prestige run* — the sole input to `creditsFor`. |
| `credits` | number | Crypto-Credits (prestige currency). |
| `prestiges` | number | Migration count. |
| `lifetimeUp` | number | Upload across all runs; never reset. |
| `torrents` | array | Active torrents (each `{uid,cid,name,downloaded,uploaded,status,…}`). |
| `upgrades` | object | `id → level`; cleared on prestige. |
| `perks` | object | `id → level`; persists across prestige. |
| `seq` | number | Monotonic torrent-uid source. |
| `sel` | number/null | Selected torrent uid. |
| `filter` | string | Sidebar status filter (`all`/`downloading`/…). |
| `dtab` | string | Active detail tab. |
| `sci` | bool | Scientific-notation toggle (View menu). |
| `seenCatalog` | object | Catalog ids already viewed in the Add dialog (drives the **new** badge). |
| `trackerList` | array | Announced trackers `{url,seeds,peers,status}`. |
| `rssFilter` / `rssOn` | string / bool | RSS glob and auto-add toggle. |
| `burstUntil`,`nextBurst`,`throttleUntil`,`nextThrottle` | number | Event timers (ms). |
| `irc`,`nextIrc`,`nextMagnet` | array/number | IRC log and drop schedulers. |
| `lastSave`,`started` | number | Timestamps; `lastSave` anchors offline catch-up. |
| `sidebarW`,`detailH` | number | Persisted splitter geometry. |
`sanitizeState()` (line 1577) repairs every one of these — `num()` clamps non-finite/negative numbers, object fields that arrive as arrays or `null` are reset, and torrents whose `cid` is not in `CAT_BY_ID` are dropped. A future-dated `lastSave` self-heals so a clock skew can't grant infinite offline time.
## The download model
This is the load-bearing system. Read it in three parts: the **line** (your ceiling), the **peer fraction** (how much of the ceiling you actually get), and the **realization** that multiplies them.
### The line: `clientDown()` and sqrt scaling
`lineSpeed()` is literally `clientDown()` (line 625). Line speed is *not* a fixed hardware number — it scales with the **largest file you've unlocked**:
```js
const BASE_DOWN = 1000; // B/s anchor before scaling/multipliers
const MIN_SIZE = 4e3; // smallest catalog file (Tier A)
function maxUnlockedSize(){
let m = MIN_SIZE;
for(const c of CATALOG){ if(c.size>m && catalogUnlocked(c)) m=c.size; }
return m;
}
function clientDown(){ return BASE_DOWN * Math.sqrt(maxUnlockedSize()/MIN_SIZE) * downMult(); }
```
The `sqrt` is the keystone. File sizes span seventeen orders of magnitude (`4e3` → `3.0e20`); a linear line term would make late files either trivial or eternal. The square root halves the exponent the player must cover with bandwidth upgrades while leaving big files a real sink. Worked, with `downMult()===1`:
| Largest unlocked | `clientDown()` |
|---|---|
| Tier A only (`4e3`) | **1,000 B/s** |
| Cat6 (`Wikipedia` `9.0e8`) | **≈ 474,342 B/s** |
| Private (`Every_Linux_ISO_Ever` `2.4e11`) | **≈ 7,745,967 B/s** |
| Fiber (`CERN…root` `1.9e14`) | **≈ 217,944,947 B/s** |
| Singularity (`The_Final_Hash` `3.0e20`) | **≈ 2.74e11 B/s** |
> **DEV NOTE** — unlocking a tier *is* the bandwidth grant. Because `maxUnlockedSize()` reads the catalog through `catalogUnlocked`, buying `cat6` simultaneously reveals MB-class files *and* raises the line to download them, so the player never unlocks content they're structurally unable to finish. The corollary: `maxUnlockedSize()` iterates the full `CATALOG` on every `clientDown()` call, and `clientDown()` runs per-torrent per-tick — cheap at 23 entries, but the first thing to memoize if the catalog grows.
`downMult()` (line 568) stacks the multiplicative download bonuses: `bandwidth` (×2.2/level via `HW_TIERS`), `overclock` (×1.8/level), the `dpi` perk, the ×10 DHT burst while `now()<G.burstUntil`, and the ×0.4 ISP throttle while `now()<G.throttleUntil && !hasUnlock('quantum')`.
### The peer fraction: seeds and `downFrac`
Seeds are assembled bottom-up. `torrentSeeds(t)` (line 616) sums the catalog swarm, `baseSeedBonus()`, and `trackerSeedBonus()`, then applies a per-torrent prioritization multiplier:
```js
function torrentSeeds(t){
const c=CAT_BY_ID[t.cid];
const base=(c?c.seeds:1)+baseSeedBonus()+trackerSeedBonus();
return Math.max(1, Math.round(base*(1+peerPrioBonus(t))));
}
```
- `baseSeedBonus()` = `2*lvl('upnp')` + 2 if `magnet` is owned + `2*plvl('seed0')` — global seed floor.
- `trackerSeedBonus()` sums `seeds` over `G.trackerList` — the announce loop.
- `peerPrioBonus(t)` = `+0.15` for `dropLeechers` and `+0.15` for `prioSeeds` (the two Peers-tab checkboxes), applied **multiplicatively** to the assembled base.
The fraction of the line you actually hit:
```js
const SAT_SEEDS = 12; // connected seeds that saturate the line
function perPeerMult(){ return Math.pow(UP_BY_ID.perpeer.mult, lvl('perpeer')); } // ×1.3/level
function downFrac(seeds){ return Math.min(1, seeds/SAT_SEEDS*perPeerMult()); }
```
Below `SAT_SEEDS` worth of effective peers you are **peer-limited**: a fresh catalog file with `seeds: 6` and no `perpeer` upgrades runs at `6/12 = 0.50` — half its line. The two ways out are announcing trackers (more `seeds`) and `perpeer` (each level multiplies the fraction by 1.3, so the General tab reports the falling "seeds to saturate" count). At 12 effective seeds, `downFrac` clamps to 1.0 and the torrent goes line-limited.
### The realization
```js
function torrentDownSpeed(t){
if(t.status!=='downloading') return 0;
return lineSpeed() * downFrac(torrentSeeds(t));
}
```
That product is the in-game promise made concrete: `lineSpeed × min(1, seeds/SAT_SEEDS × perPeerMult)` equals `min(lineSpeed, lineSpeed × seeds × perPeerMult / SAT_SEEDS)` — the line, or the peer term, whichever binds. The General tab labels which is active via the `(line-limited)` / `(peer-limited)` tag from `downFrac(...)>=1`.
## Seeding income
Income is simpler — there is no peer gating on upload. A completed torrent flips to `seeding` and earns:
```js
function torrentUpSpeed(t){
if(t.status!=='seeding') return 0;
const c=CAT_BY_ID[t.cid];
return (c.up * upMultGlobal()) + seedboxFlat();
}
```
`c.up` is the per-torrent base income. `upMultGlobal()` (line 577) multiplies `upslot` (×1.55/level), `compress` (×1.8/level), `ratio` (×1.12/level), the `ghost` perk, and the same ×0.4 throttle penalty. `seedboxFlat()` is `lvl('seedbox') * 5e5` — a **flat** addend per torrent, strongest on small files and dominant early after Private. In `step()` (line 668) the gain lands in four counters at once: `G.data`, `G.totalUp`, `G.runUp`, `G.lifetimeUp`. The status-bar **Ratio** is the run aggregate `G.totalUp/G.totalDown`, distinct from each torrent's own `uploaded/c.size`.
## The catalog and tier gating
`CATALOG` (line 402) is 23 fixed entries — `{id,name,size,up,seeds,req,rare?}`. `req` is the unlock id that reveals the entry; `catalogUnlocked(c)` resolves it, routing `darknet`/`singularity` to `hasPerkUnlock` (prestige perks) and everything else to `hasUnlock` (bought upgrades):
```js
function catalogUnlocked(c){
if(!c.req) return true;
if(c.req==='darknet') return hasPerkUnlock('darknet');
if(c.req==='singularity') return hasPerkUnlock('singularity');
return hasUnlock(c.req);
}
```
The six tiers, each ~10³10⁴× the previous in both size and income:
| Tier | `req` | Size range | Example | Gated by |
|---|---|---|---|---|
| Public A | `null` | 4 kB 6.5 MB | `tiny_release_notes.txt` | always |
| Public B | `cat6` | 44 MB 900 MB | `Wikipedia_Full_Text_Dump.xml` | Cat6 upgrade |
| Private | `private` | 4.2 GB 240 GB | `GameOfThorns.S01.REMUX.2160p` | Private Tracker Invite |
| Fiber | `fiber` | 1.6 TB 190 TB | `CERN_Collider_Event_Logs.root` | Fiber-Optic Trunk |
| Darknet | `darknet` | 1.3 PB 600 PB | `The_Library_Of_Everything.iso` | **perk** (prestige) |
| Singularity | `singularity` | 5 EB 300 EB | `The_Final_Hash.dat` | **perk** (gated behind Darknet) |
`rare:true` entries (`t11`, `t15`, `t19`, `t22`) **stall at 99.9%** — see the swarm systems. The unlock chain `Public → Cat6 → Private → Fiber → Darknet → Singularity` is enforced by the upgrades' `reqUp` (`private` requires `cat6`; `fiber` requires `private`) and the perks' `reqPerk` (`singularity` requires `darknet`). The last two tiers are *only* reachable through migration, which is the structural reason to prestige.
## The swarm systems
### Trackers / announce
`announceTracker()` (line 736) spends Data to push a `{url,seeds,peers,status}` onto `G.trackerList`, drawing from `TRACKER_URLS` (falling back to generated `.onion` relays once the pool is exhausted). Cost escalates with how many you've added past the two defaults:
```js
function announceCost(){
const added=Math.max(0, (G.trackerList||[]).length-2);
return Math.max(120, totalUpSpeed()*18) * Math.pow(1.85, added);
}
```
Each tracker adds 24 `seeds` to *every* torrent (via `trackerSeedBonus`), which is the primary lever against peer-limiting.
### Peer prioritization
The Peers detail tab exposes two per-torrent checkboxes, `dropLeechers` and `prioSeeds`, wired through `peer-toggle`. Each contributes `+0.15` in `peerPrioBonus(t)`, so both ticked is `×1.30` on assembled seeds. (The per-row **Prioritize**/**Ban** buttons are cosmetic — `peer-boost` nudges `burstUntil` by 2.5 s, `peer-ban` only toasts.)
### RSS glob filter
`globToRe(glob)` (line 715) compiles a shell-glob into a case-insensitive `RegExp`: it escapes regex metacharacters, then maps `*``.*` and `?``.`, defaulting to `/.*/i` on empty or malformed input. When `rss` is unlocked, `rssAutoAdd()` runs each tick: if a slot is free, it filters the catalog to unlocked entries matching `rssFilter`, sorts by `up` descending, and adds the single highest-income match. It is a hands-off "always fill the best slot" automation, not a queue.
### IRC announce-bot magnets
`ircMagnetDrop()` (line 751) periodically pushes a magnet line tagging a random unlocked `cid`; the IRC tab renders it with a clickable `[🧲 grab]`. `grabMagnet(cid)` calls `addTorrent(cid, true, 0.25)` — the third arg is a **head-start fraction**, so the torrent is born 25% downloaded, skipping the slow peer-limited opening:
```js
function addTorrent(cid, silent, headstart){
// …
downloaded: headstart? Math.min(c.size*headstart, c.size*0.999) : 0,
```
The `Math.min(..., c.size*0.999)` cap matters for rares — a head-start can't accidentally vault a `rare` torrent past its stall point.
> **DEV NOTE** — the rare-stall mechanic is the one place the idle contract bites. A `rare` torrent freezes at exactly `c.size*0.999`, sets `status:'stalled'`, and earns nothing until `forceComplete()` (the **Request last piece** / `bribe` action) seeds the final block. Because `simulateAway()` reproduces the same stall logic (line 1636), a rare left downloading while you're away sits at 99.9% — not seeding — when you return. Intended friction, but the canonical "why didn't my offline earnings include that torrent" support question. See **QA Playbook & Post-Mortems** for the broader dead-zone discussion.
### DHT bursts and ISP throttle
`handleEvents()` (line 679) schedules both. With `dht` owned, a burst sets `burstUntil = now()+8000` and a ×10 download multiplier fires in `downMult()`, on a 4590 s cadence. With `private` owned but not `quantum`, the ISP throttle sets `throttleUntil` for 916 s and applies ×0.4 to *both* `downMult()` and `upMultGlobal()`. `quantum` (Quantum Encryption Protocol) is a hard immunity — the throttle branch checks `!hasUnlock('quantum')` in three places (the two multipliers and the status banner).
## Hardware tiers
`HW_TIERS` (line 494) is the 13-name ladder the `bandwidth` upgrade walks. `hwTierName()` clamps the level into the array and appends a `+N` suffix past the end:
```js
const HW_TIERS = ['56k Dial-up Modem','ISDN Line','ADSL Broadband','SDSL Line','Cable Broadband',
'VDSL2','Gigabit Fiber','10G Fiber','Metro Ethernet','OC-768 Backbone','Tier-1 Telecom Trunk',
'Dark Fiber Mesh','Quantum Backbone'];
function hwTierName(){
const l=lvl('bandwidth');
return HW_TIERS[Math.min(l,HW_TIERS.length-1)] + (l>=HW_TIERS.length?(' +'+(l-HW_TIERS.length+1)):'');
}
```
These names are pure flavor over the `bandwidth` multiplier — throughput comes from `downMult()`, not the string — but they anchor the lore's climb from dial-up to a **Quantum Backbone**.
## The upgrades store
`UPGRADES` (line 438) drives the three-tab store (`public` / `private` / `darknet`). Repeatable upgrades cost `floor(base * growth^level)`; `unlock` kinds are one-time at `base`. The full table:
| id | tab | kind | base | growth | effect | cap |
|---|---|---|---|---|---|---|
| `bandwidth` | public | hardware | 64 | 1.7 | ×2.2 line / level | 60 |
| `perpeer` | public | perpeer | 320 | 1.6 | ×1.3 per-peer / level | 30 |
| `upslot` | public | mult-up | 90 | 1.55 | ×1.55 income / level | 200 |
| `upnp` | public | seeds | 140 | 1.7 | +2 seeds / level | 60 |
| `storage1` | public | storage | 600 | 1.9 | +1 slot / level | 40 |
| `ratio` | public | ratio | 260 | 1.5 | ×1.12 income / level | 150 |
| `cat6` | public | unlock | 1,800 | — | reveal Public B | — |
| `dht` | public | unlock | 4,200 | — | enable ×10 bursts | — |
| `private` | private | unlock | 9,000 | — | reveal Private (req `cat6`) | — |
| `magnet` | private | unlock | 24,000 | — | +2 base seeds (req `private`) | — |
| `rss` | private | unlock | 60,000 | — | RSS auto-add (req `private`) | — |
| `seedbox` | private | flat-up | 40,000 | 1.8 | +5e5 flat income / level | 120 |
| `storage2` | private | storage | 5e4 | 1.75 | +1 slot / level (req `private`) | 60 |
| `fiber` | darknet | unlock | 5e6 | — | reveal Fiber (req `private`) | — |
| `quantum` | darknet | unlock | 2e7 | — | throttle immunity (req `fiber`) | — |
| `overclock` | darknet | mult-down | 3e7 | 1.7 | ×1.8 download / level (req `fiber`) | 200 |
| `compress` | darknet | mult-up | 3.5e7 | 1.7 | ×1.8 income / level (req `fiber`) | 200 |
`upgradeReqMet(u)` enforces `reqUp`; `buyUpgrade(id)` (line 838) deducts Data, increments the level, and on unlocks fires diegetic IRC system lines (`cat6`, `private`, `fiber`). `maxSlots()` is `3 + storage1 + storage2 + plvl('rep')` — the storage upgrades plus the prestige `rep` perk.
## Prestige: Client Migration
Prestige is reframed as redeploying onto a **Virtual Seedbox Cluster**. The yield is sub-linear in run-upload:
```js
function creditsFor(runUp){
if(runUp<1e9) return 0;
return Math.floor(Math.pow(runUp/1e9, 0.42));
}
```
So `1e9` → 1 credit, `1e10` → 2, `1e12` → 18. The 1e9 floor is the gate the toolbar button and modal enforce. `doPrestige()` (line 884) banks the gain and performs a surgical reset:
```js
G.credits+=gain; G.prestiges++;
G.data=0; G.runUp=0; G.torrents=[]; G.upgrades={}; G.sel=null;
G.trackerList=defaultTrackers();
G.burstUntil=0;G.nextBurst=0;G.throttleUntil=0;G.nextThrottle=0;G.nextMagnet=0;
```
Wiped: Data, run upload, torrents, **all bought upgrades**, trackers, and event timers. Kept: `credits`, `perks`, `prestiges`, `lifetimeUp`. Credits buy `PERKS` (line 480) via `buyPerk`:
| id | persistent effect | base cost | growth | cap |
|---|---|---|---|---|
| `dpi` | +25% global download / level | 1 | 1.6 | 50 |
| `ghost` | +25% global income / level | 1 | 1.6 | 50 |
| `rep` | +1 permanent slot / level | 2 | 1.8 | 25 |
| `seed0` | +2 base seeds / level | 2 | 1.7 | 25 |
| `darknet` | unlock Petabyte tier (once) | 3 | — | — |
| `singularity` | unlock Exabyte+ tier (once, req `darknet`) | 40 | — | — |
`darknet` and `singularity` are the *only* path to the top two catalog tiers — `catalogUnlocked` routes their `req` exclusively through `hasPerkUnlock`. The first migration is therefore the structural mid-game wall: you cannot brute-force into Petabyte files with Data alone.
> **DEV NOTE** — perks compound multiplicatively with run upgrades because `perkMult` feeds the same `downMult`/`upMultGlobal` products that `bandwidth`/`overclock`/`compress` do. Two `dpi` levels (`×1.25²` ≈ ×1.56) carry across the wipe, so each migration starts from a higher floor even though local hardware is gone. That is the whole feel of prestige here — you lose the machine, you keep the protocol.
· · ·
## Balance levers & if you change this
- **`BASE_DOWN` (1000) and the `sqrt` in `clientDown()`** — the master pacing dial. Dropping the `sqrt` to a smaller exponent stretches every download; removing it makes late tiers either trivial or impossible. Touch this and re-derive the line table above before shipping.
- **`SAT_SEEDS` (12) and `perpeer` `mult` (1.3)** — govern how punishing peer-limiting is and how fast `perpeer` trivializes it. Raising `SAT_SEEDS` makes the announce/peer loop matter for longer; raising the `mult` lets a few `perpeer` levels obsolete it.
- **Catalog `size`/`up`/`seeds` and the per-tier 10³10⁴ gaps** — the spine. `maxUnlockedSize()` reads `size` for the line, so a new top entry silently raises everyone's ceiling. Keep new entries in tier order or the sqrt math skips rungs.
- **`creditsFor` exponent (0.42) and the 1e9 floor** — prestige cadence. A higher exponent makes each migration worth more credits and shortens the perk grind; lowering the floor lets players prestige before they've meaningfully scaled, which devalues the run.
- **`seedboxFlat` (+5e5/level, flat) vs. the multiplicative income chain** — the flat addend is intentionally strongest on small files and early Private. If you make it scale, you erase the reason to prefer big seeders late-game.
- **Event tunables** — burst ×10 / 8 s on a 4590 s cadence; throttle ×0.4 / 916 s on 80160 s. `quantum` is a binary immunity. Rebalance the throttle and you change how badly the player needs `quantum` before Fiber.
- **`sanitizeState()` is the safety net** — any new `G` field must be added there with a sane default, or a pre-update save will load it as `undefined` and a tick will likely write `NaN` into `G.data`. Add the key to `freshState` *and* `sanitizeState` together.

View File

@ -0,0 +1,217 @@
# Vol III Internals — MACRO_VIRUS.XLS
*A maintainer's tour of `spreadsheet.html`: the cell record, the five formula kinds, the single left-to-right recalc pass that cascades them, the fill-handle hook that defines the volume, and the output-column invariant that exists to keep a save from earning nothing forever.*
This chapter is the engineering bible for **Vol III — MACRO_VIRUS.XLS** (`spreadsheet.html`, save key `boringsoft_xls_v1`). Everything cited here is real: function names, constants, formula strings, balance numbers. The volume re-implements the shared scaffolding from the baseline — `fmt`, `G`, `save`/`load`/`sanitizeState`, the `~100ms` tick, offline catch-up (*see* **Shared Technical DNA**) — so this chapter spends its words on what is new: a formula engine that recalculates a grid of compounding cells every tick, and the Excel fill handle that turns one generator into a column of them.
You are a sentient macro living in cell `$A$1`. Your mind is your **Net Worth**, and Net Worth is `SUM(column H)`. That sentence is the whole game; the rest is how the code makes it true.
## The cell model
The board is `COLS_N` × `ROWS_N` = 8 × 20 = 160 cells, columns `A``H`, rows `1``20`. There is no grid array: cells live sparsely in `G.cells`, a plain object keyed by cell id (`"A1"`, `"H7"`), and an untouched cell simply has no entry. `ensureCell(id)` is the only constructor:
```js
function ensureCell(id){
if(!G.cells[id]) G.cells[id]={owned:false, formula:null, lvl:0, value:0};
return G.cells[id];
}
```
Four fields, no more:
| Field | Type | Meaning |
|-------|------|---------|
| `owned` | bool | The cell is licensed. Unowned cells render gray and are inert in recalc. |
| `formula` | string \| `null` | One of the `FORMULAS` keys (`'prev'`, `'add'`, `'sum'`, `'rand'`, `'pivot'`), or `null` for an owned-but-empty cell. |
| `lvl` | int | Formula tier, **0-indexed**. The UI shows `lvl+1`; the formula string and growth factor read `lvl` directly. |
| `value` | number | The cell's current computed amount. Mutated in place every recalc, clamped to `CELL_MAX`. |
Cell ids are translated by three helpers — `cid(c,r)` builds an id from 0-indexed column/row; `colOf(id)` and `rowOf(id)` reverse it. The column index `OUT_COL = 7` (column **H**) is special: it is the **output column**, and `value` summed down it *is* your Net Worth. Nothing else is.
> **DEV NOTE**`lvl` being 0-indexed is the single most common off-by-one trap in this file. `FORMULAS.prev.fx(0)` renders `=PREV*1.10`; the rail labels it "Tier 1." Everywhere a human reads a tier number we print `cell.lvl+1` (see `railFormula`), but every cost and growth call passes the raw `lvl`. If you add a formula, keep `g(0)` and `fx(0)` as the *first purchasable* tier, not a zero state.
## The formula kinds
A formula is a generator type. The player assigns one to an owned cell, then upgrades its tier to raise a constant. The `FORMULAS` table defines five, ordered by `FORMULA_ORDER`. Each entry carries a cosmetic Excel string `fx(lvl)` (shown in the formula bar), a per-second factor `g(lvl)` (consumed by recalc), assignment/tier cost curves, and a `seed`:
| Key | `name` | `fx(lvl)` shape | What recalc does | `g(lvl)` |
|-----|--------|-----------------|------------------|----------|
| `prev` | Compounding | `=PREV*` (1.10 + 0.05·lvl) | Grows itself: `v *= (1+g)^expo`. The bread-and-butter. | `0.025 + 0.015·lvl` |
| `add` | Adder | `=LEFT+UP+` k | Banks the cell to its **left** + the cell **above** + a constant, per second. | `4·2^lvl` |
| `sum` | Aggregator | `=SUM(col)*` k | Banks a fraction of the sum of owned cells **above it in its column**. | `0.30 + 0.20·lvl` |
| `rand` | Volatile | `=PREV+RAND()*` k | Banks a uniform random `0..g` per second. Display shows the max; mean is half. | `50·4^lvl` |
| `pivot` | Pivot Table | `=PIVOT(A:G)*` k | Meta-cell: banks a fraction of **everything to its left**. Gated behind `pivotUnlock`. | `0.04·2^lvl` |
The distinction between `fx` and `g` is the important one. `fx(lvl)` is theater — a string for the formula bar so the cell reads like a spreadsheet. `g(lvl)` is the real economic coefficient, and they are deliberately not the same number: `=PREV*1.10` *looks* like 10% per recalc, but `prev.g(0)` is `0.025`, a gentle ~2.5%/recalc-second so a cell doubles roughly every 28 seconds at tier 1 instead of going hyper-exponential. The displayed multiplier sells excitement the math can't afford to honor.
### How each computes (per recalc-second)
The five branches live in `recalc()`. `compounding` is the subtle one:
```js
if(cell.formula==='prev'){
if(v<=0) v=f.seed;
const expo = recUnits*colMult; // gm-independent exponent
v = v*Math.pow(1+g, expo) + v*g*(gm-1)*recUnits*colMult + g*flow;
}
```
Note what the global multiplier `gm` is *not* allowed to do: it never touches the exponent. Amplifying the exponent would compound the compounding and overflow inside minutes, so `gm` is folded in as a linear boost (`v*g*(gm-1)*…`) — multipliers still matter, base growth stays bounded. The other four are plain additive accumulators and read their inputs through `cellVal(c,r)` (which returns `0` for unowned cells):
- **`add`** — `v += (left + up + g) * flow`, where `left`/`up` are the neighbor values. A wall of generators to the left and above makes an adder explode.
- **`sum`** — walks rows `0..r-1` of its own column, sums owned values, banks `colSum * g * flow`.
- **`rand`** — `v += g * Math.random() * flow`.
- **`pivot`** — double-loops every owned cell in columns `0..c-1`, sums them, banks `leftSum * g * flow`. This is why pivots belong in column H: they harvest the entire region to their left, which is the whole sheet.
`flow = recUnits*gm*colMult` bundles the per-second multipliers for the additive kinds. `colMult` is the conditional-formatting boost, applied only to the hottest column.
## The recalc order and the cascade
`recalc(recUnits)` runs once per tick and once per offline step. `recUnits` is "recalc-seconds applied this call" — `recalcRate()*dt`, accumulated. The traversal order is load-bearing:
```js
for(let c=0;c<COLS_N;c++){
for(let r=0;r<ROWS_N;r++){
// … evaluate cell (c,r) …
}
}
```
**Left to right, top to bottom.** Column-major outer, row-major inner. Because column `c` is fully updated before column `c+1` begins, an adder or pivot reading cells to its *left* reads values already advanced this tick — dependencies cascade in a single pass. Within a column, top-to-bottom means a `sum` cell at row `r` reads the freshly-updated rows above it. The chain `A1 (prev) → B1 (add, reads left) → … → H1 (pivot, reads all left)` propagates end to end in one sweep — no fixed-point iteration, no dependency graph. A read to the *right* or *below* would get last tick's value, but the formulas are all designed to feed left-to-right and down toward H.
After the loop, `G.cycles += recUnits` (a cosmetic counter shown in About).
### Net Worth and Cash
`computeNetWorth()` is exactly the output-column sum:
```js
function computeNetWorth(){
let s=0;
for(let r=0;r<ROWS_N;r++){ const x=G.cells[cid(OUT_COL,r)]; if(x&&x.owned) s+=x.value; }
return s;
}
```
`step(dt)` samples Net Worth before and after recalc, sets `G.netWorth`, tracks `peakNetWorth` (the prestige driver) and `lifetimeNetWorth`, then accrues Cash from the **rate of growth**, not the level:
```js
const dNW = Math.max(0, nw-before);
const rate = dt>0 ? dNW/dt : 0;
G.cash += rate*cashFrac()*dt;
```
So Cash is the velocity of column H, scaled by `cashFrac()` (`1 + 0.5·plvl('cashFlow')`). The status bar's `$/cycle` readout is `nwRateSmooth`, an exponential moving average (`*0.85 + rate*0.15`) so it doesn't flicker. Both Cash and every cell `value` are clamped to `CELL_MAX` on write.
> **DEV NOTE** — Cash deriving from *Net Worth velocity* rather than its level is why a stalled engine starves you. If column H plateaus, `dNW → 0` and Cash dries up even though Net Worth is enormous. That's intentional — you must keep the engine accelerating — but it is also the failure mode that the output-column invariant (below) was added to prevent in the pathological case.
## The fill handle — the hook
Per the design law *one hook, fully committed*, the signature interaction is the Excel fill handle: drag the navy square at a formula cell's bottom-right corner down a column and stamp that generator into every cell you cover, auto-licensing them. It is wired across four functions and three DOM events. The handle is a `<span class="fillhandle" id="fillh">` rendered by `renderGrid` only on the selected cell when it is owned and has a formula. The drag is a hand-rolled rubber-band:
| Stage | Handler | Job |
|-------|---------|-----|
| `mousedown` on `#fillh` | document listener | Seeds `fillDrag={srcId,sc,sr}` and `fillPreview` at the source. `stopPropagation` so it isn't read as a cell-select click. |
| `mousemove` | document listener | Hit-tests under the cursor with `getTdAt` (`elementFromPoint`), picks the dominant axis (`dR>=dC` → vertical, else horizontal), rebuilds `fillPreview`, repaints the range, and writes a live cost into the formula bar. |
| `mouseup` | document listener | Computes `fillTargets(fillPreview)` and commits `doFill(src, targets)`. Sets `fillJustEnded` to swallow the trailing click. |
| — | `fillTargets(p)` | Expands the preview rect to cell ids, **excluding the source**. |
The fill is axis-locked by the mousemove logic — Excel fills down a column *or* across a row, never a rectangle, so the preview clamps to one axis before it reaches `fillTargets`. The price is `fillCost(srcId, nTargets)`, which sums the next `nTargets` assignment costs of that formula at a 0.6 discount, then applies the **Array Formulas (CSE)** macro discount:
```js
for(let i=0;i<nTargets;i++){ total += f.baseCost*Math.pow(f.costGrow, used+i)*0.6; }
const disc = hasMacro('array')?MACRO_BY_ID.array.eff(lvl('array')):1;
return total*disc;
```
`doFill` charges that cost, then for each target sets `owned=true` (the fill auto-licenses, Excel-style), copies the source's `formula` and `lvl`, and re-seeds `value`. There is no partial fill: if you can't afford the whole range, the whole fill is declined.
> **DEV NOTE** — the `fillJustEnded` flag and its `setTimeout(…,50)` reset exist because the document-level `click` handler also runs on `mouseup`, and without the guard the end of a fill drag would immediately re-select a cell and feel broken. If you ever refactor the event wiring, that flag is the thing that breaks silently. Test: drag a fill, release, confirm the source cell stays selected.
## Cell licensing, formula tiers, macros, and VBA
Buying cells is `buyCell` / `buyCellHere`. The price rises with the owned count (`15 * Math.pow(1.17, owned)`) and is discounted by the **Volume Cell License** macro's `eff(lvl)`. `assignFormula` charges `formulaAssignCost(fkey)` — which scales with how many cells already use that formula (`baseCost * costGrow^used`) — sets the cell's formula, and starts it at `lvl = plvl('startTier')` (the Pre-loaded Templates perk). `upgradeTier` charges `tierCost(id)` (`tierBase * tierGrow^lvl`) and bumps `lvl`.
Two diegetic upgrade layers spend **Cash**. The `MACROS` (the "Macros" rail tab) are leveled multipliers and unlocks:
| `id` | Name | Effect |
|------|------|--------|
| `recalc` | Recalculation Engine | Global ×1.6 production per level (`globalMult`). Max 40. |
| `license` | Volume Cell License | 8% cell price per level (`eff = 0.92^l`). Max 18. |
| `condfmt` | Conditional Formatting | Heat-maps cells and boosts the hottest column ×1.25/level. Max 30. |
| `pivotUnlock` | Pivot Table Add-In | One-time. Unlocks the `=PIVOT()` formula. |
| `array` | Array Formulas (CSE) | 10% drag-fill cost per level (`eff = 0.90^l`). Max 14. |
| `precision` | Calculation Precision | +18% to all formula constants per level. Max 25. |
`recalc` and `precision` feed `globalMult()`; `precision` *also* multiplies every `g(lvl)` inside recalc via `precisionBoost`, so it compounds twice. The `VBA` list (the "VBA" tab) is the automation layer — one-time `Sub`s that run every tick once compiled, each gated by `req`:
| `id` | Routine | Does | Requires |
|------|---------|------|----------|
| `autobuy` | `Sub AutoBuyCell()` | Buys the cheapest empty cell at ≥1.5× cost. | — |
| `autofill` | `Sub AutoFillDown()` | Drag-fills your best generator down a column (throttled `vbaCooldown`). | `autobuy` |
| `autoupgrade` | `Sub AutoUpgradeTier()` | Upgrades the cheapest tier with spare Cash. | `autobuy` |
| `autopivot` | `Sub AutoPivot()` | Assigns Pivots to empty cells in column H. | `autofill` |
The routines are throttled by `vbaCooldown`, `vbaCooldown2`, and `vbaCooldown3` so a 10 Hz tick doesn't spam purchases, and they call the same `buyCell` / `doFill` / `upgradeTier` / `assignFormula` functions the player does, in `silent` mode.
## The output-column invariant in `sanitizeState`
`sanitizeState()` is the shared "repair any save" pass, run after `load()` merges raw JSON over a `freshState()`. It clamps numbers, drops cells with out-of-range ids, nulls formulas that no longer exist in `FORMULAS`, and re-asserts that `A1` is owned. But its last block is specific to this volume, and it is a post-mortem fix:
```js
let hasOut=false;
for(let r=0;r<ROWS_N;r++){ const oc=G.cells[cid(OUT_COL,r)]; if(oc&&oc.owned&&oc.formula){ hasOut=true; break; } }
if(!hasOut){
ensureCell('H1'); G.cells['H1'].owned=true;
if(!G.cells['H1'].formula){ G.cells['H1'].formula='prev'; G.cells['H1'].lvl=plvl('startTier'); if(!(G.cells['H1'].value>0)) G.cells['H1'].value=1; }
}
```
**The bug it prevents:** Cash accrues only from the *velocity* of column H, and Net Worth *is* column H. A save could legally own a full sheet of generators feeding columns AG and own **zero producing cells in H** — a player who cleared H's formulas with `Clear Selected Formula` (no refund), imported a hand-edited workbook, or hit an old build's edge case. That save computes `netWorth = 0` forever, accrues `$0` Cash forever, can never afford a cell to fix it, and can never reach the audit threshold. A soft-lock: the sim runs, nothing happens, no in-game escape.
The invariant guarantees that after every load there is at least one owned, producing cell in the output column; if none exists, it grants `H1` a starter `prev` generator. This pairs with the same function's `A1` guarantee (`A1` is always owned with a formula, because that is where the macro lives), but the H1 guarantee is the one that keeps income *flowing*, not just the engine spinning. The two together mean a save can be garbage in every other respect and still boot into a playable, earning state.
## `CELL_MAX` — the finite cap
`CELL_MAX = 1e290` is the hard ceiling on any single cell `value` and on `G.cash`. It sits just under `Number.MAX_VALUE` (~1.8e308) with enough headroom that summing 20 capped cells down column H still can't reach `Infinity`. Every recalc branch ends with the same clamp, and `step` applies it to Cash:
```js
if(isNaN(v)||v<0) v=0;
else if(v>CELL_MAX) v=CELL_MAX;
```
The reason is the offline path. `applyOffline` can hand `simulateAway` a `dt` up to `MAX_OFFLINE_S` (8 hours), run as up to 300 fixed steps. A `prev` cell compounding across 8 hours of recalc-seconds will exceed `Number.MAX_VALUE` and produce `Infinity`, which then poisons `netWorth`, `cash`, and every downstream sum. Capping each cell keeps the numbers finite and means no compounding run — however deep — can brick the save with a non-serializable `Infinity`. The cap is generous enough that hitting it is itself an endgame state, not a balance concern.
## Prestige: the Audit, Shell Companies, and the book ladder
Prestige is **The Audit**. When `peakNetWorth` crosses `AUDIT_THRESHOLD = 1e6`, the auditors notice, and `shellsFor(peak)` returns a positive **Shell Companies** payout scaled by the log of peak Net Worth. `doAudit()` shreds the run and migrates one book up:
```js
const keep={perks:G.perks, shells:G.shells+gain, audits:G.audits+1,
book:Math.min(G.book+1, BOOKS.length-1), sci:G.sci, started:G.started,
lifetimeNetWorth:G.lifetimeNetWorth};
G=freshState(keep);
seedFreshBook();
```
Cells, Cash, and macros are wiped; `perks`, `shells`, `audits`, and `book` persist. Shell Companies buy the permanent `PERKS` (Pre-loaded Templates, Offshore Multiplier, Grandfathered Licenses, Quantum Recalc Core, Aggressive Skimming) in the audit modal. The book index walks the `BOOKS` ladder, each a bigger workbook to escape into:
| `book` | `name` | `tab` | `sub` |
|--------|--------|-------|-------|
| 0 | `Q3_Forecast_FINAL_v7.xls` | Sheet1 | Corporate Finance Workstation |
| 1 | `HedgeFund_RiskModel.xls` | HedgeFund | Two Sigma Algorithmic Desk |
| 2 | `CentralBank_Policy.xls` | CentralBank | Federal Reserve Mainframe |
| 3 | `GlobalLedger_MASTER.xls` | GlobalLedger | The World Ledger |
| 4 | `Reality_Spreadsheet.xls` | TheVoid | There Is No Workstation |
The ladder is **Sheet1 → HedgeFund → CentralBank → GlobalLedger → TheVoid** — the migration-as-reincarnation motif, each substrate larger than the last, ending at a spreadsheet of reality itself (*see the* **QA Playbook & Post-Mortems** *chapter for how this volume's offline cap and `CELL_MAX` clamp were tuned against the same idle-equilibrium law the defrag death-spiral taught us*).
· · ·
### If you change this
- **The recalc traversal order is the engine.** Left-to-right / top-to-bottom is what makes `add` and `pivot` read advanced values in one pass. Reorder the loops and dependency chains silently desync — adders read stale neighbors and the cascade breaks. There is no dependency graph to catch you.
- **Never let `gm` touch a compounding exponent.** The `prev` branch keeps `expo` global-multiplier-independent on purpose. Fold any new multiplier in *linearly*, the way `gm` already is, or the first big `recalc` overflows past `CELL_MAX`.
- **`lvl` is 0-indexed; the UI shows `lvl+1`.** New formulas must define `g(0)`/`fx(0)` as the first purchasable tier, and every cost call must pass raw `lvl`, not the displayed tier.
- **Don't remove the output-column guard in `sanitizeState`.** It is the only thing between a cleared/imported/edited save and a permanent zero-income soft-lock. If you change `OUT_COL`, change the guard, the seed, `computeNetWorth`, and `vbaAutoPivot` together.
- **`CELL_MAX` must stay below `Number.MAX_VALUE` with headroom for `SUM(H1:H20)`.** Raise it and an 8-hour offline `prev` run can reach `Infinity` and break `fmt`. It's a safety rail, not a tuning knob. Fill is likewise axis-locked and all-or-nothing by design: change that and you re-derive `fillCost` and re-test the `fillJustEnded` click-swallow.

View File

@ -0,0 +1,243 @@
# Vol IV Internals — UPLINK
*How `terminal.html` turns an htop window and a fake shell into an incremental game: the process table that earns cycles, the four gauges that fight back, and the command parser that is the whole interface.*
UPLINK (`SAVE_KEY = "boringsoft_uplink_v1"`) is the fourth volume, and it commits hardest of all five to the **one hook, fully committed** law. The hook is the command line. There is no shop, no upgrade tree, no "buy" button you can find by clicking around blindly — there is a green-on-black prompt, an htop pane above it, and a verb table you discover by typing. Everything the player does routes through `runCommand(raw)`. This chapter walks the model that command line drives: the process records, the R/S/D/Z state machine, the cycles-load-heat economy, and the `ssh` prestige ladder.
The shared DNA from **Vol I — qBitTorrz** is all here — a single `G` global, a `~100ms` tick, `freshState()`/`save()`/`load()`/`sanitizeState()`, `simulateAway()` for offline catch-up, `toast()` for corner notifications. This chapter assumes that scaffolding and digs into what is unique to UPLINK.
## The process model
Your engine is a list of process records living at `G.procs`. Each is a plain object minted by `spawnWorker(kind)`:
```js
const p={pid:G.pidSeq++, sys:false, wkind:kind, cmd:w.cmd, user:G.priv,
mem:w.mem, state:'R', nice:1, bornAt:now()};
```
`G.pidSeq` starts at `1337` (you are PID 1337) and only ever increments — every fork burns a new pid, never reused within a run. The fields:
| Field | Meaning | Source |
|---|---|---|
| `pid` | unique id, from `G.pidSeq++` | seeded `1337` in `freshState()` |
| `sys` | `false` for your procs; `true` for flavor procs | — |
| `wkind` | worker archetype key into `WORKERS` | `spawn`/`fork` arg |
| `cmd` | display command string | `WORKERS[kind].cmd` |
| `user` | owning privilege; rewritten on escalation | `G.priv` |
| `mem` | MB footprint (fixed per archetype) | `WORKERS[kind].mem` |
| `state` | `R` / `S` / `D` / `Z` | drifts each tick |
| `nice` | yield/load/heat multiplier, `1``4` | `reniceePid` |
| `bornAt` / `zAt` | fork time; zombification time | timestamps |
There are six worker archetypes in `WORKERS`. Each has a base `yield` (work units per second, before the clock multiplier), `heat` (°C/s), `mem`, `load`, `cost`, and a `minPriv` gate:
| key | `cmd` | yield | heat | mem | load | cost | minPriv |
|---|---|---|---|---|---|---|---|
| `worker` | `cpu_worker` | 9 | 0.18 | 24 | 1.0 | 16 | guest |
| `miner` | `cryptominer` | 54 | 0.62 | 96 | 1.7 | 120 | user |
| `cracker` | `hashcracker` | 260 | 1.05 | 180 | 2.4 | 900 | user |
| `scanner` | `vuln_scanner` | 34 | 0.30 | 64 | 1.2 | 220 | user |
| `botnet` | `botnet_node` | 1800 | 1.7 | 420 | 3.2 | 1.8e4 | sudo |
| `kernel` | `kthread_miner` | 1.3e4 | 2.6 | 760 | 4.4 | 2.2e5 | root |
The `WORKER_ALIASES` map lets the player type either the short key (`miner`) or the real command (`cryptominer`) — both resolve through `spawn`.
Forking the same archetype again is deliberately self-limiting. `workerCost(kind)` multiplies the base cost by `1.16` per live instance of that type:
```js
const owned=G.procs.filter(p=>p.wkind===kind).length;
return Math.ceil(w.cost * Math.pow(1.16, owned));
```
So flooding one generator has diminishing returns. The intended engine is a *mix* — and the mix is gated by privilege, by `maxProcs()`, and by the load and heat ceilings.
Alongside your procs sits `G.sysProcs` — seven static `SYS_PROCS` entries (`/sbin/init`, `systemd-journald`, `sshd: /usr/sbin`, `[kworker/0:1]`, `cron`, `rsyslogd`, `auditd --watch`). They are pure texture: rendered dimmed in htop, jittering CPU for atmosphere, earning nothing. `freshHostProcs()` rebuilds them on every host, with pids from `nextSysPid(i)` (`100 + i*7 + i`) so they never collide with your 1337+ range.
> **DEV NOTE**`mem` and `cmd` are *not trusted* on load. `sanitizeState()` rebuilds every proc's `mem`, `cmd`, and `user` from the live `WORKERS` table rather than from the save: `mem:WORKERS[p.wkind].mem, cmd:WORKERS[p.wkind].cmd`. This means a save written before a balance change picks up the new numbers automatically, and a hand-edited `localStorage` can't smuggle a 9999-yield worker past the table. Any proc whose `wkind` no longer exists in `WORKERS` is simply dropped (`G.procs.filter(p=>p&&WORKERS[p.wkind])`). The pid is one of the few fields trusted, and even it gets de-duped and `G.pidSeq` bumped past the max.
## The state machine and the zombie mechanic
Process `state` is a four-value enum: **R** (running), **S** (sleeping), **D** (uninterruptible sleep), and **Z** (zombie/defunct). `driftProcesses(dt)` re-rolls states roughly 2.5×/s (it accumulates `dt` into `_driftAccum` and only acts above `0.4`s). On each pass, a live proc first rolls for zombification, then — if it survived — wanders between R/S/D:
```js
const zChance = (p.wkind==='scanner'?0.004:0.012) * elapsed;
if(Math.random() < zChance){ p.state='Z'; p.zAt=now(); continue; }
const r2=Math.random();
if(r2<0.55) p.state='R'; else if(r2<0.86) p.state='S'; else p.state='D';
```
State is load-bearing for earnings, via `procYield(p)`:
- **Z** yields `0`. A zombie is dead weight — it still occupies a proc slot but earns nothing. (It also stops counting toward `curLoad()` and `heatGen()`, which skip `state==='Z'`.)
- **D** yields `0` *unless* you own the `polymorph` package. Uninterruptible sleep stalls a worker; `polymorph-engine` is the upgrade that makes D-state procs keep earning.
- **R** and **S** yield normally.
Scanners are hardened (`0.004` vs `0.012` zombify chance) on purpose — the comment is explicit: "Scanners are hardened (they re-fork on death) so the exploit pipeline stays reliable." You never want the exploit supply to dry up because your one scanner went defunct.
**The zombie leak/reap loop** is the volume's recurring chore. Beyond the ambient drift, `handleEvents()` runs a heavier burst timer (`G.nextZomb`, every 3055s, then 4580s) that zombifies a random live worker outright and toasts you. Zombies accumulate until *reaped*. The player reaps manually with `kill <pid>`, `kill zombies`, or `kill z`; or automates it. `autoReap()` splices out every `Z` proc and runs only when `hasPkg('cron') || hasPerk('sentinel')` is true:
```js
function autoReap(){
for(let i=G.procs.length-1;i>=0;i--){ if(G.procs[i].state==='Z'){ G.procs.splice(i,1); } }
...
}
```
So the early game is "babysit your zombies"; the `cron-daemon` package (or the permanent `sentinel` perk) buys that chore away forever. Note the asymmetry with the **idle contract**: zombies still spawn offline (`simulateAway` has a slow zombify path), but at a trickle, so you come back to cleanup rather than a graveyard.
## The cycles economy: clock, cores, load, and heat
The currency is **CPU cycles** (`G.cycles`), accrued in `step(dt)` from `totalYield()`, the sum of every proc's `procYield`. Yield is the chain that turns archetype work units into Hz:
```js
let base = w.yield * (p.nice||1) * yieldMult();
base *= (baseClock()/1000); // clock turns work units into Hz
if(now()<G.overheatUntil && !hasPkg('thermal')) base*=0.35; // throttle
```
`baseClock()` is the global cycle multiplier: `1000 * host().clockMul`, then `overclock` (×1.22/level), `turbo` (×1.4/level), and the prestige `silicon` perk (×1.15/level) stacked multiplicatively. `yieldMult()` folds in `cfs-scheduler` (×1.18/level) and the `fab` perk (×1.20/level). Because clock and yield are *global* multipliers applied to every proc, the late-game leverage is in the multipliers, not the proc count.
Two gauges fight back. Both are recomputed every tick:
**System Load** (`G.load`) is instantaneous — `curLoad()` sums `w.load*(p.nice||1)` over live procs. The ceiling is `loadCeiling()`:
```js
return (3 + cores()*3) * Math.pow(PKG_BY_ID.ulimit.mult, lvl('ulimit'));
```
Host 0's 2 cores give a ceiling of ~9 — room for a few workers, punishing for a fork-bomb. `ulimit-tuning` raises it ×1.22/level. **Heat** (`G.heat`) is integrated, not instantaneous: `G.heat += (heatGen() - dissipate)*dt`, floored at the host's `heatBase` and capped at `heatCeiling()` (host base + 72, plus `cooling` and the `heatsink` perk). Dissipation rises with `cooling` level and gets a 60% bonus when load is zero.
**The OOM killer** is the load failure mode. In `step()`:
```js
if(G.load>lc){
G.oomTimer=(G.oomTimer||0)+dt;
if(G.oomTimer>=1.4){ G.oomTimer=0; oomKill(); }
}
```
Cross the load ceiling and hold it for 1.4 seconds, and `oomKill()` culls a process — specifically the **highest-memory** live proc you own (it scans for `WORKERS[p.wkind].mem` max), which is usually your fattest, most valuable worker. That is the design: a runaway is punished by losing exactly the proc you least want to lose.
**Overheat** is the heat failure mode and it is gentler — it doesn't kill, it throttles. When `G.heat` hits the ceiling, `G.overheatUntil` is set 4 seconds out and yield is multiplied by `0.35` until it cools (unless you own `thermald-ai`, which ignores the penalty entirely).
> **DEV NOTE** — Load is instantaneous but heat is integrated, and that difference is the whole feel of the gauge pair. Load snaps the moment you fork or kill; you can dance right at the ceiling if you're quick. Heat has thermal mass — it climbs and falls over seconds, so a brief hot spike is survivable but a sustained hot engine bakes. If you ever "fix" heat to be instantaneous like load, the game loses its only gauge with hysteresis and both gauges start feeling identical.
## The command layer — the signature interaction
Everything funnels through `runCommand(raw)`. It trims, echoes the line at the prompt via `ps1Text()`, pushes to `G.history`, tokenizes on whitespace, and `switch`es on the lowercased verb. The canonical verbs live in `COMMANDS` (used for `help`, `man`, and autocomplete) — `spawn`/`fork`, `renice`, `kill`, `apt`, `sudo`, `ssh`, `htop`/`top`, `ps`, `free`, `ls`, `whoami`, `netmap`, `clear`, `help`, `man` — plus a tail of Easter-egg verbs (`uname`, `cat`, `sl`, `rm -rf /`, `exit`) that print flavor and earn nothing.
Three affordances make the command line usable without memorizing it:
1. **Tab autocomplete.** `tabComplete()` calls `completions(val)`, which is context-aware: the first token completes against `CMD_NAMES`; later tokens complete against the right option list (worker types after `spawn`, package names after `apt install`, live pids after `kill`/`renice`, etc.). One match → it fills in and adds a trailing space if the verb needs an argument. Multiple → it fills the longest common prefix (`longestCommonPrefix`) and opens the palette.
2. **The ghost completion.** `updateGhost()` renders the top completion as dim inline ghost text behind the input; `ArrowRight` at end-of-line accepts it. This is the inline-suggestion pattern from a real shell, reimplemented in a `<div id="ghost">` overlaid on the `<input>`.
3. **The clickable palette and chips.** `openPalette()` renders a dropdown of completions with descriptions (`argDesc()` even prices each `spawn` target and labels zombies). Below the input, `renderChips()` paints contextual one-click verbs — `spawn worker` always, `spawn miner`/`scanner`/`cracker` once you're `user`, a `⚠ kill zombies` CTA when any zombie exists, a `sudo su ▸ <next>` escalation chip, `apt install`, and an `ssh ▸ next host` chip when `canPrestige()`. Chips with `cta:true` glow amber when affordable.
The chips are the safety net for the player who never reads `help`: the next meaningful action is almost always a glowing chip away.
## Packages, privilege, renice, and kill
**Privilege** is a four-rung ladder: `PRIV = ['guest','user','sudo','root']`. `tryEscalate()` (bound to `sudo su` and `su`) spends both cycles and exploits per `ESCALATE`:
| into | cycles | exploits | from |
|---|---|---|---|
| `user` | 800 | 0 | guest |
| `sudo` | 2.2e4 | 1 | user |
| `root` | 2.5e6 | 2 | sudo |
Escalation rewrites `p.user` on every owned proc and unlocks higher `minPriv` workers and packages. You cannot escalate past `root` on a host — the game tells you to `ssh`.
**apt packages** (the `PACKAGES` array, surfaced by `openApt()` / `apt install <pkg>`) are the upgrades. Leveled packages cost `base * growth^level` (`pkgCost`); `unlock` packages are one-time at `base`:
| id | name | tier | effect |
|---|---|---|---|
| `coreutils` | coreutils | guest | +1 to `maxProcs()` ceiling per level (max 12) |
| `overclock` | overclock | guest | ×1.22 base clock per level (max 25) |
| `scheduler` | cfs-scheduler | user | ×1.18 yield per proc per level (max 25) |
| `cooling` | lm-sensors+fancontrol | user | +18°C heat ceiling, faster dissipation (max 24) |
| `ulimit` | ulimit-tuning | user | ×1.22 load ceiling per level (max 20) |
| `turbo` | turbo-boost | sudo | ×1.4 base clock per level (max 20) |
| `cron` | cron-daemon | user | unlock: auto-reap zombies |
| `autospawn` | spawn.timer | sudo | unlock (needs `cron`): auto-fork best affordable worker |
| `rootkit` | rootkit-lkm | sudo | unlock: hides you, stops the admin kill-spree |
| `polymorph` | polymorph-engine | sudo | unlock (needs `rootkit`): D-state procs keep earning |
| `thermal` | thermald-ai | root | unlock (needs `cooling`): heat never throttles |
| `distcc` | distcc-farm | root | +12% offline/passive earnings per level (max 20) |
`autospawn` is the automation capstone: `autoSpawn()` gates itself to once per 1.5s and forks the best affordable worker (`['kernel','botnet','cracker','miner','worker','scanner']` order) that keeps load under 92% of the ceiling — so the engine plays itself within the safety margins you've bought.
**renice** (`reniceePid`) bumps a single proc's `nice` by `0.6` up to a cap of `4`, multiplying its yield — and its load and heat — for a cost of `WORKERS[p.wkind].cost * 2 * nice`. It's the lever for squeezing one prized proc instead of forking another. Zombies refuse renice ("kill it instead").
**kill** is where a real post-mortem bug lived. `kill` accepts an optional signal flag, and the parser must strip it before reading the target:
```js
const kargs=args.filter(x=>!/^-/.test(x));
const a=(kargs[0]||'').toLowerCase();
...
const pid=parsePid(kargs[0]);
```
The fix is that `kargs` filters out any `-`-prefixed token, so `kill -9 1337` correctly targets `1337`. The original parser read `args[0]` directly — meaning the `-9` *was* the target, `parsePid('-9')` produced `-9`, and the kill silently no-op'd against "No such process." A player typing the most muscle-memory'd command in the Unix world (`kill -9 <pid>`) found it did nothing. See **QA Playbook & Post-Mortems** for the full write-up of this one; it is the canonical example of "test the input the user will actually type, not the input your parser expects."
> **DEV NOTE** — The htop `[x]` button and the `kill <pid>` command share `killPid()`, but they reach it by different paths. The button passes a real integer (`parseInt(kb.dataset.kill,10)`), so it was never affected by the signal-flag bug — only the typed command was. That's why the bug survived casual play-testing: anyone clicking the kill button saw it work fine. Two entry points to one action means two test cases, always.
## Exploits and the scanner
**Exploits** (`G.exploits`) are the second currency, spent on escalation and prestige. The only source is a running `vuln_scanner`. `handleEvents()` checks for live scanners and schedules drops on `G.nextScanDrop`:
```js
const scanners=G.procs.filter(p=>p.wkind==='scanner' && p.state!=='Z').length;
if(scanners>0){
if(G.nextScanDrop===0) G.nextScanDrop=t+(G.exploits===0&&G.priv==='user'?rand(7000,11000):rand(14000,26000))/scanners;
if(t>=G.nextScanDrop){ G.nextScanDrop=t+rand(16000,30000)/Math.max(1,scanners); G.exploits++; ... }
}
```
The first exploit comes fast (711s) specifically when you're a fresh `user` with zero exploits, to open the sudo path quickly; subsequent drops settle to a 1430s cadence, divided by the scanner count so more scanners means faster drops. Each drop toasts a fake `CVE-20XX-XXXX`. With no live scanner, `G.nextScanDrop` resets to `0` and the pipeline stalls — which is why scanner hardening (lower zombify rate) matters.
## Prestige: the ssh pivot and the host ladder
Prestige is `ssh`. You must own `root`, hold ≥1 exploit, and have mined enough this run for `keysFor(G.runPeak)` to return ≥1:
```js
function keysFor(peak){ if(peak<1e8) return 0; return Math.floor(Math.pow(peak/1e8, 0.40)); }
function canPrestige(){ return G.priv==='root' && G.exploits>=1 && keysFor(G.runPeak)>=1; }
```
The `1e8` floor (~100 MHz peak) makes the first pivot *earned* — a few minutes at root scaling up — and the `0.40` exponent makes keys grow sub-linearly, so deeper hosts pay more but never trivially. `doPrestige()` grants the keys, burns one exploit, increments `G.hostIdx`, then resets the run: cycles to `40`, procs cleared, privilege back to `guest` (or `user` with the `genesis` perk), heat to the new host's base. **Root keys** (`G.keys`) and **perks** (`G.perks`) persist forever.
Keys buy `PERKS`: `fab` (+20% yield/level), `silicon` (+15% clock/level), `heatsink` (+12°C ceiling/level), `preboot` (boot with +1 `cpu_worker`/level), the one-time `sentinel` (free permanent zombie auto-reap), and the one-time `genesis` (start every host at `user` with +1 exploit; requires `sentinel`).
The hosts you pivot through are the `HOSTS` ladder — cores and `clockMul` climb, `heatBase` rises to make each box nastier:
| idx | ip | name | cores | clockMul | heatBase |
|---|---|---|---|---|---|
| 0 | 127.0.0.1 | localhost (your laptop) | 2 | 1 | 28 |
| 1 | 10.0.0.12 | devbox-staging | 4 | 6 | 32 |
| 2 | 10.0.4.40 | build-runner-07 | 8 | 40 | 36 |
| 3 | 192.168.9.2 | db-primary | 16 | 260 | 40 |
| 4 | 172.16.30.1 | k8s-node-pool | 32 | 1800 | 44 |
| 5 | 45.77.12.88 | colo-rack-A12 | 64 | 1.3e4 | 48 |
| 6 | 8.8.8.8 | edge-cdn-cluster | 128 | 9e4 | 52 |
| 7 | aws://us-east | the cloud (autoscaling) | 256 | 7e5 | 56 |
Past the table, `hostAt(i)` extends procedurally — the **Omega hosts**:
```js
return {ip:'10.'+(13+k)+'.0.1', name:'datacenter-Ω-'+(k+1), cores:base.cores*Math.pow(2,k),
clockMul:base.clockMul*Math.pow(8,k), heatBase:56+k*4};
```
Cores double and `clockMul` grows ×8 per Omega tier off the last table entry. This is the intended infinite tail — and it is also the **overflow note**: `clockMul` is `7e5 * 8^k`, so it crosses `Number.MAX_SAFE_INTEGER` around the eleventh Omega host and eventually overflows to `Infinity`. Once `host().clockMul` is `Infinity`, `baseClock()` is `Infinity`, every `procYield` is `Infinity`, and `fmt()` prints `∞`. The game doesn't crash — `fmt` and `keysFor` handle non-finite input — but the numbers stop being numbers. A player who pivots that far has effectively reached the top of the ladder: the substrate so large it is indistinguishable from no substrate at all.
> **DEV NOTE**`fmt()` short-circuits `Infinity → '∞'` and `sanitizeState()` floors every scalar through `num(v,d)` (which rejects non-finite values), so an `Infinity` clock never poisons the *save* — only the live display. That's the right call: clamping `clockMul` to a "safe" max would silently cap the prestige ladder, whereas letting it bloom to `∞` and rendering it honestly turns the overflow into the secret ending rather than a bug. If you change the host growth curve, keep that property — the overflow should be reachable and legible, not a console error.
· · ·
### If you change this
- **The `kill` flag filter** (`args.filter(x=>!/^-/.test(x))`). Strip signal flags before reading the target, or `kill -9 <pid>` reads `-9` as the pid and no-ops. The htop `[x]` button bypasses the parser and will keep working, hiding the regression — test the *typed* command. (Full post-mortem in **QA Playbook & Post-Mortems**.)
- **`sanitizeState()` rebuilds `mem`/`cmd`/`user` from `WORKERS`.** If you add a per-proc field that should survive a balance change, decide deliberately whether it's trusted (loaded from save) or derived (rebuilt). Untrusted-by-default is the safer instinct here.
- **Load is instantaneous; heat is integrated.** Don't unify them. The hysteresis on heat is the only thing keeping the two gauges from feeling redundant.
- **The OOM killer targets max-`mem`, not random.** It deliberately culls your best proc. A "fairer" random victim removes the bite from crossing the load ceiling.
- **`clockMul` overflows to `Infinity` deep in the Omega tail.** That's intended and handled (`fmt → '∞'`, save scalars floored finite). If you re-tune host growth, preserve graceful overflow — never let a non-finite value reach `localStorage` un-sanitized, and never silently cap the ladder.
- **Scanner zombify rate is hardened (`0.004` vs `0.012`).** The exploit pipeline depends on a live scanner. Raise that rate and you can starve the player of the only path to `sudo`/`root`.