Drop7 Research
src/core/typescript/engine.test.tsTypeScript387 lines · 10.2 KB
import assert from "node:assert/strict";
import test from "node:test";
import {
  BOARD_SIZE,
  CLEAR_BONUS,
  CRACKED,
  EMPTY,
  LEVEL_BONUS,
  MOVES_PER_LEVEL,
  SOLID,
  applyGravity,
  boardFromRows,
  boardToRows,
  contiguousLineLength,
  createGame,
  emptyBoard,
  enumerateCascadeOutcomes,
  enumerateMoveOutcomes,
  findPoppers,
  forEachMoveOutcome,
  legalColumns,
  playMove,
  raiseCoveredRow,
  resolveCascade,
  scoreForWave,
  seededRandom,
  type Cell,
  type GameState,
} from "./engine.ts";

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

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

test("scoring constants match the original game", () => {
  assert.equal(LEVEL_BONUS, 17_000);
  assert.equal(CLEAR_BONUS, 70_000);
  assert.deepEqual(
    Array.from({ length: 8 }, (_, index) => scoreForWave(index + 1)),
    [7, 39, 109, 224, 391, 617, 907, 1_267],
  );
});

test("gravity preserves the order of discs in every column", () => {
  const board = boardFromRows([
    row(E, E, E, E, E, E, E),
    row(2, E, E, E, E, E, E),
    row(E, E, E, E, E, E, E),
    row(5, E, E, E, E, E, E),
    row(E, E, 7, E, E, E, E),
    row(CRACKED, E, E, E, E, E, E),
    row(E, E, SOLID, E, E, E, E),
  ]);

  const rows = boardToRows(applyGravity(board));
  assert.deepEqual(
    rows.map((cells) => cells[0]),
    [E, E, E, E, 2, 5, CRACKED],
  );
  assert.deepEqual(
    rows.map((cells) => cells[2]),
    [E, E, E, E, E, 7, SOLID],
  );
});

test("line counts stop at gaps and include covered discs", () => {
  const board = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    row(2, SOLID, 3, E, 4, CRACKED, E),
  ]);

  assert.equal(contiguousLineLength(board, 6, 0, "row"), 3);
  assert.equal(contiguousLineLength(board, 6, 2, "row"), 3);
  assert.equal(contiguousLineLength(board, 6, 4, "row"), 2);
  assert.equal(contiguousLineLength(board, 6, 3, "row"), 0);
});

test("a wave clears all matching discs simultaneously", () => {
  const board = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    row(2, 2, E, E, E, E, E),
  ]);
  const result = resolveCascade(board, () => 0);

  assert.equal(result.score, 14);
  assert.equal(result.waves.length, 1);
  assert.equal(result.waves[0].cleared, 2);
  assert.deepEqual(result.board, emptyBoard());
});

test("a move records its landing before any explosions", () => {
  const game = stateWith(emptyBoard(), {
    nextDisc: 4,
    movesRemaining: 2,
  });
  const result = playMove(game, 3, () => 0.5);

  assert.ok(result);
  assert.equal(result.animation[0].kind, "drop");
  assert.deepEqual(result.animation[0].indexes, [6 * BOARD_SIZE + 3]);
  assert.equal(result.animation[0].board[6 * BOARD_SIZE + 3], 4);
});

test("headless moves can skip presentation snapshots", () => {
  const game = stateWith(emptyBoard(), {
    nextDisc: 4,
    movesRemaining: 2,
  });
  const result = playMove(game, 3, () => 0.5, {
    captureAnimation: false,
  });

  assert.ok(result);
  assert.deepEqual(result.animation, []);
  assert.equal(result.state.board[6 * BOARD_SIZE + 3], 4);
});

test("matching discs get distinct sequential burst snapshots", () => {
  const game = stateWith(
    boardFromRows([
      blank(),
      blank(),
      blank(),
      blank(),
      blank(),
      blank(),
      row(2, E, E, E, E, E, E),
    ]),
    { nextDisc: 2, movesRemaining: 2 },
  );
  const result = playMove(game, 1, () => 0.5);

  assert.ok(result);
  const bursts = result.animation.filter((frame) => frame.kind === "burst");
  assert.equal(bursts.length, 2);
  assert.deepEqual(
    bursts.map((frame) => frame.indexes[0]),
    [6 * BOARD_SIZE, 6 * BOARD_SIZE + 1],
  );
  assert.equal(bursts[0].board[6 * BOARD_SIZE], 2);
  assert.equal(bursts[1].board[6 * BOARD_SIZE], E);
  assert.equal(bursts[1].board[6 * BOARD_SIZE + 1], 2);
});

test("two hits in one wave fully reveal a solid disc", () => {
  const board = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    row(3, SOLID, 3, E, E, E, E),
  ]);
  const result = resolveCascade(board, () => 0.999);

  assert.equal(result.score, 14);
  assert.equal(result.waves[0].revealed, 1);
  assert.equal(result.board[6 * BOARD_SIZE + 1], 7);
});

test("gravity, cracks, reveals, and scoring compose across chain waves", () => {
  const board = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    row(2, E, E, E, E, E, E),
    row(3, E, E, E, E, E, E),
    row(SOLID, E, E, E, E, E, E),
  ]);
  const result = resolveCascade(board, () => 0.999);

  assert.equal(result.score, scoreForWave(1) + scoreForWave(2));
  assert.deepEqual(
    result.waves.map((wave) => [wave.cleared, wave.revealed]),
    [
      [1, 0],
      [1, 1],
    ],
  );
  assert.equal(result.board[6 * BOARD_SIZE], 7);
});

