Drop7 Research
src/core/typescript/tunneling-heuristic.test.tsTypeScript257 lines · 6.6 KB
import assert from "node:assert/strict";
import test from "node:test";
import {
  BOARD_SIZE,
  EMPTY,
  MOVES_PER_LEVEL,
  SOLID,
  boardFromRows,
  playMove,
  seededRandom,
  type Board,
  type Cell,
  type DiscValue,
  type GameState,
} from "./engine.ts";
import {
  HEURISTIC_GAME_OVER_UTILITY,
  evaluateHeuristic,
} from "./heuristic.ts";
import {
  evaluateTunnelingAction,
  evaluateTunnelingState,
  extractTunnelingActionFeatures,
  extractTunnelingFeatures,
} from "./tunneling-heuristic.ts";

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

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

test("higher covers carry more danger and edge danger is explicit", () => {
  const low = singleCellBoard(6, 3, SOLID);
  const high = singleCellBoard(2, 3, SOLID);
  const center = extractTunnelingFeatures(position(low));
  const edge = extractTunnelingFeatures(
    position(singleCellBoard(6, 0, SOLID)),
  );

  assert.ok(
    extractTunnelingFeatures(position(high)).coveredAltitudeRisk >
      center.coveredAltitudeRisk,
  );
  assert.equal(edge.coveredAltitudeRisk, center.coveredAltitudeRisk);
  assert.ok(edge.edgeCoveredAltitudeRisk > center.edgeCoveredAltitudeRisk);
});

test("a trench earns access only for a covered cliff, not roughness alone", () => {
  const coveredWall = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, E, SOLID, E, E, E, E),
    row(E, E, SOLID, E, E, E, E),
    row(E, E, SOLID, E, E, E, E),
  ]);
  const blockedChannel = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, 7, SOLID, 7, E, E, E),
    row(E, 7, SOLID, 7, E, E, E),
    row(E, 7, SOLID, 7, E, E, E),
  ]);
  const numberedWall = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, E, 7, E, E, E, E),
    row(E, E, 7, E, E, E, E),
    row(E, E, 7, E, E, E, E),
  ]);

  assert.ok(
    extractTunnelingFeatures(position(coveredWall)).coveredCliffAccess > 0,
  );
  assert.equal(
    extractTunnelingFeatures(position(blockedChannel)).coveredCliffAccess,
    0,
  );
  assert.equal(
    extractTunnelingFeatures(position(numberedWall)).coveredCliffAccess,
    0,
  );
});

test("coherent high-number columns store more tunnel work than split discs", () => {
  const together = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, E, E, 6, E, E, E),
    row(E, E, E, 6, E, E, E),
  ]);
  const split = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, E, 6, E, 6, E, E),
  ]);

  assert.ok(
    extractTunnelingFeatures(position(together))
      .highNumberColumnCohesion >
      extractTunnelingFeatures(position(split)).highNumberColumnCohesion,
  );
});

test("action features value high-disc trench work and high edge damage", () => {
  const trench = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, E, SOLID, E, E, E, E),
    row(E, E, SOLID, E, E, E, E),
    row(E, E, SOLID, 6, E, E, E),
  ]);
  const trenchState = position(trench, 6);
  const trenchMove = playMove(trenchState, 3, seededRandom(1), {
    captureAnimation: true,
  });
  assert.ok(trenchMove);
  const trenchFeatures = extractTunnelingActionFeatures(
    trenchState,
    3,
    trenchMove,
  );
  assert.ok(trenchFeatures.highNumberCommitment > 0);
  assert.ok(trenchFeatures.highNumberTrenchProgress > 0);

  const centerState = position(singleCellBoard(6, 3, SOLID), 1);
  const edgeState = position(singleCellBoard(6, 0, SOLID), 1);
  const centerMove = playMove(centerState, 3, seededRandom(2), {
    captureAnimation: true,
  });
  const edgeMove = playMove(edgeState, 0, seededRandom(2), {
    captureAnimation: true,
  });
  assert.ok(centerMove && edgeMove);
  const centerHit = extractTunnelingActionFeatures(
    centerState,
    3,
    centerMove,
  );
  const edgeHit = extractTunnelingActionFeatures(edgeState, 0, edgeMove);
  assert.equal(edgeHit.coveredCrackAltitude, centerHit.coveredCrackAltitude);
  assert.ok(edgeHit.edgeCoverDamage > centerHit.edgeCoverDamage);
  assert.ok(
    evaluateTunnelingAction(edgeState, 0, edgeMove) >
      evaluateTunnelingAction(centerState, 3, centerMove),
  );
});

test("state and action features reflect exactly", () => {
  const board = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(SOLID, E, E, E, E, E, E),
    row(SOLID, E, E, E, E, E, E),
    row(SOLID, 6, E, E, E, E, E),
  ]);
  const state = position(board, 6);
  const move = playMove(state, 1, seededRandom(3), {
    captureAnimation: true,
  });
  const reflectedState = position(mirrorBoard(board), 6);
  const reflectedMove = playMove(
    reflectedState,
    BOARD_SIZE - 1 - 1,
    seededRandom(3),
    { captureAnimation: true },
  );
  assert.ok(move && reflectedMove);

  assert.deepEqual(
    extractTunnelingFeatures(state),
    extractTunnelingFeatures(reflectedState),
  );
  assert.deepEqual(
    extractTunnelingActionFeatures(state, 1, move),
    extractTunnelingActionFeatures(reflectedState, 5, reflectedMove),
  );
  assert.equal(
    evaluateTunnelingState(state),
    evaluateTunnelingState(reflectedState),
  );
});

test("tunneling scales are bounded residuals over combined", () => {
  const state = position(singleCellBoard(3, 0, SOLID));
  assert.equal(
    evaluateTunnelingState(state),
    evaluateHeuristic(state, "combined"),
  );
  assert.equal(
    evaluateTunnelingState(state, 0),
    evaluateHeuristic(state, "combined"),
  );
  assert.equal(
    evaluateTunnelingState({ ...state, gameOver: true }),
    HEURISTIC_GAME_OVER_UTILITY,
  );
  assert.throws(() => evaluateTunnelingState(state, -1), /non-negative/);
  assert.throws(
    () => evaluateTunnelingState(state, Number.NaN),
    /finite/,
  );
});

function singleCellBoard(rowIndex: number, column: number, cell: Cell) {
  return boardFromRows(
    Array.from({ length: BOARD_SIZE }, (_, rowIndexCandidate) =>
      Array.from({ length: BOARD_SIZE }, (_, columnCandidate) =>
        rowIndexCandidate === rowIndex && columnCandidate === column
          ? cell
          : E,
      ),
    ),
  );
}

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;
}