// ---------------------------------------------------------------------------
// PIMC — Perfect Information Monte Carlo Search Engine
// For each root move: generate D determinizations, run α-β per determinization,
// score = 0.7 × normalizedAvg + 0.3 × winRate
// ---------------------------------------------------------------------------

import { GameState, PlayerIndex, Card, Suit, SUITS, AIMove, PRIMIERA_VALUES } from './types';
import { applyMove, findCaptures, cloneState, teamOf, nextPlayer } from './engine';
import { CardInferenceEngine } from './card-inference';
import { CategoryStates, ParityState } from './ai-strategy';
import { evaluateTeamPositionPIMC, quickEvalRootMovePIMC, generateSamplesForPIMC } from './ai-legacy';
import { CardTracker } from './card-tracker';

// PIMC systematically gets fewer scope than legacy because the alpha-beta search trades
// guaranteed scope for material gains it values at >390. Boost the accumulated scope
// weight from 390 (in evaluateTeamPositionPIMC) to 540 so scopa-taking is preferred
// unless the material advantage exceeds 150 extra points.
const PIMC_SCOPE_BOOST = 150; // effective scope weight: 390 + 150 = 540

// Correct the 150-point gap between scoreKnownImmediateCapturePressure's scopa bonus (240)
// and the actual match point value (390). In determinized PIMC states, all hands are exact,
// so we can directly check which upcoming players hold a scopa card and apply the delta.
// The weights [1, 0.72, 0.44] mirror UPCOMING_TABLE_EXPOSURE_WEIGHTS in ai-legacy.ts.
const PIMC_SCOPA_CORRECTION = 150; // 390 - 240
const PIMC_EXPOSURE_WEIGHTS = [1, 0.72, 0.44] as const;

function leafEval(
  state: GameState,
  maximizingTeam: 0 | 1,
  rootPlayer: PlayerIndex | undefined,
): number {
  let score = evaluateTeamPositionPIMC(state, maximizingTeam, rootPlayer);

  // Boost accumulated scope count beyond the 390 already in evaluateTeamPositionPIMC.
  let myScope = 0, oppScope = 0;
  for (let p = 0; p < 4; p++) {
    if (teamOf(p as PlayerIndex) === maximizingTeam) myScope += state.players[p].scope;
    else oppScope += state.players[p].scope;
  }
  score += (myScope - oppScope) * PIMC_SCOPE_BOOST;

  if (state.table.length > 0) {
    let p = state.currentPlayer;
    for (const w of PIMC_EXPOSURE_WEIGHTS) {
      if (state.players[p].hand.length > 0) {
        const canScopa = state.players[p].hand.some(c => {
          const caps = findCaptures(c, state.table);
          return caps.some(cap => cap.length === state.table.length);
        });
        if (canScopa) {
          score += Math.round((teamOf(p) === maximizingTeam ? 1 : -1) * PIMC_SCOPA_CORRECTION * w);
        }
      }
      p = nextPlayer(p);
    }
  }
  return score;
}

export interface PIMCOptions {
  determinizations: number;   // default 12
  maxDepthMidgame: number;    // default 5
  maxDepthEndgame: number;    // default 8
  timeBudgetMs: number;       // default 4300
  stabilityWeight: number;    // default 0.3
  timingSource?: { now(): number }; // optional; defaults to Date.now
}

export interface PIMCMoveScore {
  move: AIMove;
  averageScore: number;
  winRate: number;
  pimcScore: number;
}

export type RandomSource = () => number;

export const DEFAULT_PIMC_OPTIONS: PIMCOptions = {
  determinizations: 12,
  maxDepthMidgame: 5,
  maxDepthEndgame: 8,
  timeBudgetMs: 4300,
  stabilityWeight: 0.3,
};

// ---------------------------------------------------------------------------
// Main PIMC Search Entry Point
// ---------------------------------------------------------------------------

