/** * 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(); 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'); }); });