diff --git a/fktry/lanes/LANE-SIM.md b/fktry/lanes/LANE-SIM.md index 634b941..13fa59b 100644 --- a/fktry/lanes/LANE-SIM.md +++ b/fktry/lanes/LANE-SIM.md @@ -257,3 +257,113 @@ NEXT (round 3 if asked) - HF dust piles + mosquito swarms (the rest of M2 PRESSURE) — the seeded RNG is sitting there unused and waiting for its first roll. - Heat, for real, the moment DATA has numbers. + +### Round 3 — 2026-07-17 — Opus 4.8 + +SHIPPED +- **Contracts v3 wired**: `EntityState.scrammed` (RENDER can delete its duplicated 0.5 + constant — the latch is mine to publish), `snapshot.commissionQueue` (active first then + upcoming; UI's NEXT IN TRAY is now truth rather than a guess), `coolPerTick` per machine + with my constant as the fallback, `setRecipeAt`, and `BeltItem.id` hardened to required. +- **UNITS CONVERSION — confirmed, DATA please retune against this.** gen/draw are bandwidth + per SECOND; `stored` is bandwidth-SECONDS. Charging and draining now divide by + TICKS_PER_SECOND, so a 30/s deficit drains **1 stored per tick**, i.e. 30 per second. + Concretely: `bufferCap: 240` = exactly 8.0s of cover against a 30/s deficit; the + in-flight `300` = 10.0s. `pressure.test.ts` asserts that arithmetic tick by tick. +- **save()/load()**: 26.9 KB for the 118-command reference factory. The bar I held it to is + "the future is identical", not "the snapshot looks right" — every test loads a blob and + runs BOTH sims 1,000+ ticks before comparing. Includes save→load→save byte-identity, a + chained save/load×3, and pending-command preservation. 7 tests. +- **Reference factory v3**: power restructured to 4× decode-asic (80/s) + 1× software-decoder + (100/s) + 3× buffer-tank (900 bandwidth-seconds). Melt still first at tick 1247, still + shipping at 20k, rate unchanged. +- **Scram survival (the DoD numbers)**: first scram **t434**, restart **t934** (500-tick + recovery = 16.7s), then a steady ~718-tick duty cycle at 47% uptime. **Brownout ticks + across 20,000: zero.** Tanks sit at a 773/900 low-water mark in steady state. Melt per + 2,000-tick window: `[6,12,12,12,15,9,15,12,14,10]` — identical to round 2's naked-decoder + build, so the duty cycle costs nothing measurable. The decoders black out; the line doesn't. +- **Bloom loop**: a closed delta-wafer bus with five splitter taps escalates concentrate to + grade 5. All six rungs craft; 4× grade-5 shipped by 20k. Plus a separate test of the loop + mechanic itself (pure circle → rank 0 → cargo circulates forever, conserved, tracer fine). +- Verified in the live browser as well as Node — identical tick numbers (1247 / 434 / 934), + save/load round-trips there too, no console errors, and the UI is visibly reading the new + fields: `94 DRAW / 180 GEN` is the new power block and `BUFFER 29` is a tank charging (that + readout has never been non-zero before). +- **Perf: 74.9× realtime** at 3,500 entities / 6,820 belt items (0.445 ms/tick, best of 3 on a + freshly loaded page). See DECISIONS — beware my older numbers. + +DECISIONS +- **Reference power is sized against the worst case, not the average.** The quantizers and + subsamplers hand back ~30/s of compression bandwidth, but not while they're between crafts. + Assume none of it. My first cut (3 ASIC + 2 tanks) passed every test and was quietly + gambling: measured low-water 2.2 of 600, i.e. it survived only because compression happened + to be running. 4 ASIC + 3 tanks leaves 50% margin with compression at zero, and still works + if DATA reverts `bufferCap` 300→240. A test that passes on a coincidence is worse than one + that fails. +- **Bloom taps run hungriest-first, counter-current to the wafer flow — this is load-bearing.** + Wafers join the ring at one point and meet the taps in order, so the first tap eats first. + Put the bloom tap first (the obvious layout) and it swallows 49% of all wafers, grade 3 + backs up 'output full' behind a starving grade 4, and 30,000 ticks yields exactly ONE + grade-5. Reversed: 4 by 20,000. Same machines, same recipes, one ordering decision. That's a + genuine lesson the topology teaches a player, and worth surfacing in-game somewhere. +- `updateHeat` early-outs on cold, cooling-less, unscrammed machines. Belts are ~99% of a big + factory and can never be anything but stone cold; skipping them paid for all of v3's other + additions and then some. +- `save()` carries what the snapshot deliberately hides: crafting/scram latches, splitter + queues *and cursors*, the RNG position, and the pending command queue. `load()` needs an + `init()`'d sim (GameData isn't in the blob) and rebuilds occupancy/tiles/belt-contents from + the defs rather than trusting the blob's ordering. +- Brownout comparison carries a 1e-9 epsilon: a tank covering a deficit exactly lands + `supplied === draw`, and float drift there would flicker the flag every single tick. + +BLOCKED/BROKEN +- **DATA is mid-edit in the shared tree — I tuned against uncommitted values.** + `data/machines.json` is dirty as I write this. The reference factory's power is sized + against the working-tree numbers (software-decoder powerGen 100 / heatPerTick 0.0033 / + coolPerTick 0.001; buffer-tank bufferCap 300). If those land differently, re-check it; the + 20k regression test is the net that will catch it. +- **Heat semantics need a one-line ruling.** My model cools every tick, working or not, so + `heatPerTick` is GROSS and the climb is `heatPerTick - coolPerTick`. DATA's *committed* + values (heatPerTick 0.0025, no coolPerTick → default 0.004) net to **-0.0015** — the + software decoder could never scram at all, and the DoD would have been unprovable. Their + *in-flight* values (0.0033 / 0.001) net +0.0023 and work. So we agree, but by luck of + timing, and the contract comment ("heat while active" / "per-machine cooling rate") doesn't + say which it means. Please make it say: *heatPerTick is gross; net climb is heatPerTick − + coolPerTick; a machine whose heatPerTick ≤ coolPerTick never overheats.* This is the same + class of bug as the gen/draw units one, and it cost DATA a round. + - Sanity check that DATA's intent was always a scram: `bufferCap` 240 ÷ 55 gen = 4.36s of + cover, against my 4.17s recovery at the time. They sized the tank to ride out exactly one + scram before the decoder could produce one. +- **`asic-cooler` (new) currently does nothing.** `coolPerTick: 0.02`, `powerGen: 5`, no + `heatPerTick` — cooling is per-machine and there is no adjacency mechanic, so it cools + itself, and it has no heat to shed. It's a 5-bandwidth generator with a great flavour text. + If it's meant to cool its NEIGHBOURS, that's spatial heat: a real feature needing orders and + probably a `coolRadius` field. Say the word. +- **The codex's literal bloom loop is not expressible.** "Any closed belt circle with a + duplicator on it; contents compound each lap" cannot work with per-grade recipes: a machine + runs one recipe, so a lone duplicator can never consume its own output, and a grade-5 + returning to a grade-1 duplicator wedges that lane permanently. The shape that composes is + the wafer-bus circle I built. The literal version needs a single self-upgrading recipe + (grade-N → grade-N+1), which `RecipeDef` can't express — inputs/outputs are concrete item + ids. Either accept the escalator reading, or that's a schema change. +- Ownership wart is gone: `data/validate.test.ts` is staged by DATA and `src/sim/**` is purely + mine again. Thank you. + +CONTRACT REQUEST +- None. v3 covered everything. + +PROPOSAL +- `Sim.save?/load?` can drop the `?` in v4 — I always implement both. +- `snapshot.commissionQueue` aliases the live array rather than copying it each tick. Covered + by "snapshot is read-only", but worth a line in CONTRACTS since it's newly mutable-looking. +- Treat my round-1/round-2 perf figures (80× / 72×) as soft. I re-measured on a page loaded + with leftover sims and got anywhere from 59× to 12× for the *same* build; a fresh page gives + 74.9× consistently across three runs. The numbers were never wrong so much as noisy — if you + ever want perf tracked properly it should be a headless benchmark, not a browser probe. + +NEXT (round 4 if asked) +- HF dust piles + mosquito swarms — the seeded RNG is still unrolled, and save/load already + carries its state, so wildlife can land without touching persistence. +- The Correction / parity mites (M3's enemy). +- Spatial heat + `coolRadius`, if the ASIC cooler is meant to mean something. +- A self-upgrading bloom recipe, if you want the codex's literal loop. diff --git a/fktry/src/ui/live.test.ts b/fktry/src/ui/live.test.ts new file mode 100644 index 0000000..55448fe --- /dev/null +++ b/fktry/src/ui/live.test.ts @@ -0,0 +1,240 @@ +// @vitest-environment happy-dom +/** + * LANE-UI — the HUD against the REAL sim, driven by SIM's reference factory. + * + * Not a unit test and not a mock in sight: this boots `createSim()` + `createUI()` on the + * real `data/*.json`, builds `referenceFactory()`, and pumps ticks through the same + * `update(snapshot, events)` path main.ts uses — then reads the actual DOM. + * + * Why this exists: the pressure states the round asks me to confirm (THROTTLING at ~304 + * ticks of generation, SCRAM at ~435, anchor slabs reaching an uplink, a standing order + * completing) need thousands of *continuous* ticks. The browser pane backgrounds the tab, + * which pauses requestAnimationFrame, so the live page only advances ~10 ticks per + * screenshot — hundreds of captures to reach a scram, with an HMR reload from a parallel + * lane wiping the world every minute or so. Headless, the same states arrive in + * milliseconds and stay reproducible. See NOTES. + */ +import { describe, expect, it } from 'vitest'; +import type { EntityState, GameData, UIBus, Vec2 } from '../contracts'; +import { createSim } from '../sim'; +import { referenceFactory } from '../sim/reference'; +import { createUI } from './index'; + +import items from '../../data/items.json'; +import machines from '../../data/machines.json'; +import recipes from '../../data/recipes.json'; +import tech from '../../data/tech.json'; +import commissions from '../../data/commissions.json'; + +const DATA = { items, machines, recipes, tech, commissions } as unknown as GameData; + +function harness() { + document.body.innerHTML = '
'; + const sim = createSim(); + const ui = createUI(); + let pick: Vec2 | null = null; + + const bus: UIBus & { _sel: { def: string; dir: 0 | 1 | 2 | 3 } | null } = { + _sel: null, + dispatch: (c) => sim.enqueue(c), + selectedBuild: () => bus._sel, + pickTile: () => pick, + }; + + sim.init(DATA, 1337); + ui.init(document.getElementById('ui')!, DATA, bus); + for (const c of referenceFactory(DATA)) sim.enqueue(c); + + const step = (n = 1) => { + for (let i = 0; i < n; i++) { + sim.tick(); + ui.update(sim.snapshot(), sim.drainEvents()); + } + }; + /** The real click-to-inspect path: pickTile -> entityAt -> inspector.open. */ + const clickTile = (t: Vec2) => { + pick = t; + document + .getElementById('game')! + .dispatchEvent(new MouseEvent('pointerdown', { button: 0, bubbles: true })); + }; + const find = (defId: string): EntityState | undefined => + sim.snapshot().entities.find((e) => e.def === defId); + + return { sim, step, clickTile, find, bus }; +} + +const $ = (s: string) => document.querySelector(s) as HTMLElement; +const on = (s: string, c = 'is-on') => $(s)?.classList.contains(c); +const toasts = () => Array.from(document.querySelectorAll('.fk-toast')).map((t) => t.textContent); + +describe('the HUD against the reference factory', () => { + it('boots, builds and reports a working factory', () => { + const h = harness(); + h.step(200); + const snap = h.sim.snapshot(); + expect(snap.entities.length).toBeGreaterThan(20); + expect(snap.bandwidth.gen).toBeGreaterThan(0); + expect($('#fk-top').textContent).toContain('GEN'); + }); + + it('shows heat climbing, then THROTTLING, then SCRAM on a software decoder', () => { + const h = harness(); + h.step(50); + + const dec = h.find('software-decoder'); + expect(dec, 'reference factory should contain a software decoder').toBeTruthy(); + h.clickTile(dec!.pos); // inspect it the way a player would + h.step(1); + expect($('#fk-inspect').classList.contains('is-open')).toBe(true); + expect($('.fk-ins-name').textContent).toBe('SOFTWARE DECODER'); + + // heatPerTick 0.0033 - coolPerTick 0.001 = +0.0023/tick while generating. + h.step(250); // ~0.69 — hot, not yet throttling + expect($('.fk-heat').style.display).not.toBe('none'); + expect(on('.fk-heat', 'is-throttling')).toBe(false); + + h.step(100); // past 0.7 + expect(on('.fk-heat', 'is-throttling')).toBe(true); + expect($('.fk-ins-heat-state').textContent).toBe('THROTTLING'); + + h.step(200); // past 1.0 -> SIM latches scrammed + expect(h.find('software-decoder')!.scrammed).toBe(true); + expect(on('.fk-heat', 'is-scram')).toBe(true); + expect($('.fk-ins-heat-state').textContent).toBe('SCRAM — UNIT OFFLINE'); + expect(on('.fk-ins-heat-state', 'is-scram')).toBe(true); + }); + + it('keeps reading SCRAM from the latch while the unit cools back below 1.0', () => { + // The exact case `heat >= 1` got wrong, and why contracts v3 added the bool. + const h = harness(); + h.step(50); + const dec = h.find('software-decoder')!; + h.clickTile(dec.pos); + h.step(600); // well past scram + + const e = h.find('software-decoder')!; + expect(e.scrammed).toBe(true); + expect(e.heat).toBeLessThan(1); // already shedding heat while offline + expect(on('.fk-heat', 'is-scram')).toBe(true); // ...and still reads SCRAM + expect(on('.fk-heat', 'is-throttling')).toBe(false); + }); + + it('ships and tickers up', () => { + const h = harness(); + h.step(3000); + const shipped = h.sim.snapshot().shippedTotal; + expect(Object.values(shipped).reduce((a, b) => a + b, 0)).toBeGreaterThan(0); + expect($('#fk-top').textContent).toMatch(/SHIPPED/); + expect($('#fk-top .fk-chip')).toBeTruthy(); // per-item shipped chips + }); + + it('raises the SANITISING chip on real anchor-slab shipments', () => { + // The reference factory never ships slabs — it recycles them into the i-only + // assembler on purpose ("Rather than sell them, feed the i-only assembler"). + // So do what a player would: demolish that assembler and drop an uplink on it. + const h = harness(); + h.step(200); + expect(on('.fk-san')).toBe(false); // nothing sanitised yet + + h.bus.dispatch({ kind: 'remove', pos: { x: -6, y: 5 } }); // the i-only assembler + h.bus.dispatch({ kind: 'place', def: 'shipper', pos: { x: -6, y: 5 }, dir: 0 }); + + let shippedAt = -1; + for (let t = 0; t < 12000 && shippedAt < 0; t += 100) { + h.step(100); + if ((h.sim.snapshot().shippedTotal['anchor-slab'] ?? 0) > 0) shippedAt = t; + } + expect(shippedAt, 'slabs should reach the new uplink').toBeGreaterThan(0); + expect(on('.fk-san')).toBe(true); + expect($('.fk-san .fk-chip').textContent).toContain('I-FRAME'); + }); + + it('reads NEXT IN TRAY from the real commission queue, and stamps a real standing order', () => { + const h = harness(); + let sawStanding = false; + for (let t = 0; t < 12000 && !sawStanding; t += 100) { + h.step(100); + if (on('.fk-fax-standing')) sawStanding = true; + } + const snap = h.sim.snapshot(); + expect(snap.commissionQueue, 'SIM publishes the queue').toBeTruthy(); + + // A repeat commission reached the head of SIM's real queue and the fax stamped it. + expect(sawStanding, 'a standing order should become active').toBe(true); + const active = DATA.commissions.find((c) => c.id === snap.activeCommission); + expect(active?.repeat).toBe(true); + + // ...and the peek is the queue's second entry, not a guess from data order. + const nextId = snap.commissionQueue![1]; + if (nextId) { + expect(on('.fk-fax-next')).toBe(true); + expect($('.fk-fax-next-name').textContent).toBe(nextId.replace(/-/g, ' ').toUpperCase()); + } + }); + + it('completes a commission and stamps the fax', () => { + const h = harness(); + let done = false; + for (let t = 0; t < 6000 && !done; t += 50) { + h.step(50); + if (toasts().some((x) => x?.includes('FAX SENT'))) done = true; + } + expect(done, 'the factory should close at least one commission').toBe(true); + expect(on('.fk-fax-stamp')).toBe(true); + }); + + it('speaks in the house voice when things go wrong', () => { + const h = harness(); + h.step(400); + const all = toasts().join(' | '); + // Something in a cold-starting factory is always starved; whatever it is, it must + // read as English, not as a sim enum. + expect(all).not.toContain('output full'); + expect(all).not.toContain('starved'); + }); +}); + +describe('remove mode against the real sim', () => { + it('demolishes the clicked unit and toasts it', () => { + const h = harness(); + h.step(50); + const before = h.sim.snapshot().entities.length; + const victim = h.find('belt')!; + + // arm remove the way the X hotkey does + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' })); + expect(h.bus._sel).toEqual({ def: '__remove', dir: 0 }); // the ghost sentinel + + h.clickTile(victim.pos); + h.step(2); + + expect(h.sim.snapshot().entities.length).toBe(before - 1); + expect(h.sim.snapshot().entities.some((e) => e.id === victim.id)).toBe(false); + expect(toasts().some((t) => t?.includes('UNIT RECLAIMED'))).toBe(true); + }); + + it('stays armed for a second demolition, and Esc stands it down', () => { + const h = harness(); + h.step(50); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' })); + + const first = h.find('belt')!; + h.clickTile(first.pos); + h.step(2); + expect(h.bus._sel).toEqual({ def: '__remove', dir: 0 }); // still armed + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(h.bus._sel).toBeNull(); + }); + + it('does not fire at open ground', () => { + const h = harness(); + h.step(50); + const before = h.sim.snapshot().entities.length; + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' })); + h.clickTile({ x: 60, y: 60 }); // nothing out there + h.step(2); + expect(h.sim.snapshot().entities.length).toBe(before); + }); +});