export function pimcSearch(
  state: GameState,
  playerIdx: PlayerIndex,
  legalMoves: AIMove[],
  inference: CardInferenceEngine,
  categoryStates: CategoryStates,
  parityState: ParityState,
  options: Partial<PIMCOptions> = {},
  rng: RandomSource = Math.random,
  tracker?: CardTracker,
): PIMCMoveScore[] {
  const opts: PIMCOptions = { ...DEFAULT_PIMC_OPTIONS, ...options };
  if (legalMoves.length === 0) return [];
  if (legalMoves.length === 1) {
    return [{ move: legalMoves[0], averageScore: 0, winRate: 1, pimcScore: 1 }];
  }

  const nowFn: () => number = opts.timingSource ? () => opts.timingSource!.now() : Date.now;
  const deadline = nowFn() + opts.timeBudgetMs;
  const myTeam = teamOf(playerIdx);
  const myHand = state.players[playerIdx].hand;
  const totalRemaining = state.players.reduce((sum, p) => sum + p.hand.length, 0);
  const isEndgame = totalRemaining <= 16;
  const depth = isEndgame ? opts.maxDepthEndgame : opts.maxDepthMidgame;

  // Accumulators per move
  const scoreSums = new Map<string, number>();
  const winCounts = new Map<string, number>();
  const trialCounts = new Map<string, number>();
  for (const m of legalMoves) {
    const key = moveKey(m);
    scoreSums.set(key, 0);
    winCounts.set(key, 0);
    trialCounts.set(key, 0);
  }

  let completedDeterminizations = 0;

  // Killer moves shared across ALL determinizations — structural patterns
  // (e.g. always dump sevens, avoid giving scopa) transfer across hand assignments.
  const killers: (AIMove | null)[][] = Array.from({ length: depth + 2 }, () => [null, null]);

  // Generate determinized game states using the legacy's stratified bucketing algorithm.
  // Much more accurate than random inference sampling: assigns high-value cards (7s, denari)
  // to the most likely holders. buildExactSampleStates (triggered in deep endgame) can
  // return UP TO 48 states; cap to opts.determinizations to keep per-move time predictable.
  const determinizations = generateSamplesForPIMC(
    state, playerIdx, tracker, opts.determinizations, rng, undefined,
  ).slice(0, opts.determinizations);

  for (const det of determinizations) {
    if (nowFn() >= deadline) break;

    // Pre-sort root moves by the FULL legacy quickEval (position + scoreMoveObjectiveBias)
    // when a tracker is available. This directly uses the legacy AI's tactical intelligence
    // for root ordering, which is the key advantage the legacy AI has over plain PIMC.
    // Without tracker, fall back to evaluateTeamPositionPIMC.
    const rootMoveOrder = legalMoves.map(move => {
      const score = tracker
        ? quickEvalRootMovePIMC(move, det, playerIdx, tracker)
        : (() => { const { nextState: ns } = applyMove(det, playerIdx, move.card, move.capture); return evaluateTeamPositionPIMC(ns, myTeam); })();
      return { move, score };
    });
    rootMoveOrder.sort((a, b) => b.score - a.score);
    const sortedMoves = rootMoveOrder.map(x => x.move);

    // Score each move under this determinization.
    // Cross-move α: after each root move, if its score improves α, subsequent moves
    // get a tighter α window — mirroring the legacy master's root-level PVS.
    let sampleAlpha = -Infinity;
    let bestScoreThisDet = -Infinity;
    const moveScoresThisDet = new Map<string, number>();

    for (const move of sortedMoves) {
      if (nowFn() >= deadline) break;
      const key = moveKey(move);

      // Apply move to determinized state
      const { nextState: stateAfterMove } = applyMove(det, playerIdx, move.card, move.capture);

      // Run α-β minimax. Use sampleAlpha (cross-move α) for pruning.
      const score = alphaBetaPIMC(
        stateAfterMove,
        depth - 1,
        sampleAlpha,
        Infinity,
        myTeam,
        killers,
        deadline,
        nowFn,
        playerIdx,
        tracker,
      );

      // Update cross-move α — subsequent root moves must beat this to be relevant
      if (score > sampleAlpha) sampleAlpha = score;

      moveScoresThisDet.set(key, score);
      scoreSums.set(key, (scoreSums.get(key) ?? 0) + score);
      trialCounts.set(key, (trialCounts.get(key) ?? 0) + 1);

      if (score > bestScoreThisDet) bestScoreThisDet = score;
    }

    // Track win counts (how many moves tied for best)
    for (const move of legalMoves) {
      const key = moveKey(move);
      const s = moveScoresThisDet.get(key) ?? -Infinity;
      if (s >= bestScoreThisDet - 1) {
        winCounts.set(key, (winCounts.get(key) ?? 0) + 1);
      }
    }

    completedDeterminizations++;
  }

  if (completedDeterminizations === 0) completedDeterminizations = 1;

  // Compute final PIMC scores
  const results: PIMCMoveScore[] = legalMoves.map(move => {
    const key = moveKey(move);
    const trials = trialCounts.get(key) ?? 1;
    const avgScore = (scoreSums.get(key) ?? 0) / trials;
    const winRate = (winCounts.get(key) ?? 0) / completedDeterminizations;
    const pimcScore = (1 - opts.stabilityWeight) * avgScore + opts.stabilityWeight * winRate * 1000;
    return { move, averageScore: avgScore, winRate, pimcScore };
  });

  results.sort((a, b) => b.pimcScore - a.pimcScore);
  return results;
}

