/** * Research v1 and the v4 gating rule. * * The mechanics run on a fixture; the gating rule is checked against DATA's real tech.json. * The fixture exists so these tests pin the RULE rather than DATA's numbers — which moved * twice while this round was being written. For the same path proved end-to-end on the real * tree, with a real lab and real science packs, see realtech.test.ts. */ import { describe, expect, it } from 'vitest'; import { createSim } from './index'; import { grantResearch } from './testkit'; import { RESEARCH_TICKS_PER_PACK } from './constants'; import type { Command, Dir, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent, TechDef, } from '../contracts'; import items from '../../data/items.json'; import machines from '../../data/machines.json'; import recipes from '../../data/recipes.json'; import techJson from '../../data/tech.json'; import commissions from '../../data/commissions.json'; const REAL = { items, machines, recipes, tech: techJson, commissions } as unknown as GameData; const N: Dir = 0, E: Dir = 1, S: Dir = 2; const item = (id: string): ItemDef => ({ id, name: id, codex: 'fixture', tier: 0, color: '#fff' }); const mach = (m: Partial & Pick): MachineDef => ({ name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0, asset: 'fixture', ...m, }); const rec = (r: Partial & Pick): RecipeDef => ({ inputs: {}, ticks: 1, bandwidth: 0, ...r, }); const tech = (t: TechDef): TechDef => t; /** * A tree that can actually be entered: the pack press is ungated, so packs exist before * any research does. (This is exactly the property DATA's real tree is missing.) */ const FIXTURE: GameData = { items: ['rock', 'pack', 'gold'].map(item), machines: [ mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }), mach({ id: 'belt', kind: 'belt', beltSpeed: 2 }), mach({ id: 'ship', kind: 'shipper' }), mach({ id: 'packer', kind: 'crafter', recipes: ['press-pack'] }), // ungated mach({ id: 'lab', kind: 'lab' }), // ungated mach({ id: 'split', kind: 'splitter' }), mach({ id: 'goldworks', kind: 'crafter', recipes: ['smelt-gold'] }), // GATED mach({ id: 'other', kind: 'crafter', recipes: ['plain', 'fancy'] }), // recipe 'fancy' GATED ], recipes: [ rec({ id: 'dig', machine: 'mine', outputs: { rock: 1 }, ticks: 30 }), rec({ id: 'press-pack', machine: 'packer', inputs: { rock: 1 }, outputs: { pack: 1 }, ticks: 5 }), rec({ id: 'smelt-gold', machine: 'goldworks', inputs: { rock: 1 }, outputs: { gold: 1 }, ticks: 5 }), rec({ id: 'plain', machine: 'other', inputs: { rock: 1 }, outputs: { gold: 1 }, ticks: 5 }), rec({ id: 'fancy', machine: 'other', inputs: { rock: 2 }, outputs: { gold: 2 }, ticks: 5 }), ], tech: [ tech({ id: 'metallurgy', era: 'reel', cost: { pack: 3 }, unlocks: ['goldworks', 'smelt-gold'] }), tech({ id: 'flourish', era: 'reel', cost: { pack: 2 }, unlocks: ['fancy'] }), ], commissions: [{ id: 'c1', flavor: 'gold', wants: { gold: 99 }, rewardBandwidth: 1 }], }; function newSim(data: GameData = FIXTURE, seed = 1): Sim { const sim = createSim(); sim.init(data, seed); return sim; } const place = (def: string, x: number, y: number, dir: Dir = N): Command => ({ kind: 'place', def, pos: { x, y }, dir }); function run(sim: Sim, ticks: number, sink?: SimEvent[]): void { for (let i = 0; i < ticks; i++) { sim.tick(); const evs = sim.drainEvents(); if (sink) for (const e of evs) sink.push(e); } } const at = (sim: Sim, x: number, y: number) => sim.snapshot().entities.find((e) => e.pos.x === x && e.pos.y === y); /** mine -> packer -> lab, plus somewhere for the packs to be counted. */ function packLine(sim: Sim): void { sim.enqueue(place('mine', 0, 0)); sim.enqueue(place('belt', 1, 0, E)); sim.enqueue(place('packer', 2, 0)); sim.enqueue(place('belt', 3, 0, E)); sim.enqueue(place('lab', 4, 0)); } describe('gating', () => { it('locks every id a tech names and leaves everything else alone', () => { const sim = newSim(); sim.enqueue(place('goldworks', 5, 5)); // named by metallurgy.unlocks -> dropped sim.enqueue(place('packer', 7, 5)); // named by nothing -> free run(sim, 1); expect(at(sim, 5, 5)).toBeUndefined(); expect(at(sim, 7, 5)).toBeDefined(); }); it('drops setRecipe and setRecipeAt for a locked recipe, silently', () => { const sim = newSim(); sim.enqueue(place('other', 5, 5)); run(sim, 1); const id = at(sim, 5, 5)!.id; expect(at(sim, 5, 5)!.recipe).toBe('plain'); // defaults to the first UNLOCKED recipe sim.enqueue({ kind: 'setRecipe', entity: id, recipe: 'fancy' }); // locked run(sim, 1); expect(at(sim, 5, 5)!.recipe).toBe('plain'); sim.enqueue({ kind: 'setRecipeAt', pos: { x: 5, y: 5 }, recipe: 'fancy' }); // locked run(sim, 1); expect(at(sim, 5, 5)!.recipe).toBe('plain'); }); it('gates nothing when the data has no tech at all', () => { const sim = newSim({ ...FIXTURE, tech: [] }); sim.enqueue(place('goldworks', 5, 5)); run(sim, 1); expect(at(sim, 5, 5)).toBeDefined(); // absence from the tree means free, not forbidden }); }); describe('research', () => { it('runs the whole path: pick a target, feed packs, unlock, build what was refused', () => { const sim = newSim(); expect(sim.snapshot().research).toEqual({ active: null, progress: {}, unlocked: [] }); // Refused up front. sim.enqueue(place('goldworks', 5, 5)); run(sim, 1); expect(at(sim, 5, 5)).toBeUndefined(); packLine(sim); sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); run(sim, 1); expect(sim.snapshot().research!.active).toBe('metallurgy'); const evs: SimEvent[] = []; let done = -1; for (let i = 0; i < 800 && done < 0; i++) { run(sim, 1, evs); const e = evs.find((x) => x.kind === 'researched'); if (e) done = (e as { tick: number }).tick; } expect(done).toBeGreaterThan(0); expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1); expect(evs.find((e) => e.kind === 'researched')).toMatchObject({ tech: 'metallurgy' }); const r = sim.snapshot().research!; expect(r.unlocked).toEqual(['metallurgy']); expect(r.active).toBeNull(); // completing clears the target rather than guessing a next expect(r.progress).toEqual({}); // The same command that was silently dropped now works. sim.enqueue(place('goldworks', 5, 5)); run(sim, 1); expect(at(sim, 5, 5)).toBeDefined(); expect(at(sim, 5, 5)!.recipe).toBe('smelt-gold'); // its recipe unlocked with it }); it('tracks progress toward the active tech and stops taking packs it no longer needs', () => { const sim = newSim(); packLine(sim); sim.enqueue({ kind: 'setResearch', tech: 'flourish' }); // costs 2 packs const evs: SimEvent[] = []; for (let i = 0; i < 400; i++) { run(sim, 1, evs); if (evs.some((e) => e.kind === 'researched')) break; const p = sim.snapshot().research!.progress['pack'] ?? 0; expect(p).toBeLessThanOrEqual(2); // never banks past the cost } expect(sim.snapshot().research!.unlocked).toEqual(['flourish']); // With nothing being researched, the lab stops accepting deliveries entirely. run(sim, 200); expect(at(sim, 4, 0)!.inputBuf).toEqual({}); }); it('stacks: two labs feed one total', () => { const sim = newSim(); sim.enqueue(place('mine', 0, 0)); sim.enqueue(place('belt', 1, 0, E)); sim.enqueue(place('packer', 2, 0)); sim.enqueue(place('belt', 3, 0, E)); sim.enqueue(place('lab', 4, 0)); // first lab on the line sim.enqueue(place('belt', 4, 1, N)); // a second lab fed from the other side sim.enqueue(place('lab', 4, 2)); sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); const evs: SimEvent[] = []; run(sim, 800, evs); expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1); expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']); }); it('abandons progress when the target changes, and refuses nonsense targets', () => { const sim = newSim(); packLine(sim); sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); // 3 packs // Catch it mid-research: metallurgy completes on the third pack, and completing would // zero progress for a different reason than the one under test. let banked = 0; for (let i = 0; i < 400 && banked === 0; i++) { run(sim, 1); banked = sim.snapshot().research!.progress['pack'] ?? 0; } expect(banked).toBeGreaterThan(0); sim.enqueue({ kind: 'setResearch', tech: 'flourish' }); run(sim, 1); expect(sim.snapshot().research!.progress).toEqual({}); // packs are the cost of indecision sim.enqueue({ kind: 'setResearch', tech: 'no-such-tech' }); run(sim, 1); expect(sim.snapshot().research!.active).toBe('flourish'); // unchanged sim.enqueue({ kind: 'setResearch', tech: null }); run(sim, 1); expect(sim.snapshot().research!.active).toBeNull(); }); it('survives save/load, and a restored save can still build what it unlocked', () => { const a = newSim(); packLine(a); a.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); const evs: SimEvent[] = []; for (let i = 0; i < 800; i++) { run(a, 1, evs); if (evs.some((e) => e.kind === 'researched')) break; } expect(a.snapshot().research!.unlocked).toEqual(['metallurgy']); const b = newSim(); b.load(a.save()); expect(b.snapshot().research).toEqual(a.snapshot().research); // The future still matches: research state is part of the world, not decoration. run(a, 500); run(b, 500); expect(b.snapshot()).toEqual(a.snapshot()); // And the unlock survived as a RULE, not just as a list — a save that restored the // text but not the gate would silently re-lock the player's own factory. Placed in // both so the sims stay in step. for (const s of [a, b]) { s.enqueue(place('goldworks', 9, 9)); run(s, 1); expect(at(s, 9, 9)).toBeDefined(); } expect(b.snapshot()).toEqual(a.snapshot()); }); }); // Derived, never hard-coded: which machines DATA gates is theirs to change, and it moved // twice while this round was being written. The rule is mine to pin down; the list isn't. const gatedByReal = new Set(REAL.tech.flatMap((t) => t.unlocks)); const aGatedMachine = REAL.machines.find((m) => gatedByReal.has(m.id))!; const aFreeMachine = REAL.machines.find( (m) => !gatedByReal.has(m.id) && m.kind === 'crafter' && m.footprint.x <= 2, )!; describe('gating against the real tech tree', () => { it('locks the machines DATA gates and frees the ones it does not', () => { const sim = newSim(REAL, 7); sim.enqueue(place(aGatedMachine.id, 0, 0)); sim.enqueue(place(aFreeMachine.id, 8, 0)); run(sim, 1); expect(at(sim, 0, 0), `${aGatedMachine.id} is gated and must not place`).toBeUndefined(); expect(at(sim, 8, 0), `${aFreeMachine.id} is ungated and must place`).toBeDefined(); }); it('opens the real gate when the real tech completes', () => { const unlocker = REAL.tech.find((t) => t.unlocks.includes(aGatedMachine.id))!; const sim = newSim(REAL, 7); grantResearch(sim, [unlocker.id]); sim.enqueue(place(aGatedMachine.id, 0, 0)); run(sim, 1); expect(at(sim, 0, 0)).toBeDefined(); expect(sim.snapshot().research!.unlocked).toEqual([unlocker.id]); }); }); describe('research takes time', () => { it('a pack is an investment, not a purchase — it costs RESEARCH_TICKS_PER_PACK', () => { const sim = newSim(); packLine(sim); sim.enqueue({ kind: 'setResearch', tech: 'flourish' }); // 2 packs // Wait for the first pack to physically reach the lab, then time the conversion. let arrived = -1; for (let i = 0; i < 600 && arrived < 0; i++) { run(sim, 1); if ((at(sim, 4, 0)!.inputBuf['pack'] ?? 0) > 0) arrived = sim.snapshot().tick; } expect(arrived).toBeGreaterThan(0); let credited = -1; for (let i = 0; i < RESEARCH_TICKS_PER_PACK * 3 && credited < 0; i++) { run(sim, 1); if ((sim.snapshot().research!.progress['pack'] ?? 0) > 0) credited = sim.snapshot().tick; } expect(credited).toBeGreaterThan(0); // The lab was busy for the full duration, not instantaneous. (The tick the pack lands // is also the tick the lab starts on it, hence the -1.) expect(credited - arrived).toBeGreaterThanOrEqual(RESEARCH_TICKS_PER_PACK - 1); expect(credited - arrived).toBeLessThanOrEqual(RESEARCH_TICKS_PER_PACK + 1); }); it('shows its work: a lab mid-pack reports progress, and clears it when idle', () => { const sim = newSim(); packLine(sim); sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); let seenWorking = false; for (let i = 0; i < 1200 && !seenWorking; i++) { run(sim, 1); const p = at(sim, 4, 0)!.progress; if (p > 0 && p < 1) seenWorking = true; } expect(seenWorking, 'the lab never showed partial progress').toBe(true); // Once there's nothing to research, the lab stops showing a half-done pack. sim.enqueue({ kind: 'setResearch', tech: null }); run(sim, 2); expect(at(sim, 4, 0)!.progress).toBe(0); }); it('two labs really do halve the wait', () => { // Three mines so pack SUPPLY isn't the bottleneck — otherwise this measures the miner, // not the labs, and a second lab would change nothing. const finishTick = (labs: number): number => { const sim = newSim(); sim.enqueue(place('mine', 0, 0)); sim.enqueue(place('belt', 1, 0, E)); sim.enqueue(place('mine', 0, 2)); sim.enqueue(place('belt', 1, 2, E)); sim.enqueue(place('belt', 2, 2, N)); sim.enqueue(place('belt', 2, 1, N)); sim.enqueue(place('mine', 0, -2)); sim.enqueue(place('belt', 1, -2, E)); sim.enqueue(place('belt', 2, -2, S)); sim.enqueue(place('belt', 2, -1, S)); sim.enqueue(place('packer', 2, 0)); sim.enqueue(place('belt', 3, 0, E)); sim.enqueue(place('split', 4, 0)); sim.enqueue(place('belt', 5, 0, E)); sim.enqueue(place('lab', 6, 0)); if (labs > 1) { sim.enqueue(place('belt', 4, 1, S)); // the splitter's other face feeds lab two sim.enqueue(place('lab', 4, 2)); } sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); // 3 packs for (let i = 0; i < 4000; i++) { sim.tick(); for (const e of sim.drainEvents()) if (e.kind === 'researched') return e.tick; } return -1; }; const one = finishTick(1); const two = finishTick(2); expect(one).toBeGreaterThan(0); expect(two).toBeGreaterThan(0); expect(two, 'a second lab should finish the tech sooner').toBeLessThan(one); }); it('abandoning a target forfeits the half-processed pack in the lab', () => { const sim = newSim(); packLine(sim); sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); let working = false; for (let i = 0; i < 1200 && !working; i++) { run(sim, 1); working = at(sim, 4, 0)!.progress > 0; } expect(working).toBe(true); run(sim, 20); // let it get meaningfully into the pack before we yank the target const banked = at(sim, 4, 0)!.progress; expect(banked).toBeGreaterThan(2 / RESEARCH_TICKS_PER_PACK); // genuinely part-done sim.enqueue({ kind: 'setResearch', tech: 'flourish' }); run(sim, 1); // Back to scratch: at most the single tick it has already spent on the new target. expect(at(sim, 4, 0)!.progress).toBeLessThanOrEqual(1 / RESEARCH_TICKS_PER_PACK); expect(at(sim, 4, 0)!.progress).toBeLessThan(banked); }); }); describe('grantResearch', () => { it('unlocks instantly, emits researched, and lets the build through', () => { const sim = newSim(); sim.enqueue(place('goldworks', 5, 5)); run(sim, 1); expect(at(sim, 5, 5)).toBeUndefined(); // refused while locked const evs: SimEvent[] = []; sim.enqueue({ kind: 'grantResearch', tech: ['metallurgy'] }); run(sim, 1, evs); expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']); expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1); sim.enqueue(place('goldworks', 5, 5)); run(sim, 1); expect(at(sim, 5, 5)).toBeDefined(); }); it('is idempotent and ignores nonsense', () => { const sim = newSim(); sim.enqueue({ kind: 'grantResearch', tech: ['metallurgy', 'metallurgy', 'no-such-tech'] }); const evs: SimEvent[] = []; run(sim, 1, evs); expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']); expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1); sim.enqueue({ kind: 'grantResearch', tech: ['metallurgy'] }); // again const evs2: SimEvent[] = []; run(sim, 1, evs2); expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']); expect(evs2.filter((e) => e.kind === 'researched')).toHaveLength(0); // nothing changed }); it('completes the active target out from under the labs', () => { const sim = newSim(); packLine(sim); sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); run(sim, 120); // still mid-research: 3 packs at 60 ticks each can't be done yet expect(sim.snapshot().research!.active).toBe('metallurgy'); sim.enqueue({ kind: 'grantResearch', tech: ['metallurgy'] }); run(sim, 1); expect(sim.snapshot().research!.active).toBeNull(); expect(sim.snapshot().research!.progress).toEqual({}); expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']); }); it('survives save/load like any other research', () => { const a = newSim(); a.enqueue({ kind: 'grantResearch', tech: ['metallurgy', 'flourish'] }); run(a, 1); const b = newSim(); b.load(a.save()); expect(b.snapshot().research!.unlocked).toEqual(['metallurgy', 'flourish']); b.enqueue(place('goldworks', 9, 9)); run(b, 1); expect(at(b, 9, 9)).toBeDefined(); }); });