Round 5 (MEGA), all eight orders. THE COMMISSION LADDER — the fax is the tutorial. The queue used to open on one MELT, i.e. "build eight machines correctly, in order, with no feedback until the last one works". Now four rungs teach one mechanic each and never say so: 20 raw ore (extractor->belt->uplink) -> 15 luma (the demuxer, and the slurry is your problem) -> 18 hf-dust (waste is inventory) -> 1 melt (graduation; first-taste keeps its id, reward 50->80). Measured against SIM's real sim: rung 1 clears at tick 650 = 21.7s with 4 machine types, against a 3-minute budget. data/seams.json — seam regions per stratum, richness, and a seam-free relicReserve in the SE so SIM's seed-derived optical fossil always has somewhere legal to land. stream-crust-west covers all three reference-factory extractors, pinned by a test. data/stations.json — the whole dial: FKTRY FM, THE STANDARD (30 lines of Correction propaganda, 10 minutes before a repeat), NUMBERS, DEAD AIR. The numbers station is a real treasure map: each 5-figure group is [x+32][y+32][richness*9] and three of them are the hidden seams. The validator recomputes them from seams.json, so the map cannot drift, and no decoy is allowed to decode onto real treasure. Also: - mosquito trap: input-free recipe, the shape LANE-SIM detects traps by (my first pass took ingredients, which would have silently made it an ordinary crafter). Unblocks vaporwave-sunset after four rounds; FORWARD_CANON pruned. - cooler final 0.0018 -> 0.001: the additive ruling made two coolers immortal. Now 1 doubles output, 2 nearly eliminates downtime, 3 is an absurd shrine. - SIM's tuning constants imported; the hand-copies are retired. - the optical fossil canon: §1's fossils "sing when excavated", so the world is silent until you dig up the severed audio bus. One margin hint, mid-ladder. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
846 lines
37 KiB
TypeScript
846 lines
37 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 { TICKS_PER_SECOND } from '../src/contracts';
|
|
// LANE-SIM's tuning constants — imported, never retyped (round 5). A hand-copied 0.7
|
|
// keeps passing after the sim moves to 0.65; an import fails loudly, which is the point.
|
|
import {
|
|
HEAT_COOL_DEFAULT, HEAT_COOL_MAX, HEAT_RESTART, HEAT_THROTTLE_START,
|
|
} from '../src/sim/constants';
|
|
|
|
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';
|
|
import seamsJson from './seams.json';
|
|
import stationsJson from './stations.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<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([]);
|
|
});
|
|
|
|
// 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', () => {
|
|
// Round 5: these were hand-copied from src/sim/index.ts and went stale in silence by
|
|
// design — a copy of 0.7 doesn't fail when the sim moves to 0.65. LANE-SIM exports them
|
|
// now, so the copies are retired and this arithmetic is pinned to the real curve.
|
|
const TPS = TICKS_PER_SECOND;
|
|
const THROTTLE_START = HEAT_THROTTLE_START;
|
|
const RESTART = HEAT_RESTART;
|
|
const SIM_DEFAULT_COOL = HEAT_COOL_DEFAULT;
|
|
|
|
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 and traps run input-free recipes', () => {
|
|
// An input-free recipe takes something from the world rather than off a belt.
|
|
// Extractors take it from the ground; the mosquito trap takes a live swarm, which
|
|
// is wildlife in the snapshot and never an item on a belt. LANE-SIM identifies a
|
|
// trap by exactly this shape — an empty-input recipe yielding the swarm item — so
|
|
// giving `bait-swarm` ingredients would quietly stop it being a trap at all.
|
|
const CATCHES_WILDLIFE = new Set(['bait-swarm']);
|
|
const bad = recipes.filter((r) => Object.keys(r.inputs).length === 0)
|
|
.filter((r) => !CATCHES_WILDLIFE.has(r.id))
|
|
.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('the trap keeps the shape LANE-SIM detects traps by', () => {
|
|
// src/sim/wildlife.test.ts: "The sim identifies traps by that recipe shape, not by a
|
|
// machine id." If this drifts, the trap silently becomes an ordinary crafter that
|
|
// conjures mosquitoes from nothing.
|
|
const trap = recipes.find((r) => r.id === 'bait-swarm')!;
|
|
expect(Object.keys(trap.inputs)).toEqual([]);
|
|
expect(trap.outputs['mosquito-swarm']).toBeGreaterThan(0);
|
|
});
|
|
|
|
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 the first rung of the ladder — the queue order is the tutorial', () => {
|
|
// Round 5: the head used to be 'first-taste' (one MELT), which asked a brand-new
|
|
// player to build the entire M1 chain before the first fax cleared. The teaching
|
|
// ladder now occupies the front of the queue; see 'the commission ladder' below.
|
|
expect(commissions[0].id).toBe('raw-payload');
|
|
});
|
|
|
|
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<string> {
|
|
const have = new Set<string>();
|
|
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<string>();
|
|
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.
|
|
// Round 5 retired 'mosquito-swarm' — the trap catches them now, which finally makes
|
|
// `vaporwave-sunset` fillable after four rounds of wanting live mosquitoes.
|
|
const FORWARD_CANON = new Set([
|
|
'gibbs-wraith', // §6: must come "willingly". Nothing coerces one yet.
|
|
'cinema-pack', // needs the Fortress (§7).
|
|
'sodium-light', // §10: The Correction hoards the prisms.
|
|
]);
|
|
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', 'mosquito-swarm'].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([]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------- the ground
|
|
//
|
|
// data/seams.json is consumed by SIM (an extractor only runs while its footprint
|
|
// overlaps a seam) and RENDER (era skins the ground). Nothing else in the repo knows
|
|
// where the ore is, so a bad rect here is an unmineable world.
|
|
|
|
interface SeamRect { x: number; y: number; w: number; h: number }
|
|
interface SeamDef {
|
|
id: string; era: TechDef['era']; resource: string;
|
|
rect: SeamRect; richness: number; hidden?: boolean;
|
|
}
|
|
const seams = seamsJson.seams as unknown as SeamDef[];
|
|
const relicReserve = seamsJson.relicReserve as unknown as SeamRect;
|
|
|
|
/** Do two tile rects touch? Both are inclusive of their min corner, w/h tiles wide. */
|
|
function rectsOverlap(a: SeamRect, b: SeamRect): boolean {
|
|
return !(a.x > b.x + b.w - 1 || a.x + a.w - 1 < b.x ||
|
|
a.y > b.y + b.h - 1 || a.y + a.h - 1 < b.y);
|
|
}
|
|
|
|
describe('seams', () => {
|
|
it('every seam has a unique kebab-case id and a known era', () => {
|
|
expect(duplicates(seams.map((s) => s.id))).toEqual([]);
|
|
const bad = seams
|
|
.filter((s) => !KEBAB.test(s.id) || !ERAS.includes(s.era))
|
|
.map((s) => `${s.id} -> era "${s.era}"`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('every seam yields an item that exists', () => {
|
|
const bad = seams.filter((s) => !itemIds.has(s.resource)).map((s) => `${s.id} -> "${s.resource}"`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('every seam is a real rect inside the grid', () => {
|
|
// GRID_MIN/GRID_MAX are LANE-SIM's (src/sim/geom.ts). A seam outside them is ground
|
|
// the player can never stand on.
|
|
const bad: string[] = [];
|
|
for (const s of seams) {
|
|
const { x, y, w, h } = s.rect;
|
|
if (![x, y, w, h].every(Number.isInteger) || w < 1 || h < 1) bad.push(`${s.id} rect ${JSON.stringify(s.rect)}`);
|
|
if (x < -32 || y < -32 || x + w - 1 > 31 || y + h - 1 > 31) bad.push(`${s.id} out of the -32..31 grid`);
|
|
if (!(s.richness > 0 && s.richness <= 1)) bad.push(`${s.id} richness ${s.richness}`);
|
|
}
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('no two seams overlap — a tile belongs to one stratum', () => {
|
|
const clashes: string[] = [];
|
|
for (let i = 0; i < seams.length; i++) {
|
|
for (let j = i + 1; j < seams.length; j++) {
|
|
if (rectsOverlap(seams[i].rect, seams[j].rect)) clashes.push(`${seams[i].id} / ${seams[j].id}`);
|
|
}
|
|
}
|
|
expect(clashes).toEqual([]);
|
|
});
|
|
|
|
it('the relic reserve is genuinely seam-free', () => {
|
|
// SIM plants the optical fossil 18-28 tiles out, "never on a seam". If seams creep
|
|
// into the reserved corner there may be nowhere legal left to put it.
|
|
const intruders = seams.filter((s) => rectsOverlap(s.rect, relicReserve)).map((s) => s.id);
|
|
expect(intruders).toEqual([]);
|
|
});
|
|
|
|
it('the reference factory stands on ore', () => {
|
|
// SIM's reference factory puts three 2x2 extractors here. Off-seam they idle with
|
|
// 'no seam' and the whole demo produces nothing.
|
|
const REFERENCE_EXTRACTORS = [{ x: -30, y: 0 }, { x: -30, y: -3 }, { x: -30, y: 3 }];
|
|
const stranded = REFERENCE_EXTRACTORS
|
|
.filter((e) => !seams.some((s) => rectsOverlap(s.rect, { x: e.x, y: e.y, w: 2, h: 2 })))
|
|
.map((e) => `extractor at (${e.x},${e.y}) is off-seam`);
|
|
expect(stranded).toEqual([]);
|
|
});
|
|
|
|
it('there are hidden bonus seams for the numbers station to point at', () => {
|
|
expect(seams.filter((s) => s.hidden).length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------- the band
|
|
|
|
describe('stations', () => {
|
|
const stations = stationsJson.stations as unknown as Array<Record<string, any>>;
|
|
const byId = (id: string) => stations.find((s) => s.id === id)!;
|
|
|
|
it('every station has a unique id, a known band and a positive frequency', () => {
|
|
expect(duplicates(stations.map((s) => s.id as string))).toEqual([]);
|
|
const bad = stations
|
|
.filter((s) => !KEBAB.test(s.id) || !['AM', 'FM', 'SW'].includes(s.band) || !(s.freq > 0))
|
|
.map((s) => `${s.id} -> ${s.band} ${s.freq}`);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('no two stations sit on the same spot on the dial', () => {
|
|
const dial = stations.map((s) => `${s.band} ${s.freq}`);
|
|
expect(duplicates(dial)).toEqual([]);
|
|
});
|
|
|
|
it('every station has a name, a format tag and a line of flavor', () => {
|
|
const bad = stations
|
|
.filter((s) => !String(s.name).trim() || !String(s.format).trim() || !String(s.flavor).trim())
|
|
.map((s) => s.id as string);
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
it('THE STANDARD can broadcast for ten minutes without repeating itself', () => {
|
|
// At the ~20s a chyron line needs to be readable, ten minutes is 30 lines.
|
|
const lines = byId('the-standard').chyron as string[];
|
|
expect(lines.length).toBeGreaterThanOrEqual(30);
|
|
expect(duplicates(lines)).toEqual([]);
|
|
expect(lines.filter((l) => !l.trim())).toEqual([]);
|
|
});
|
|
|
|
it('the numbers station really does encode the hidden seams', () => {
|
|
// The treasure map has to point at treasure. Recompute the planted groups from
|
|
// seams.json: if a hidden seam moves and the broadcast does not, this fails.
|
|
const n = byId('numbers').numbers;
|
|
const encode = (s: SeamDef) =>
|
|
String(s.rect.x + 32).padStart(2, '0') +
|
|
String(s.rect.y + 32).padStart(2, '0') +
|
|
String(Math.round(s.richness * 9));
|
|
const expected = seams.filter((s) => s.hidden).map(encode).sort();
|
|
expect([...(n.plantedGroups as string[])].sort()).toEqual(expected);
|
|
});
|
|
|
|
it('every broadcast group is the right shape, and the decoys lead nowhere', () => {
|
|
const n = byId('numbers').numbers;
|
|
const groups = [...(n.plantedGroups as string[]), ...(n.decoyPool as string[])];
|
|
const malformed = groups.filter((g) => !new RegExp(`^[0-9]{${n.groupSize}}$`).test(g));
|
|
expect(malformed).toEqual([]);
|
|
expect(duplicates(groups)).toEqual([]);
|
|
|
|
// A decoy that happens to decode onto a real hidden seam isn't a decoy, it's a leak.
|
|
const hiddenTiles = new Set(seams.filter((s) => s.hidden).map((s) => `${s.rect.x},${s.rect.y}`));
|
|
const leaks = (n.decoyPool as string[])
|
|
.map((g) => ({ g, x: Number(g.slice(0, 2)) - 32, y: Number(g.slice(2, 4)) - 32 }))
|
|
.filter(({ x, y }) => hiddenTiles.has(`${x},${y}`))
|
|
.map(({ g, x, y }) => `decoy ${g} decodes to the real seam at (${x},${y})`);
|
|
expect(leaks).toEqual([]);
|
|
});
|
|
|
|
it('dead air has its one ghost', () => {
|
|
const ghost = byId('dead-air').ghost;
|
|
expect(String(ghost.line).trim().length).toBeGreaterThan(0);
|
|
expect(ghost.everyMinutes).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------- the ladder
|
|
//
|
|
// The fax is the tutorial. The first four commissions teach one mechanic each, in
|
|
// order, and the first one has to be completable by someone who has never seen the
|
|
// game. These guard the teaching order the way the tick-0 guard protects the bootstrap.
|
|
|
|
describe('the commission ladder', () => {
|
|
const gated = new Set(tech.flatMap((t) => t.unlocks));
|
|
const ungatedRecipes = recipes.filter((r) => !gated.has(r.id) && !gated.has(r.machine));
|
|
|
|
/**
|
|
* The smallest set of machine types that can produce `target` from bare ground at
|
|
* tick 0, or null if nothing ungated makes it. Fixpoint over recipes, keeping the
|
|
* cheapest producer for each item — "how many different machines must a new player
|
|
* build to fill this order".
|
|
*/
|
|
const cheapest = new Map<string, Set<string>>();
|
|
for (;;) {
|
|
let improved = false;
|
|
for (const r of ungatedRecipes) {
|
|
const inputs = Object.keys(r.inputs);
|
|
if (!inputs.every((i) => cheapest.has(i))) continue;
|
|
const need = new Set<string>([r.machine]);
|
|
for (const i of inputs) for (const m of cheapest.get(i)!) need.add(m);
|
|
for (const o of Object.keys(r.outputs)) {
|
|
const known = cheapest.get(o);
|
|
if (!known || need.size < known.size) { cheapest.set(o, need); improved = true; }
|
|
}
|
|
}
|
|
if (!improved) break;
|
|
}
|
|
const machinesToMake = (target: string): Set<string> | null => cheapest.get(target) ?? null;
|
|
|
|
const RUNGS = ['raw-payload', 'first-refinement', 'dust-quota', 'first-taste'];
|
|
|
|
it('opens on the teaching ladder, in teaching order', () => {
|
|
expect(commissions.slice(0, RUNGS.length).map((c) => c.id)).toEqual(RUNGS);
|
|
});
|
|
|
|
it('rung 1 is buildable at tick 0 out of a handful of machines', () => {
|
|
// "A belt, a drill, an uplink." If rung 1 needs research or a deep chain, the game
|
|
// opens by asking a new player for something they cannot possibly make.
|
|
const rung1 = commissions[0];
|
|
for (const want of Object.keys(rung1.wants)) {
|
|
const needed = machinesToMake(want);
|
|
expect(needed, `rung 1 wants ${want}, which cannot be made at tick 0`).not.toBeNull();
|
|
// + the belt and the uplink, which carry it but craft nothing.
|
|
expect(needed!.size, `rung 1 should need few machine types, needs ${[...needed!]}`)
|
|
.toBeLessThanOrEqual(2);
|
|
}
|
|
});
|
|
|
|
it('every rung is craftable at tick 0 — the tutorial never gates itself', () => {
|
|
const blocked: string[] = [];
|
|
for (const id of RUNGS) {
|
|
const c = commissions.find((x) => x.id === id)!;
|
|
for (const want of Object.keys(c.wants)) {
|
|
if (machinesToMake(want) === null) blocked.push(`${id} wants ${want}, unmakeable at tick 0`);
|
|
}
|
|
}
|
|
expect(blocked).toEqual([]);
|
|
});
|
|
|
|
it('each rung teaches strictly more machinery than the one before', () => {
|
|
// The ladder should climb. If a later rung needs no more than an earlier one, it is
|
|
// not teaching anything new and the slot is wasted.
|
|
const depth = RUNGS.map((id) => {
|
|
const c = commissions.find((x) => x.id === id)!;
|
|
return Math.max(...Object.keys(c.wants).map((w) => machinesToMake(w)?.size ?? 0));
|
|
});
|
|
const flat = depth.slice(1).map((d, i) => ({ d, prev: depth[i], id: RUNGS[i + 1] }))
|
|
.filter(({ d, prev }) => d <= prev)
|
|
.map(({ id, d, prev }) => `${id} needs ${d} machine types, no more than the rung before (${prev})`);
|
|
expect(flat).toEqual([]);
|
|
});
|
|
|
|
it('the ladder is cheap and the rewards climb', () => {
|
|
const rungs = RUNGS.map((id) => commissions.find((x) => x.id === id)!);
|
|
// Rewards ascend across the ladder and none of the tutorial rungs out-pays the
|
|
// ordinary queue that follows.
|
|
for (let i = 1; i < rungs.length; i++) {
|
|
expect(rungs[i].rewardBandwidth).toBeGreaterThan(rungs[i - 1].rewardBandwidth);
|
|
}
|
|
expect(rungs[rungs.length - 1].rewardBandwidth).toBeLessThan(commissions[RUNGS.length].rewardBandwidth);
|
|
// No standing orders in the tutorial — a repeating rung would never let the player
|
|
// off the first step.
|
|
expect(rungs.filter((c) => c.repeat).map((c) => c.id)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('the cooler pairing', () => {
|
|
const soft = machines.find((m) => m.id === 'software-decoder')!;
|
|
const cooler = machines.find((m) => m.id === 'asic-cooler')!;
|
|
const cool = (m: MachineDef) => m.coolPerTick ?? HEAT_COOL_DEFAULT;
|
|
/** Contracts v5: auras are ADDITIVE, capped at HEAT_COOL_MAX. */
|
|
const withCoolers = (n: number) =>
|
|
Math.min(cool(soft) + n * cooler.coolPerTick!, HEAT_COOL_MAX);
|
|
const duty = (c: number) => {
|
|
const net = soft.heatPerTick! - c;
|
|
if (net <= 0) return 1; // never heats
|
|
const up = (1 - HEAT_RESTART) / net;
|
|
return up / (up + (1 - HEAT_RESTART) / c);
|
|
};
|
|
|
|
it('one cooler is a real upgrade, not a rounding error', () => {
|
|
expect(duty(withCoolers(1))).toBeGreaterThan(duty(withCoolers(0)) * 1.5);
|
|
});
|
|
|
|
it('a bare decoder still throttles, and one cooler does not make it immortal', () => {
|
|
// The codex's decoder "throttles theatrically" — that is its whole character. A
|
|
// single cooler must not switch the mechanic off.
|
|
expect(soft.heatPerTick!).toBeGreaterThan(withCoolers(0));
|
|
expect(soft.heatPerTick!).toBeGreaterThan(withCoolers(1));
|
|
});
|
|
|
|
it('immortality costs an absurd shrine of coolers, not a casual pair', () => {
|
|
expect(soft.heatPerTick!).toBeGreaterThan(withCoolers(2)); // two is not enough
|
|
expect(withCoolers(3)).toBeGreaterThanOrEqual(soft.heatPerTick!); // three is
|
|
});
|
|
});
|