Drop7 Research
src/core/typescript/recursive-potential.tsTypeScript496 lines · 14.3 KB
import {
  BOARD_SIZE,
  CRACKED,
  EMPTY,
  SOLID,
  isNumbered,
  type Board,
  type Cell,
  type GameState,
} from "./engine.ts";
import {
  HEURISTIC_GAME_OVER_UTILITY,
  evaluateHeuristic,
} from "./heuristic.ts";

export interface RecursivePotentialFeatures {
  physicalSeedEnergy: number;
  physicallySeededDiscs: number;
  firstPropagationEnergy: number;
  propagatedDiscEnergy: number;
  deepChainEnergy: number;
  crackedExposure: number;
  solidCrackPotential: number;
  solidRevealPotential: number;
  deepCoverExposure: number;
  propagationWaves: number;
  maxActivation: number;
}

export interface RecursivePotentialAnalysis {
  features: RecursivePotentialFeatures;
  /** Readiness by mirror-canonical board index; non-numbered cells stay zero. */
  activation: readonly number[];
  /** Physical-addition readiness by canonical index before propagation. */
  physicalSeeds: readonly number[];
}

export interface RecursivePotentialWeights {
  deepChainEnergy: number;
  deepCoverExposure: number;
}

export const MAX_RECURSIVE_PROPAGATION_WAVES = 8;
export const RECURSIVE_POTENTIAL_WEIGHTS: RecursivePotentialWeights = {
  deepChainEnergy: 480,
  deepCoverExposure: 160,
};

const PROPAGATION_DISCOUNT = 0.78;
const CONVERGENCE_EPSILON = 1e-8;

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

interface DiscNode {
  index: number;
  row: number;
  column: number;
  value: number;
  seed: number;
  horizontalExcess: number;
  verticalExcess: number;
  horizontalSupporters: number[];
  verticalSupporters: number[];
}

interface CoverEnergy {
  crackedExposure: number;
  solidCrackPotential: number;
  solidRevealPotential: number;
  weighted: number;
}

/**
 * Propagate physically seeded activation through overloaded disc components.
 *
 * A numbered disc is initially live only when a legal sequence of additions
 * can grow one of its current lines to the disc value. An overloaded disc can
 * then inherit readiness from numbered supporters that may disappear first.
 * Iteration begins at those physical seeds, so a closed dependency cycle of
 * ones or twos remains exactly dormant instead of creating energy from itself.
 * Eight monotone waves bound both feedback and runtime on the 7x7 board.
 */
