Drop7 Research
src/core/typescript/heuristic.tsTypeScript568 lines · 15.6 KB
import {
  BOARD_SIZE,
  CRACKED,
  EMPTY,
  SOLID,
  isNumbered,
  type Board,
  type GameState,
} from "./engine.ts";

export type HeuristicProfileName =
  | "legacy"
  | "survival"
  | "potential"
  | "anti-clog"
  | "combined";

export const DEFAULT_HEURISTIC_PROFILE: HeuristicProfileName = "combined";
export const HEURISTIC_GAME_OVER_UTILITY = -250_000;

export interface HeuristicFeatures {
  openColumns: number;
  heightLoad: number;
  solidCells: number;
  crackedCells: number;
  numberedCells: number;
  highLowNumbers: number;
  legacyNearMatches: number;
  directPotential: number;
  latentChainPotential: number;
  crackedExposure: number;
  solidExposure: number;
  adjacentOnes: number;
  tripleTwos: number;
  deadLowNumbers: number;
}

export type HeuristicWeights = Readonly<Record<keyof HeuristicFeatures, number>>;

const SURVIVAL_WEIGHTS = {
  openColumns: 180,
  heightLoad: -10,
  solidCells: -620,
  crackedCells: -220,
  numberedCells: -18,
  highLowNumbers: -90,
  legacyNearMatches: 0,
  directPotential: 0,
  latentChainPotential: 0,
  crackedExposure: 0,
  solidExposure: 0,
  adjacentOnes: 0,
  tripleTwos: 0,
  deadLowNumbers: 0,
} satisfies HeuristicWeights;

const POTENTIAL_WEIGHTS = {
  ...SURVIVAL_WEIGHTS,
  directPotential: 140,
  latentChainPotential: 360,
  crackedExposure: 100,
  solidExposure: 40,
} satisfies HeuristicWeights;

const ANTI_CLOG_WEIGHTS = {
  ...SURVIVAL_WEIGHTS,
  adjacentOnes: -550,
  tripleTwos: -750,
  deadLowNumbers: -120,
} satisfies HeuristicWeights;

export const HEURISTIC_PROFILES = {
  legacy: {
    ...SURVIVAL_WEIGHTS,
    legacyNearMatches: 55,
  },
  survival: SURVIVAL_WEIGHTS,
  potential: POTENTIAL_WEIGHTS,
  "anti-clog": ANTI_CLOG_WEIGHTS,
  combined: {
    ...POTENTIAL_WEIGHTS,
    adjacentOnes: ANTI_CLOG_WEIGHTS.adjacentOnes,
    tripleTwos: ANTI_CLOG_WEIGHTS.tripleTwos,
    deadLowNumbers: ANTI_CLOG_WEIGHTS.deadLowNumbers,
  },
} as const satisfies Readonly<Record<HeuristicProfileName, HeuristicWeights>>;

interface LineAnalysis {
  lengths: number[];
  starts: number[];
  ends: number[];
}

interface DiscAnalysis {
  index: number;
  row: number;
  column: number;
  value: number;
  horizontalLength: number;
  verticalLength: number;
  horizontalAddition: number;
  verticalAddition: number;
  addition: number;
  horizontalRelease: number;
  verticalRelease: number;
  release: number;
}

/**
 * Extract a small, mirror-invariant feature vector for horizon evaluation.
 *
 * Direct potential is rooted in additions that can actually grow a line to a
 * disc's number. Latent potential is deliberately allowed to depend only on
 * those addition-rooted discs. That prevents closed cycles such as adjacent
 * ones from claiming that each disc can somehow ignite the other.
 */
