bitmax/docs/handbook/12-vol-III-spreadsheet.md
monster 87cfead4d3 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>
2026-06-14 16:21:31 +10:00

218 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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.