/** * 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]+)*$/; // Multi-digit section numbers: the codex grew a §10 (THE COMPOSITE SEAM) and the old // single-digit form silently rejected every reference into it. const CODEX_REF = /^[1-9][0-9]*:[a-z0-9]+(-[a-z0-9]+)*$/; const HEX = /^#[0-9a-f]{6}$/; const KINDS: MachineKind[] = [ 'extractor', 'crafter', 'belt', 'splitter', 'power', 'buffer', 'shipper', 'lab', // v4 ]; 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(); const dupes = new Set(); 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[] { 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 { const have = new Set(); 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([]); }); // v4: the emissive accent falls back to a colour derived from the first output item. // A machine with no recipes has no first output, so the fallback has nothing to // derive from — those must carry an explicit accent or they render unlit. it('every machine with no output to derive from carries an explicit accent', () => { const bad = machines .filter((m) => m.recipes.length === 0 && !m.accent) .map((m) => `${m.id} (${m.kind}) has no recipes and no accent — nothing to derive an accent from`); expect(bad).toEqual([]); }); it('every accent is a hex colour, and differs from the chassis it sits on', () => { const bad = machines .filter((m) => m.accent !== undefined) .filter((m) => !HEX.test(m.accent!) || m.accent === m.color) .map((m) => `${m.id} -> accent ${m.accent} on chassis ${m.color}`); expect(bad).toEqual([]); }); it('a cooling aura belongs only to a machine that actually cools', () => { const bad = machines .filter((m) => m.coolRadius !== undefined) .filter((m) => !(m.coolRadius! >= 1) || !Number.isInteger(m.coolRadius) || !(m.coolPerTick! > 0)) .map((m) => `${m.id} -> coolRadius ${m.coolRadius}, coolPerTick ${m.coolPerTick}`); expect(bad).toEqual([]); }); it('exactly one lab exists, and research is impossible without it', () => { const labs = machines.filter((m) => m.kind === 'lab'); expect(labs.map((m) => m.id)).toEqual(['archaeology-lab']); }); 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([]); }); }); // The v4 gating rule ("a machine/recipe id in ANY TechDef.unlocks is locked until that // tech completes") turned the tech tree from decoration into a lock on the whole game. // It also turned three rounds of tech authoring into a bootstrap deadlock: every pack // chain was gated behind packs you could not yet make, so 0 of 24 nodes were reachable // and first-taste was impossible. These tests are why that can't come back. describe('the bootstrap: what the player can do before researching anything', () => { const gated = new Set(tech.flatMap((t) => t.unlocks)); /** Under the v4 rule a recipe is runnable only if neither it nor its machine is gated. */ const ungated = recipes.filter((r) => !gated.has(r.id) && !gated.has(r.machine)); /** Items reachable from a given set of runnable recipes. */ function reachableFrom(rs: RecipeDef[]): Set { const have = new Set(); for (;;) { const before = have.size; for (const r of rs) { 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; } } it('MELT is craftable at tick 0 — the opening commission must be possible', () => { // first-taste wants 1 melt and is the head of the queue. If the melt chain is gated, // the game opens on a commission the player cannot fill and research they cannot start. expect([...reachableFrom(ungated)]).toContain('melt'); }); it('at least one science pack is craftable at tick 0, or research can never start', () => { const day0 = reachableFrom(ungated); const packs = items.filter((i) => i.id.endsWith('-pack')).map((i) => i.id); const bootstrap = packs.filter((p) => day0.has(p)); expect(bootstrap.length).toBeGreaterThan(0); }); it('every tech is eventually affordable — no node is stranded behind its own output', () => { // Walk the real loop: research what you can afford, which unlocks recipes, which makes // more packs, which affords more research. Anything left over can never be bought. const open = new Set(ungated.map((r) => r.id)); let have = reachableFrom(recipes.filter((r) => open.has(r.id))); const unlocked = new Set(); for (;;) { let moved = false; for (const t of tech) { if (unlocked.has(t.id)) continue; if (Object.keys(t.cost).every((c) => have.has(c))) { unlocked.add(t.id); for (const u of t.unlocks) { for (const r of recipes) if (r.id === u || r.machine === u) open.add(r.id); } moved = true; } } if (!moved) break; have = reachableFrom(recipes.filter((r) => open.has(r.id))); } const stranded = tech .filter((t) => !unlocked.has(t.id)) .map((t) => `${t.id} costs ${JSON.stringify(t.cost)} — unaffordable no matter what you build`); expect(stranded).toEqual([]); }); it('the lab is starting equipment', () => { const labs = machines.filter((m) => m.kind === 'lab').map((m) => m.id); const lockedLabs = labs.filter((id) => gated.has(id)); expect(lockedLabs).toEqual([]); // a lab behind research is a lab you can never build }); }); 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', // §10: The Correction hoards the prisms. Nothing in the factory makes 589nm. 'sodium-light', ]); 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([]); }); });