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>
271 lines
22 KiB
Markdown
271 lines
22 KiB
Markdown
# 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.
|