Drop7 Research
src/core/typescript/virtual-ignition.test.tsTypeScript175 lines · 4.4 KB
import assert from "node:assert/strict";
import test from "node:test";
import {
  BOARD_SIZE,
  CRACKED,
  EMPTY,
  MOVES_PER_LEVEL,
  SOLID,
  boardFromRows,
  findPoppers,
  type Board,
  type Cell,
  type GameState,
} from "./engine.ts";
import {
  HEURISTIC_GAME_OVER_UTILITY,
  evaluateHeuristic,
} from "./heuristic.ts";
import {
  analyzeVirtualIgnition,
  evaluateVirtualIgnition,
} from "./virtual-ignition.ts";

const E = EMPTY;
const row = (...cells: Cell[]) => cells;
const blank = () => row(E, E, E, E, E, E, E);

function position(board: Board, overrides: Partial<GameState> = {}): GameState {
  return {
    board,
    nextDisc: 4,
    score: 0,
    level: 1,
    movesRemaining: MOVES_PER_LEVEL,
    movesPlayed: 0,
    gameOver: false,
    ...overrides,
  };
}

test("a stored 7/6/5 column contains a two-wave virtual cascade", () => {
  const stored = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, E, E, 7, E, E, E),
    row(E, E, E, 6, E, E, E),
    row(E, E, E, 5, E, E, E),
  ]);
  const broken = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, E, E, 7, E, E, E),
    row(E, E, E, 3, E, E, E),
    row(E, E, E, 5, E, E, E),
  ]);
  assert.deepEqual(findPoppers(stored), []);
  const storedAnalysis = analyzeVirtualIgnition(position(stored));
  const brokenAnalysis = analyzeVirtualIgnition(position(broken));
  const seven = storedAnalysis.seeds.find((seed) => seed.value === 7);

  assert.ok(seven);
  assert.equal(seven.additionCost, 4);
  assert.equal(seven.readiness, 0.125);
  assert.equal(seven.downstreamWaves, 2);
  assert.equal(seven.downstreamClears, 2);
  assert.ok(seven.cascadeDepthEnergy > 0);
  assert.ok(
    storedAnalysis.features.cascadeDepthEnergy >
      brokenAnalysis.features.cascadeDepthEnergy,
  );
  assert.ok(
    evaluateVirtualIgnition(position(stored)) >
      evaluateVirtualIgnition(position(broken)),
  );
});

test("virtual ignition applies one-hit solid cracks and cracked reveals", () => {
  const board = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, E, CRACKED, 1, SOLID, E, E),
  ]);
  const analysis = analyzeVirtualIgnition(position(board));
  const seed = analysis.seeds.find((candidate) => candidate.value === 1);

  assert.ok(seed);
  assert.equal(seed.additionCost, 0);
  assert.equal(seed.initialCoverCracks, 1);
  assert.equal(seed.initialCoverReveals, 1);
  assert.ok(seed.coverReduction >= 1);
});

test("closed low-number cycles cannot ignite themselves", () => {
  const coverRow = row(SOLID, SOLID, E, SOLID, SOLID, SOLID, E);
  const board = boardFromRows([
    coverRow,
    coverRow,
    coverRow,
    coverRow,
    coverRow,
    coverRow,
    row(1, 1, E, 2, 2, 2, E),
  ]);
  assert.deepEqual(findPoppers(board), []);
  const analysis = analyzeVirtualIgnition(position(board));

  assert.deepEqual(analysis.seeds, []);
  assert.ok(
    Object.values(analysis.features).every((feature) => feature === 0),
  );
});

test("virtual ignition is mirror exact and independent of accumulated score", () => {
  const board = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, 7, E, E, E, E, E),
    row(E, 6, SOLID, E, E, E, E),
    row(E, 5, CRACKED, E, E, E, E),
  ]);
  const state = position(board);
  const forward = analyzeVirtualIgnition(state);
  const mirrored = analyzeVirtualIgnition(position(mirrorBoard(board)));

  assert.deepEqual(mirrored, forward);
  assert.equal(
    evaluateVirtualIgnition({ ...state, score: 1_000_000 }),
    evaluateVirtualIgnition(state),
  );
});

test("virtual ignition is a bounded residual over combined", () => {
  const state = position(boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, E, E, 7, E, E, E),
    row(E, E, E, 6, E, E, E),
    row(E, E, E, 5, E, E, E),
  ]));
  assert.equal(
    evaluateVirtualIgnition(state, 0),
    evaluateHeuristic(state, "combined"),
  );
  assert.equal(
    evaluateVirtualIgnition({ ...state, gameOver: true }),
    HEURISTIC_GAME_OVER_UTILITY,
  );
  assert.throws(() => evaluateVirtualIgnition(state, -1), /non-negative/);
  assert.throws(
    () => analyzeVirtualIgnition(state, { scenarios: 0 }),
    /scenarios/,
  );
});

function mirrorBoard(board: Board): Board {
  const result: Cell[] = [];
  for (let rowIndex = 0; rowIndex < BOARD_SIZE; rowIndex += 1) {
    for (let column = BOARD_SIZE - 1; column >= 0; column -= 1) {
      result.push(board[rowIndex * BOARD_SIZE + column]);
    }
  }
  return result;
}