export function extractHeuristicFeatures(state: GameState): HeuristicFeatures {
  const { board } = state;
  const horizontal = analyzeLines(board, "horizontal");
  const vertical = analyzeLines(board, "vertical");
  const columnHeights = getColumnHeights(board);
  const discs: DiscAnalysis[] = [];
  const discByIndex = new Map<number, DiscAnalysis>();

  const features: HeuristicFeatures = {
    openColumns: 0,
    heightLoad: 0,
    solidCells: 0,
    crackedCells: 0,
    numberedCells: 0,
    highLowNumbers: 0,
    legacyNearMatches: 0,
    directPotential: 0,
    latentChainPotential: 0,
    crackedExposure: 0,
    solidExposure: 0,
    adjacentOnes: 0,
    tripleTwos: 0,
    deadLowNumbers: 0,
  };

  for (let column = 0; column < BOARD_SIZE; column += 1) {
    if (board[column] === EMPTY) features.openColumns += 1;
  }

  for (let row = 0; row < BOARD_SIZE; row += 1) {
    const elevation = BOARD_SIZE - row;
    for (let column = 0; column < BOARD_SIZE; column += 1) {
      const index = row * BOARD_SIZE + column;
      const cell = board[index];
      if (cell === EMPTY) continue;

      features.heightLoad += elevation * elevation;
      if (cell === SOLID) {
        features.solidCells += 1;
        continue;
      }
      if (cell === CRACKED) {
        features.crackedCells += 1;
        continue;
      }
      if (!isNumbered(cell)) continue;

      features.numberedCells += 1;
      if (cell <= 2 && elevation >= 5) features.highLowNumbers += 1;

      const horizontalLength = horizontal.lengths[index];
      const verticalLength = vertical.lengths[index];
      if (
        Math.min(
          Math.abs(cell - horizontalLength),
          Math.abs(cell - verticalLength),
        ) === 1
      ) {
        features.legacyNearMatches += 1;
      }

      const verticalAddition =
        cell > columnHeights[column]
          ? readiness(cell - columnHeights[column])
          : 0;
      const horizontalCost = minimumHorizontalAdditionCost(
        row,
        cell,
        horizontal.starts[index],
        horizontal.ends[index],
        horizontalLength,
        columnHeights,
      );
      const horizontalAddition =
        horizontalCost === null ? 0 : readiness(horizontalCost);
      const addition = unionReadiness(
        horizontalAddition,
        verticalAddition,
      );
      const analysis: DiscAnalysis = {
        index,
        row,
        column,
        value: cell,
        horizontalLength,
        verticalLength,
        horizontalAddition,
        verticalAddition,
        addition,
        horizontalRelease: 0,
        verticalRelease: 0,
        release: 0,
      };
      discs.push(analysis);
      discByIndex.set(index, analysis);
      features.directPotential += addition;
    }
  }

  for (const disc of discs) {
    const horizontalRelease = releaseReadiness(
      disc.horizontalLength - disc.value,
      numberedDiscReadinessInHorizontalLine(
        disc,
        horizontal,
        discByIndex,
      ),
    );
    const verticalRelease = releaseReadiness(
      disc.verticalLength - disc.value,
      numberedDiscReadinessInVerticalLine(disc, vertical, discByIndex),
    );
    disc.horizontalRelease = horizontalRelease;
    disc.verticalRelease = verticalRelease;
    disc.release = unionReadiness(horizontalRelease, verticalRelease);
    features.latentChainPotential += disc.release;

    if (
      disc.value <= 2 &&
      disc.horizontalLength > disc.value &&
      disc.verticalLength > disc.value
    ) {
      features.deadLowNumbers += 1 - unionReadiness(disc.addition, disc.release);
    }
  }

  features.adjacentOnes = countAdjacentOnes(board, discByIndex);
  features.tripleTwos = countLockedTwoRuns(board, discByIndex);

  for (let index = 0; index < board.length; index += 1) {
    const cell = board[index];
    if (cell !== SOLID && cell !== CRACKED) continue;

    const neighborReadiness: number[] = [];
    const row = Math.floor(index / BOARD_SIZE);
    const column = index % BOARD_SIZE;
    for (const [rowDelta, columnDelta] of ORTHOGONAL_DIRECTIONS) {
      const neighborRow = row + rowDelta;
      const neighborColumn = column + columnDelta;
      if (!isInside(neighborRow, neighborColumn)) continue;
      const neighbor = discByIndex.get(
        neighborRow * BOARD_SIZE + neighborColumn,
      );
      if (!neighbor) continue;
      neighborReadiness.push(
        unionReadiness(neighbor.addition, neighbor.release),
      );
    }

    neighborReadiness.sort((a, b) => b - a);
    if (cell === CRACKED) {
      features.crackedExposure += unionAll(neighborReadiness);
    } else {
      // A first hit still has value because it cracks the cover, while two
      // independently plausible hits are what can expose its hidden number.
      features.solidExposure +=
        (neighborReadiness[0] ?? 0) * 0.35 +
        (neighborReadiness[1] ?? 0) * 0.65;
    }
  }

  return features;
}

export function scoreHeuristicFeatures(
  features: HeuristicFeatures,
  weights: HeuristicWeights,
) {
  let utility = 0;
  for (const key of FEATURE_KEYS) utility += features[key] * weights[key];
  return utility;
}