export function analyzeRecursivePotential(
  state: GameState,
): RecursivePotentialAnalysis {
  const board = canonicalBoard(state.board);
  const horizontal = analyzeLines(board, "horizontal");
  const vertical = analyzeLines(board, "vertical");
  const columnHeights = getColumnHeights(board);
  const nodes: DiscNode[] = [];
  const nodeByIndex = new Map<number, DiscNode>();

  for (let row = 0; row < BOARD_SIZE; row += 1) {
    for (let column = 0; column < BOARD_SIZE; column += 1) {
      const index = row * BOARD_SIZE + column;
      const value = board[index];
      if (!isNumbered(value)) continue;

      const horizontalLength = horizontal.lengths[index];
      const verticalLength = vertical.lengths[index];
      const verticalAddition =
        value > columnHeights[column]
          ? readiness(value - columnHeights[column])
          : 0;
      const horizontalCost = minimumHorizontalAdditionCost(
        row,
        value,
        horizontal.starts[index],
        horizontal.ends[index],
        horizontalLength,
        columnHeights,
      );
      const horizontalAddition =
        horizontalCost === null ? 0 : readiness(horizontalCost);
      const alreadyActive =
        value === horizontalLength || value === verticalLength ? 1 : 0;
      const node: DiscNode = {
        index,
        row,
        column,
        value,
        seed: Math.max(
          alreadyActive,
          unionReadiness(horizontalAddition, verticalAddition),
        ),
        horizontalExcess: Math.max(0, horizontalLength - value),
        verticalExcess: Math.max(0, verticalLength - value),
        horizontalSupporters: [],
        verticalSupporters: [],
      };
      nodes.push(node);
      nodeByIndex.set(index, node);
    }
  }

  for (const node of nodes) {
    node.horizontalSupporters = lineSupporters(
      node,
      horizontal,
      nodeByIndex,
      "horizontal",
    );
    node.verticalSupporters = lineSupporters(
      node,
      vertical,
      nodeByIndex,
      "vertical",
    );
  }

  const physicalSeeds = Array<number>(board.length).fill(0);
  let activation = Array<number>(board.length).fill(0);
  for (const node of nodes) {
    physicalSeeds[node.index] = node.seed;
    activation[node.index] = node.seed;
  }

  let firstActivation = activation.slice();
  let firstPropagationEnergy = 0;
  let deepChainEnergy = 0;
  let propagationWaves = 0;

  for (
    let wave = 1;
    wave <= MAX_RECURSIVE_PROPAGATION_WAVES;
    wave += 1
  ) {
    const next = activation.slice();
    let waveEnergy = 0;
    for (const node of nodes) {
      const horizontalRelease = releaseReadiness(
        node.horizontalExcess,
        node.horizontalSupporters,
        activation,
      );
      const verticalRelease = releaseReadiness(
        node.verticalExcess,
        node.verticalSupporters,
        activation,
      );
      const propagated =
        unionReadiness(horizontalRelease, verticalRelease) *
        PROPAGATION_DISCOUNT;
      const candidate = Math.max(node.seed, propagated);
      if (candidate <= activation[node.index]) continue;
      const delta = candidate - activation[node.index];
      next[node.index] = candidate;
      waveEnergy += delta;
    }

    if (waveEnergy <= CONVERGENCE_EPSILON) break;
    activation = next;
    propagationWaves = wave;
    if (wave === 1) {
      firstPropagationEnergy = waveEnergy;
      firstActivation = activation.slice();
    } else {
      // Later activations represent successively deeper chain waves. Reward
      // that depth, but much less aggressively than Drop7's realized score.
      deepChainEnergy += waveEnergy * (1 + wave * 0.5);
    }
  }

  const firstCover = coverEnergy(board, firstActivation);
  const finalCover = coverEnergy(board, activation);
  let physicalSeedEnergy = 0;
  let physicallySeededDiscs = 0;
  let propagatedDiscEnergy = 0;
  let maxActivation = 0;
  for (const node of nodes) {
    const seed = physicalSeeds[node.index];
    const final = activation[node.index];
    physicalSeedEnergy += seed;
    if (seed > 0) physicallySeededDiscs += 1;
    propagatedDiscEnergy += final - seed;
    maxActivation = Math.max(maxActivation, final);
  }

  return {
    features: {
      physicalSeedEnergy,
      physicallySeededDiscs,
      firstPropagationEnergy,
      propagatedDiscEnergy,
      deepChainEnergy,
      crackedExposure: finalCover.crackedExposure,
      solidCrackPotential: finalCover.solidCrackPotential,
      solidRevealPotential: finalCover.solidRevealPotential,
      deepCoverExposure: Math.max(
        0,
        finalCover.weighted - firstCover.weighted,
      ),
      propagationWaves,
      maxActivation,
    },
    activation,
    physicalSeeds,
  };
}

export function extractRecursivePotentialFeatures(state: GameState) {
  return analyzeRecursivePotential(state).features;
}

