From 4da8096de9db3c39ef20d42d553f4eac833a2e8e Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 14:12:42 +1000 Subject: [PATCH] [ui] live: research via testkit, and the round-5 behaviours against the real sim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research is granted through SIM's own testkit.grantResearch now, not by hand-editing a save blob here — that was my round-4 fragility and it broke this round exactly as predicted. The research loop also runs the lab for real: v5 converts one pack per RESEARCH_TICKS_PER_PACK, so the test drives 45 packs instead of asserting after 3 ticks. New coverage against the real sim: the mercy toast fires once and stays once across a second brownout; the inspect selection is published on open and dropped both on Esc and when the inspected unit is demolished; the tuner stays hidden until relics[0].found actually flips, then reveals with the ceremony toast and the toast standoff. The commission tests now build the rig a player would (extractor -> belt -> uplink) because DATA's queue head asks for raw ore the reference factory never ships — see NOTES on head-of-line blocking. Also stopped hardcoding the i-only assembler's coordinates; reference.ts moved it again. Co-Authored-By: Claude Opus 4.8 --- fktry/src/ui/live.test.ts | 160 +++++++++++++++++++++++++++++++------- 1 file changed, 132 insertions(+), 28 deletions(-) diff --git a/fktry/src/ui/live.test.ts b/fktry/src/ui/live.test.ts index 05fb955..098b725 100644 --- a/fktry/src/ui/live.test.ts +++ b/fktry/src/ui/live.test.ts @@ -17,7 +17,9 @@ import { describe, expect, it } from 'vitest'; import type { EntityState, GameData, SelectionState, Sim, UIBus, Vec2 } from '../contracts'; import { createSim } from '../sim'; +import { RESEARCH_TICKS_PER_PACK } from '../sim/constants'; import { referenceFactory } from '../sim/reference'; +import { grantAllResearch, grantResearch } from '../sim/testkit'; import { createUI } from './index'; import items from '../../data/items.json'; @@ -28,12 +30,12 @@ import commissions from '../../data/commissions.json'; const DATA = { items, machines, recipes, tech, commissions } as unknown as GameData; -/** 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)); -} +/** + * v5 (order 4): research is granted through LANE-SIM's own `testkit`, not by hand-editing + * a save blob here. My round-4 version duplicated that round-trip and coupled this suite + * to SIM's save schema — exactly the fragility I flagged then. + */ +const unlockAll = (sim: Sim) => grantAllResearch(sim, DATA); function harness(opts: { unlocked?: boolean; reference?: boolean } = {}) { document.body.innerHTML = '
'; @@ -176,33 +178,51 @@ describe('the HUD against the reference factory', () => { 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', () => { + /** + * DATA's v5 commission queue opens with `raw-payload` — 20 raw MDAT ORE — and the + * reference factory refines every scrap of its ore rather than shipping it, so the head + * commission can never complete and blocks all 19 behind it (see NOTES: head-of-line + * blocking). A player would do the obvious thing: tap raw ore straight to an uplink. + * `GameData` ships no seam map, so the whole map is minable and this works anywhere. + */ + function shipRawOre(h: ReturnType, x = 24, y = 24) { + // extractor (2x2) -> belt -> uplink (2x2), all facing east. The belt is required: + // machines only ever push onto belts, never straight into an adjacent machine. + h.bus.dispatch({ kind: 'place', def: 'seam-extractor', pos: { x, y }, dir: 1 }); + h.bus.dispatch({ kind: 'place', def: 'belt', pos: { x: x + 2, y }, dir: 1 }); + h.bus.dispatch({ kind: 'place', def: 'shipper', pos: { x: x + 3, y }, dir: 1 }); + } + + it('reads NEXT IN TRAY from the real commission queue, and advances it on completion', () => { const h = harness({ unlocked: true }); - let sawStanding = false; - for (let t = 0; t < 12000 && !sawStanding; t += 100) { + h.step(10); + + const first = h.sim.snapshot(); + expect(first.commissionQueue, 'SIM publishes the queue').toBeTruthy(); + expect(first.activeCommission).toBe(first.commissionQueue![0]); + + // The peek is the queue's second entry, not a guess from data order. + const nextId = first.commissionQueue![1]; + expect(on('.fk-fax-next')).toBe(true); + expect($('.fk-fax-next-name').textContent).toBe(nextId.replace(/-/g, ' ').toUpperCase()); + + // Satisfy the head commission for real, and watch the tray move up. + shipRawOre(h); + for (let t = 0; t < 12000 && h.sim.snapshot().activeCommission === first.activeCommission; 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()); } + const after = h.sim.snapshot(); + expect(after.activeCommission, 'the queue should advance once the head is paid') + .toBe(nextId); + expect($('.fk-fax-next-name').textContent) + .toBe(after.commissionQueue![1].replace(/-/g, ' ').toUpperCase()); }); it('completes a commission and stamps the fax', () => { const h = harness({ unlocked: true }); + shipRawOre(h); let done = false; - for (let t = 0; t < 6000 && !done; t += 50) { + for (let t = 0; t < 12000 && !done; t += 50) { h.step(50); if (toasts().some((x) => x?.includes('FAX SENT'))) done = true; } @@ -308,8 +328,19 @@ describe('research against the real sim', () => { 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); + // 4. the lab actually works for it: v5 SIM converts one pack per + // RESEARCH_TICKS_PER_PACK ticks, so research is a process, not an instant. + const packs = Object.values(cost).reduce((a, b) => a + b, 0); + h.step(RESEARCH_TICKS_PER_PACK); // one pack's worth + expect(h.sim.snapshot().research!.progress, 'the lab should be converting packs') + .not.toEqual({}); + + // ...run it to completion (generous bound; headless this is milliseconds). + for (let i = 0; i < packs + 4 && !h.sim.snapshot().research!.unlocked.includes(SOFTWARE_TECH); i++) { + h.step(RESEARCH_TICKS_PER_PACK); + } + + // 5. the unlock lands: real event, toast in the house voice, padlock falls off. expect(h.sim.snapshot().research!.unlocked).toContain(SOFTWARE_TECH); expect(toasts().some((t) => t?.includes('STANDARD RATIFIED'))).toBe(true); @@ -318,7 +349,7 @@ describe('research against the real sim', () => { .toBe(false); expect(lockBtn()!.getAttribute('title')).not.toContain('REQUIRES:'); - // 5. and now it builds. + // 6. 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(); @@ -326,6 +357,79 @@ describe('research against the real sim', () => { }); +describe('onboarding and ceremony against the real sim', () => { + it('gives the opening exactly one mercy hint on the first brownout, then never again', () => { + // Cold start: one extractor, no generator. The alarm alone teaches nothing. + const h = harness({ reference: false }); + h.bus.dispatch({ kind: 'place', def: 'seam-extractor', pos: { x: 0, y: 0 }, dir: 1 }); + h.step(30); + + const mercy = () => toasts().filter((t) => t?.includes('THE COMMITTEE SUGGESTS: POWER')); + expect(h.sim.snapshot().bandwidth.brownout, 'a powerless factory browns out').toBe(true); + expect(mercy(), 'the hint fires on the first brownout').toHaveLength(1); + + // Ride it out and brown out again — the hint must not become a nag. + h.bus.dispatch({ kind: 'place', def: 'decode-asic', pos: { x: 6, y: 0 }, dir: 1 }); + h.step(300); + h.bus.dispatch({ kind: 'remove', pos: { x: 6, y: 0 } }); + h.step(300); + expect(mercy(), 'and only ever once a session').toHaveLength(1); + }); + + it('publishes the inspect selection while the panel is open, and drops it on close', () => { + const h = harness({ unlocked: true }); + h.step(20); + const unit = h.find('seam-extractor')!; + + h.clickTile(unit.pos); + h.step(1); + expect(h.state(), 'renderer gets the entity to highlight') + .toEqual({ mode: 'inspect', entity: unit.id }); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + h.step(1); + expect(h.state(), 'highlight stops when the panel shuts').toBeNull(); + }); + + it('drops the highlight when the inspected unit is demolished under it', () => { + const h = harness({ unlocked: true }); + h.step(20); + const unit = h.find('seam-extractor')!; + h.clickTile(unit.pos); + h.step(1); + expect(h.state()).toEqual({ mode: 'inspect', entity: unit.id }); + + h.bus.dispatch({ kind: 'remove', pos: unit.pos }); + h.step(3); + expect(h.state(), 'no highlight on a unit that no longer exists').toBeNull(); + }); + + it('reveals the tuner dock only once the fossil is actually excavated', () => { + const h = harness({ unlocked: true }); + h.step(20); + + const dock = () => document.getElementById('fk-tuner')!; + const relic = h.sim.snapshot().relics?.[0]; + expect(relic, 'SIM seeds an optical fossil').toBeTruthy(); + expect(relic!.found).toBe(false); + expect(dock().classList.contains('is-revealed'), 'silent until it is dug up').toBe(false); + + h.bus.dispatch({ kind: 'excavate', pos: relic!.pos }); + // Excavation isn't instant; give it room to finish. + for (let i = 0; i < 200 && !h.sim.snapshot().relics![0].found; i++) h.step(10); + + expect(h.sim.snapshot().relics![0].found, 'the fossil should come up').toBe(true); + expect(dock().classList.contains('is-revealed'), 'the dock arrives with it').toBe(true); + expect(toasts().some((t) => t?.includes('THE BITSTREAM HAS A VOICE'))).toBe(true); + + // The dock lands in the toast stack's corner, so the toasts must step aside. + expect( + document.getElementById('fk-toasts')!.classList.contains('is-standoff'), + 'toasts should clear the dial rather than print over it', + ).toBe(true); + }); +}); + describe('remove mode against the real sim', () => { it('demolishes the clicked unit and toasts it', () => { const h = harness({ unlocked: true });