Drop7 Research
src/core/typescript/virtual-ignition.tsTypeScript567 lines · 16.4 KB
import {
  BOARD_SIZE,
  CRACKED,
  EMPTY,
  SOLID,
  isNumbered,
  type Board,
  type Cell,
  type DiscValue,
  type GameState,
} from "./engine.ts";
import {
  HEURISTIC_GAME_OVER_UTILITY,
  evaluateHeuristic,
} from "./heuristic.ts";

export interface VirtualIgnitionFeatures {
  ignitionReadiness: number;
  seedClearPotential: number;
  initialCoverCracks: number;
  initialCoverReveals: number;
  downstreamClears: number;
  downstreamCoverReveals: number;
  downstreamWaves: number;
  cascadeDepthEnergy: number;
  coverReduction: number;
}

export type VirtualIgnitionWeights = Readonly<
  Record<keyof VirtualIgnitionFeatures, number>
>;

export interface VirtualIgnitionSeedAnalysis {
  index: number;
  value: DiscValue;
  additionCost: number;
  readiness: number;
  initialCoverCracks: number;
  initialCoverReveals: number;
  downstreamClears: number;
  downstreamCoverReveals: number;
  downstreamWaves: number;
  cascadeDepthEnergy: number;
  coverReduction: number;
}

export interface VirtualIgnitionAnalysis {
  features: VirtualIgnitionFeatures;
  seeds: readonly VirtualIgnitionSeedAnalysis[];
  scenarios: number;
}

export interface VirtualIgnitionOptions {
  scenarios?: number;
}

export const VIRTUAL_IGNITION_WEIGHTS: VirtualIgnitionWeights = {
  ignitionReadiness: 40,
  seedClearPotential: 40,
  initialCoverCracks: 90,
  initialCoverReveals: 180,
  downstreamClears: 160,
  downstreamCoverReveals: 260,
  downstreamWaves: 240,
  cascadeDepthEnergy: 80,
  coverReduction: 160,
};

export const DEFAULT_VIRTUAL_IGNITION_SCENARIOS = 7;
export const MAX_VIRTUAL_ADDITIONS = 6;

const VIRTUAL = 10 as const;
const MAX_SCENARIOS = 14;
const MAX_CASCADE_WAVES = BOARD_SIZE * BOARD_SIZE;
const REVEAL_DOMAIN = 0x5649_5245;
const EVENT_MULTIPLIER = 0x9e37_79b9;

type VirtualCell = Cell | typeof VIRTUAL;
type VirtualBoard = readonly VirtualCell[];

interface AdditionPlan {
  columns: readonly number[];
  cost: number;
}

interface IgnitionOutcome {
  initialCoverCracks: number;
  initialCoverReveals: number;
  downstreamClears: number;
  downstreamCoverReveals: number;
  downstreamWaves: number;
  cascadeDepthEnergy: number;
  coverReduction: number;
}

/**
 * Estimate latent cascades by virtually completing a physically reachable
 * trigger, removing that seed, applying exact adjacent cover hits, and then
 * resolving every causally downstream wave.
 *
 * Added support discs use an inert internal marker. They occupy line length
 * and survive gravity, but cannot introduce an invented numbered trigger.
 * This isolates the stored chain already present on the board. Readiness
 * exponentially discounts the number of real additions needed to reach that
 * virtual trigger. Since analysis starts only from such a physical plan,
 * dependency cycles with no outside seed remain exactly dormant.
 */
