Drop7 Research
src/core/typescript/heuristic.test.tsTypeScript360 lines · 9.3 KB
import assert from "node:assert/strict";
import test from "node:test";
import {
  BOARD_SIZE,
  CRACKED,
  EMPTY,
  MOVES_PER_LEVEL,
  SOLID,
  boardFromRows,
  contiguousLineLength,
  findPoppers,
  isNumbered,
  playMove,
  type Board,
  type Cell,
  type GameState,
} from "./engine.ts";
import {
  DEFAULT_HEURISTIC_PROFILE,
  HEURISTIC_GAME_OVER_UTILITY,
  evaluateHeuristic,
  extractHeuristicFeatures,
  type HeuristicProfileName,
} from "./heuristic.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("legacy profile exactly preserves the former horizon utility", () => {
  const board = boardFromRows([
    blank(),
    blank(),
    row(E, E, 1, E, E, E, E),
    row(E, E, 7, E, E, E, E),
    row(E, 3, SOLID, E, E, E, E),
    row(E, 6, CRACKED, E, E, E, E),
    row(E, 4, 2, E, E, E, E),
  ]);
  const state = position(board);

  assert.equal(evaluateHeuristic(state, "legacy"), legacyReference(state));
  assert.equal(
    evaluateHeuristic({ ...state, gameOver: true }, "legacy"),
    HEURISTIC_GAME_OVER_UTILITY,
  );
});

test("combined is the default heuristic profile", () => {
  const state = position(boardFromRows(Array.from({ length: 7 }, blank)));
  assert.equal(DEFAULT_HEURISTIC_PROFILE, "combined");
  assert.equal(evaluateHeuristic(state), evaluateHeuristic(state, "combined"));
});

test("an externally triggerable tower carries latent chain energy", () => {
  const loaded = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(4, E, E, E, E, E, E),
    row(4, E, E, E, E, E, E),
    row(2, E, E, E, E, E, E),
  ]);
  const neutral = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(6, E, E, E, E, E, E),
    row(7, E, E, E, E, E, E),
    row(2, E, E, E, E, E, E),
  ]);

  assert.deepEqual(findPoppers(loaded), []);
  assert.deepEqual(findPoppers(neutral), []);
  const loadedFeatures = extractHeuristicFeatures(position(loaded));
  const neutralFeatures = extractHeuristicFeatures(position(neutral));
  assert.ok(loadedFeatures.directPotential > neutralFeatures.directPotential);
  assert.ok(
    loadedFeatures.latentChainPotential >
      neutralFeatures.latentChainPotential,
  );
  assert.ok(
    evaluateHeuristic(position(loaded), "potential") >
      evaluateHeuristic(position(neutral), "potential"),
  );

  const move = playMove(position(loaded, { nextDisc: 7 }), 0, () => 0.5);
  assert.ok(move);
  assert.deepEqual(
    move.waves.map((wave) => wave.cleared),
    [2, 1],
  );
});

test("a quiet placement can store a larger chain than an immediate clear", () => {
  const board = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(5, E, E, E, E, E, E),
    row(5, E, E, E, E, E, E),
    row(2, E, 6, 6, 6, 6, E),
  ]);
  const state = position(board, { nextDisc: 5, movesRemaining: 4 });
  const setup = playMove(state, 0, () => 0.5);
  const immediate = playMove(state, 6, () => 0.5);

  assert.ok(setup);
  assert.ok(immediate);
  assert.deepEqual(setup.waves, []);
  assert.equal(immediate.scoreDelta, 7);
  const setupFeatures = extractHeuristicFeatures(setup.state);
  const immediateFeatures = extractHeuristicFeatures(immediate.state);
  assert.ok(
    setupFeatures.directPotential + setupFeatures.latentChainPotential >
      immediateFeatures.directPotential +
        immediateFeatures.latentChainPotential,
  );
  assert.ok(
    evaluateHeuristic(setup.state, "potential") >
      immediate.scoreDelta + evaluateHeuristic(immediate.state, "potential"),
  );

  const released = playMove(
    { ...setup.state, nextDisc: 7 },
    0,
    () => 0.5,
  );
  assert.ok(released);
  assert.deepEqual(
    released.waves.map((wave) => wave.cleared),
    [3, 1],
  );
  assert.equal(released.scoreDelta, 60);
});

test("buried adjacent ones retain an escape-aware edge penalty", () => {
  const adjacent = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, E, 7, 7, 7, E, E),
    row(E, E, 1, 1, 7, E, E),
  ]);
  const separated = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, E, 7, 7, 7, E, E),
    row(E, E, 1, 7, 1, E, E),
  ]);

  assert.deepEqual(findPoppers(adjacent), []);
  assert.deepEqual(findPoppers(separated), []);
  assert.ok(extractHeuristicFeatures(position(adjacent)).adjacentOnes > 0.8);
  assert.equal(extractHeuristicFeatures(position(separated)).adjacentOnes, 0);
  assert.ok(
    evaluateHeuristic(position(adjacent), "anti-clog") <
      evaluateHeuristic(position(separated), "anti-clog"),
  );
});

