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>
218 lines
18 KiB
Markdown
218 lines
18 KiB
Markdown
# 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 A–G 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.
|