Round 3 orders, all six. The validator move landed in 4803b6a (swept into
LANE-SCREEN's commit by the shared index); this is the rest.
- heat retune: round 2's numbers were below the sim's always-on cooling, so
nothing could ever warm up. Decoder now 100 gen at 0.0033 heat / 0.001 cool
-> throttles 10.2s, scrams 14.5s, 23.9s cycle, 25.8/s average (1.29x the
ASIC). Verified against LANE-SIM's real sim, not just arithmetic.
- powerGen had to move: the scram/restart hysteresis forces ~30% duty, so at
55 gen the decoder averaged worse than a steady ASIC and nobody would build
one.
- two new tests refuse decorative heat (per-tick and per-craft). They caught a
fresh instance of the same bug in chart-stco-map before it shipped.
- bufferCap 240 -> 300: one tank now covers exactly one decoder scram.
- ASIC COOLER spec'd (aura deferred to M4, no contract field requested yet);
THE MUXER + moov brain sub-lines; reel + broadcast era tech.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
440 lines
18 KiB
TypeScript
440 lines
18 KiB
TypeScript
/**
|
|
* LANE-DATA territory. Lives beside the JSON it guards (round 3: moved out of
|
|
* src/sim/, which returns fully to LANE-SIM).
|
|
*
|
|
* Validates data/*.json against the contracts. It is the handshake that keeps five
|
|
* parallel lanes from drowning in dangling ids: if a lane references a machine,
|
|
* recipe, item or tech that data doesn't define, this test names it.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { CommissionDef, ItemDef, MachineDef, MachineKind, RecipeDef, TechDef } from '../src/contracts';
|
|
|
|
import itemsJson from './items.json';
|
|
import machinesJson from './machines.json';
|
|
import recipesJson from './recipes.json';
|
|
import techJson from './tech.json';
|
|
import commissionsJson from './commissions.json';
|
|
|
|
const items = itemsJson as unknown as ItemDef[];
|
|
const machines = machinesJson as unknown as MachineDef[];
|
|
const recipes = recipesJson as unknown as RecipeDef[];
|
|
const tech = techJson as unknown as TechDef[];
|
|
const commissions = commissionsJson as unknown as CommissionDef[];
|
|
|
|
const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
const CODEX_REF = /^[1-9]:[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
const HEX = /^#[0-9a-f]{6}$/;
|
|
|
|
const KINDS: MachineKind[] = ['extractor', 'crafter', 'belt', 'splitter', 'power', 'buffer', 'shipper'];
|
|
const ERAS: TechDef['era'][] = ['reel', 'broadcast', 'disc', 'stream', 'fortress'];
|
|
|
|
const itemIds = new Set(items.map((i) => i.id));
|
|
const machineIds = new Set(machines.map((m) => m.id));
|
|
const recipeIds = new Set(recipes.map((r) => r.id));
|
|
|
|
/** Collect ids that appear more than once. */
|
|
function duplicates(ids: string[]): string[] {
|
|
const seen = new Set<string>();
|
|
const dupes = new Set<string>();
|
|
for (const id of ids) (seen.has(id) ? dupes : seen).add(id);
|
|
return [...dupes];
|
|
}
|
|
|
|
/** Item ids referenced by a count map that no item defines. */
|
|
function unknownItems(counts: Record<string, number>): string[] {
|
|
return Object.keys(counts).filter((id) => !itemIds.has(id));
|
|
}
|
|
|
|
/** Everything an extractor can pull, closed over every recipe that can then run. */
|
|
function producible(): Set<string> {
|
|
const have = new Set<string>();
|
|
for (;;) {
|
|
const before = have.size;
|
|
for (const r of recipes) {
|
|
if (Object.keys(r.inputs).every((i) => have.has(i))) {
|
|
for (const o of Object.keys(r.outputs)) have.add(o);
|
|
}
|
|
}
|
|
if (have.size === before) return have;
|
|
}
|
|
}
|
|
|
|
describe('ids and formatting', () => {
|
|
it('every collection has unique ids', () => {
|
|
expect(duplicates(items.map((i) => i.id))).toEqual([]);
|
|
expect(duplicates(machines.map((m) => m.id))).toEqual([]);
|
|
expect(duplicates(recipes.map((r) => r.id))).toEqual([]);
|
|
expect(duplicates(tech.map((t) => t.id))).toEqual([]);
|
|
expect(duplicates(commissions.map((c) => c.id))).toEqual([]);
|
|
});
|
|
|
|
it('every id is kebab-case', () => {
|
|
const bad = [...items, ...machines, ...recipes, ...tech, ...commissions]
|
|
.map((d) => d.id)
|
|
.filter((id) => !KEBAB.test(id));
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('every item and machine points at a codex section', () => {
|
|
const bad = [...items, ...machines]
|
|
.filter((d) => !CODEX_REF.test(d.codex))
|
|
.map((d) => `${d.id} -> "${d.codex}"`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('items', () => {
|
|
it('every colour is a 6-digit lowercase hex', () => {
|
|
const bad = items.filter((i) => !HEX.test(i.color)).map((i) => `${i.id} -> "${i.color}"`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('every tier is 0..3', () => {
|
|
const bad = items.filter((i) => !Number.isInteger(i.tier) || i.tier < 0 || i.tier > 3)
|
|
.map((i) => `${i.id} -> ${i.tier}`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('every item has a display name', () => {
|
|
expect(items.filter((i) => !i.name.trim()).map((i) => i.id)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('machines', () => {
|
|
it('every kind is a known MachineKind', () => {
|
|
const bad = machines.filter((m) => !KINDS.includes(m.kind)).map((m) => `${m.id} -> "${m.kind}"`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('every footprint is at least 1x1 in whole tiles', () => {
|
|
const bad = machines.filter((m) => {
|
|
const { x, y } = m.footprint ?? { x: 0, y: 0 };
|
|
return !Number.isInteger(x) || !Number.isInteger(y) || x < 1 || y < 1;
|
|
}).map((m) => m.id);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('every listed recipe exists and points back at the machine', () => {
|
|
const missing: string[] = [];
|
|
const mismatched: string[] = [];
|
|
for (const m of machines) {
|
|
for (const rid of m.recipes) {
|
|
const r = recipes.find((x) => x.id === rid);
|
|
if (!r) missing.push(`${m.id} lists unknown recipe "${rid}"`);
|
|
else if (r.machine !== m.id) mismatched.push(`${m.id} lists "${rid}" but that recipe runs on "${r.machine}"`);
|
|
}
|
|
}
|
|
expect(missing).toEqual([]);
|
|
expect(mismatched).toEqual([]);
|
|
});
|
|
|
|
it('belts declare a belt speed and power machines declare generation', () => {
|
|
const bad = [
|
|
...machines.filter((m) => m.kind === 'belt' && !(m.beltSpeed! > 0)).map((m) => `${m.id} has no beltSpeed`),
|
|
...machines.filter((m) => m.kind === 'power' && !(m.powerGen! > 0)).map((m) => `${m.id} has no powerGen`),
|
|
];
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('every machine declares a non-negative idle draw and an asset key', () => {
|
|
const bad = machines
|
|
.filter((m) => !(m.powerDraw >= 0) || !KEBAB.test(m.asset))
|
|
.map((m) => `${m.id} -> draw ${m.powerDraw}, asset "${m.asset}"`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
// v2 art direction: RENDER prefers our colour over one derived from outputs, and UI's
|
|
// inspector prints our flavor instead of a hardcoded lookup. Both need full coverage
|
|
// or a machine goes black-on-black / voiceless.
|
|
it('every machine is art-directed: a hex colour and a line of flavor', () => {
|
|
const bad = machines
|
|
.filter((m) => !m.color || !HEX.test(m.color) || !m.flavor?.trim())
|
|
.map((m) => `${m.id} -> color ${m.color ?? 'MISSING'}, flavor ${m.flavor ? 'ok' : 'MISSING'}`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('only buffers declare a capacity, and they all do', () => {
|
|
const bad = [
|
|
...machines.filter((m) => m.kind === 'buffer' && !(m.bufferCap! > 0))
|
|
.map((m) => `${m.id} is a buffer with no bufferCap`),
|
|
...machines.filter((m) => m.kind !== 'buffer' && m.bufferCap !== undefined)
|
|
.map((m) => `${m.id} is not a buffer but declares bufferCap`),
|
|
];
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('machine heat is a sane 0..1 per-tick trickle, never an instant scram', () => {
|
|
const bad = machines.filter((m) => m.heatPerTick !== undefined)
|
|
.filter((m) => !(m.heatPerTick! > 0 && m.heatPerTick! <= 0.05))
|
|
.map((m) => `${m.id} -> heatPerTick ${m.heatPerTick}`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('the ASIC stays cool and the software decoder does not', () => {
|
|
// The codex's whole point (§4): cool-and-inflexible vs hot-and-mighty. If this
|
|
// flips, the power choice has stopped being a choice.
|
|
const asic = machines.find((m) => m.id === 'decode-asic')!;
|
|
const soft = machines.find((m) => m.id === 'software-decoder')!;
|
|
expect(asic.heatPerTick).toBeUndefined();
|
|
expect(soft.heatPerTick).toBeGreaterThan(0);
|
|
expect(soft.powerGen!).toBeGreaterThan(asic.powerGen!);
|
|
});
|
|
});
|
|
|
|
// Round 2 shipped heat numbers that could never fire: every heatPerTick sat below the
|
|
// sim's always-on cooling, so heat was decorative. These lock the arithmetic down so a
|
|
// well-meaning tweak can't quietly make it decorative again.
|
|
describe('the heat curve actually fires', () => {
|
|
// MIRRORED from src/sim/index.ts (LANE-SIM owns the curve; these are not exported).
|
|
// If SIM changes them, these numbers go stale silently — re-check on any heat round.
|
|
const TPS = 30;
|
|
const THROTTLE_START = 0.7; // heat above this throttles
|
|
const RESTART = 0.5; // a scrammed machine restarts once it cools below this
|
|
const SIM_DEFAULT_COOL = 0.004;
|
|
|
|
const soft = machines.find((m) => m.id === 'software-decoder')!;
|
|
const cool = (m: MachineDef) => m.coolPerTick ?? SIM_DEFAULT_COOL;
|
|
/** Net heat per tick while the machine is working. Negative = can never overheat. */
|
|
const net = (m: MachineDef) => (m.heatPerTick ?? 0) - cool(m);
|
|
|
|
it('every heat source out-heats its own cooling — or it is just decoration', () => {
|
|
const decorative = machines
|
|
.filter((m) => m.heatPerTick !== undefined)
|
|
.filter((m) => net(m) <= 0)
|
|
.map((m) => `${m.id}: heats ${m.heatPerTick}/tick but cools ${cool(m)}/tick — never warms up`);
|
|
expect(decorative).toEqual([]);
|
|
});
|
|
|
|
it('every heated recipe banks more than its machine sheds during the craft', () => {
|
|
// A recipe's heat lands once per craft, but the machine cools every tick of it.
|
|
// heat <= ticks * coolPerTick means the number is a comment, not a mechanic.
|
|
const decorative = recipes
|
|
.filter((r) => r.heat !== undefined)
|
|
.map((r) => ({ r, m: machines.find((x) => x.id === r.machine)! }))
|
|
.filter(({ r, m }) => r.heat! <= r.ticks * cool(m))
|
|
.map(({ r, m }) =>
|
|
`${r.id}: banks ${r.heat} per craft but ${m.id} sheds ${(r.ticks * cool(m)).toFixed(3)} over its ${r.ticks} ticks`);
|
|
expect(decorative).toEqual([]);
|
|
});
|
|
|
|
it('the software decoder throttles at ~10s and scrams at 13-15s of continuous load', () => {
|
|
const secondsTo = (heat: number) => heat / net(soft) / TPS;
|
|
expect(secondsTo(THROTTLE_START)).toBeGreaterThan(8);
|
|
expect(secondsTo(THROTTLE_START)).toBeLessThan(12);
|
|
expect(secondsTo(1)).toBeGreaterThan(13);
|
|
expect(secondsTo(1)).toBeLessThan(15);
|
|
});
|
|
|
|
it('the software decoder duty-cycles at ~25s, and is still worth its footprint', () => {
|
|
// Steady state after the first scram: heat swings RESTART -> 1 -> RESTART.
|
|
const upTicks = (1 - RESTART) / net(soft);
|
|
const downTicks = (1 - RESTART) / cool(soft);
|
|
const periodS = (upTicks + downTicks) / TPS;
|
|
expect(periodS).toBeGreaterThan(20);
|
|
expect(periodS).toBeLessThan(30);
|
|
|
|
// Generation is throttled linearly from 1.0 down to 0.5 across the throttle window,
|
|
// and is zero while scrammed. Average it over the whole cycle.
|
|
const fullTicks = (THROTTLE_START - RESTART) / net(soft); // un-throttled
|
|
const throttledTicks = (1 - THROTTLE_START) / net(soft); // 1.0 -> 0.5 output
|
|
const energy = soft.powerGen! * (fullTicks + throttledTicks * 0.75);
|
|
const average = energy / (upTicks + downTicks);
|
|
|
|
const asic = machines.find((m) => m.id === 'decode-asic')!;
|
|
// Same 2x2 footprint, so if the hot one doesn't beat the cool one on average,
|
|
// nobody would ever build it and the codex's "burst gen" is a lie.
|
|
expect(average).toBeGreaterThan(asic.powerGen! * 1.2);
|
|
});
|
|
|
|
it('the hex splicer runs a lesser fever than the decoder', () => {
|
|
const splicer = machines.find((m) => m.id === 'hex-splicer')!;
|
|
const secondsToScram = Math.min(
|
|
...recipes.filter((r) => r.machine === 'hex-splicer' && r.heat)
|
|
.map((r) => (1 / (r.heat! - r.ticks * cool(splicer))) * r.ticks / TPS),
|
|
);
|
|
expect(secondsToScram).toBeGreaterThan(30); // lesser...
|
|
expect(secondsToScram).toBeLessThan(90); // ...but a fever
|
|
expect(secondsToScram).toBeGreaterThan(1 / net(soft) / TPS); // slower than the decoder
|
|
});
|
|
});
|
|
|
|
describe('recipes', () => {
|
|
it('every recipe runs on a machine that exists', () => {
|
|
const bad = recipes.filter((r) => !machineIds.has(r.machine)).map((r) => `${r.id} -> "${r.machine}"`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('every recipe is listed by the machine it runs on', () => {
|
|
const orphans = recipes.filter((r) => {
|
|
const m = machines.find((x) => x.id === r.machine);
|
|
return m && !m.recipes.includes(r.id);
|
|
}).map((r) => `${r.id} is not listed by ${r.machine}`);
|
|
expect(orphans).toEqual([]);
|
|
});
|
|
|
|
it('every input and output item exists', () => {
|
|
const bad: string[] = [];
|
|
for (const r of recipes) {
|
|
for (const id of unknownItems(r.inputs)) bad.push(`${r.id} consumes unknown item "${id}"`);
|
|
for (const id of unknownItems(r.outputs)) bad.push(`${r.id} produces unknown item "${id}"`);
|
|
}
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('every recipe produces something in whole positive counts', () => {
|
|
const bad: string[] = [];
|
|
for (const r of recipes) {
|
|
if (Object.keys(r.outputs).length === 0) bad.push(`${r.id} has no outputs`);
|
|
for (const [id, n] of [...Object.entries(r.inputs), ...Object.entries(r.outputs)]) {
|
|
if (!Number.isInteger(n) || n < 1) bad.push(`${r.id} -> "${id}" count ${n}`);
|
|
}
|
|
}
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('every recipe takes a whole positive number of ticks', () => {
|
|
const bad = recipes.filter((r) => !Number.isInteger(r.ticks) || r.ticks < 1)
|
|
.map((r) => `${r.id} -> ${r.ticks} ticks`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('only extractors run input-free recipes', () => {
|
|
const bad = recipes.filter((r) => Object.keys(r.inputs).length === 0)
|
|
.filter((r) => machines.find((m) => m.id === r.machine)?.kind !== 'extractor')
|
|
.map((r) => `${r.id} conjures items on non-extractor ${r.machine}`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('every heat value is a 0..1 fraction per craft', () => {
|
|
const bad = recipes.filter((r) => r.heat !== undefined && !(r.heat >= 0 && r.heat <= 1))
|
|
.map((r) => `${r.id} -> heat ${r.heat}`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('tech', () => {
|
|
it('every era is a known era', () => {
|
|
const bad = tech.filter((t) => !ERAS.includes(t.era)).map((t) => `${t.id} -> "${t.era}"`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('every cost is paid in items that exist, in whole positive counts', () => {
|
|
const bad: string[] = [];
|
|
for (const t of tech) {
|
|
for (const id of unknownItems(t.cost)) bad.push(`${t.id} costs unknown item "${id}"`);
|
|
for (const [id, n] of Object.entries(t.cost)) {
|
|
if (!Number.isInteger(n) || n < 1) bad.push(`${t.id} -> "${id}" count ${n}`);
|
|
}
|
|
if (Object.keys(t.cost).length === 0) bad.push(`${t.id} is free`);
|
|
}
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('every unlock names a machine or a recipe that exists', () => {
|
|
const bad: string[] = [];
|
|
for (const t of tech) {
|
|
if (t.unlocks.length === 0) bad.push(`${t.id} unlocks nothing`);
|
|
for (const u of t.unlocks) {
|
|
if (!machineIds.has(u) && !recipeIds.has(u)) bad.push(`${t.id} unlocks unknown "${u}"`);
|
|
}
|
|
}
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('no two techs unlock the same thing', () => {
|
|
expect(duplicates(tech.flatMap((t) => t.unlocks))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('commissions', () => {
|
|
it('every want names an item that exists', () => {
|
|
const bad: string[] = [];
|
|
for (const c of commissions) {
|
|
for (const id of unknownItems(c.wants)) bad.push(`${c.id} wants unknown item "${id}"`);
|
|
}
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('every commission wants a whole positive quantity of something', () => {
|
|
const bad: string[] = [];
|
|
for (const c of commissions) {
|
|
if (Object.keys(c.wants).length === 0) bad.push(`${c.id} wants nothing`);
|
|
for (const [id, n] of Object.entries(c.wants)) {
|
|
if (!Number.isInteger(n) || n < 1) bad.push(`${c.id} -> "${id}" count ${n}`);
|
|
}
|
|
}
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('every commission has fax text and a positive reward', () => {
|
|
const bad = commissions
|
|
.filter((c) => !c.flavor.trim() || !(c.rewardBandwidth > 0))
|
|
.map((c) => `${c.id} -> reward ${c.rewardBandwidth}`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('opens on first-taste — the queue order is the difficulty ramp', () => {
|
|
// SIM's M1 chain test asserts activeCommission === 'first-taste' on a fresh sim,
|
|
// so the head of this list is load-bearing, not decorative.
|
|
expect(commissions[0].id).toBe('first-taste');
|
|
});
|
|
|
|
it('rewards climb monotonically down the queue', () => {
|
|
const slips = commissions
|
|
.map((c, i) => ({ c, prev: commissions[i - 1] }))
|
|
.filter(({ c, prev }) => prev && c.rewardBandwidth < prev.rewardBandwidth)
|
|
.map(({ c, prev }) => `${c.id} (${c.rewardBandwidth}) pays less than ${prev.id} (${prev.rewardBandwidth})`);
|
|
expect(slips).toEqual([]);
|
|
});
|
|
|
|
it('has standing orders, and they are the ones that can actually repeat', () => {
|
|
const standing = commissions.filter((c) => c.repeat);
|
|
expect(standing.length).toBeGreaterThanOrEqual(2);
|
|
// A repeating order must be re-fillable forever: nothing it wants may be
|
|
// forward canon, or the factory jams on a standing order it can never clear.
|
|
const bad = standing
|
|
.flatMap((c) => Object.keys(c.wants).map((w) => ({ c, w })))
|
|
.filter(({ w }) => !producible().has(w))
|
|
.map(({ c, w }) => `standing order ${c.id} wants unproducible "${w}"`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('economy reachability', () => {
|
|
it('the M1 flow reaches MELT from raw ore', () => {
|
|
expect([...producible()]).toContain('melt');
|
|
});
|
|
|
|
it('every item is either producible or declared forward canon', () => {
|
|
// Forward canon: named in the codex and shippable one day, but nothing makes them yet.
|
|
// Keep this list short and delete entries as recipes land. Round 2 retired
|
|
// container-pack (the moov line exists now); wildlife needs traps, cinema needs
|
|
// the Fortress.
|
|
const FORWARD_CANON = new Set([
|
|
'mosquito-swarm', 'gibbs-wraith', 'cinema-pack',
|
|
]);
|
|
const have = producible();
|
|
const orphans = items
|
|
.map((i) => i.id)
|
|
.filter((id) => !have.has(id) && !FORWARD_CANON.has(id));
|
|
expect(orphans).toEqual([]);
|
|
});
|
|
|
|
it('the forward-canon allowlist has no stowaways', () => {
|
|
// The allowlist is a licence to be unreachable. The moment a recipe makes one of
|
|
// these, it must leave the list, or it stops proving anything.
|
|
const have = producible();
|
|
const retired = ['container-pack'].filter((id) => !have.has(id));
|
|
expect(retired).toEqual([]);
|
|
});
|
|
|
|
it('no recipe is a free lunch: every craft that only recycles its own output loses something', () => {
|
|
const bad = recipes.filter((r) => {
|
|
const ids = Object.keys(r.outputs).filter((o) => r.inputs[o] !== undefined);
|
|
return ids.length > 0 && ids.every((o) => r.outputs[o] > r.inputs[o]) &&
|
|
Object.keys(r.inputs).every((i) => r.outputs[i] !== undefined);
|
|
}).map((r) => `${r.id} multiplies its own inputs`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
});
|