test("adjacent-one edges discount perpendicular release routes on both axes", () => {
  const horizontalPair = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(4, 7, E, E, E, E, E),
    row(4, 7, E, E, E, E, E),
    row(1, 1, E, E, E, E, E),
  ]);
  const verticalPair = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    row(1, 3, E, E, E, E, E),
    row(1, 3, E, E, E, E, E),
  ]);

  assert.deepEqual(findPoppers(horizontalPair), []);
  assert.deepEqual(findPoppers(verticalPair), []);
  assert.equal(
    extractHeuristicFeatures(position(horizontalPair)).adjacentOnes,
    0.5,
  );
  assert.equal(
    extractHeuristicFeatures(position(verticalPair)).adjacentOnes,
    0,
  );

  const horizontalRelease = playMove(
    position(horizontalPair, { nextDisc: 4 }),
    0,
    () => 0.5,
  );
  const verticalRelease = playMove(
    position(verticalPair, { nextDisc: 3 }),
    1,
    () => 0.5,
  );
  assert.ok(horizontalRelease);
  assert.ok(verticalRelease);
  assert.deepEqual(
    horizontalRelease.waves.map((wave) => wave.cleared),
    [3, 1, 1],
  );
  assert.deepEqual(
    verticalRelease.waves.map((wave) => wave.cleared),
    [3, 2],
  );
});

test("buried consecutive twos retain an escape-aware run penalty", () => {
  const triple = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, 6, 6, 6, 6, E, E),
    row(E, 7, 7, 7, 7, E, E),
    row(E, 2, 2, 2, 7, E, E),
  ]);
  const split = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, 6, 6, 6, 6, E, E),
    row(E, 7, 7, 7, 7, E, E),
    row(E, 2, 2, 7, 2, E, E),
  ]);

  assert.deepEqual(findPoppers(triple), []);
  assert.deepEqual(findPoppers(split), []);
  assert.ok(extractHeuristicFeatures(position(triple)).tripleTwos > 0.5);
  assert.equal(extractHeuristicFeatures(position(split)).tripleTwos, 0);
  assert.ok(
    evaluateHeuristic(position(triple), "anti-clog") <
      evaluateHeuristic(position(split), "anti-clog"),
  );
});

test("a bottom-row run of twos is not clogged when it can fire vertically", () => {
  const reachable = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, 2, 2, 2, 7, E, E),
  ]);

  assert.deepEqual(findPoppers(reachable), []);
  const features = extractHeuristicFeatures(position(reachable));
  assert.equal(features.tripleTwos, 0);
  assert.ok(features.directPotential >= 3);
});

test("all named profiles and features are mirror invariant", () => {
  const board = boardFromRows([
    blank(),
    blank(),
    blank(),
    row(E, E, E, E, E, 6, E),
    row(E, 4, E, E, E, 5, E),
    row(E, 4, SOLID, E, E, 7, E),
    row(E, 2, CRACKED, 1, 1, 7, E),
  ]);
  const mirrored = mirrorBoard(board);
  const state = position(board);
  const mirrorState = position(mirrored);

  assert.deepEqual(
    extractHeuristicFeatures(state),
    extractHeuristicFeatures(mirrorState),
  );
  for (const profile of [
    "legacy",
    "survival",
    "potential",
    "anti-clog",
    "combined",
  ] satisfies HeuristicProfileName[]) {
    assert.equal(
      evaluateHeuristic(state, profile),
      evaluateHeuristic(mirrorState, profile),
    );
  }
});

function legacyReference(state: GameState) {
  if (state.gameOver) return HEURISTIC_GAME_OVER_UTILITY;

  let utility = 0;
  for (let column = 0; column < BOARD_SIZE; column += 1) {
    if (state.board[column] === EMPTY) utility += 180;
  }
  for (let rowIndex = 0; rowIndex < BOARD_SIZE; rowIndex += 1) {
    for (let column = 0; column < BOARD_SIZE; column += 1) {
      const cell = state.board[rowIndex * BOARD_SIZE + column];
      if (cell === EMPTY) continue;

      const height = BOARD_SIZE - rowIndex;
      utility -= height * height * 10;
      if (cell === SOLID) utility -= 620;
      if (cell === CRACKED) utility -= 220;
      if (!isNumbered(cell)) continue;

      utility -= 18;
      const rowDistance = Math.abs(
        cell -
          contiguousLineLength(state.board, rowIndex, column, "row"),
      );
      const columnDistance = Math.abs(
        cell -
          contiguousLineLength(state.board, rowIndex, column, "column"),
      );
      if (Math.min(rowDistance, columnDistance) === 1) utility += 55;
      if (cell <= 2 && height >= 5) utility -= 90;
    }
  }
  return utility;
}

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