[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:
parent
a6ecf88623
commit
9f3a254d85
@ -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
240
fktry/src/ui/live.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user