export function evaluateHeuristic(
  state: GameState,
  profile: HeuristicProfileName = DEFAULT_HEURISTIC_PROFILE,
) {
  if (state.gameOver) return HEURISTIC_GAME_OVER_UTILITY;
  return scoreHeuristicFeatures(
    extractHeuristicFeatures(state),
    HEURISTIC_PROFILES[profile],
  );
}

const FEATURE_KEYS = [
  "openColumns",
  "heightLoad",
  "solidCells",
  "crackedCells",
  "numberedCells",
  "highLowNumbers",
  "legacyNearMatches",
  "directPotential",
  "latentChainPotential",
  "crackedExposure",
  "solidExposure",
  "adjacentOnes",
  "tripleTwos",
  "deadLowNumbers",
] as const satisfies readonly (keyof HeuristicFeatures)[];

const ORTHOGONAL_DIRECTIONS = [
  [-1, 0],
  [1, 0],
  [0, -1],
  [0, 1],
] as const;

function analyzeLines(
  board: Board,
  axis: "horizontal" | "vertical",
): LineAnalysis {
  const lengths = Array<number>(board.length).fill(0);
  const starts = Array<number>(board.length).fill(-1);
  const ends = Array<number>(board.length).fill(-1);

  for (let fixed = 0; fixed < BOARD_SIZE; fixed += 1) {
    let cursor = 0;
    while (cursor < BOARD_SIZE) {
      const cursorIndex =
        axis === "horizontal"
          ? fixed * BOARD_SIZE + cursor
          : cursor * BOARD_SIZE + fixed;
      if (board[cursorIndex] === EMPTY) {
        cursor += 1;
        continue;
      }

      const start = cursor;
      while (cursor < BOARD_SIZE) {
        const index =
          axis === "horizontal"
            ? fixed * BOARD_SIZE + cursor
            : cursor * BOARD_SIZE + fixed;
        if (board[index] === EMPTY) break;
        cursor += 1;
      }
      const end = cursor - 1;
      const length = end - start + 1;
      for (let position = start; position <= end; position += 1) {
        const index =
          axis === "horizontal"
            ? fixed * BOARD_SIZE + position
            : position * BOARD_SIZE + fixed;
        lengths[index] = length;
        starts[index] = start;
        ends[index] = end;
      }
    }
  }

  return { lengths, starts, ends };
}

function getColumnHeights(board: Board) {
  const heights = Array<number>(BOARD_SIZE).fill(0);
  for (let column = 0; column < BOARD_SIZE; column += 1) {
    for (let row = 0; row < BOARD_SIZE; row += 1) {
      if (board[row * BOARD_SIZE + column] !== EMPTY) heights[column] += 1;
    }
  }
  return heights;
}

/** Optimistic physical cost to grow this row component to exactly `value`. */
function minimumHorizontalAdditionCost(
  row: number,
  value: number,
  segmentStart: number,
  segmentEnd: number,
  segmentLength: number,
  columnHeights: readonly number[],
) {
  if (segmentStart < 0 || value <= segmentLength) return null;

  const elevation = BOARD_SIZE - row;
  let best = Number.POSITIVE_INFINITY;
  for (let start = 0; start + value <= BOARD_SIZE; start += 1) {
    const end = start + value - 1;
    if (start > segmentStart || end < segmentEnd) continue;
    if (start > 0 && columnHeights[start - 1] >= elevation) continue;
    if (end + 1 < BOARD_SIZE && columnHeights[end + 1] >= elevation) continue;

    let cost = 0;
    for (let column = start; column <= end; column += 1) {
      cost += Math.max(0, elevation - columnHeights[column]);
    }
    if (cost > 0 && cost < best) best = cost;
  }
  return Number.isFinite(best) ? best : null;
}

function numberedDiscReadinessInHorizontalLine(
  target: DiscAnalysis,
  horizontal: LineAnalysis,
  discByIndex: ReadonlyMap<number, DiscAnalysis>,
) {
  const support: number[] = [];
  const start = horizontal.starts[target.index];
  const end = horizontal.ends[target.index];
  for (let column = start; column <= end; column += 1) {
    const index = target.row * BOARD_SIZE + column;
    if (index === target.index) continue;
    const disc = discByIndex.get(index);
    if (disc) support.push(disc.addition);
  }
  return support;
}