// ---------------------------------------------------------------------------
// Determinization Builder
// ---------------------------------------------------------------------------

function buildDeterminizedState(
  state: GameState,
  playerIdx: PlayerIndex,
  inference: CardInferenceEngine,
  myHand: import('./types').Card[],
  rng: RandomSource,
): GameState {
  const det = cloneState(state);
  const table = state.table;

  // Collect all cards that need to be assigned to other players
  const assignedIds = new Set<string>(myHand.map(c => c.id));
  for (const c of table) assignedIds.add(c.id);

  // For each other player with cards, sample from constrained unseen
  for (let i = 0; i < 4; i++) {
    if (i === playerIdx) continue;
    const p = state.players[i];
    if (p.hand.length === 0) continue;

    const constrained = inference.getLikelyHandForSampling(
      i as PlayerIndex,
      p.hand.length,
      myHand,
      table,
      rng,
    ).filter(c => !assignedIds.has(c.id));

    // Take as many as we can from constrained, pad with random if needed
    const assigned = constrained.slice(0, p.hand.length);

    // If not enough, fill with random unseen not yet assigned
    if (assigned.length < p.hand.length) {
      const allUnseen = inference.getConstrainedUnseen(i as PlayerIndex, myHand, table)
        .filter(c => !assignedIds.has(c.id) && !assigned.some(a => a.id === c.id));
      // Shuffle allUnseen
      for (let j = allUnseen.length - 1; j > 0; j--) {
        const k = Math.floor(rng() * (j + 1));
        [allUnseen[j], allUnseen[k]] = [allUnseen[k], allUnseen[j]];
      }
      while (assigned.length < p.hand.length && allUnseen.length > 0) {
        assigned.push(allUnseen.shift()!);
      }
    }

    // Assign to determinized state
    det.players[i].hand = assigned;
    for (const c of assigned) assignedIds.add(c.id);
  }

  return det;
}

// ---------------------------------------------------------------------------
// α-β Minimax for PIMC
// Uses 1-ply eval ordering at top levels (depth >= 3) for good pruning without
// excessive overhead, PVS for principal-variation search efficiency, and
// killer moves at all levels for cheap but effective ordering.
// ---------------------------------------------------------------------------

function movesMatch(a: AIMove, b: AIMove): boolean {
  if (a.card.id !== b.card.id) return false;
  if (a.capture.length !== b.capture.length) return false;
  const bIds = new Set(b.capture.map(c => c.id));
  return a.capture.every(c => bIds.has(c.id));
}

function applyKillerOrdering(moves: AIMove[], killerSlot: (AIMove | null)[]): AIMove[] {
  // Captures are already first; within non-captures, try killers first
  const captures: AIMove[] = [];
  const nonCaps: AIMove[] = [];
  for (const m of moves) {
    (m.capture.length > 0 ? captures : nonCaps).push(m);
  }
  if (nonCaps.length === 0) return captures;
  const killerFirst: AIMove[] = [];
  const rest: AIMove[] = [];
  for (const m of nonCaps) {
    const isKiller = killerSlot.some(k => k !== null && movesMatch(m, k));
    (isKiller ? killerFirst : rest).push(m);
  }
  return [...captures, ...killerFirst, ...rest];
}

