[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:
parent
0bf0e5a076
commit
4da8096de9
@ -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 = '<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');
|
||||
});
|
||||
|
||||
it('reads NEXT IN TRAY from the real commission queue, and stamps a real standing order', () => {
|
||||
const h = harness({ unlocked: true });
|
||||
let sawStanding = false;
|
||||
for (let t = 0; t < 12000 && !sawStanding; t += 100) {
|
||||
h.step(100);
|
||||
if (on('.fk-fax-standing')) sawStanding = true;
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
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);
|
||||
it('reads NEXT IN TRAY from the real commission queue, and advances it on completion', () => {
|
||||
const h = harness({ unlocked: true });
|
||||
h.step(10);
|
||||
|
||||
// ...and the peek is the queue's second entry, not a guess from data order.
|
||||
const nextId = snap.commissionQueue![1];
|
||||
if (nextId) {
|
||||
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);
|
||||
}
|
||||
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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user