export function analyzeVirtualIgnition(
  state: GameState,
  options: VirtualIgnitionOptions = {},
): VirtualIgnitionAnalysis {
  const scenarios = options.scenarios ?? DEFAULT_VIRTUAL_IGNITION_SCENARIOS;
  if (!Number.isSafeInteger(scenarios) || scenarios < 1 || scenarios > MAX_SCENARIOS) {
    throw new Error(`virtual ignition scenarios must be from 1 to ${MAX_SCENARIOS}`);
  }
  const board = canonicalBoard(state.board);
  const features = zeroFeatures();
  const seeds: VirtualIgnitionSeedAnalysis[] = [];
  const boardHash = hashBoard(board);

  for (let index = 0; index < board.length; index += 1) {
    const value = board[index];
    if (!isNumbered(value)) continue;
    const plan = minimumAdditionPlan(board, index, value);
    if (!plan || plan.cost > MAX_VIRTUAL_ADDITIONS) continue;
    const prepared = prepareBoard(board, plan.columns);
    if (!prepared || !isTrigger(prepared, index, value)) continue;
    const readiness = additionReadiness(plan.cost);
    const totals = zeroOutcome();
    for (let scenario = 0; scenario < scenarios; scenario += 1) {
      addOutcome(
        totals,
        ignite(
          prepared,
          index,
          board,
          scenarioRandom(boardHash ^ Math.imul(index + 1, 0x85eb_ca6b), scenario, scenarios),
        ),
      );
    }
    scaleOutcome(totals, 1 / scenarios);
    const seed: VirtualIgnitionSeedAnalysis = {
      index,
      value,
      additionCost: plan.cost,
      readiness,
      ...totals,
    };
    seeds.push(seed);
    features.ignitionReadiness += readiness;
    features.seedClearPotential += readiness;
    features.initialCoverCracks += readiness * totals.initialCoverCracks;
    features.initialCoverReveals += readiness * totals.initialCoverReveals;
    features.downstreamClears += readiness * totals.downstreamClears;
    features.downstreamCoverReveals +=
      readiness * totals.downstreamCoverReveals;
    features.downstreamWaves += readiness * totals.downstreamWaves;
    features.cascadeDepthEnergy += readiness * totals.cascadeDepthEnergy;
    features.coverReduction += readiness * totals.coverReduction;
  }
  return { features, seeds, scenarios };
}

export function extractVirtualIgnitionFeatures(
  state: GameState,
  options: VirtualIgnitionOptions = {},
) {
  return analyzeVirtualIgnition(state, options).features;
}

export function scoreVirtualIgnitionFeatures(
  features: VirtualIgnitionFeatures,
  weights: VirtualIgnitionWeights = VIRTUAL_IGNITION_WEIGHTS,
) {
  let result = 0;
  for (const key of Object.keys(features) as (keyof VirtualIgnitionFeatures)[]) {
    result += features[key] * weights[key];
  }
  return result;
}

/** Combined heuristic plus an independently scalable virtual-cascade residual. */
export function evaluateVirtualIgnition(
  state: GameState,
  scale = 1,
  options: VirtualIgnitionOptions = {},
) {
  validateScale(scale);
  if (state.gameOver) return HEURISTIC_GAME_OVER_UTILITY;
  const board = canonicalBoard(state.board);
  const canonicalState = board === state.board ? state : { ...state, board };
  return (
    evaluateHeuristic(canonicalState, "combined") +
    scale *
      scoreVirtualIgnitionFeatures(
        extractVirtualIgnitionFeatures(canonicalState, options),
      )
  );
}

function minimumAdditionPlan(
  board: Board,
  index: number,
  value: DiscValue,
): AdditionPlan | null {
  const row = Math.floor(index / BOARD_SIZE);
  const column = index % BOARD_SIZE;
  const heights = columnHeights(board);
  const rowLength = lineLength(board, row, column, "row");
  const columnLength = lineLength(board, row, column, "column");
  if (rowLength === value || columnLength === value) {
    return { columns: [], cost: 0 };
  }
  const candidates: AdditionPlan[] = [];
  if (columnLength < value && heights[column] < value) {
    const cost = value - heights[column];
    candidates.push({ columns: Array<number>(cost).fill(column), cost });
  }
  if (rowLength < value) {
    const [start, end] = segmentBounds(board, row, column);
    const needed = value - rowLength;
    const elevation = BOARD_SIZE - row;
    for (let left = 0; left <= needed; left += 1) {
      const right = needed - left;
      if (start - left < 0 || end + right >= BOARD_SIZE) continue;
      const columns: number[] = [];
      for (let candidate = start - left; candidate < start; candidate += 1) {
        appendColumnFill(columns, board, candidate, row, elevation, heights);
      }
      for (let candidate = end + 1; candidate <= end + right; candidate += 1) {
        appendColumnFill(columns, board, candidate, row, elevation, heights);
      }
      candidates.push({ columns, cost: columns.length });
    }
  }
  candidates.sort(
    (first, second) =>
      first.cost - second.cost ||
      uniqueCount(first.columns) - uniqueCount(second.columns) ||
      compareColumns(first.columns, second.columns),
  );
  return candidates[0] ?? null;
}

function appendColumnFill(
  columns: number[],
  board: Board,
  column: number,
  row: number,
  elevation: number,
  heights: readonly number[],
) {
  if (board[indexOf(row, column)] !== EMPTY) return;
  const additions = Math.max(0, elevation - heights[column]);
  for (let count = 0; count < additions; count += 1) columns.push(column);
}

