glytch/fktry/src/sim/bloom.test.ts
LANE-SIM 039eff15fa [sim] research + gating, spatial cooling, exported constants, reference v4
Round 4 per lanes/LANE-SIM.md.

- research v1: kind:'lab', setResearch, snapshot.research, 'researched'.
  Gating: any id a tech unlocks is refused until that tech lands; ids no
  tech names are free forever. Labs take only what the active tech still
  needs; several labs feed one total; research survives save/load as a
  RULE, so a restored save can still build what it unlocked
- realtech.test.ts: a cold-start research run on DATA's own tree -- ore ->
  packs -> archaeology lab -> tech -> a refused recipe now accepted. Also
  the tripwire if the tech bootstrap ever closes over again; it was
  circular and unenterable at the start of this round
- spatial cooling: coolRadius, chebyshev from the footprint, auras sum,
  total capped at HEAT_COOL_MAX (0.05 -- past that a stack buys nothing
  but still costs bandwidth and floor). Proven by measuring shed rate
- src/sim/constants.ts: 12 constants so DATA imports truth instead of
  hand-copying numbers that go stale in silence
- reference v4: anchor-slab overflow tap, so the demo ships slabs (18 per
  20k). Needed TWO melt lanes: with one, melt spills onto the slab lane
  and the splitter phase-locks with the reactor's push order -- slab to
  the assembler, melt to the uplink, forever, and no slab ever sold. It
  passed 20k ticks looking perfect. referenceFactoryTech() derives the
  techs the layout needs, from the commands
- also wired: bandwidth.capacity, EntityState.scrammed, commissionQueue

65 sim tests, 318 across the repo. Melt still tick 1248, zero brownouts
over 20k.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:20:32 +10:00

235 lines
9.4 KiB
TypeScript