test("exact gray-disc outcomes retain their full probability mass", () => {
  const board = boardFromRows([
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    blank(),
    row(3, SOLID, 3, E, E, E, E),
  ]);
  const outcomes = enumerateCascadeOutcomes(board);

  assert.equal(outcomes.length, 7);
  assert.ok(
    Math.abs(
      outcomes.reduce((sum, outcome) => sum + outcome.probability, 0) - 1,
    ) < 1e-12,
  );
  assert.ok(outcomes.some((outcome) => outcome.score === 14 + 39));
  assert.equal(
    outcomes.filter((outcome) => outcome.score === 14).length,
    6,
  );
});

test("the Hardcore game starts above a solid row and cracks it on a 1", () => {
  const game = createGame(() => 0);
  const result = playMove(game, 0, () => 0);

  assert.ok(result);
  assert.equal(result.scoreDelta, 7);
  assert.equal(result.state.board[6 * BOARD_SIZE], CRACKED);
  assert.equal(result.state.movesRemaining, MOVES_PER_LEVEL - 1);
  assert.equal(result.state.nextDisc, 1);
});

test("clearing the board awards the original screen-clear bonus", () => {
  const game = stateWith(emptyBoard(), {
    nextDisc: 1,
    movesRemaining: 2,
  });
  const result = playMove(game, 3, () => 0.5);

  assert.ok(result);
  assert.equal(result.scoreDelta, CLEAR_BONUS + 7);
  assert.equal(result.clearedBoard, true);
});

test("every fifth move raises a solid row and awards the level bonus", () => {
  const game = stateWith(
    boardFromRows([
      blank(),
      blank(),
      blank(),
      blank(),
      blank(),
      blank(),
      row(SOLID, SOLID, SOLID, SOLID, SOLID, SOLID, SOLID),
    ]),
    { nextDisc: 7, movesRemaining: 1 },
  );
  const result = playMove(game, 0, () => 0.5);

  assert.ok(result);
  assert.equal(result.levelAdvanced, true);
  assert.equal(result.scoreDelta, LEVEL_BONUS);
  assert.equal(result.state.level, 2);
  assert.equal(result.state.movesRemaining, MOVES_PER_LEVEL);
  assert.deepEqual(
    boardToRows(result.state.board)[6],
    row(SOLID, SOLID, SOLID, SOLID, SOLID, SOLID, SOLID),
  );
});

test("a level-up explosion continues the fifth move's chain depth", () => {
  const game = stateWith(
    boardFromRows([
      blank(),
      blank(),
      blank(),
      blank(),
      blank(),
      row(3, E, E, E, E, E, E),
      row(SOLID, SOLID, SOLID, SOLID, SOLID, SOLID, SOLID),
    ]),
    { nextDisc: 1, movesRemaining: 1 },
  );
  const result = playMove(game, 6, () => 0.999);

  assert.ok(result);
  assert.deepEqual(
    result.waves.map((wave) => wave.depth),
    [1, 2],
  );
  assert.equal(result.scoreDelta, LEVEL_BONUS + scoreForWave(1) + scoreForWave(2));
});

test("a rising row ends the game instead of discarding an occupied top cell", () => {
  const rows = Array.from({ length: BOARD_SIZE }, blank);
  rows[0][3] = 6;
  const raised = raiseCoveredRow(boardFromRows(rows));
  assert.equal(raised, null);
});

test("the exact move model includes all seven next discs", () => {
  const game = createGame(() => 0.5);
  const outcomes = enumerateMoveOutcomes(game, 3);

  assert.equal(new Set(outcomes.map((outcome) => outcome.state.nextDisc)).size, 7);
  assert.ok(
    Math.abs(
      outcomes.reduce((sum, outcome) => sum + outcome.probability, 0) - 1,
    ) < 1e-12,
  );
});

test("streamed move outcomes preserve exact probability and expected score", () => {
  const game = stateWith(
    boardFromRows([
      blank(),
      blank(),
      blank(),
      blank(),
      blank(),
      blank(),
      row(CRACKED, E, E, E, E, E, E),
    ]),
    { nextDisc: 2 },
  );
  const retained = enumerateMoveOutcomes(game, 0);
  let streamedProbability = 0;
  let streamedExpectedScore = 0;

  forEachMoveOutcome(game, 0, (outcome) => {
    streamedProbability += outcome.probability;
    streamedExpectedScore += outcome.probability * outcome.scoreDelta;
  });

  const retainedProbability = retained.reduce(
    (sum, outcome) => sum + outcome.probability,
    0,
  );
  const retainedExpectedScore = retained.reduce(
    (sum, outcome) => sum + outcome.probability * outcome.scoreDelta,
    0,
  );
  assert.ok(Math.abs(streamedProbability - retainedProbability) < 1e-12);
  assert.ok(Math.abs(streamedExpectedScore - retainedExpectedScore) < 1e-9);
});

test("seeded games remain settled and gravity-packed through game over", () => {
  for (let seed = 1; seed <= 12; seed += 1) {
    const random = seededRandom(seed);
    let game = createGame(random);
    let previousScore = game.score;

    for (let move = 0; move < 200 && !game.gameOver; move += 1) {
      const columns = legalColumns(game.board);
      const column = columns[Math.floor(random() * columns.length)];
      const result = playMove(game, column, random);
      assert.ok(result);
      game = result.state;

      assert.ok(game.score >= previousScore);
      assert.equal(findPoppers(game.board).length, 0);
      for (let columnIndex = 0; columnIndex < BOARD_SIZE; columnIndex += 1) {
        let foundDisc = false;
        for (let rowIndex = 0; rowIndex < BOARD_SIZE; rowIndex += 1) {
          const cell = game.board[rowIndex * BOARD_SIZE + columnIndex];
          if (cell !== EMPTY) foundDisc = true;
          if (foundDisc) assert.notEqual(cell, EMPTY);
        }
      }
      previousScore = game.score;
    }
  }
});