/** Combined heuristic plus only the multi-wave residual not already scored. */
export function evaluateRecursivePotential(
  state: GameState,
  scale = 1,
) {
  if (!Number.isFinite(scale) || scale < 0) {
    throw new Error("recursive potential scale must be non-negative and finite");
  }
  if (state.gameOver) return HEURISTIC_GAME_OVER_UTILITY;

  const board = canonicalBoard(state.board);
  const canonicalState = board === state.board ? state : { ...state, board };
  const features = extractRecursivePotentialFeatures(canonicalState);
  const recursiveBonus =
    features.deepChainEnergy *
      RECURSIVE_POTENTIAL_WEIGHTS.deepChainEnergy +
    features.deepCoverExposure *
      RECURSIVE_POTENTIAL_WEIGHTS.deepCoverExposure;
  return evaluateHeuristic(canonicalState, "combined") + scale * recursiveBonus;
}

function releaseReadiness(
  excess: number,
  supporterIndexes: readonly number[],
  activation: readonly number[],
) {
  if (excess <= 0 || supporterIndexes.length < excess) return 0;
  return probabilityAtLeast(
    supporterIndexes.map((index) => activation[index]),
    excess,
  );
}

function probabilityAtLeast(
  probabilities: readonly number[],
  threshold: number,
) {
  if (threshold <= 0) return 1;
  if (probabilities.length < threshold) return 0;
  const distribution = Array<number>(probabilities.length + 1).fill(0);
  distribution[0] = 1;
  let processed = 0;
  for (const probability of probabilities) {
    for (let count = processed; count >= 0; count -= 1) {
      const mass = distribution[count];
      distribution[count] = mass * (1 - probability);
      distribution[count + 1] += mass * probability;
    }
    processed += 1;
  }
  let result = 0;
  for (let count = threshold; count <= probabilities.length; count += 1) {
    result += distribution[count];
  }
  return Math.max(0, Math.min(1, result));
}

function coverEnergy(
  board: Board,
  activation: readonly number[],
): CoverEnergy {
  const result: CoverEnergy = {
    crackedExposure: 0,
    solidCrackPotential: 0,
    solidRevealPotential: 0,
    weighted: 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;
      const cell = board[index];
      if (cell !== CRACKED && cell !== SOLID) continue;
      const neighbors: number[] = [];
      for (const [rowDelta, columnDelta] of ORTHOGONAL_DIRECTIONS) {
        const neighborRow = row + rowDelta;
        const neighborColumn = column + columnDelta;
        if (!isInside(neighborRow, neighborColumn)) continue;
        const readiness =
          activation[neighborRow * BOARD_SIZE + neighborColumn];
        if (readiness > 0) neighbors.push(readiness);
      }
      const oneHit = unionAll(neighbors);
      if (cell === CRACKED) {
        result.crackedExposure += oneHit;
        result.weighted += oneHit;
      } else {
        const twoHits = probabilityAtLeast(neighbors, 2);
        result.solidCrackPotential += oneHit;
        result.solidRevealPotential += twoHits;
        result.weighted += oneHit * 0.3 + twoHits * 0.7;
      }
    }
  }
  return result;
}

function lineSupporters(
  target: DiscNode,
  lines: LineAnalysis,
  nodeByIndex: ReadonlyMap<number, DiscNode>,
  axis: "horizontal" | "vertical",
) {
  const supporters: number[] = [];
  const start = lines.starts[target.index];
  const end = lines.ends[target.index];
  for (let position = start; position <= end; position += 1) {
    const index =
      axis === "horizontal"
        ? target.row * BOARD_SIZE + position
        : position * BOARD_SIZE + target.column;
    if (index !== target.index && nodeByIndex.has(index)) {
      supporters.push(index);
    }
  }
  return supporters;
}

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

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 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 canonicalBoard(board: Board) {
  for (let row = 0; row < BOARD_SIZE; row += 1) {
    const offset = row * BOARD_SIZE;
    for (let column = 0; column < BOARD_SIZE; column += 1) {
      const forward = board[offset + column];
      const reflected = board[offset + BOARD_SIZE - 1 - column];
      if (forward < reflected) return board;
      if (forward > reflected) return mirrorBoard(board);
    }
  }
  return board;
}

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

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

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