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>
235 lines
9.4 KiB
TypeScript
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');
|
|
});
|
|
});
|