function alphaBetaPIMC(
  state: GameState,
  depth: number,
  alpha: number,
  beta: number,
  maximizingTeam: 0 | 1,
  killers: (AIMove | null)[][], // indexed by depth
  deadline: number,
  nowFn: () => number,
  rootPlayer?: PlayerIndex,
  tracker?: CardTracker,
): number {
  // Terminal / leaf
  if (state.roundOver || state.gameOver || nowFn() >= deadline) {
    return leafEval(state, maximizingTeam, rootPlayer);
  }
  if (depth === 0) {
    return leafEval(state, maximizingTeam, rootPlayer);
  }

  const currentPlayer = state.currentPlayer;
  const currentTeam = teamOf(currentPlayer);
  const isMaximizing = currentTeam === maximizingTeam;
  const hand = state.players[currentPlayer].hand;

  if (hand.length === 0) {
    return leafEval(state, maximizingTeam, rootPlayer);
  }

  // Generate candidate moves (capture-first from movePIMCOrderScore)
  const rawMoves = generateMovesForPIMC(state, currentPlayer);
  if (rawMoves.length === 0) {
    return leafEval(state, maximizingTeam, rootPlayer);
  }

  // Move ordering: at depth ≥ 3 use 1-ply leafEval with scopa-gift penalty (expensive but
  // worth it near the root). At depth 1–2 fall back to cheap killer heuristic.
  let orderedMoves: AIMove[];
  if (rawMoves.length > 1) {
    if (depth >= 3) {
      const nextP = nextPlayer(currentPlayer);
      const nextIsOpp = teamOf(nextP) !== teamOf(currentPlayer);
      const scored = rawMoves.map(m => {
        const { nextState: ns } = applyMove(state, currentPlayer, m.card, m.capture);
        let s = leafEval(ns, maximizingTeam, rootPlayer);
        if (isMaximizing && nextIsOpp && ns.table.length > 0 && ns.players[nextP].hand.length > 0) {
          const giftsScopa = ns.players[nextP].hand.some(c => {
            const caps = findCaptures(c, ns.table);
            return caps.some(cap => cap.length === ns.table.length);
          });
          if (giftsScopa) s -= 720;
        }
        return { m, s };
      });
      scored.sort((a, b) => isMaximizing ? b.s - a.s : a.s - b.s);
      orderedMoves = scored.map(x => x.m);
    } else {
      const slot = depth < killers.length ? killers[depth] : [null, null];
      orderedMoves = applyKillerOrdering(rawMoves, slot);
    }
  } else {
    orderedMoves = rawMoves;
  }

  let bestScore = isMaximizing ? -Infinity : Infinity;
  let bestMove: AIMove | null = null;
  let isFirstMove = true;

  for (const move of orderedMoves) {
    const { nextState: next } = applyMove(state, currentPlayer, move.card, move.capture);
    let score: number;

    if (isFirstMove) {
      // Full-window search on expected best move
      score = alphaBetaPIMC(next, depth - 1, alpha, beta, maximizingTeam, killers, deadline, nowFn, rootPlayer, tracker);
    } else if (isMaximizing) {
      // PVS: null-window scout, re-search if it fails high
      score = alphaBetaPIMC(next, depth - 1, alpha, alpha + 1, maximizingTeam, killers, deadline, nowFn, rootPlayer, tracker);
      if (score > alpha && score < beta) {
        score = alphaBetaPIMC(next, depth - 1, alpha, beta, maximizingTeam, killers, deadline, nowFn, rootPlayer, tracker);
      }
    } else {
      // Minimizing: null-window scout, re-search if fails low
      score = alphaBetaPIMC(next, depth - 1, beta - 1, beta, maximizingTeam, killers, deadline, nowFn, rootPlayer, tracker);
      if (score < beta && score > alpha) {
        score = alphaBetaPIMC(next, depth - 1, alpha, beta, maximizingTeam, killers, deadline, nowFn, rootPlayer, tracker);
      }
    }

    if (isMaximizing) {
      if (score > bestScore) { bestScore = score; bestMove = move; }
      if (score > alpha) alpha = score;
    } else {
      if (score < bestScore) { bestScore = score; bestMove = move; }
      if (score < beta) beta = score;
    }
    if (beta <= alpha) {
      // β-cutoff: store killer if this is a non-capture (quiet) move
      if (bestMove && bestMove.capture.length === 0 && depth < killers.length) {
        const slot = killers[depth];
        if (!slot[0] || !movesMatch(slot[0], bestMove)) {
          slot[1] = slot[0];
          slot[0] = bestMove;
        }
      }
      break;
    }
    isFirstMove = false;
  }

  return bestScore;
}