function prepareBoard(board: Board, columns: readonly number[]) {
  const prepared: VirtualCell[] = [...board];
  for (const column of columns) {
    let placed = false;
    for (let row = BOARD_SIZE - 1; row >= 0; row -= 1) {
      const index = indexOf(row, column);
      if (prepared[index] !== EMPTY) continue;
      prepared[index] = VIRTUAL;
      placed = true;
      break;
    }
    if (!placed) return null;
  }
  return prepared;
}

function ignite(
  prepared: VirtualBoard,
  seedIndex: number,
  original: Board,
  random: () => number,
): IgnitionOutcome {
  const initial = clearWave(prepared, [seedIndex], random);
  let board = applyVirtualGravity(initial.board);
  let downstreamClears = 0;
  let downstreamCoverReveals = 0;
  let downstreamWaves = 0;
  let cascadeDepthEnergy = 0;
  for (let depth = 1; depth <= MAX_CASCADE_WAVES; depth += 1) {
    const poppers = findVirtualPoppers(board);
    if (poppers.length === 0) break;
    const wave = clearWave(board, poppers, random);
    downstreamClears += poppers.length;
    downstreamCoverReveals += wave.reveals;
    downstreamWaves += 1;
    // The forced ignition is virtual wave one; natural waves begin at depth 2.
    cascadeDepthEnergy += poppers.length * (depth + 1) ** 2;
    board = applyVirtualGravity(wave.board);
  }
  return {
    initialCoverCracks: initial.cracks,
    initialCoverReveals: initial.reveals,
    downstreamClears,
    downstreamCoverReveals,
    downstreamWaves,
    cascadeDepthEnergy,
    coverReduction: Math.max(0, coverCount(original) - coverCount(board)),
  };
}

function clearWave(
  board: VirtualBoard,
  poppers: readonly number[],
  random: () => number,
) {
  const result: VirtualCell[] = [...board];
  const popping = new Set(poppers);
  for (const index of popping) result[index] = EMPTY;
  let cracks = 0;
  let reveals = 0;
  for (let row = 0; row < BOARD_SIZE; row += 1) {
    for (let column = 0; column < BOARD_SIZE; column += 1) {
      const index = indexOf(row, column);
      const cell = board[index];
      if (cell !== SOLID && cell !== CRACKED) continue;
      let hits = 0;
      for (const [rowDelta, columnDelta] of DIRECTIONS) {
        const neighborRow = row + rowDelta;
        const neighborColumn = column + columnDelta;
        if (
          inside(neighborRow, neighborColumn) &&
          popping.has(indexOf(neighborRow, neighborColumn))
        ) {
          hits += 1;
        }
      }
      if (hits === 0) continue;
      if (cell === CRACKED || hits >= 2) {
        result[index] = randomDisc(random);
        reveals += 1;
      } else {
        result[index] = CRACKED;
        cracks += 1;
      }
    }
  }
  return { board: result, cracks, reveals };
}

function findVirtualPoppers(board: VirtualBoard) {
  const result: number[] = [];
  for (let row = 0; row < BOARD_SIZE; row += 1) {
    for (let column = 0; column < BOARD_SIZE; column += 1) {
      const index = indexOf(row, column);
      const cell = board[index];
      if (!isNumbered(cell as Cell)) continue;
      if (
        lineLength(board, row, column, "row") === cell ||
        lineLength(board, row, column, "column") === cell
      ) {
        result.push(index);
      }
    }
  }
  return result;
}

function applyVirtualGravity(board: VirtualBoard) {
  const result = Array<VirtualCell>(board.length).fill(EMPTY);
  for (let column = 0; column < BOARD_SIZE; column += 1) {
    let destination = BOARD_SIZE - 1;
    for (let row = BOARD_SIZE - 1; row >= 0; row -= 1) {
      const cell = board[indexOf(row, column)];
      if (cell === EMPTY) continue;
      result[indexOf(destination, column)] = cell;
      destination -= 1;
    }
  }
  return result;
}

function isTrigger(
  board: VirtualBoard,
  index: number,
  value: DiscValue,
) {
  const row = Math.floor(index / BOARD_SIZE);
  const column = index % BOARD_SIZE;
  return (
    lineLength(board, row, column, "row") === value ||
    lineLength(board, row, column, "column") === value
  );
}

