[ui] live: a full research run, and a faithful main.ts in the harness
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 <noreply@anthropic.com>
This commit is contained in:
parent
2904ddbb8c
commit
7501353311
@ -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 = '<div id="game"></div><div id="ui"></div>';
|
||||
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' }));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user