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 });