/**
 * The playable browser build.
 *
 * These tests drive `game.ts` exactly as the page does — take the offered actions, submit one, look
 * at the new position — so "it compiles" is never mistaken for "it plays".
 */

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';

import { cardDescription, describeIntent } from '../src/sim/view.ts';
import {
  actionGroups,
  actionMenu,
  handPlayable,
  currentActor,
  fromSave,
  newGame,
  submit,
  toSave,
  view,
} from '../src/web/game.ts';

/** Play a whole game by always taking the first offered action. */
function playThrough(seed: number, maxTurns = 20_000) {
  const game = newGame(seed);
  let turns = 0;
  for (; turns < maxTurns; turns++) {
    if (currentActor(game) === null) break;
    const { options, groups } = actionGroups(game);
    if (groups.length === 0 || options.length === 0) break;
    const first = groups[0]!.actions[0]!;
    if (!submit(game, options[first.index]!)) break;
  }
  return { game, turns };
}

describe('the browser game plays', () => {
  it('reaches the end of a game through the same calls the page makes', () => {
    const { game, turns } = playThrough(77);
    assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
    assert.equal(game.state.status, 'finished');
    assert.ok(game.state.outcome !== null, 'a finished game must have an outcome');
  });

  it('never offers an action the engine then refuses', () => {
    // The action list comes from legalActions, which delegates to check — so every button on the
    // page must be accepted. A rejection here means the UI and the rules disagree.
    const game = newGame(21);
    for (let i = 0; i < 300; i++) {
      if (currentActor(game) === null) break;
      const { options, groups } = actionGroups(game);
      if (groups.length === 0) break;
      const pick = groups[groups.length - 1]!.actions[0]!;
      assert.ok(submit(game, options[pick.index]!), 'the engine refused an offered action');
    }
  });

  it('offers every legal action somewhere, dropping none', () => {
    // Grouping is presentation. If a kind is not named in GROUP_ORDER it must still appear, or a
    // legal move becomes unreachable in a way that is very hard to notice.
    const game = newGame(5);
    for (let i = 0; i < 120; i++) {
      if (currentActor(game) === null) break;
      const { options, groups } = actionGroups(game);
      if (options.length === 0) break;
      const shown = new Set(groups.flatMap((g) => g.actions.map((a) => a.index)));
      const kinds = new Set(options.map((o) => o.type));
      const shownKinds = new Set([...shown].map((i2) => options[i2]!.type));
      assert.deepEqual(shownKinds, kinds, 'a kind of legal action was not offered at all');
      submit(game, options[[...shown][0]!]!);
    }
  });

  it('restores a saved game to the same position', () => {
    // A save is the seed plus the intents; replaying them must reproduce the game exactly. That is
    // the payoff of event sourcing, and it is only true while the RNG stays seeded.
    const game = newGame(909);
    for (let i = 0; i < 80; i++) {
      if (currentActor(game) === null) break;
      const { options, groups } = actionGroups(game);
      if (groups.length === 0) break;
      submit(game, options[groups[0]!.actions[0]!.index]!);
    }

    const restored = fromSave(toSave(game));
    assert.equal(restored.state.players[0]!.revenue, game.state.players[0]!.revenue);
    assert.equal(restored.state.clock.day, game.state.clock.day);
    assert.equal(restored.state.clock.stage, game.state.clock.stage);
    assert.equal(restored.state.status, game.state.status);
    assert.deepEqual(view(restored).cells, view(game).cells, 'the board differs after restore');
  });

  it('is deterministic — the same seed deals the same game', () => {
    const a = playThrough(4242).game;
    const b = playThrough(4242).game;
    assert.equal(a.state.players[0]!.revenue, b.state.players[0]!.revenue);
    assert.deepEqual(view(a).cells, view(b).cells);
  });
});