function numberedDiscReadinessInVerticalLine(
  target: DiscAnalysis,
  vertical: LineAnalysis,
  discByIndex: ReadonlyMap<number, DiscAnalysis>,
) {
  const support: number[] = [];
  const start = vertical.starts[target.index];
  const end = vertical.ends[target.index];
  for (let row = start; row <= end; row += 1) {
    const index = row * BOARD_SIZE + target.column;
    if (index === target.index) continue;
    const disc = discByIndex.get(index);
    if (disc) support.push(disc.addition);
  }
  return support;
}

function releaseReadiness(
  excessLength: number,
  supporterReadiness: readonly number[],
) {
  if (excessLength <= 0 || supporterReadiness.length < excessLength) return 0;
  const sorted = [...supporterReadiness].sort((a, b) => b - a);
  return sorted[excessLength - 1] * readiness(excessLength);
}

function countAdjacentOnes(
  board: Board,
  discByIndex: ReadonlyMap<number, DiscAnalysis>,
) {
  let count = 0;
  for (let row = 0; row < BOARD_SIZE; row += 1) {
    for (let column = 0; column < BOARD_SIZE; column += 1) {
      const index = row * BOARD_SIZE + column;
      if (board[index] !== 1) continue;
      const disc = discByIndex.get(index);
      if (!disc) continue;

      if (column + 1 < BOARD_SIZE && board[index + 1] === 1) {
        const neighbor = discByIndex.get(index + 1);
        if (neighbor) {
          count += lockedness(
            perpendicularReadiness(disc, "horizontal"),
            perpendicularReadiness(neighbor, "horizontal"),
          );
        }
      }
      if (row + 1 < BOARD_SIZE && board[index + BOARD_SIZE] === 1) {
        const neighbor = discByIndex.get(index + BOARD_SIZE);
        if (neighbor) {
          count += lockedness(
            perpendicularReadiness(disc, "vertical"),
            perpendicularReadiness(neighbor, "vertical"),
          );
        }
      }
    }
  }
  return count;
}

function countLockedTwoRuns(
  board: Board,
  discByIndex: ReadonlyMap<number, DiscAnalysis>,
) {
  let count = 0;
  for (const axis of ["horizontal", "vertical"] as const) {
    for (let fixed = 0; fixed < BOARD_SIZE; fixed += 1) {
      let cursor = 0;
      while (cursor < BOARD_SIZE) {
        const index =
          axis === "horizontal"
            ? fixed * BOARD_SIZE + cursor
            : cursor * BOARD_SIZE + fixed;
        if (board[index] !== 2) {
          cursor += 1;
          continue;
        }
        const start = cursor;
        while (cursor < BOARD_SIZE) {
          const nextIndex =
            axis === "horizontal"
              ? fixed * BOARD_SIZE + cursor
              : cursor * BOARD_SIZE + fixed;
          if (board[nextIndex] !== 2) break;
          cursor += 1;
        }
        const excess = cursor - start - 2;
        if (excess <= 0) continue;

        const escapeReadiness: number[] = [];
        for (let position = start; position < cursor; position += 1) {
          const runIndex =
            axis === "horizontal"
              ? fixed * BOARD_SIZE + position
              : position * BOARD_SIZE + fixed;
          const disc = discByIndex.get(runIndex);
          if (disc) {
            escapeReadiness.push(perpendicularReadiness(disc, axis));
          }
        }
        count += excess * excess * lockedness(...escapeReadiness);
      }
    }
  }
  return count;
}

function perpendicularReadiness(
  disc: DiscAnalysis,
  runAxis: "horizontal" | "vertical",
) {
  return runAxis === "horizontal"
    ? unionReadiness(disc.verticalAddition, disc.verticalRelease)
    : unionReadiness(disc.horizontalAddition, disc.horizontalRelease);
}

function lockedness(...escapeReadiness: readonly number[]) {
  return 1 - Math.max(0, ...escapeReadiness);
}

function readiness(cost: number) {
  return cost >= 1 ? 2 ** (1 - cost) : 0;
}

function unionReadiness(first: number, second: number) {
  return 1 - (1 - first) * (1 - second);
}

function unionAll(values: readonly number[]) {
  let inverse = 1;
  for (const value of values) inverse *= 1 - value;
  return 1 - inverse;
}

function isInside(row: number, column: number) {
  return (
    row >= 0 &&
    row < BOARD_SIZE &&
    column >= 0 &&
    column < BOARD_SIZE
  );
}