Drop7 Research
src/core/typescript/recursive-potential.test.tsTypeScript153 lines · 4.4 KB
import assert from "node:assert/strict";
import test from "node:test";
import {
  BOARD_SIZE,
  EMPTY,
  MOVES_PER_LEVEL,
  SOLID,
  boardFromRows,
  findPoppers,
  playMove,
  type Board,
  type Cell,
  type GameState,
} from "./engine.ts";
import {
  HEURISTIC_GAME_OVER_UTILITY,
  evaluateHeuristic,
} from "./heuristic.ts";
import {
  MAX_RECURSIVE_PROPAGATION_WAVES,
  analyzeRecursivePotential,
  evaluateRecursivePotential,
} from "./recursive-potential.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 physical seed propagates through multiple dependent waves", () => {
  const board = deepChainBoard();
  assert.deepEqual(findPoppers(board), []);
  const analysis = analyzeRecursivePotential(position(board));
  // Analysis is returned in its mirror-canonical orientation. This fixture
  // canonicalizes to the reflection of the board used by the engine below.
  const first = 6 * BOARD_SIZE + 6;
  const second = first - 1;
  const third = 5 * BOARD_SIZE + 5;

  assert.equal(analysis.physicalSeeds[first], 1);
  assert.equal(analysis.physicalSeeds[second], 0);
  assert.equal(analysis.physicalSeeds[third], 0);
  assert.ok(analysis.activation[second] > 0.7);
  assert.ok(analysis.activation[third] > 0.5);
  assert.ok(analysis.features.firstPropagationEnergy > 0);
  assert.ok(analysis.features.deepChainEnergy > 0);
  assert.ok(analysis.features.deepCoverExposure > 0);
  assert.ok(analysis.features.propagationWaves >= 2);
  assert.ok(
    analysis.features.propagationWaves <=
      MAX_RECURSIVE_PROPAGATION_WAVES,
  );

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

test("closed one and two cycles cannot create their own activation", () => {
  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 = analyzeRecursivePotential(position(board));
  assert.ok(analysis.physicalSeeds.every((value) => value === 0));
  assert.ok(analysis.activation.every((value) => value === 0));
  assert.equal(analysis.features.physicalSeedEnergy, 0);
  assert.equal(analysis.features.propagatedDiscEnergy, 0);
  assert.equal(analysis.features.deepChainEnergy, 0);
  assert.equal(analysis.features.propagationWaves, 0);
});

test("recursive analysis and evaluation are exactly mirror safe", () => {
  const board = deepChainBoard();
  const mirrored = mirrorBoard(board);
  const forward = analyzeRecursivePotential(position(board));
  const reverse = analyzeRecursivePotential(position(mirrored));

  assert.deepEqual(forward.features, reverse.features);
  assert.deepEqual(forward.activation, reverse.activation);
  assert.deepEqual(forward.physicalSeeds, reverse.physicalSeeds);
  assert.equal(
    evaluateRecursivePotential(position(board)),
    evaluateRecursivePotential(position(mirrored)),
  );
});

test("recursive profile is a bounded residual over combined", () => {
  const state = position(deepChainBoard());
  assert.equal(
    evaluateRecursivePotential(state, 0),
    evaluateHeuristic(state, "combined"),
  );
  assert.ok(
    evaluateRecursivePotential(state) > evaluateHeuristic(state, "combined"),
  );
  assert.equal(
    evaluateRecursivePotential({ ...state, gameOver: true }),
    HEURISTIC_GAME_OVER_UTILITY,
  );
  assert.throws(() => evaluateRecursivePotential(state, -1), /non-negative/);
  assert.throws(
    () => evaluateRecursivePotential(state, Number.NaN),
    /finite/,
  );
});

function deepChainBoard() {
  return boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(E, SOLID, E, E, E, E, E),
    row(SOLID, 2, SOLID, E, E, E, E),
    row(4, 2, SOLID, E, E, E, 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;
}