[ui] live harness: the HUD against the real sim and reference factory

Boots createSim() + createUI() on the real data/*.json, builds
referenceFactory(), and pumps ticks through the same update(snap, events) path
main.ts uses — then reads the actual DOM. No mocks.

Confirms what the round asked to see with eyes but the harness wouldn't allow:
heat climbing to THROTTLING and then SCRAM on a software decoder (inspected via
the real pickTile -> entityAt -> open click path), the unit still reading SCRAM
while it cools back below 1.0, SANITISING on real anchor-slab shipments,
NEXT IN TRAY matching SIM's real queue, a real standing order reaching the head
and stamping the fax, and remove mode demolishing a real entity.

Why headless: the browser pane backgrounds the tab, which pauses rAF, so the
live page advances ~10 ticks per screenshot — SCRAM needs ~435 ticks of
continuous generation, and parallel lanes' HMR reloads wiped the world every
minute or so. Same code, deterministic, milliseconds, and it stays a regression
test.

The reference factory never ships anchor slabs — it recycles them into the
i-only assembler by design — so the SANITISING case demolishes that assembler
and drops an uplink on it, which is what a player would do.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 18:34:40 +10:00
parent a6ecf88623
commit 9f3a254d85
2 changed files with 350 additions and 0 deletions

View File

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

240
fktry/src/ui/live.test.ts Normal file
View File

@ -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 = '<div id="game"></div><div id="ui"></div>';
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);
});
});