describe('the action menu presents choices the way they are made', () => {
  it('offers a card ONCE, with its locations underneath', () => {
    // A single turn offered 29 track buttons and 7 card buttons in one flat list, with no way to
    // tell which square each referred to. Subject first, location second.
    const game = newGame(555);
    submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);

    const menu = actionMenu(game);
    const items = menu.placeable.flatMap((g) => g.items);
    assert.ok(items.length > 0, 'nothing placeable was offered');

    const keys = items.map((i) => i.subjectKey);
    assert.equal(new Set(keys).size, keys.length, 'the same card appeared as two subjects');
    for (const item of items) {
      const labels = item.spots.map((sp) => sp.label);
      assert.equal(new Set(labels).size, labels.length, `${item.subject} lists a spot twice`);
      assert.ok(item.spots.length > 0, `${item.subject} has nowhere to go but was offered`);
    }
  });

  it('loses no legal action in the reshuffle', () => {
    // Splitting into direct + placeable must not drop anything: every option must still be reachable.
    const game = newGame(88);
    for (let i = 0; i < 120; i++) {
      if (currentActor(game) === null) break;
      const menu = actionMenu(game);
      if (menu.options.length === 0) break;
      const reachable = new Set([
        ...menu.direct.flatMap((g) => g.actions.map((a) => a.index)),
        ...menu.placeable.flatMap((g) => g.items.flatMap((it) => it.spots.map((sp) => sp.index))),
      ]);
      const kinds = new Set(menu.options.map((o) => o.type));
      const shown = new Set([...reachable].map((k) => menu.options[k]!.type));
      assert.deepEqual(shown, kinds, 'a kind of legal action became unreachable');
      submit(game, menu.options[[...reachable][0]!]!);
    }
  });

  it('names the card in every face-up slot', () => {
    // "slot 2" is unusable information: the whole point of a face-up slot is choosing it on sight.
    const game = newGame(555);
    submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
    const draw = actionMenu(game).direct.find((g) => g.title === 'Draw');
    assert.ok(draw, 'no draw group');
    for (const a of draw!.actions) {
      assert.ok(!/^draw\.|^slot \d+$/.test(a.label), `raw intent name on a button: ${a.label}`);
    }
    assert.ok(
      draw!.actions.some((a) => /face-up slot/.test(a.label)),
      'face-up slots are not named',
    );
  });

  it('never offers a train card a place on the board', () => {
    // A train card goes to the TIMETABLE. Seed 555 offered Extra X15 at six squares with six
    // rotations each — all identical, because the placement was accepted and then ignored.
    const game = newGame(555);
    for (let i = 0; i < 200; i++) {
      if (currentActor(game) === null) break;
      const { options } = actionGroups(game);
      if (options.length === 0) break;
      for (const o of options) {
        if (o.type !== 'card.play') continue;
        const kind = game.state.cards.get(o.cardId)?.kind.kind;
        if (kind === 'timetabledTrain' || kind === 'extraTrain') {
          assert.equal(o.placement, undefined, 'a train card was offered a board square');
        }
      }
      submit(game, options[0]!);
    }
  });

  it('reports what is left of the personal track supply', () => {
    // Track is not drawn from the deck — 26 pieces per player, at most one laid a turn — so the
    // board alone could never answer "how many straights have I got left".
    const game = newGame(31);
    const supply = view(game).trackSupply;
    assert.ok(supply.length > 0, 'no track supply reported');
    assert.equal(supply.reduce((n, t) => n + t.left, 0), 26, 'the opening supply should be 26');
    assert.ok(supply.every((t) => !/none/.test(t.piece)), 'un-handed pieces should not say "none"');
  });
});

describe('board highlighting', () => {
  it('gives every spot a coordinate to highlight', () => {
    const game = newGame(555);
    submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
    const items = actionMenu(game).placeable.flatMap((g) => g.items);
    assert.ok(items.length > 0);
    for (const it of items) {
      for (const sp of it.spots) {
        assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`);
        assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`);
        assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree');
      }
    }
  });

  it('highlights squares OUTSIDE the current district', () => {
    // The commonest placement is just beyond the existing cards — that is what extending means. A
    // grid sized only to the cards already down would highlight nothing exactly when it matters.
    const game = newGame(555);
    submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);

    const cells = view(game).cells;
    const minCol = Math.min(...cells.map((c) => c.col));
    const maxCol = Math.max(...cells.map((c) => c.col));
    const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((i) => i.spots);

    assert.ok(
      spots.some((sp) => sp.coord.col < minCol || sp.coord.col > maxCol || sp.coord.row < 0),
      'no legal spot lies outside the current cards — the bounds test would be vacuous',
    );
  });
});

