From 7501353311fd8024dd3d4deb7b216e36d1909d0d Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 20:13:50 +1000 Subject: [PATCH] [ui] live: a full research run, and a faithful main.ts in the harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- fktry/src/ui/live.test.ts | 187 ++++++++++++++++++++++++++++++++------ 1 file changed, 159 insertions(+), 28 deletions(-) diff --git a/fktry/src/ui/live.test.ts b/fktry/src/ui/live.test.ts index 55448fe..4aa383e 100644 --- a/fktry/src/ui/live.test.ts +++ b/fktry/src/ui/live.test.ts @@ -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 = '
'; 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' }));