// ---------------------------------------------------------------------------
// Move Generator (for PIMC tree — all hands are known)
// ---------------------------------------------------------------------------

/**
 * Heuristic score for α-β move ordering.
 * Captures ranked before dumps; within captures, high-value cards first.
 * Good ordering → earlier α/β cutoffs → effectively deeper search.
 */
function movePIMCOrderScore(move: AIMove, table: Card[]): number {
  if (move.capture.length === 0) return 0;
  let score = 1000; // All captures beat all dumps
  const allCards = [move.card, ...move.capture];
  for (const c of allCards) {
    if (c.suit === 'denara' && c.value === 7) score += 500; // Settebello capture
    else if (c.suit === 'denara') score += 100;
    else if (c.value === 7) score += 80;
    score += PRIMIERA_VALUES[c.value] ?? 10;
  }
  if (move.capture.length === table.length) score += 300; // Scopa
  return score;
}

function generateMovesForPIMC(state: GameState, playerIdx: PlayerIndex): AIMove[] {
  const hand = state.players[playerIdx].hand;
  const table = state.table;
  const moves: AIMove[] = [];

  for (const card of hand) {
    const captures = findCaptures(card, table);
    if (captures.length > 0) {
      for (const cap of captures) {
        moves.push({ card, capture: cap });
      }
    } else {
      moves.push({ card, capture: [] });
    }
  }

  // Initial capture-first sort (cheap; 1-ply eval in alphaBetaPIMC will refine this)
  if (moves.length > 1) {
    moves.sort((a, b) => movePIMCOrderScore(b, table) - movePIMCOrderScore(a, table));
  }

  return moves;
}

// ---------------------------------------------------------------------------
// Majority-race helper (mirrors legacy scoreMajorityRace)
// ---------------------------------------------------------------------------

function scoreMajorityRaceLocal(
  myValue: number,
  oppValue: number,
  target: number,
  unitWeight: number,
  thresholdBonus: number,
): number {
  let s = (myValue - oppValue) * unitWeight;
  if (myValue >= target && oppValue < target) {
    s += thresholdBonus;
  } else if (oppValue >= target && myValue < target) {
    s -= thresholdBonus;
  } else {
    const myDist = Math.max(0, target - myValue);
    const oppDist = Math.max(0, target - oppValue);
    s += (oppDist - myDist) * Math.round(unitWeight * 0.75);
  }
  return s;
}

// ---------------------------------------------------------------------------
// Enhanced Team Evaluation — computed from raw state, no stale context
// ---------------------------------------------------------------------------