describe('the page explains itself', () => {
  it('says what every card in hand and every face-up slot does', () => {
    // A hand of bare names is unplayable: "Steam Turbines" carries no hint of its effect, and all of
    // this text already existed in the content tables without reaching the screen.
    for (const seed of [555, 430, 7, 99]) {
      const f = view(newGame(seed));
      assert.equal(f.handWhat.length, f.hand.length, 'a hand card has no description slot');
      f.hand.forEach((name, i) => {
        const what = f.handWhat[i]!;
        assert.ok(what.length > 0, `${name} has no description`);
        // camelCase leaking to the screen is the recurring failure here.
        assert.doesNotMatch(what, /[a-z][A-Z]/, `raw camelCase in "${name}": ${what}`);
        assert.doesNotMatch(what, /playerChoicebound|undefined|NaN/, `broken text: ${what}`);
      });
      f.departments.forEach((name, i) => {
        if (name === '—') return;
        assert.ok(f.departmentsWhat[i]!.length > 0, `face-up ${name} has no description`);
      });
    }
  });

  it('says what every card on the BOARD does, in readable words', () => {
    // A played card becomes a cell with a name on it — "turnout", "Freight House", "waiting area" —
    // and the explanation that was visible while it sat in hand disappears exactly when it starts
    // mattering. Play a long way in so every card kind reaches the grid.
    const game = newGame(111);
    for (let i = 0; i < 400; i++) {
      if (currentActor(game) === null) break;
      const { options } = actionGroups(game);
      if (options.length === 0) break;
      const pick =
        options.find((o) => o.type === 'card.play' && o.placement) ??
        options.find((o) => o.type === 'track.lay') ??
        options[0]!;
      if (!submit(game, pick)) break;
    }

    const cells = view(game).cells;
    assert.ok(cells.length > 4, 'not enough of the board was built to be a real check');
    for (const c of cells) {
      assert.ok(c.what.length > 0, `(${c.row},${c.col}) ${c.label} has no explanation`);
      // camelCase on the board is the failure that keeps recurring — labels AND descriptions.
      assert.doesNotMatch(c.label, /[a-z][A-Z]/, `raw camelCase label: ${c.label}`);
      assert.doesNotMatch(c.what, /[a-z][A-Z]/, `raw camelCase in "${c.label}": ${c.what}`);
    }
  });

  it('spells out which way a turnout will and will not let a train run', () => {
    // §A.1 is an ABSENT edge, not a one-way street, and it is invisible on a card that just says
    // "turnout". Getting it wrong is how a crew ends up somewhere it cannot leave.
    const game = newGame(3);
    const area = game.state.officeAreas.get(0)!;
    area.grid.set('-1,0', {
      geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } },
      baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
    });
    const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
    assert.match(cell.what, /stem east/);
    assert.match(cell.what, /diverges south/);
    assert.match(cell.what, /NEVER/, 'the missing edge is not spelled out');
  });

  it('describes every card kind in the deck, not just the easy ones', () => {
    // Walk the whole deck rather than one hand: the categories differ, and a missing branch would
    // show as a blank line under a card name only for the seeds that happen to deal it.
    const game = newGame(1);
    const seen = new Set<string>();
    for (const [id, card] of game.state.cards) {
      const what = cardDescription(game.state, id);
      assert.ok(what.length > 0, `${card.kind.kind} has no description`);
      seen.add(card.kind.kind);
    }
    assert.ok(seen.size >= 7, `only ${seen.size} card kinds seen — the deck should hold more`);
  });

  it('marks cards that cannot be played yet, and says why', () => {
    // A Station upgrade drawn at a Whistle Post is dead weight — upgrades are strictly sequential
    // (Gap 3b) — but the hand showed it identically to a playable card, so taking it looked like an
    // action that did nothing.
    const game = newGame(111);
    submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
    submit(game, actionGroups(game).options.find((o) => o.type === 'draw.fromDepartment' && o.slot === 0)!);

    const f = view(game);
    const playable = handPlayable(game);
    assert.equal(playable.length, f.hand.length, 'a hand card has no playable flag');

    const upgrades = f.hand
      .map((name, i) => ({ name, what: f.handWhat[i]!, can: playable[i]! }))
      .filter((c) => /upgrade/.test(c.name));
    assert.ok(upgrades.length > 0, 'this seed should deal an Office upgrade');
    for (const u of upgrades) {
      // Only Depot is reachable from a Whistle Post.
      if (/Depot/.test(u.name)) continue;
      assert.equal(u.can, false, `${u.name} should not be playable from a Whistle Post`);
      assert.match(u.what, /requires the Office to be a/, `${u.name} does not say what it needs`);
    }
  });

  it('agrees with the buttons about what is playable', () => {
    // The flag is derived from legalActions, so it must never disagree with the offered actions.
    const game = newGame(7);
    for (let i = 0; i < 60; i++) {
      if (currentActor(game) === null) break;
      const hand = game.state.decks.hands.get(0) ?? [];
      const flags = handPlayable(game);
      const offered = new Set(
        actionGroups(game)
          .options.filter((o) => o.type === 'card.play')
          .map((o) => (o as { cardId: string }).cardId),
      );
      hand.forEach((id, k) => {
        assert.equal(flags[k], offered.has(id), 'playable flag disagrees with the action list');
      });
      const { options } = actionGroups(game);
      if (options.length === 0) break;
      submit(game, options[0]!);
    }
  });

  it('never puts an internal tray id on a BUTTON either', () => {
    // The history check below missed this: `maneuver.redFlags` was labelled "Red Flags on tray3",
    // so the id leaked through the action list rather than the log. Every button, every turn.
    const game = newGame(2345);
    for (let i = 0; i < 400; i++) {
      if (currentActor(game) === null) break;
      const { options } = actionGroups(game);
      if (options.length === 0) break;
      for (const o of options) {
        const label = describeIntent(game.state, o);
        assert.doesNotMatch(label, /\btray\d+\b/, `internal tray id on a button: ${label}`);
      }
      if (!submit(game, options[0]!)) break;
    }
  });

  it('never offers to load a caboose', () => {
    // A caboose carries the crew, not freight. "add loaded caboose" appeared because the button was
    // formatted by hand instead of using the labeller that already knew.
    const game = newGame(2345);
    for (let i = 0; i < 400; i++) {
      if (currentActor(game) === null) break;
      const { options } = actionGroups(game);
      if (options.length === 0) break;
      for (const o of options) {
        assert.doesNotMatch(
          describeIntent(game.state, o),
          /(loaded|empty) caboose/,
          'a caboose was described as loaded or empty',
        );
      }
      if (!submit(game, options[0]!)) break;
    }
  });

  it('warns that an industry on the Running Track is in the way of arrivals', () => {
    // §11.2 — a Facility carries its own rails, so it does NOT block traffic. But §10 makes a car
    // left standing between the Limits and the Office a collision, and nothing said so.
    const game = newGame(9);
    const area = game.state.officeAreas.get(0)!;
    const industry = {
      geometry: { kind: 'facility' as const, facility: 'grocersWarehouse' as const, axis: 'ew' as const },
      baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
      facility: {
        kind: 'freight' as const, subtype: 'grocersWarehouse',
        allows: { outbound: false, inbound: true },
        outboundBox: [], inboundBox: [], capacity: { outbound: 0, inbound: 1 },
        menAtWork: [null, null, null], industryTrack: { length: 2, cars: [] },
        laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
      },
    };
    area.grid.set(`${area.runningRow},2`, industry as never);
    area.grid.set(`${area.runningRow - 1},0`, JSON.parse(JSON.stringify(industry)) as never);

    const cells = view(game).cells;
    const onRunning = cells.find((c) => c.row === area.runningRow && c.col === 2)!;
    const below = cells.find((c) => c.row === area.runningRow - 1 && c.col === 0)!;

    assert.match(onRunning.what, /RUNNING TRACK/, 'no warning on a Running Track industry');
    assert.match(onRunning.what, /pass straight through/, 'does not say traffic still passes');
    assert.doesNotMatch(below.what, /RUNNING TRACK/, 'a Secondary Track industry must not warn');
  });

  it('never puts an internal tray id in front of a player', () => {
    // The §8.1 clearance question read "may tray2 follow tray3 into the next Subdivision?" — the
    // sharpest decision in the game, phrased in internal identifiers.
    const game = newGame(430);
    for (let i = 0; i < 600; i++) {
      if (currentActor(game) === null) break;
      const { options } = actionGroups(game);
      if (options.length === 0) break;
      submit(game, options[0]!);
    }
    for (const line of game.log) {
      assert.doesNotMatch(line.text, /\btray\d+\b/, `internal tray id shown to the player: ${line.text}`);
    }
  });

  it('spells out what each clearance ruling costs', () => {
    const game = newGame(5);
    const s = game.state;
    s.trays.set('tray2', {
      id: 'tray2', trainNumber: 4, trainIsExtra: false, engineFront: true,
      consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
    });
    s.trays.set('tray3', {
      id: 'tray3', trainNumber: 7, trainIsExtra: false, engineFront: true,
      consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
    });
    s.clock.pendingDecision = { train: 'tray2', occupiedBy: 'tray3' };

    const allow = describeIntent(s, { type: 'mainline.clearance', allow: true });
    const hold = describeIntent(s, { type: 'mainline.clearance', allow: false });
    for (const label of [allow, hold]) {
      assert.match(label, /Train 4/, `the ruling does not name the train: ${label}`);
      assert.doesNotMatch(label, /tray\d/, `internal id on a button: ${label}`);
    }
    // Deliberately NOT asserting that granting clearance mentions a collision: no rear-end is
    // implemented, and promising one would describe a consequence the engine never delivers.
    assert.match(allow, /same Mainline card/, 'granting clearance does not say what happens');
    assert.match(hold, /losing the Stage/, 'holding does not mention the cost');
  });

  it('states the objective and whether you are keeping up', () => {
    const f = view(newGame(430));
    assert.equal(f.objective.target, 20);
    assert.equal(f.objective.days, 5);
    assert.match(f.objective.note, /of 20/);
    assert.match(f.objective.note, /Days? left/);
  });

  it('explains what each Local Operations option spends', () => {
    // The most consequential decision of the Stage, previously labelled "choose switch".
    const game = newGame(555);
    const choices = actionGroups(game)
      .options.filter((o) => o.type === 'localOps.choose')
      .map((o) => describeIntent(game.state, o));
    assert.ok(choices.length > 0);
    for (const c of choices) {
      assert.ok(c.length > 25, `too terse to be an explanation: "${c}"`);
      assert.match(c, /—/, `no explanation after the option name: "${c}"`);
    }
  });

  it('says which way a piece will point, never "rotation 2"', () => {
    const game = newGame(555);
    submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
    const track = actionMenu(game)
      .placeable.flatMap((g) => g.items)
      .filter((i) => /straight|curve|turnout/.test(i.subject));
    assert.ok(track.length > 0, 'no track pieces offered');

    for (const item of track) {
      for (const sp of item.spots) {
        assert.doesNotMatch(sp.label, /rotation \d/, `opaque rotation label: ${sp.label}`);
      }
      // A piece with more than one orientation must say which is which, or the spots are ambiguous.
      const perSquare = new Map<string, number>();
      for (const sp of item.spots) {
        const k = `${sp.coord.row},${sp.coord.col}`;
        perSquare.set(k, (perSquare.get(k) ?? 0) + 1);
      }
      if ([...perSquare.values()].some((n) => n > 1)) {
        assert.ok(
          item.spots.some((sp) => /east|west|north|south/.test(sp.label)),
          `${item.subject} offers two orientations on one square without naming them`,
        );
      }
    }
  });
});

