glytch/fktry/src/sim/research.test.ts
LANE-SIM 71c885e009 [sim] the relic, wildlife, seams, research time, grantResearch, reference v5
Round 5 (MEGA) per lanes/LANE-SIM.md.

- THE RELIC: one optical fossil per world, position a pure function of the
  seed, 18-28 tiles out, never on a seam, never under the demo. excavate
  within a tile uncovers it; it stays found through save/load forever.
  Folds into DATA's relicReserve when the seam map supplies one
- wildlife v1: piles grow from dust a machine is BACKED UP on, not dust
  merely present -- belt your dust away and nothing ever hatches. First
  cut grew from raw held and the tidy factory still bred 56 swarms; the
  asymmetry is the whole mechanic. Swarms drift to production machinery
  and slow it; traps are recognised by recipe shape (no inputs, cans a
  swarm) so DATA's real trap needs no code. SWARM_MAX caps a plague at 8
  -- a permanently jammed machine bred 29 in one demo run
- seams: extractors idle with 'no seam' off-ore. Consumes DATA's real
  seams.json shape, defensively -- GameData.seams still needs wiring
- research takes time: RESEARCH_TICKS_PER_PACK, stepped whole packs; two
  labs measurably halve the wait (200 ticks vs 260)
- grantResearch: idempotent, save/load safe, verified through the live bus
- reference v5: a second works on the eastern seam that researches a tech
  at tick 1235. Separate from the melt line by choice -- the western
  floorplan has no columns left and threading three ingredients through it
  would make the melt regression hostage to the science

Melt still first at 1247, still shipping at 20k, zero brownout ticks,
179/179 placements legal, zero extractors off-seam once seams are wired.
99 sim tests, 446 across the repo. 63x realtime at 3,500 entities.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:07:08 +10:00

450 lines
18 KiB
TypeScript

/**
* 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<MachineDef> & Pick<MachineDef, 'id' | 'kind'>): MachineDef => ({
name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0,
asset: 'fixture', ...m,
});
const rec = (r: Partial<RecipeDef> & Pick<RecipeDef, 'id' | 'machine' | 'outputs'>): 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();
});
});