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>
307 lines
22 KiB
Markdown
307 lines
22 KiB
Markdown
# 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 ~20–40s; 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 20–40 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.
|