describe('the static build', () => {
  const root = join(import.meta.dirname, '..');
  const dist = join(root, 'dist');

  it('builds, and needs nothing but a static host', () => {
    execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });

    assert.ok(existsSync(join(dist, 'index.html')), 'no index.html');
    assert.ok(existsSync(join(dist, 'web/main.js')), 'no entry script');
    assert.ok(existsSync(join(dist, 'engine/apply.js')), 'the engine did not emit');
  });

  it('emits no import that a browser cannot resolve', () => {
    // Node's type-stripping lets the source import './x.ts'; a browser cannot. The build rewrites
    // those to .js, and nothing may reach for a bare module or a node: builtin.
    const files: string[] = [];
    const walk = (dir: string): void => {
      for (const e of readdirSync(dir, { withFileTypes: true })) {
        if (e.isDirectory()) walk(join(dir, e.name));
        else if (e.name.endsWith('.js')) files.push(join(dir, e.name));
      }
    };
    walk(dist);
    assert.ok(files.length > 5, 'suspiciously few emitted files');

    for (const f of files) {
      const src = readFileSync(f, 'utf8');
      // Anchor to real import/export statements. A loose `from '...'` also matches PROSE — the
      // turnout comment "a train entering a turnout from \"A\"" was read as importing a module
      // called A, which is the kind of false alarm that trains you to ignore a failing test.
      const imports = /^\s*(?:import|export)[^'"\n]*?from\s+['"]([^'"]+)['"]/gm;
      for (const m of src.matchAll(imports)) {
        const spec = m[1]!.split('?')[0]!;
        assert.ok(!spec.endsWith('.ts'), `${f} imports a .ts path: ${spec}`);
        assert.ok(!spec.startsWith('node:'), `${f} imports a Node builtin: ${spec}`);
        assert.ok(spec.startsWith('.') || spec.startsWith('/'), `${f} imports bare module: ${spec}`);
      }
      assert.ok(!/^[^/*]*\bprocess\.\w/m.test(src), `${f} references process`);
      assert.ok(!/\brequire\(/.test(src), `${f} uses require()`);
    }
  });

  it('renders clickable highlights on the board when a card is picked', async () => {
    // The real check: load the EMITTED bundle with a DOM stub, click a card, and confirm the board
    // came back with highlighted squares wired to handlers. Asserting the data has coordinates says
    // nothing about whether the page draws them.
    const els = new Map<string, Record<string, unknown>>();
    const make = (): Record<string, unknown> => {
      let html = '';
      // Cache per selector and clear it when innerHTML changes. Returning fresh objects each call
      // would drop the handlers the page assigns — the test would then report "no button" for a
      // page that works perfectly.
      let cache = new Map<string, Record<string, unknown>[]>();
      const node: Record<string, unknown> = {
        textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
        querySelectorAll(sel: string) {
          const hit = cache.get(sel);
          if (hit) return hit;
          const out: Record<string, unknown>[] = [];
          const re = sel.startsWith('[')
            ? /data-at="([^"]*)"/g
            : new RegExp(`<button class="${sel.split('.')[1]}[^"]*"([^>]*)>`, 'g');
          let m: RegExpExecArray | null;
          while ((m = re.exec(html)) !== null) {
            const data: Record<string, string> = {};
            if (sel.startsWith('[')) data['at'] = m[1]!;
            else {
              const attr = /data-(\w+)="([^"]*)"/g;
              let a: RegExpExecArray | null;
              while ((a = attr.exec(m[1]!)) !== null) data[a[1]!] = a[2]!;
            }
            out.push({ dataset: data, onclick: null });
          }
          cache.set(sel, out);
          return out;
        },
      };
      Object.defineProperty(node, 'innerHTML', {
        get: () => html,
        set: (v: string) => {
          html = v;
          cache = new Map();
        },
      });
      return node;
    };

    // STRICT: only ids that really exist in the served page. The stub used to conjure an element
    // for any id asked for, so a `$('target')` left behind after removing #target from the HTML
    // would pass here and throw on load in a browser — the page would simply never start.
    const served = new Set(
      [...readFileSync(join(dist, 'index.html'), 'utf8').matchAll(/id="([a-zA-Z]+)"/g)].map(
        (m) => m[1]!,
      ),
    );
    const g = globalThis as Record<string, unknown>;
    g['document'] = {
      getElementById: (id: string) => {
        if (!served.has(id)) return null;
        if (!els.has(id)) els.set(id, make());
        return els.get(id);
      },
    };
    g['location'] = { search: '?seed=555' };
    const store = new Map<string, string>();
    g['localStorage'] = {
      getItem: (k: string) => store.get(k) ?? null,
      setItem: (k: string, v: string) => void store.set(k, v),
      removeItem: (k: string) => void store.delete(k),
    };
    // No parameter properties — `erasableSyntaxOnly` forbids them, since Node strips types rather
    // than compiling them.
    g['URLSearchParams'] = class {
      search: string;
      constructor(search: string) {
        this.search = search;
      }
      get(k: string): string | null {
        return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? null;
      }
    };

    await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);

    const actions = els.get('actions')!;
    const grid = els.get('grid')!;

    // Take the draw option, then pick the first placeable subject.
    const clickFirst = (el: Record<string, unknown>, sel: string): boolean => {
      const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
      const nodes = fn.call(el, sel);
      const target = nodes[0];
      if (!target) return false;
      (target['onclick'] as (() => void) | null)?.();
      return true;
    };

    assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
    assert.ok(clickFirst(actions, 'button.subj'), 'no card/track subject to pick');

    const html = String(grid['innerHTML']);
    assert.match(html, /class="cell[^"]*legal/, 'picking a card highlighted nothing');
    assert.match(html, /data-at="/, 'highlighted squares carry no click target');
    assert.match(html, /ghost/, 'no empty square was offered as a destination');
  });

  it('busts the cache on every module, so a deploy cannot half-load', () => {
    // The first real deploy served a fresh index.html against a CACHED main.js: the HTML had
    // dropped an element the old script still asked for, so the page threw `missing element:
    // target` and never started. Filenames never change, so without a version query a static host
    // will happily mix two builds.
    const html = readFileSync(join(dist, 'index.html'), 'utf8');
    const entry = /<script type="module" src="([^"]+)"/.exec(html)?.[1] ?? '';
    assert.match(entry, /\?v=/, 'the entry script is not cache-busted');

    const files: string[] = [];
    const walk = (dir: string): void => {
      for (const e of readdirSync(dir, { withFileTypes: true })) {
        if (e.isDirectory()) walk(join(dir, e.name));
        else if (e.name.endsWith('.js')) files.push(join(dir, e.name));
      }
    };
    walk(dist);

    let checked = 0;
    for (const f of files) {
      const src = readFileSync(f, 'utf8');
      for (const m of src.matchAll(/^\s*(?:import|export)[^'"\n]*?from\s+['"](\.[^'"]+)['"]/gm)) {
        assert.match(m[1]!, /\.js\?v=/, `${f} imports ${m[1]} without a version`);
        checked++;
      }
    }
    assert.ok(checked > 5, `only ${checked} relative imports seen — the check is too weak`);
  });

  it('resolves every busted import to a real file', () => {
    // Appending a query must not break resolution: the path before `?` still has to exist.
    const files: string[] = [];
    const walk = (dir: string): void => {
      for (const e of readdirSync(dir, { withFileTypes: true })) {
        if (e.isDirectory()) walk(join(dir, e.name));
        else if (e.name.endsWith('.js')) files.push(join(dir, e.name));
      }
    };
    walk(dist);
    for (const f of files) {
      const src = readFileSync(f, 'utf8');
      for (const m of src.matchAll(/^\s*(?:import|export)[^'"\n]*?from\s+['"](\.[^'"]+)['"]/gm)) {
        const target = resolve(dirname(f), m[1]!.split('?')[0]!);
        assert.ok(existsSync(target), `${f} imports ${m[1]}, which does not exist`);
      }
    }
  });

  it('asks only for elements the page actually has', () => {
    // Cheap and total: compare every $('id') in the source against the ids in the served HTML.
    // Getting this wrong does not degrade the page, it stops the game starting at all.
    const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
    const asked = new Set([...src.matchAll(/\$\('([a-zA-Z]+)'\)/g)].map((m) => m[1]!));
    const html = readFileSync(join(dist, 'index.html'), 'utf8');
    const present = new Set([...html.matchAll(/id="([a-zA-Z]+)"/g)].map((m) => m[1]!));
    // `again` is created by the game-over screen before it is looked up.
    present.add('again');
    for (const id of asked) {
      assert.ok(present.has(id), `main.ts asks for #${id}, which the page does not contain`);
    }
  });

  it('serves a page that loads the game as a module', () => {
    const html = readFileSync(join(dist, 'index.html'), 'utf8');
    assert.match(html, /<script type="module" src="\.\/web\/main\.js(\?v=[^"]*)?">/);
    assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), 'page fetches something external');
    for (const id of ['grid', 'division', 'actions', 'log', 'facs', 'hand', 'blocked']) {
      assert.ok(html.includes(`id="${id}"`), `page is missing #${id}`);
    }
  });
});
