/**
 * 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.
    // Round 6: the FIREWALL joins the trap. It cans a live PARITY MITE the same way —
    // an input-free recipe whose "input" is a creature in the world, not cargo on a belt.
    const CATCHES_WILDLIFE = new Set(['bait-swarm', 'catch-mite']);
    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('the firewall keeps the shape LANE-SIM detects wildlife-catchers by', () => {
    // Round 6, coordinated with SIM: a firewall is spotted by recipe shape exactly like a
    // trap — empty inputs, output is the canned creature (here the parity mite). Ingredients
    // would silently demote it to an ordinary crafter that mints The Correction from nothing.
    const fw = recipes.find((r) => r.id === 'catch-mite')!;
    expect(Object.keys(fw.inputs)).toEqual([]);
    expect(fw.outputs['parity-mite']).toBeGreaterThan(0);
  });

  it('something can catch the parity mite the fax orders alive', () => {
    // correction-specimen wants ONE UNIT OF THE CORRECTION, ALIVE. If nothing cans a mite,
    // the fax opens an order it can never fill and never celebrates its first catch.
    const catcher = recipes.find(
      (r) => (r.outputs['parity-mite'] ?? 0) > 0 && Object.keys(r.inputs).length === 0);
    expect(catcher, 'no input-free recipe cans a parity mite').toBeTruthy();
    const wanters = commissions.filter((c) => (c.wants['parity-mite'] ?? 0) > 0).map((c) => c.id);
    expect(wanters.length, 'no commission wants a canned mite').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 decodes onto ANY seam — hidden OR visible — isn't a decoy, it's a leak:
    // it hands the player a real dig site the station never meant to give away. Round 6:
    // the old check only guarded the three hidden 1x1 tiles, and two decoys (08127, 27503)
    // were quietly sitting on reel-beds-deep and broadcast-shelf-south. Now every decoy is
    // decoded to a tile and checked against every seam rect, and the leak is named.
    const point = (x: number, y: number): SeamRect => ({ x, y, w: 1, h: 1 });
    const leaks = (n.decoyPool as string[])
      .map((g) => ({ g, x: Number(g.slice(0, 2)) - 32, y: Number(g.slice(2, 4)) - 32 }))
      .flatMap(({ g, x, y }) =>
        seams
          .filter((s) => rectsOverlap(point(x, y), s.rect))
          .map((s) => `decoy ${g} decodes to (${x},${y}), inside real seam ${s.id}`));
    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
  });
});
