[ui] live: a full research run, and a faithful main.ts in the harness

The harness bus is now a real v4 host: it owns the typed selection and it is
the thing that dispatches place/remove on a world click. That is what caught
the round-3 double-fire.

New: the whole loop the DoD asked for, against the real sim — locked machine
visible, its `place` silently dropped, build a lab, pick the tech through the
panel, packs delivered, `researched` fires, toast lands, padlock falls off, and
the same `place` now succeeds.

Packs are teleported into the lab's intake via the sim's own save()/load()
because nothing in the game can make a science pack yet (see NOTES);
everything after that is the sim's real drainLabs -> researched -> applyUnlocks
path, unmocked.

Reference-factory tests now run with research pre-granted, because SIM's gating
drops the software decoders reference.ts depends on. Stopped hardcoding the
i-only assembler's coordinates too — reference.ts moved it mid-round and broke
my own test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 20:13:50 +10:00
parent 2904ddbb8c
commit 7501353311

View File

@ -15,7 +15,7 @@
* milliseconds and stay reproducible. See NOTES.
*/
import { describe, expect, it } from 'vitest';
import type { EntityState, GameData, UIBus, Vec2 } from '../contracts';
import type { EntityState, GameData, SelectionState, Sim, UIBus, Vec2 } from '../contracts';
import { createSim } from '../sim';
import { referenceFactory } from '../sim/reference';
import { createUI } from './index';
@ -28,22 +28,46 @@ import commissions from '../../data/commissions.json';
const DATA = { items, machines, recipes, tech, commissions } as unknown as GameData;
function harness() {
/** Grant research through the sim's own save/load — `load()` re-runs applyUnlocks(). */
function unlockAll(sim: Sim) {
const s = JSON.parse(sim.save());
s.research = { active: null, progress: {}, unlocked: DATA.tech.map((t) => t.id) };
sim.load(JSON.stringify(s));
}
function harness(opts: { unlocked?: boolean; reference?: boolean } = {}) {
document.body.innerHTML = '<div id="game"></div><div id="ui"></div>';
const sim = createSim();
const ui = createUI();
const game = document.getElementById('game')!;
let pick: Vec2 | null = null;
let state: SelectionState = null;
const bus: UIBus & { _sel: { def: string; dir: 0 | 1 | 2 | 3 } | null } = {
_sel: null,
// A faithful stand-in for main.ts's v4 bus: it owns the selection, and it is the one
// that dispatches `place`/`remove` on a world click. The UI must never do that itself.
const bus: UIBus = {
dispatch: (c) => sim.enqueue(c),
selectedBuild: () => bus._sel,
selectedBuild: () =>
state && state.mode === 'build' ? { def: state.def, dir: state.dir } : null,
pickTile: () => pick,
selection: () => state,
setSelection: (s) => { state = s; },
};
sim.init(DATA, 1337);
if (opts.unlocked) unlockAll(sim); // before any tick, so placements aren't dropped
ui.init(document.getElementById('ui')!, DATA, bus);
for (const c of referenceFactory(DATA)) sim.enqueue(c);
// main.ts's own click glue, copied verbatim in behaviour.
game.addEventListener('pointerdown', (e) => {
if ((e as MouseEvent).button !== 0) return;
const sel = bus.selection!();
if (!sel || !pick) return;
if (sel.mode === 'remove') sim.enqueue({ kind: 'remove', pos: pick });
else sim.enqueue({ kind: 'place', def: sel.def, pos: pick, dir: sel.dir });
});
if (opts.reference !== false) for (const c of referenceFactory(DATA)) sim.enqueue(c);
const step = (n = 1) => {
for (let i = 0; i < n; i++) {
@ -51,17 +75,15 @@ function harness() {
ui.update(sim.snapshot(), sim.drainEvents());
}
};
/** The real click-to-inspect path: pickTile -> entityAt -> inspector.open. */
/** A real click on the world: main.ts's handler and the UI's both see it. */
const clickTile = (t: Vec2) => {
pick = t;
document
.getElementById('game')!
.dispatchEvent(new MouseEvent('pointerdown', { button: 0, bubbles: true }));
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 };
return { sim, step, clickTile, find, bus, state: () => state };
}
const $ = (s: string) => document.querySelector(s) as HTMLElement;
@ -70,7 +92,7 @@ const toasts = () => Array.from(document.querySelectorAll('.fk-toast')).map((t)
describe('the HUD against the reference factory', () => {
it('boots, builds and reports a working factory', () => {
const h = harness();
const h = harness({ unlocked: true });
h.step(200);
const snap = h.sim.snapshot();
expect(snap.entities.length).toBeGreaterThan(20);
@ -79,7 +101,7 @@ describe('the HUD against the reference factory', () => {
});
it('shows heat climbing, then THROTTLING, then SCRAM on a software decoder', () => {
const h = harness();
const h = harness({ unlocked: true });
h.step(50);
const dec = h.find('software-decoder');
@ -107,7 +129,7 @@ describe('the HUD against the reference factory', () => {
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();
const h = harness({ unlocked: true });
h.step(50);
const dec = h.find('software-decoder')!;
h.clickTile(dec.pos);
@ -121,7 +143,7 @@ describe('the HUD against the reference factory', () => {
});
it('ships and tickers up', () => {
const h = harness();
const h = harness({ unlocked: true });
h.step(3000);
const shipped = h.sim.snapshot().shippedTotal;
expect(Object.values(shipped).reduce((a, b) => a + b, 0)).toBeGreaterThan(0);
@ -133,12 +155,16 @@ describe('the HUD against the reference factory', () => {
// 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();
const h = harness({ unlocked: true });
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 });
// Find it by what it *is*, not where it was — reference.ts moves its layout around.
const iOnly = h.sim.snapshot().entities.find((e) => e.recipe === 'assemble-gop-i-only');
expect(iOnly, 'reference factory should recycle slabs through an i-only assembler')
.toBeTruthy();
h.bus.dispatch({ kind: 'remove', pos: iOnly!.pos });
h.bus.dispatch({ kind: 'place', def: 'shipper', pos: iOnly!.pos, dir: 0 });
let shippedAt = -1;
for (let t = 0; t < 12000 && shippedAt < 0; t += 100) {
@ -151,7 +177,7 @@ describe('the HUD against the reference factory', () => {
});
it('reads NEXT IN TRAY from the real commission queue, and stamps a real standing order', () => {
const h = harness();
const h = harness({ unlocked: true });
let sawStanding = false;
for (let t = 0; t < 12000 && !sawStanding; t += 100) {
h.step(100);
@ -174,7 +200,7 @@ describe('the HUD against the reference factory', () => {
});
it('completes a commission and stamps the fax', () => {
const h = harness();
const h = harness({ unlocked: true });
let done = false;
for (let t = 0; t < 6000 && !done; t += 50) {
h.step(50);
@ -185,7 +211,7 @@ describe('the HUD against the reference factory', () => {
});
it('speaks in the house voice when things go wrong', () => {
const h = harness();
const h = harness({ unlocked: true });
h.step(400);
const all = toasts().join(' | ');
// Something in a cold-starting factory is always starved; whatever it is, it must
@ -195,16 +221,121 @@ describe('the HUD against the reference factory', () => {
});
});
describe('research against the real sim', () => {
const SOFTWARE_TECH = 'stream-software-decoding'; // gates 'software-decoder'
const lockBtn = () =>
Array.from(document.querySelectorAll('.fk-build-btn')).find((b) =>
b.textContent?.includes('SOFTWARE DECODER'),
) as HTMLElement | undefined;
/** Walk the build bar to the page holding a machine. */
function pageTo(name: string) {
for (let i = 0; i < 12; i++) {
if (lockBtn()) return true;
window.dispatchEvent(new KeyboardEvent('keydown', { key: ']' }));
}
return !!lockBtn();
}
it('padlocks a tech-gated machine and names the tech it needs', () => {
const h = harness({ reference: false });
h.step(1);
expect(pageTo('SOFTWARE DECODER')).toBe(true);
const btn = lockBtn()!;
expect(btn.classList.contains('is-locked')).toBe(true);
expect(btn.querySelector('.fk-build-lock')!.classList.contains('is-on')).toBe(true);
expect(btn.getAttribute('title')).toContain('REQUIRES:');
expect(btn.getAttribute('title')).toContain('STREAM SOFTWARE DECODING');
});
it('leaves an ungated machine alone — the rule only locks what a tech claims', () => {
const h = harness({ reference: false });
h.step(1);
// Walk the pages to LOGISTICS rather than assuming the belt is on the open one.
let belt: Element | undefined;
for (let i = 0; i < 12 && !belt; i++) {
belt = Array.from(document.querySelectorAll('.fk-build-btn')).find((b) =>
b.textContent?.includes('STREAM BELT'),
);
if (!belt) window.dispatchEvent(new KeyboardEvent('keydown', { key: ']' }));
}
expect(belt, 'belt should be somewhere in the bar').toBeTruthy();
expect(belt!.classList.contains('is-locked')).toBe(false);
});
it('refuses to select a locked machine, by click or hotkey', () => {
const h = harness({ reference: false });
h.step(1);
pageTo('SOFTWARE DECODER');
lockBtn()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(h.state()).toBeNull(); // nothing picked up
});
it('runs the whole loop: lock → pick research → deliver → unlock → place succeeds', () => {
const h = harness({ reference: false });
h.step(1);
// 1. locked, and the sim agrees: a place command is silently dropped.
h.clickTile({ x: 0, y: 0 });
h.sim.enqueue({ kind: 'place', def: 'software-decoder', pos: { x: 20, y: 20 }, dir: 0 });
h.step(2);
expect(h.find('software-decoder'), 'gated machine must not place').toBeUndefined();
// 2. a lab is buildable from a cold start, and picking a tech goes through the panel.
h.sim.enqueue({ kind: 'place', def: 'archaeology-lab', pos: { x: 10, y: 10 }, dir: 0 });
h.step(2);
expect(h.find('archaeology-lab')).toBeTruthy();
window.dispatchEvent(new KeyboardEvent('keydown', { key: 't' })); // open the panel
h.step(1);
expect(document.getElementById('fk-tech')!.classList.contains('is-open')).toBe(true);
const node = Array.from(document.querySelectorAll('.fk-tech-node')).find((n) =>
n.textContent?.includes('SOFTWARE DECODING'),
) as HTMLElement;
node.dispatchEvent(new MouseEvent('click', { bubbles: true })); // -> setResearch
h.step(2);
expect(h.sim.snapshot().research!.active).toBe(SOFTWARE_TECH);
expect(node.classList.contains('is-active')).toBe(true);
// 3. deliver the packs. Nothing in the game can make them yet (see NOTES), so they
// are teleported into the lab's intake via save/load — every step after this is
// the sim's own drainLabs -> researched -> applyUnlocks path, unmocked.
const cost = DATA.tech.find((t) => t.id === SOFTWARE_TECH)!.cost;
const s = JSON.parse(h.sim.save());
const lab = s.entities.find((e: any) => e.state.def === 'archaeology-lab');
lab.state.inputBuf = { ...cost };
h.sim.load(JSON.stringify(s));
// 4. the unlock lands: real event, toast in the house voice, padlock falls off.
h.step(3);
expect(h.sim.snapshot().research!.unlocked).toContain(SOFTWARE_TECH);
expect(toasts().some((t) => t?.includes('STANDARD RATIFIED'))).toBe(true);
pageTo('SOFTWARE DECODER');
expect(lockBtn()!.classList.contains('is-locked'), 'padlock should fall off live')
.toBe(false);
expect(lockBtn()!.getAttribute('title')).not.toContain('REQUIRES:');
// 5. and now it builds.
h.sim.enqueue({ kind: 'place', def: 'software-decoder', pos: { x: 20, y: 20 }, dir: 0 });
h.step(2);
expect(h.find('software-decoder'), 'unlocked machine must place').toBeTruthy();
});
});
describe('remove mode against the real sim', () => {
it('demolishes the clicked unit and toasts it', () => {
const h = harness();
const h = harness({ unlocked: true });
h.step(50);
const before = h.sim.snapshot().entities.length;
const victim = h.find('belt')!;
// arm remove the way the X hotkey does
// arm remove the way the X hotkey does — typed protocol, no magic string
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
expect(h.bus._sel).toEqual({ def: '__remove', dir: 0 }); // the ghost sentinel
expect(h.state()).toEqual({ mode: 'remove' });
h.clickTile(victim.pos);
h.step(2);
@ -215,21 +346,21 @@ describe('remove mode against the real sim', () => {
});
it('stays armed for a second demolition, and Esc stands it down', () => {
const h = harness();
const h = harness({ unlocked: true });
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
expect(h.state()).toEqual({ mode: 'remove' }); // still armed
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(h.bus._sel).toBeNull();
expect(h.state()).toBeNull();
});
it('does not fire at open ground', () => {
const h = harness();
const h = harness({ unlocked: true });
h.step(50);
const before = h.sim.snapshot().entities.length;
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));