function lineLength(
  board: VirtualBoard,
  row: number,
  column: number,
  axis: "row" | "column",
) {
  if (board[indexOf(row, column)] === EMPTY) return 0;
  const [rowStep, columnStep] = axis === "row" ? [0, 1] : [1, 0];
  let result = 1;
  for (const direction of [-1, 1]) {
    let nextRow = row + rowStep * direction;
    let nextColumn = column + columnStep * direction;
    while (
      inside(nextRow, nextColumn) &&
      board[indexOf(nextRow, nextColumn)] !== EMPTY
    ) {
      result += 1;
      nextRow += rowStep * direction;
      nextColumn += columnStep * direction;
    }
  }
  return result;
}

function segmentBounds(board: Board, row: number, column: number) {
  let start = column;
  let end = column;
  while (start > 0 && board[indexOf(row, start - 1)] !== EMPTY) start -= 1;
  while (
    end + 1 < BOARD_SIZE &&
    board[indexOf(row, end + 1)] !== EMPTY
  ) {
    end += 1;
  }
  return [start, end] as const;
}

function columnHeights(board: Board) {
  const result = 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[indexOf(row, column)] !== EMPTY) result[column] += 1;
    }
  }
  return result;
}

function scenarioRandom(hash: number, scenario: number, scenarios: number) {
  let event = 0;
  return () => {
    const rotation =
      mix32(hash ^ REVEAL_DOMAIN ^ Math.imul(event + 1, EVENT_MULTIPLIER)) %
      scenarios;
    const stratum = (scenario + rotation) % scenarios;
    event += 1;
    return (stratum + 0.5) / scenarios;
  };
}

function randomDisc(random: () => number): DiscValue {
  return (Math.floor(Math.max(0, Math.min(0.999999999999, random())) * 7) +
    1) as DiscValue;
}

function canonicalBoard(board: Board) {
  for (let row = 0; row < BOARD_SIZE; row += 1) {
    for (let column = 0; column < BOARD_SIZE; column += 1) {
      const forward = board[indexOf(row, column)];
      const reverse = board[indexOf(row, BOARD_SIZE - 1 - column)];
      if (forward < reverse) return board;
      if (forward > reverse) return mirrorBoard(board);
    }
  }
  return board;
}

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

function hashBoard(board: Board) {
  let hash = 0x811c_9dc5;
  for (const cell of board) hash = Math.imul(hash ^ (cell + 1), 0x0100_0193);
  return mix32(hash);
}

function coverCount(board: VirtualBoard) {
  return board.filter((cell) => cell === SOLID || cell === CRACKED).length;
}

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

function zeroFeatures(): VirtualIgnitionFeatures {
  return {
    ignitionReadiness: 0,
    seedClearPotential: 0,
    initialCoverCracks: 0,
    initialCoverReveals: 0,
    downstreamClears: 0,
    downstreamCoverReveals: 0,
    downstreamWaves: 0,
    cascadeDepthEnergy: 0,
    coverReduction: 0,
  };
}

function zeroOutcome(): IgnitionOutcome {
  return {
    initialCoverCracks: 0,
    initialCoverReveals: 0,
    downstreamClears: 0,
    downstreamCoverReveals: 0,
    downstreamWaves: 0,
    cascadeDepthEnergy: 0,
    coverReduction: 0,
  };
}

function addOutcome(target: IgnitionOutcome, source: IgnitionOutcome) {
  for (const key of Object.keys(target) as (keyof IgnitionOutcome)[]) {
    target[key] += source[key];
  }
}

function scaleOutcome(target: IgnitionOutcome, scale: number) {
  for (const key of Object.keys(target) as (keyof IgnitionOutcome)[]) {
    target[key] *= scale;
  }
}

function uniqueCount(values: readonly number[]) {
  return new Set(values).size;
}

function compareColumns(first: readonly number[], second: readonly number[]) {
  for (let index = 0; index < Math.min(first.length, second.length); index += 1) {
    if (first[index] !== second[index]) return first[index] - second[index];
  }
  return first.length - second.length;
}

function validateScale(scale: number) {
  if (!Number.isFinite(scale) || scale < 0) {
    throw new Error("virtual ignition scale must be non-negative and finite");
  }
}

function indexOf(row: number, column: number) {
  return row * BOARD_SIZE + column;
}

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

function mix32(value: number) {
  let mixed = value >>> 0;
  mixed = Math.imul(mixed ^ (mixed >>> 16), 0x7feb_352d);
  mixed = Math.imul(mixed ^ (mixed >>> 15), 0x846c_a68b);
  return (mixed ^ (mixed >>> 16)) >>> 0;
}

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