/**
* The bloom loop — M3's signature topology, proved early.
*
* Two separate claims are tested here, because they fail for different reasons:
*
* 1. The loop MECHANIC: a closed belt circle has no terminal machine, so it ranks 0 and
* machines will push onto it. Cargo circulates forever instead of wedging, and the
* tracer doesn't spin trying to walk it.
*
* 2. DATA's grade recipes COMPOSE: a closed delta-wafer bus with splitter taps feeding
* one duplicator per grade escalates bloom-concentrate all the way to grade 5.
*
* On the topology: the codex says "any closed belt circle with a P-FRAME DUPLICATOR on it;
* contents compound each lap". With DATA's recipes that exact build cannot work — each
* grade is a distinct recipe and a machine runs one recipe, so a lone duplicator can never
* consume its own output. The shape that DOES compose puts the circle underneath as a
* shared wafer bus: unclaimed wafers keep going round, and every duplicator gets fed on a
* later lap. The loop is what makes the taps fair. See NOTES round 3.
*/
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { grantAllResearch } from './testkit';
import type { Command, Dir, GameData, Sim, SimEvent } from '../contracts';
import items from '../../data/items.json';
import machines from '../../data/machines.json';
import recipes from '../../data/recipes.json';
import tech from '../../data/tech.json';
import commissions from '../../data/commissions.json';
const DATA = { items, machines, recipes, tech, commissions } as unknown as GameData;
const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3;
function newSim(seed = 1337): Sim {
const sim = createSim();
sim.init(DATA, seed);
grantAllResearch(sim, DATA); // the bloom duplicator is gated behind stream-bloom-loops
return sim;
}
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);
}
}
describe('closed belt circles', () => {
it('accept cargo and circulate it forever without losing or wedging it', () => {
const sim = newSim();
const cmds: Command[] = [];
const b = (x: number, y: number, dir: Dir) => cmds.push({ kind: 'place', def: 'belt', pos: { x, y }, dir });
cmds.push({ kind: 'place', def: 'decode-asic', pos: { x: 0, y: 10 }, dir: N });
cmds.push({ kind: 'place', def: 'seam-extractor', pos: { x: 0, y: 0 }, dir: N }); // 2x2 at (0,0)
// A 4x4 ring the miner can reach: it feeds (2,0), which is part of the circle.
for (let x = 2; x <= 4; x++) b(x, 0, E);
b(5, 0, S);
for (let y = 1; y <= 3; y++) b(5, y, S);
b(5, 4, W);
for (let x = 4; x >= 3; x--) b(x, 4, W);
b(2, 4, N);
for (let y = 3; y >= 1; y--) b(2, y, N); // (2,1) -> (2,0) closes the ring
for (const c of cmds) sim.enqueue(c);
run(sim, 400);
const loaded = sim.snapshot().beltItems.length;
expect(loaded).toBeGreaterThan(0); // rank 0: the miner was willing to push onto a loop
// Cargo keeps moving: track one item and prove it changes tiles rather than parking.
const tracked = sim.snapshot().beltItems[0].id;
const seen = new Set<number>();
for (let i = 0; i < 600; i++) {
run(sim, 1);
const it = sim.snapshot().beltItems.find((x) => x.id === tracked);
if (it) seen.add(it.entity);
}
expect(seen.size).toBeGreaterThan(3); // it went round, it didn't sit at a dead end
// And the ring fills to a stable holding pattern rather than deleting anything.
const before = sim.snapshot().beltItems.length;
run(sim, 2000);
expect(sim.snapshot().beltItems.length).toBeGreaterThanOrEqual(before);
expect(sim.snapshot().entities.find((e) => e.def === 'seam-extractor')!.jammed).not.toBeNull();
});
});
/**
* ore -> demuxer -> mv-flux -> [split] -+-> p-caster -> delta wafers -> the BUS (a closed
* | circle, tapped by 5 splitters)
* +-> tap-bloom
* tap-bloom -> concentrate -> grade1 -> grade2 -> grade3 -> grade4 -> grade5 -> uplink
*/
function bloomLoop(): Command[] {
const cmds: Command[] = [];
const P = (def: string, x: number, y: number, dir: Dir = N) =>
cmds.push({ kind: 'place', def, pos: { x, y }, dir });
const b = (x: number, y: number, dir: Dir) => P('belt', x, y, dir);
const runX = (x0: number, x1: number, y: number, dir: Dir) => {
const s = x0 <= x1 ? 1 : -1;
for (let x = x0; ; x += s) { b(x, y, dir); if (x === x1) break; }
};
const runY = (y0: number, y1: number, x: number, dir: Dir) => {
const s = y0 <= y1 ? 1 : -1;
for (let y = y0; ; y += s) { b(x, y, dir); if (y === y1) break; }
};
const recipeAt = (x: number, y: number, recipe: string) =>
cmds.push({ kind: 'setRecipeAt', pos: { x, y }, recipe });
// Power: ASICs only. Heat is not what this test is about.
for (let i = 0; i < 8; i++) P('decode-asic', -30 + i * 3, -12);
// Feeder: ore -> demuxer. Luma and slurry are byproducts here; sell both.
P('seam-extractor', -30, 0);
P('seam-extractor', -30, -3);
P('seam-extractor', -30, 3);
runY(-2, -1, -28, S);
runY(3, 1, -28, N);
runX(-28, -27, 0, E);
P('demuxer', -26, 0);
runX(-24, -23, 0, E);
P('shipper', -22, 0);
runY(2, 3, -26, S);
P('shipper', -27, 4);
// mv-flux splits between the wafer press and the bloom tap. The tap only wants 4 of
// every ~56, so it fills, blocks, and the splitter hands the rest to the p-caster.
b(-25, -1, N);
P('lane-splitter', -25, -2);
b(-25, -3, N);
P('p-caster', -26, -5);
runX(-24, 14, -2, E);
runY(-2, 1, 15, S);
b(15, 2, W); // -> tap-bloom at (14,2), at the far end of the bus
// Wafers leave the press, run the long way round, and merge into the bus's right edge.
runX(-26, 17, -6, E);
runY(-6, 9, 18, S);
b(18, 10, W);
b(17, 10, W); // -> (16,10), a bus belt
// THE BUS: a closed circle, (0,4) to (16,14), with five splitter taps on its top edge.
const taps = [2, 5, 8, 11, 14];
for (let x = 0; x <= 15; x++) {
if (taps.includes(x)) P('lane-splitter', x, 4);
else b(x, 4, E);
}
b(16, 4, S);
runY(5, 13, 16, S);
b(16, 14, W);
runX(15, 1, 14, W);
b(0, 14, N);
runY(13, 5, 0, N); // (0,5) -> (0,4): the circle closes
// Each tap hands wafers north, out of the ring, to its duplicator.
for (const x of taps) b(x, 3, N);
// The escalator runs COUNTER-CURRENT to the wafer flow, and that ordering is load
// bearing. Wafers join the ring at its right edge and reach the taps left-to-right, so
// whoever taps first eats first. Grade 5 wants 8 wafers a craft and grade 4 wants 5,
// while the bloom tap only wants 8 per finished grade-1 — put the tap first (measured
// it) and it swallows half of everything, grade 3 backs up 'output full' behind a
// starving grade 4, and 30,000 ticks yields exactly one grade 5. Hungriest first.
P('bloom-duplicator', 14, 2); // tap-bloom (default recipe), fed last
b(14, 1, N);
P('bloom-duplicator', 14, 0);
recipeAt(14, 0, 'bloom-grade-1');
runX(13, 12, 0, W);
b(11, 0, S);
b(11, 1, S);
P('bloom-duplicator', 11, 2);
recipeAt(11, 2, 'bloom-grade-2');
runX(10, 9, 2, W);
P('bloom-duplicator', 8, 2);
recipeAt(8, 2, 'bloom-grade-3');
runX(7, 6, 2, W);
P('bloom-duplicator', 5, 2);
recipeAt(5, 2, 'bloom-grade-4');
runX(4, 3, 2, W);
P('bloom-duplicator', 2, 2);
recipeAt(2, 2, 'bloom-grade-5'); // hungriest, tapped first
b(1, 2, W);
P('shipper', -1, 1);
return cmds;
}
describe('bloom loop', () => {
it('escalates concentrate to grade 5 off a closed wafer bus', () => {
const sim = newSim();
for (const c of bloomLoop()) sim.enqueue(c);
const evs: SimEvent[] = [];
run(sim, 20000, evs);
const snap = sim.snapshot();
// Every rung of DATA's ladder actually ran.
for (const g of ['tap-bloom', 'bloom-grade-1', 'bloom-grade-2', 'bloom-grade-3',
'bloom-grade-4', 'bloom-grade-5']) {
expect(evs.filter((e) => e.kind === 'crafted' && e.recipe === g).length,
`${g} never crafted`).toBeGreaterThan(0);
}
expect(snap.shippedTotal['bloom-grade-5'] ?? 0).toBeGreaterThan(0);
});
it('keeps wafers circulating so the far taps get fed too', () => {
const sim = newSim();
for (const c of bloomLoop()) sim.enqueue(c);
run(sim, 20000);
const snap = sim.snapshot();
// The last tap on the bus is the hungriest (8 wafers) and the furthest from the
// injection point. It only ever eats because unclaimed wafers come round again.
const grade5 = snap.entities.find((e) => e.pos.x === 2 && e.pos.y === 2)!;
expect(grade5.recipe).toBe('bloom-grade-5');
expect(grade5.jammed).not.toBe('output full');
// Wafers are genuinely resident on the ring rather than all consumed on arrival.
const ringIds = new Set(snap.entities.filter((e) =>
(e.def === 'belt' || e.def === 'lane-splitter') && e.pos.y >= 4 && e.pos.y <= 14
&& e.pos.x >= 0 && e.pos.x <= 16).map((e) => e.id));
const onRing = snap.beltItems.filter((i) => ringIds.has(i.entity));
expect(onRing.length).toBeGreaterThan(0);
expect(onRing.every((i) => i.item === 'delta-wafer')).toBe(true);
});
it('never wedges the head of the chain', () => {
const sim = newSim();
for (const c of bloomLoop()) sim.enqueue(c);
run(sim, 20000);
const demuxer = sim.snapshot().entities.find((e) => e.def === 'demuxer')!;
expect(demuxer.jammed).not.toBe('output full');
});
});