[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.
|
* milliseconds and stay reproducible. See NOTES.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
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 { createSim } from '../sim';
|
||||||
import { referenceFactory } from '../sim/reference';
|
import { referenceFactory } from '../sim/reference';
|
||||||
import { createUI } from './index';
|
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;
|
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>';
|
document.body.innerHTML = '<div id="game"></div><div id="ui"></div>';
|
||||||
const sim = createSim();
|
const sim = createSim();
|
||||||
const ui = createUI();
|
const ui = createUI();
|
||||||
|
const game = document.getElementById('game')!;
|
||||||
let pick: Vec2 | null = null;
|
let pick: Vec2 | null = null;
|
||||||
|
let state: SelectionState = null;
|
||||||
|
|
||||||
const bus: UIBus & { _sel: { def: string; dir: 0 | 1 | 2 | 3 } | null } = {
|
// A faithful stand-in for main.ts's v4 bus: it owns the selection, and it is the one
|
||||||
_sel: null,
|
// that dispatches `place`/`remove` on a world click. The UI must never do that itself.
|
||||||
|
const bus: UIBus = {
|
||||||
dispatch: (c) => sim.enqueue(c),
|
dispatch: (c) => sim.enqueue(c),
|
||||||
selectedBuild: () => bus._sel,
|
selectedBuild: () =>
|
||||||
|
state && state.mode === 'build' ? { def: state.def, dir: state.dir } : null,
|
||||||
pickTile: () => pick,
|
pickTile: () => pick,
|
||||||
|
selection: () => state,
|
||||||
|
setSelection: (s) => { state = s; },
|
||||||
};
|
};
|
||||||
|
|
||||||
sim.init(DATA, 1337);
|
sim.init(DATA, 1337);
|
||||||
|
if (opts.unlocked) unlockAll(sim); // before any tick, so placements aren't dropped
|
||||||
ui.init(document.getElementById('ui')!, DATA, bus);
|
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) => {
|
const step = (n = 1) => {
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
@ -51,17 +75,15 @@ function harness() {
|
|||||||
ui.update(sim.snapshot(), sim.drainEvents());
|
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) => {
|
const clickTile = (t: Vec2) => {
|
||||||
pick = t;
|
pick = t;
|
||||||
document
|
game.dispatchEvent(new MouseEvent('pointerdown', { button: 0, bubbles: true }));
|
||||||
.getElementById('game')!
|
|
||||||
.dispatchEvent(new MouseEvent('pointerdown', { button: 0, bubbles: true }));
|
|
||||||
};
|
};
|
||||||
const find = (defId: string): EntityState | undefined =>
|
const find = (defId: string): EntityState | undefined =>
|
||||||
sim.snapshot().entities.find((e) => e.def === defId);
|
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;
|
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', () => {
|
describe('the HUD against the reference factory', () => {
|
||||||
it('boots, builds and reports a working factory', () => {
|
it('boots, builds and reports a working factory', () => {
|
||||||
const h = harness();
|
const h = harness({ unlocked: true });
|
||||||
h.step(200);
|
h.step(200);
|
||||||
const snap = h.sim.snapshot();
|
const snap = h.sim.snapshot();
|
||||||
expect(snap.entities.length).toBeGreaterThan(20);
|
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', () => {
|
it('shows heat climbing, then THROTTLING, then SCRAM on a software decoder', () => {
|
||||||
const h = harness();
|
const h = harness({ unlocked: true });
|
||||||
h.step(50);
|
h.step(50);
|
||||||
|
|
||||||
const dec = h.find('software-decoder');
|
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', () => {
|
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.
|
// 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);
|
h.step(50);
|
||||||
const dec = h.find('software-decoder')!;
|
const dec = h.find('software-decoder')!;
|
||||||
h.clickTile(dec.pos);
|
h.clickTile(dec.pos);
|
||||||
@ -121,7 +143,7 @@ describe('the HUD against the reference factory', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ships and tickers up', () => {
|
it('ships and tickers up', () => {
|
||||||
const h = harness();
|
const h = harness({ unlocked: true });
|
||||||
h.step(3000);
|
h.step(3000);
|
||||||
const shipped = h.sim.snapshot().shippedTotal;
|
const shipped = h.sim.snapshot().shippedTotal;
|
||||||
expect(Object.values(shipped).reduce((a, b) => a + b, 0)).toBeGreaterThan(0);
|
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
|
// 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").
|
// 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.
|
// 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);
|
h.step(200);
|
||||||
expect(on('.fk-san')).toBe(false); // nothing sanitised yet
|
expect(on('.fk-san')).toBe(false); // nothing sanitised yet
|
||||||
|
|
||||||
h.bus.dispatch({ kind: 'remove', pos: { x: -6, y: 5 } }); // the i-only assembler
|
// Find it by what it *is*, not where it was — reference.ts moves its layout around.
|
||||||
h.bus.dispatch({ kind: 'place', def: 'shipper', pos: { x: -6, y: 5 }, dir: 0 });
|
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;
|
let shippedAt = -1;
|
||||||
for (let t = 0; t < 12000 && shippedAt < 0; t += 100) {
|
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', () => {
|
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;
|
let sawStanding = false;
|
||||||
for (let t = 0; t < 12000 && !sawStanding; t += 100) {
|
for (let t = 0; t < 12000 && !sawStanding; t += 100) {
|
||||||
h.step(100);
|
h.step(100);
|
||||||
@ -174,7 +200,7 @@ describe('the HUD against the reference factory', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('completes a commission and stamps the fax', () => {
|
it('completes a commission and stamps the fax', () => {
|
||||||
const h = harness();
|
const h = harness({ unlocked: true });
|
||||||
let done = false;
|
let done = false;
|
||||||
for (let t = 0; t < 6000 && !done; t += 50) {
|
for (let t = 0; t < 6000 && !done; t += 50) {
|
||||||
h.step(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', () => {
|
it('speaks in the house voice when things go wrong', () => {
|
||||||
const h = harness();
|
const h = harness({ unlocked: true });
|
||||||
h.step(400);
|
h.step(400);
|
||||||
const all = toasts().join(' | ');
|
const all = toasts().join(' | ');
|
||||||
// Something in a cold-starting factory is always starved; whatever it is, it must
|
// 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', () => {
|
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();
|
const h = harness({ unlocked: true });
|
||||||
h.step(50);
|
h.step(50);
|
||||||
const before = h.sim.snapshot().entities.length;
|
const before = h.sim.snapshot().entities.length;
|
||||||
const victim = h.find('belt')!;
|
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' }));
|
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.clickTile(victim.pos);
|
||||||
h.step(2);
|
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', () => {
|
it('stays armed for a second demolition, and Esc stands it down', () => {
|
||||||
const h = harness();
|
const h = harness({ unlocked: true });
|
||||||
h.step(50);
|
h.step(50);
|
||||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
|
||||||
|
|
||||||
const first = h.find('belt')!;
|
const first = h.find('belt')!;
|
||||||
h.clickTile(first.pos);
|
h.clickTile(first.pos);
|
||||||
h.step(2);
|
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' }));
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
expect(h.bus._sel).toBeNull();
|
expect(h.state()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not fire at open ground', () => {
|
it('does not fire at open ground', () => {
|
||||||
const h = harness();
|
const h = harness({ unlocked: true });
|
||||||
h.step(50);
|
h.step(50);
|
||||||
const before = h.sim.snapshot().entities.length;
|
const before = h.sim.snapshot().entities.length;
|
||||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user