[ui] live: research via testkit, and the round-5 behaviours against the real sim

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 14:12:42 +10:00
parent 0bf0e5a076
commit 4da8096de9

View File

@ -17,7 +17,9 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { EntityState, GameData, SelectionState, Sim, UIBus, Vec2 } from '../contracts'; import type { EntityState, GameData, SelectionState, Sim, UIBus, Vec2 } from '../contracts';
import { createSim } from '../sim'; import { createSim } from '../sim';
import { RESEARCH_TICKS_PER_PACK } from '../sim/constants';
import { referenceFactory } from '../sim/reference'; import { referenceFactory } from '../sim/reference';
import { grantAllResearch, grantResearch } from '../sim/testkit';
import { createUI } from './index'; import { createUI } from './index';
import items from '../../data/items.json'; 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; 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) { * v5 (order 4): research is granted through LANE-SIM's own `testkit`, not by hand-editing
const s = JSON.parse(sim.save()); * a save blob here. My round-4 version duplicated that round-trip and coupled this suite
s.research = { active: null, progress: {}, unlocked: DATA.tech.map((t) => t.id) }; * to SIM's save schema exactly the fragility I flagged then.
sim.load(JSON.stringify(s)); */
} const unlockAll = (sim: Sim) => grantAllResearch(sim, DATA);
function harness(opts: { unlocked?: boolean; reference?: boolean } = {}) { function harness(opts: { unlocked?: boolean; reference?: boolean } = {}) {
document.body.innerHTML = '<div id="game"></div><div id="ui"></div>'; document.body.innerHTML = '<div id="game"></div><div id="ui"></div>';
@ -176,33 +178,51 @@ describe('the HUD against the reference factory', () => {
expect($('.fk-san .fk-chip').textContent).toContain('I-FRAME'); 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<typeof harness>, 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 }); const h = harness({ unlocked: true });
let sawStanding = false; h.step(10);
for (let t = 0; t < 12000 && !sawStanding; t += 100) {
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); 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', () => { it('completes a commission and stamps the fax', () => {
const h = harness({ unlocked: true }); const h = harness({ unlocked: true });
shipRawOre(h);
let done = false; let done = false;
for (let t = 0; t < 6000 && !done; t += 50) { for (let t = 0; t < 12000 && !done; t += 50) {
h.step(50); h.step(50);
if (toasts().some((x) => x?.includes('FAX SENT'))) done = true; if (toasts().some((x) => x?.includes('FAX SENT'))) done = true;
} }
@ -308,8 +328,19 @@ describe('research against the real sim', () => {
lab.state.inputBuf = { ...cost }; lab.state.inputBuf = { ...cost };
h.sim.load(JSON.stringify(s)); h.sim.load(JSON.stringify(s));
// 4. the unlock lands: real event, toast in the house voice, padlock falls off. // 4. the lab actually works for it: v5 SIM converts one pack per
h.step(3); // 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(h.sim.snapshot().research!.unlocked).toContain(SOFTWARE_TECH);
expect(toasts().some((t) => t?.includes('STANDARD RATIFIED'))).toBe(true); expect(toasts().some((t) => t?.includes('STANDARD RATIFIED'))).toBe(true);
@ -318,7 +349,7 @@ describe('research against the real sim', () => {
.toBe(false); .toBe(false);
expect(lockBtn()!.getAttribute('title')).not.toContain('REQUIRES:'); 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.sim.enqueue({ kind: 'place', def: 'software-decoder', pos: { x: 20, y: 20 }, dir: 0 });
h.step(2); h.step(2);
expect(h.find('software-decoder'), 'unlocked machine must place').toBeTruthy(); 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', () => { describe('remove mode against the real sim', () => {
it('demolishes the clicked unit and toasts it', () => { it('demolishes the clicked unit and toasts it', () => {
const h = harness({ unlocked: true }); const h = harness({ unlocked: true });