function evaluateTeamPositionEnhanced(
  state: GameState,
  team: 0 | 1,
  _categoryStates?: CategoryStates | null,
  _parityState?: ParityState | null,
): number {
  const opp = team === 0 ? 1 : 0;
  const myScore = state.teamScores[team];
  const oppScore = state.teamScores[opp];

  // Build team snapshots in a single pass
  let myCards = 0, myDenari = 0, mySettebello = false, myScope = 0;
  let myPrimiera = 0, myPrimieraSuits = 0, mySevens = 0, mySevenSuits = 0, mySixes = 0, myAces = 0;
  let oppCards = 0, oppDenari = 0, oppSettebello = false, oppScope = 0;
  let oppPrimiera = 0, oppPrimieraSuits = 0, oppSevens = 0, oppSevenSuits = 0, oppSixes = 0, oppAces = 0;

  const myBestBySuit: Partial<Record<Suit, number>> = {};
  const oppBestBySuit: Partial<Record<Suit, number>> = {};
  const mySevenSuitsSet = new Set<Suit>();
  const oppSevenSuitsSet = new Set<Suit>();

  for (let i = 0; i < 4; i++) {
    const isMine = teamOf(i as PlayerIndex) === team;
    const p = state.players[i];
    if (isMine) myScope += p.scope; else oppScope += p.scope;

    const bestBySuit = isMine ? myBestBySuit : oppBestBySuit;
    const sevenSet = isMine ? mySevenSuitsSet : oppSevenSuitsSet;

    for (const card of p.pile) {
      const primScore = PRIMIERA_VALUES[card.value] ?? 10;
      if (isMine) {
        myCards++;
        if (card.suit === 'denara') { myDenari++; if (card.value === 7) mySettebello = true; }
        if (card.value === 7) { mySevens++; sevenSet.add(card.suit); }
        if (card.value === 6) mySixes++;
        if (card.value === 1) myAces++;
      } else {
        oppCards++;
        if (card.suit === 'denara') { oppDenari++; if (card.value === 7) oppSettebello = true; }
        if (card.value === 7) { oppSevens++; sevenSet.add(card.suit); }
        if (card.value === 6) oppSixes++;
        if (card.value === 1) oppAces++;
      }
      const best = bestBySuit[card.suit] ?? 0;
      if (primScore > best) bestBySuit[card.suit] = primScore;
    }
  }

  for (const suit of SUITS) {
    const myS = myBestBySuit[suit] ?? 0;
    const oppS = oppBestBySuit[suit] ?? 0;
    if (myS > 0) { myPrimiera += myS; myPrimieraSuits++; }
    if (oppS > 0) { oppPrimiera += oppS; oppPrimieraSuits++; }
  }
  mySevenSuits = mySevenSuitsSet.size;
  oppSevenSuits = oppSevenSuitsSet.size;

  const totalPlayed = myCards + oppCards;
  const phase = Math.min(1, totalPlayed / 28);

  let score = 0;

  // Match point leadership (dominant term)
  const matchWeight = (myScore.totalPoints >= 9 || oppScore.totalPoints >= 9) ? 360 : 260;
  score += (myScore.totalPoints - oppScore.totalPoints) * (matchWeight + phase * 40);
  if (myScore.totalPoints >= 10 && oppScore.totalPoints < 10) score += 260;
  if (oppScore.totalPoints >= 10 && myScore.totalPoints < 10) score -= 260;

  // Scope
  score += (myScope - oppScope) * 390;

  // Settebello
  if (mySettebello) score += 420;
  if (oppSettebello) score -= 420;

  // Cards majority race (target: 21)
  score += scoreMajorityRaceLocal(myCards, oppCards, 21, Math.round(24 + phase * 22), 240);

  // Denari majority race (target: 6)
  score += scoreMajorityRaceLocal(myDenari, oppDenari, 6, Math.round(70 + phase * 22), 220);

  // Primiera — best-per-suit totals + suit coverage + sevens/sixes/aces
  score += (myPrimiera - oppPrimiera) * Math.round(4.5 + phase * 3);
  score += (myPrimieraSuits - oppPrimieraSuits) * 124;
  if (myPrimieraSuits === 4 && oppPrimieraSuits < 4) score += 180;
  if (oppPrimieraSuits === 4 && myPrimieraSuits < 4) score -= 180;
  score += (mySevenSuits - oppSevenSuits) * 92;
  score += (mySevens - oppSevens) * 68;
  score += (mySixes - oppSixes) * 16;
  score += (myAces - oppAces) * 12;

  // Table ownership: last capturing team gets all remaining table cards at round end
  if (state.table.length > 0 && state.lastCapturTeam !== null) {
    const cardsRemaining = state.players.reduce((sum, p) => sum + p.hand.length, 0);
    const urgency = cardsRemaining <= 4 ? 1.35 : cardsRemaining <= 8 ? 1.15 : 0.75;
    let tableValue = 0;
    for (const c of state.table) {
      let w = 18 + (PRIMIERA_VALUES[c.value] ?? 10) * 3;
      if (c.suit === 'denara') w += 42;
      if (c.value === 7) w += 44;
      if (c.suit === 'denara' && c.value === 7) w += 120;
      tableValue += w;
    }
    score += (state.lastCapturTeam === team ? 1 : -1) * Math.round(tableValue * urgency);
  }

  // Table exposure: coins and 7s on table reward the team that plays next
  if (state.table.length > 0) {
    const nextTeamSign = teamOf(state.currentPlayer) === team ? 1 : -1;
    let pressure = 0;
    let shortTable = state.table.length <= 2;
    for (const c of state.table) {
      if (c.suit === 'denara') pressure += 34;
      if (c.value === 7) pressure += 42;
      if (c.suit === 'denara' && c.value === 7) pressure += 120;
    }
    if (shortTable) pressure += 36;
    score += nextTeamSign * Math.round(pressure * (shortTable ? 1.2 : 0.8));
  }

  return score;
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function moveKey(move: AIMove): string {
  const capIds = move.capture.map(c => c.id).sort().join(',');
  return `${move.card.id}|${capIds}`;
}
