Five incremental games disguised as boring 1998-era desktop utilities, launched from a Win98 shell (ENTROPY OS): - Vol I qBitTorrz (index.html) — torrent-client idler; seed, climb hardware tiers, migrate - Vol II DEFRAG.EXE (defrag.html) — steer a disk defragmenter; drag-select; outrun entropy - Vol III MACRO_VIRUS.XLS(spreadsheet.html) — build a compounding economy by drag-filling formulas - Vol IV UPLINK (terminal.html) — terminal + live htop; manage load/heat; escalate to root - Vol V INBOX ZERO (inbox.html) — keyboard-triage an exponential inbox; write regex rules - ENTROPY OS (desktop.html) — the Win98 launcher that holds them all Each is a single self-contained HTML file (vanilla JS, no deps, no build), with idle/offline progress, scientific-scale numbers, diegetic upgrades, and a prestige that re-frames the fiction. Also includes MANIFESTO.md (design thesis) and docs/ — the Collector's Edition companion: a Dev Handbook and a Lore Bible (lore complete; 5 handbook chapters land in the next commit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
334 lines
23 KiB
Markdown
334 lines
23 KiB
Markdown
# Vol V Internals — INBOX ZERO
|
||
|
||
*The engineering manual for `inbox.html`: how plausible mail is minted, how the flood ramps, the overwhelm penalty that nearly broke the economy, the rules engine that automates your judgment, and the Inbox-Zero prestige loop.*
|
||
|
||
INBOX ZERO is Volume V of *Boring Software* — an Outlook 97 three-pane mail client that is secretly a survival game against exponential noise. Where **Vol IV — UPLINK** asks you to balance load against the OOM killer, this volume asks a simpler, crueler question: can you process mail faster than it arrives? The honest answer is *no* — not by hand — and the whole arc is the player discovering that, then encoding their judgment into rules until the inbox empties itself.
|
||
|
||
The save key is `boringsoft_inbox_v1`. The shared scaffolding (`fmt`, the `~100ms` tick, offline catch-up, `sanitizeState`, toasts) is the same contract every volume inherits; this chapter documents only what is specific to the inbox, and assumes you know the DNA from §6 of the style canon.
|
||
|
||
· · ·
|
||
|
||
## The MAIL model
|
||
|
||
A message is a flat object minted by `genMail(forceType)`. The shape it returns:
|
||
|
||
```js
|
||
return {
|
||
id:_mid++, type, from:fromName, addr:fromAddr, subject,
|
||
value: Math.max(0, baseVal),
|
||
unread:true, flagged:meta.flag, vip:meta.vip,
|
||
att: type==='work'&&Math.random()<0.45 || type==='phishing'&&Math.random()<0.6,
|
||
t: now(), body: pick(BODIES[type]),
|
||
};
|
||
```
|
||
|
||
| Field | Type | Meaning |
|
||
|-------|------|---------|
|
||
| `id` | int | Monotonic, from the module-level `_mid` counter. Selection (`G.sel`) and DOM rows key off this. |
|
||
| `type` | string | One of `work`, `newsletter`, `spam`, `phishing`, `vip`. |
|
||
| `from` / `addr` | string | Display name and `user@domain`, procedurally generated per type. |
|
||
| `subject` | string | A template from the per-type subject pool, with `{n}` and `{proj}` substituted. |
|
||
| `value` | number | The *base* Productivity the mail is worth — a per-action multiplier is applied later, never stored here. |
|
||
| `unread` | bool | Flipped to `false` the moment it's selected (`selectMail`). |
|
||
| `flagged` / `vip` | bool | `flag`/`vip` from the type's metadata; phishing and VIP both flag. |
|
||
| `att` | bool | Has an attachment. Decorative, except phishing attachments render as `invoice_overdue.pdf.exe`. |
|
||
| `t` | epoch ms | Arrival time, drives the `relTime` "3m ago" column. |
|
||
| `body` | string | A canned paragraph from `BODIES[type]`. |
|
||
|
||
> **DEV NOTE** — `value` is the *base* worth, not the banked Productivity. This is deliberate and load-bearing: a mail minted on the Startup plane and triaged after you've ascended to Government must pay out at *Government* rates. If we'd frozen the multiplier into `value` at spawn time, every mail that survived an ascension would underpay. Multipliers are always applied at the point of triage (`manualMult`, `ruleMult`), never baked in. The reading pane even previews this live: `+fmt(m.value*manualMult()) PP if archived`.
|
||
|
||
### The procedural generators
|
||
|
||
The minting is unglamorous string assembly, and that is the point — it has to read like real corporate mail, not like a video game. Senders are built from name and domain pools (`FIRST`, `LAST`, `WORK_DOMAINS`, `NEWS_DOMAINS`, `SPAM_DOMAINS`, `PHISH_DOMAINS`, `VIP_DOMAINS`). The flavor is half *The Office* cast (`Schrute`, `Beesly`, `Halpert`) and half spam-folder archaeology (`secure-paypa1.com`, `bank0famerica.net`, `nigerian-prince.org`).
|
||
|
||
Subject lines come from a per-type pool with two placeholders:
|
||
|
||
```js
|
||
let subject = pick(subT).replace(/\{n\}/g,n).replace(/\{proj\}/g,proj);
|
||
```
|
||
|
||
`{n}` is a random integer in `[1, 9999)`; `{proj}` is one of the `PROJ` codenames (`Phoenix`, `Atlas`, `Synergy`, …). Address construction is type-specific — work mail derives the address *from* the display name (`fromName.toLowerCase().replace(/[^a-z]/g,'.')`), so "Dwight Schrute" becomes `dwight.schrute@boringsoft.net`, while VIP mail always lands at the current plane's domain (`pick(VIP_DOMAINS)+'@'+plane().domain`). That last detail matters for rules: a `from contains CEO` filter keeps working across planes because the VIP names are stable even as the domain shifts.
|
||
|
||
### The five types — value and sign
|
||
|
||
`MAIL_TYPES` is the single source of truth for spawn weighting and base worth:
|
||
|
||
| Type | `weight` | `baseVal` | `flag` | `vip` | Triage intent |
|
||
|------|---------|----------|--------|-------|---------------|
|
||
| `work` | 34 | 14 | — | — | Archive (the bread and butter) |
|
||
| `newsletter` | 24 | 5 | — | — | Archive cheaply, or rule away |
|
||
| `spam` | 24 | 0 | — | — | Delete |
|
||
| `phishing` | 10 | 0 | ✓ | — | Delete (it's flagged, not VIP) |
|
||
| `vip` | 8 | 90 | ✓ | ✓ | **Reply** — deleting it costs you |
|
||
|
||
`rollType()` is a standard weighted pick over `TYPE_ORDER` (total weight 100, so the percentages read directly). Base value is then jittered by ±30% so two work mails are never identically priced:
|
||
|
||
```js
|
||
const baseVal = meta.baseVal * (0.7+Math.random()*0.6);
|
||
```
|
||
|
||
Note that spam and phishing have `baseVal: 0` — they are worth *nothing* to archive. The "negative value" of spam isn't stored as a negative number; it's expressed structurally, in the asymmetry of `deleteSel`. Deleting a worthless mail pays a tiny "tidied up" reward (`m.value*0.1 + 0.5`); deleting a *VIP* applies a flat penalty of half its banked value and a scolding toast. So the sign of an action is contextual: the same `delete` keystroke is correct on spam and a self-inflicted wound on a VIP. That is the whole risk surface of manual triage in one function.
|
||
|
||
· · ·
|
||
|
||
## The arrival pipeline
|
||
|
||
`spawnMail(n)` is the funnel every new mail passes through, online and offline:
|
||
|
||
```js
|
||
function spawnMail(n){
|
||
for(let k=0;k<n;k++){
|
||
const m = genMail();
|
||
if(!applyRules(m)){
|
||
// not auto-handled -> lands in inbox
|
||
G.inbox.push(m);
|
||
}
|
||
}
|
||
if(G.inbox.length && G.sel==null) G.sel = G.inbox[0].id;
|
||
}
|
||
```
|
||
|
||
The ordering is the design: **rules run before the mail ever touches the inbox.** If a rule matches, `applyRules` banks the Productivity and the mail never enters `G.inbox` (the one exception is `flag`, which re-pushes it — see the rules section). This is why a good rule-set produces a *visibly* self-clearing inbox rather than one that fills and then drains.
|
||
|
||
### `arrivalRate()` — the flood
|
||
|
||
```js
|
||
function arrivalRate(){
|
||
const base = 0.8 * plane().rate;
|
||
const ramp = 1 + Math.min(8, (G.peakThroughput>0? Math.log10(1+G.peakThroughput)*0.18 : 0));
|
||
return base * ramp;
|
||
}
|
||
```
|
||
|
||
Base is `0.8` emails per second, multiplied by the current plane's `rate`. The `ramp` is a slow within-run time-pressure curve keyed off `peakThroughput` — the better you've been doing this run, the faster mail comes, capped at a 9× multiplier (`1 + min(8, …)`). The log-scaling means the ramp is brutal early and forgiving late; it exists to keep a stagnant inbox from being trivially idle-able while you're not investing.
|
||
|
||
`step(dt)` accumulates fractional arrivals so the rate is honored even at sub-1/s on the Startup plane:
|
||
|
||
```js
|
||
G.spawnAccum += rate*dt;
|
||
let toSpawn = Math.floor(G.spawnAccum);
|
||
if(toSpawn>0){ G.spawnAccum -= toSpawn; if(toSpawn>400) toSpawn=400; spawnMail(toSpawn); }
|
||
```
|
||
|
||
The `toSpawn>400` clamp is a per-tick ceiling so a throttled tab that hands us a 5-second `dt` (the tick's own cap) on The Void can't try to mint thousands of objects in one synchronous loop and jank the frame. Offline catch-up uses the same accumulator with a higher clamp (`2000`) and only piles unhandled mail up to `inboxCap()*3` to bound memory.
|
||
|
||
### `inboxCap()` — and the post-mortem
|
||
|
||
```js
|
||
function inboxCap(){
|
||
// base buffer gives a new player ~20s of grace before the flood overwhelms them;
|
||
return 18 + lvl('cap')*UP_BY_ID.cap.add + perkAdd('serenity') + G.ascensions*0;
|
||
}
|
||
```
|
||
|
||
Base capacity is **18**. The `cap` upgrade adds `+14` per level; the `serenity` perk adds `+10` per level permanently.
|
||
|
||
> **DEV NOTE** — the post-mortem. The base cap was originally **8**, which is roughly what a real "manageable inbox" feels like. It was a disaster. On the Startup plane the flood plus the first ramp tick could overflow an 8-slot cap inside the first ten seconds, before a new player had read the help dialog or bought a single upgrade. The status bar screamed OVERWHELMED, income halved, and the player's first impression was a game punishing them for not yet knowing the rules. We rebalanced the base to 18 — about 20 seconds of grace at the opening rate — and re-tuned the `cap` upgrade's add from `+5` to `+14` so a single purchase is a *felt* relief, not a rounding error. The vestigial `+ G.ascensions*0` term is the scar: we briefly granted cap per ascension, decided perks should own that axis instead (`serenity`), and left the zeroed term as a deliberate marker of where the knob used to be. Don't delete it without reading this note — it's a comment that compiles.
|
||
|
||
### `isOverwhelmed()` and the penalty
|
||
|
||
```js
|
||
function isOverwhelmed(){ return now() < G.overwhelmedUntil || G.inbox.length > inboxCap(); }
|
||
function overwhelmPenalty(){ return isOverwhelmed() ? 0.5 : 1; }
|
||
```
|
||
|
||
Overwhelm is sticky. The instant `G.inbox.length` exceeds the cap, `step` sets `G.overwhelmedUntil = now()+1500` — a 1.5-second tail so that dropping to exactly cap doesn't flicker the status bar off and on every tick. While overwhelmed, `overwhelmPenalty()` returns `0.5` and is multiplied into *every* income path: `manualMult`, `ruleMult`, and through them every archive, reply, delete, and rule-banked dollar.
|
||
|
||
> **DEV NOTE** — the wired-in fix. The original overwhelm mechanic *slowed arrivals* when you were drowning — which is exactly backwards. It made the game easier the worse you were doing, so the inbox self-corrected and the pressure evaporated. The comment in `step` is the fossil of that decision: `if(isOverwhelmed()) rate *= 1.0; // arrivals don't slow; YOUR income tanks instead`. The `*= 1.0` is a no-op on purpose, a tombstone marking where the bad multiplier was. The real penalty moved into `overwhelmPenalty()` and was threaded through the multiplier functions so it could never be forgotten at a call site — there is exactly one place to be overwhelmed and exactly one place it bites. The status bar copy ("⚠ OVERWHELMED — income halved until you dig out") promises this to the player, so the constant is a contract, not a free parameter.
|
||
|
||
· · ·
|
||
|
||
## Triage actions and the keyboard
|
||
|
||
There are three manual actions, each operating on the selected mail. Productivity is banked through one of three multiplier helpers:
|
||
|
||
```js
|
||
function manualMult(){
|
||
return plane().ppMult * Math.pow(UP_BY_ID.hotkeys.mult, lvl('hotkeys'))
|
||
* perkMult('clarity') * overwhelmPenalty();
|
||
}
|
||
```
|
||
|
||
| Action | Function | Key | Payout | Notes |
|
||
|--------|---------|-----|--------|-------|
|
||
| Archive | `archiveSel` | `e` | `value × manualMult()` | `×readBonus()` if already read; `×0.85` if archived unread before the `reading` upgrade. |
|
||
| Reply | `replySel` | `r` | `value × 1.5 × manualMult() × replyMult()` | Costs `replyCost()` Focus (floor 3, default 8). The only action VIPs reward. |
|
||
| Delete | `deleteSel` | `#` / `Del` / `Backspace` | `(value×0.1 + 0.5) × manualMult()` | On a VIP instead: `−value×0.5×manualMult()`, clamped at 0, with a warning toast. |
|
||
|
||
`reading` and Focus deserve a note. Archiving an *unread* mail "sight unseen" pays `×0.85` until you buy **Speed-Reading Course** (`reading`), after which reads pay a `readBonus()` premium instead. `archiveAllRead()` exploits this — it bulk-archives every read, non-VIP mail at full `readBonus`, which is the intended reward for the `j/k`-to-read-then-bulk-archive rhythm. Replies are Focus-gated: `replyCost()` currently floors at 3 but is wired to scale (the `8 - lvl('focusregen')*0.0` term is another zeroed knob awaiting a balance pass).
|
||
|
||
The keyboard handler is the volume's tactile hook. It early-returns inside form fields (so typing a rule pattern doesn't archive mail), respects `Escape` to close modals, reserves `Ctrl/Cmd+S` for save, and otherwise dispatches:
|
||
|
||
```js
|
||
switch(e.key){
|
||
case 'j': case 'ArrowDown': e.preventDefault(); moveSel(1); break;
|
||
case 'k': case 'ArrowUp': e.preventDefault(); moveSel(-1); break;
|
||
case 'e': e.preventDefault(); archiveSel(); break;
|
||
case 'r': e.preventDefault(); replySel(); break;
|
||
case '#': e.preventDefault(); deleteSel(); break;
|
||
case 'Delete': case 'Backspace': e.preventDefault(); deleteSel(); break;
|
||
}
|
||
```
|
||
|
||
A nice touch: if you're viewing a non-inbox folder and press a triage key, the handler snaps you back to the inbox first, so muscle memory never silently no-ops. `moveSel` marks mail read as it passes, which is what feeds `archiveAllRead`. The same actions are reachable from the toolbar, the Edit menu, and a right-click context menu — but the keyboard is the one that feels like a real mail client, and that fidelity *is* the design (see §5 of the manifesto: fidelity over flash).
|
||
|
||
· · ·
|
||
|
||
## The rules engine
|
||
|
||
Rules are the automation layer — the moment the game stops being twitch triage and becomes systems-building. A rule record:
|
||
|
||
```js
|
||
{field, op, pattern, action, enabled, hits}
|
||
```
|
||
|
||
| Field | Domain | Source |
|
||
|-------|--------|--------|
|
||
| `field` | `from`, `subject`, `address`, `type` | `RULE_FIELDS` |
|
||
| `op` | `contains`, `matches`, `is` | `RULE_OPS` (`matches` is regex, gated behind an upgrade) |
|
||
| `pattern` | string | user-entered |
|
||
| `action` | `archive`, `delete`, `reply`, `flag` | `RULE_ACTIONS` |
|
||
| `enabled` | bool | toggled by the checkbox in the manager |
|
||
| `hits` | int | lifetime auto-triage count, shown per-rule and summed in the folder tree |
|
||
|
||
### Compilation
|
||
|
||
`compileRule(r)` precompiles the matcher once, not per-mail. For `matches` it builds a case-insensitive `RegExp`; for `contains`/`is` it lowercases the pattern into `_needle`. A bad regex is caught and the rule is flagged `_bad` rather than allowed to throw inside the hot path:
|
||
|
||
```js
|
||
function compileRule(r){
|
||
try{
|
||
if(r.op==='matches'){ r._re = new RegExp(r.pattern, 'i'); }
|
||
else { r._re = null; r._needle = String(r.pattern).toLowerCase(); }
|
||
r._bad = false;
|
||
}catch(e){ r._bad = true; r._re=null; }
|
||
}
|
||
```
|
||
|
||
`contains` is a `String.includes`; `is` is exact equality; `matches` runs the precompiled regex against the *raw* field value (the `i` flag handles case). The "glob/regex" distinction in practice is: without the `regex` upgrade you have substring and exact matching only, which covers most real filtering (`address contains spam.` → delete). The `regex` unlock turns on the `matches` operator and full pattern power — the builder even suggests `^(prize|winner|won)` as a starter.
|
||
|
||
### Application and slots
|
||
|
||
```js
|
||
function applyRules(m){
|
||
const slots = ruleSlots();
|
||
const active = G.rules.filter(r=>r.enabled).slice(0, slots);
|
||
for(const r of active){
|
||
if(ruleMatches(r, m)){
|
||
r.hits = (r.hits||0) + 1;
|
||
autoTriage(m, r.action);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
```
|
||
|
||
Two things are doing real work here. First, **rule slots cap how many rules actually run** — `ruleSlots()` is `1 + lvl('ruleslots') + perkAdd('preset')`, and only the first `slots` *enabled* rules are evaluated. You can author more rules than you can run; the surplus sit dormant until you buy slots. Second, **first match wins** (the `return true`), so order is strategy — a broad `archive` rule above a specific `delete` rule will eat mail the delete rule wanted.
|
||
|
||
`autoTriage` mirrors the manual payouts but banks through `ruleMult()` (which adds the `rulespeed` upgrade and the `enlightened` perk on top of plane and clarity):
|
||
|
||
```js
|
||
function autoTriage(m, action){
|
||
let gain = 0;
|
||
if(action==='archive'){ gain = m.value; folderInc('archive'); }
|
||
else if(action==='delete'){ gain = m.value*0.1; folderInc('spam'); }
|
||
else if(action==='reply'){ gain = m.value*1.4; folderInc('sent'); }
|
||
else if(action==='flag'){ m.flagged=true; m._ruleHandled=false; G.inbox.push(m); return; }
|
||
const banked = gain * ruleMult();
|
||
G.pp += banked;
|
||
if(hasUp('autoresponder')) G.pp += banked * lvl('autoresponder')*UP_BY_ID.autoresponder.mult;
|
||
trackThroughput(banked);
|
||
}
|
||
```
|
||
|
||
`flag` is the odd one out: it doesn't bank or remove, it pushes the mail *into* the inbox with a highlight — the escape hatch for "auto-surface this VIP, don't auto-handle it." Everything else banks and is gone. The `autoresponder` upgrade skims a passive `12%`-per-level bonus on top of rule income, which is the idle payoff the spec promised.
|
||
|
||
`addRule()` is the gatekeeper for authoring: it rejects empty patterns, refuses when `G.rules.length >= ruleSlots()`, blocks `matches` without the `regex` upgrade, charges **5 Focus** to author, and refunds that Focus if the regex turns out invalid. So even building automation costs the regenerating resource — you can't spam-author rules while drowning.
|
||
|
||
> **DEV NOTE** — rules run in `simulateAway` too. Offline catch-up calls the exact same `applyRules` per spawned mail, which is the whole reason an idle inbox can stay near zero: your encoded judgment keeps working while the tab is closed. The welcome-back toast reports it honestly — "your Rules auto-banked **N PP**" — and warns if the inbox overflowed past cap while you were gone. If you ever change the arrival or rule math, change it in *one* place that both `step` and `simulateAway` call, or online and offline economies will silently diverge. They currently share `arrivalRate`, `genMail`, and `applyRules`; keep it that way.
|
||
|
||
· · ·
|
||
|
||
## Planes and prestige
|
||
|
||
The five planes are the prestige ladder, each a multiplier on both volume and power:
|
||
|
||
| Plane | `rate` | `ppMult` | Domain |
|
||
|-------|-------|---------|--------|
|
||
| Startup | 1 | 1 | `boringsoft.net` |
|
||
| Enterprise | 6 | 7 | `enterprise-corp.com` |
|
||
| Government | 32 | 55 | `agency.gov` |
|
||
| The Whole Internet | 180 | 520 | `all-of.net` |
|
||
| The Void | 1100 | 6400 | `∅.void` |
|
||
|
||
Note `ppMult` outpaces `rate` at every step (7 vs 6, 55 vs 32, 520 vs 180, 6400 vs 1100). Ascension is a *net positive* on throughput — the volume terrifies, but the power compounds faster. That's the carrot that makes climbing feel good rather than punishing.
|
||
|
||
### The Inbox-Zero gate
|
||
|
||
You cannot prestige on cooldown or by spending; you must **genuinely reach Inbox Zero**:
|
||
|
||
```js
|
||
function canAscend(){ return G.inbox.length===0 && enlightenmentFor()>=1; }
|
||
```
|
||
|
||
With the flood always arriving, hitting exactly zero is a real achievement — you have to out-triage or out-rule the incoming rate long enough to drain the last mail, then immediately ascend before the next spawn. `enlightenmentFor()` is the prestige currency, sqrt-scaled off `peakThroughput` with a floor of 2000 and a plane bonus:
|
||
|
||
```js
|
||
function enlightenmentFor(){
|
||
const p = G.peakThroughput;
|
||
if(p < 2000) return 0;
|
||
return Math.floor(Math.pow(p/2000, 0.42) * (G.plane+1));
|
||
}
|
||
```
|
||
|
||
`doAscend()` banks the Enlightenment, increments `plane` and `ascensions`, and resets the run: `pp`, `inbox`, `peakThroughput`, `spawnAccum`, `overwhelmedUntil`, and `folderCounts` all clear; Focus refills to max. Crucially it **keeps** perks and Enlightenment, and carries `perkAdd('preset')` rules forward — the **Inherited Filters** perk — re-stamping them with `hits:0` and recompiling. Your judgment survives the reincarnation even though everything else burns.
|
||
|
||
### Serenity perks (Enlightenment)
|
||
|
||
The five permanent perks, bought in the Ascend dialog with Enlightenment, persist across every run:
|
||
|
||
| Perk | Effect per level | Max |
|
||
|------|-----------------|-----|
|
||
| `clarity` | ×1.30 global Productivity | 50 |
|
||
| `serenity` | +10 base Inbox cap | 50 |
|
||
| `flow` | +0.4 Focus regen | 40 |
|
||
| `preset` | +1 carried-over rule slot per ascension | 20 |
|
||
| `enlightened` | ×1.25 Productivity from *rules* (compounds with `rulespeed`) | 40 |
|
||
|
||
The endgame is reaching Inbox Zero on **The Void** — the secret ending — where the mail rate is 1100× and the only thing that empties the inbox is an automation stack so complete it triages the entire internet faster than it arrives. The Ascend dialog acknowledges it: *"You are on The Void — the highest plane. Reaching Zero here is the secret ending."* Thematically it rhymes with every other volume's terminus — **Vol I — qBitTorrz**'s Singularity, **Vol II — DEFRAG.EXE**'s migrated platter — the substrate grown so large it's indistinguishable from no substrate at all.
|
||
|
||
· · ·
|
||
|
||
## Save-key registry and state shape
|
||
|
||
`freshState()` seeds the global `G`. The persistence-relevant keys:
|
||
|
||
| Key | Default | Role |
|
||
|-----|--------|------|
|
||
| `pp` | 0 | Productivity (the spendable run currency) |
|
||
| `focus` / `focusMax` | 30 / 30 | Regenerating pool for Replies & rule authoring |
|
||
| `enlightenment` | 0 | Prestige currency (persists) |
|
||
| `peakThroughput` | 0 | Best PP/s this run; drives `enlightenmentFor` and the ramp |
|
||
| `plane` / `ascensions` | 0 / 0 | Prestige depth |
|
||
| `inbox` | `[]` | Live mail array; the survival gauge's numerator |
|
||
| `sel` / `folder` | `null` / `inbox` | Selection and active folder |
|
||
| `folderCounts` | `{archive,spam,sent}` | Lifetime-this-plane processed counters |
|
||
| `upgrades` / `perks` | `{}` / `{}` | Level maps, keyed by id |
|
||
| `rules` | `[]` | Rule records (sans the recompiled `_re`/`_needle`) |
|
||
| `overwhelmedUntil` | 0 | Sticky-overwhelm timestamp |
|
||
| `spawnAccum` | 0 | Fractional-arrival accumulator |
|
||
|
||
`sanitizeState()` is the guard that makes a corrupt save un-brickable, per the shared contract. It coerces every numeric to a finite default, clamps `plane` into `[0, PLANES.length-1]`, drops unknown upgrade/perk ids, rebuilds the inbox array element-by-element (defaulting unknown `type`s to `work`), and — importantly — **revalidates every rule against `RULE_FIELDS`/`RULE_OPS`/`RULE_ACTIONS` and recompiles it**, so a save edited to contain a malicious or malformed regex can't execute as anything but a flagged-`_bad` no-op. It also reconciles `_mid` past the highest surviving id so new mail never collides with loaded mail.
|
||
|
||
· · ·
|
||
|
||
## If you change this
|
||
|
||
- **`inboxCap()` base (18).** This is a tuned grace window, not a free number — read the cap post-mortem note above. Lowering it reintroduces the open-second overwhelm that made new players feel punished. If you change it, re-tune the `cap` upgrade's `add` (14) in the same pass.
|
||
- **The overwhelm penalty.** `overwhelmPenalty()` returns `0.5` and the status bar literally promises "income halved." It is wired through `manualMult`, `ruleMult`, and `autoTriage`. If you change the factor, change the UI copy; if you add a new income path, route it through a multiplier helper, not a raw `G.pp +=`. Do **not** resurrect the arrivals-slow-down mechanic — the `*= 1.0` tombstone in `step` exists to stop exactly that.
|
||
- **`arrivalRate()` and the ramp.** Online (`step`) and offline (`simulateAway`) must call the same rate, generator, and rule code or the two economies diverge. The `0.8` base and the `log10×0.18`, cap-8 ramp are balanced against the plane `rate` table — bumping one without the other breaks the climb.
|
||
- **`value` is base, not banked.** Never bake a multiplier into a stored `value`, or post-ascension mail underpays. Apply multipliers at triage time, always.
|
||
- **First-match-wins + slot truncation.** `applyRules` evaluates only the first `ruleSlots()` enabled rules and stops on first match. If you make rules run in parallel or all-match, you change the core strategy (rule ordering) and must rewrite the manager's "Active rules run top-to-bottom; first match wins" copy.
|
||
- **The zeroed knobs (`G.ascensions*0`, `lvl('focusregen')*0.0`).** These are intentional markers of retired or reserved balance axes. Don't prune them as dead code without reading why they're zero — they document where a tuning lever used to live, or will.
|