import { deserializeCoord, isBoardCoordInBounds, serializeCoord } from '../board.ts' import { checkWin } from '../checkWin.ts' import { SeededRandom, weightedIndex } from '../random.ts' import type { Cell, GameRules } from '../types.ts' import { CH_CURRENT_THREATS, CH_OPPONENT_THREATS, CH_OVERLINE_TRAPS, encodeFeaturePlanes, } from './FeatureEncoder.ts' import type { PlayerColor, PolicyTarget, Swap2ColorDecision, Swap2Phase, Swap2Phase1Decision, } from '../dataset/types.ts' import { BitBoard, bitBoardCheckWin } from './BitBoard.ts' import type { PuctTranspositionEntry, PuctTranspositionTable } from './PuctTranspositionTable.ts' export interface PuctOptions { simulations: number cPuct: number candidateRadius: number maxCandidates: number enableTranspositionTable?: boolean transpositionTable?: PuctTranspositionTable generation?: number } export type MaybePromise = T | Promise export interface PuctContext { board: Record currentPlayer: PlayerColor playerSymbols: [string, string] rules: GameRules boardSize: number phase: Swap2Phase moveNumber: number } export interface PuctSearchResult { selectedMove?: { x: number; y: number } decision?: Swap2Phase1Decision | Swap2ColorDecision policyTarget: PolicyTarget mctsValue: number rootActionStats: PuctRootActionStats[] metadata: SearchStateMetadata } export type SearchPhase = Swap2Phase | 'offer_place_1' | 'offer_place_2' export type SearchAction = | { kind: 'place'; x: number; y: number } | { kind: 'special'; decision: Swap2Phase1Decision | Swap2ColorDecision } export interface PuctRootActionStats { action: SearchAction visits: number totalValue: number totalSquaredValue: number meanValue: number variance: number standardError: number } export interface SearchState { board: Map bitBoard: BitBoard phase: SearchPhase actorSeat: PlayerColor rootActorSeat: PlayerColor moveNumber: number rules: GameRules boardSize: number playerSymbols: [string, string] stoneColor?: PlayerColor playerToMove?: PlayerColor seatColors?: [PlayerColor, PlayerColor] terminalValue?: number } export interface SearchStateMetadata { actorSeat: PlayerColor actorColor?: PlayerColor stoneColor?: PlayerColor playerToMove?: PlayerColor } interface SearchEdge { action: SearchAction prior: number visits: number totalValue: number totalSquaredValue: number child?: SearchNode } interface SearchNode { state: SearchState expanded: boolean value: number edges: SearchEdge[] ttKey?: PuctStateKey } interface PuctStateKey { key: string transform: BoardTransform } type BoardTransform = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 const SWAP2_MOBILITY_WEIGHT = 350 const SWAP2_THREAT_WEIGHT = 0.75 export interface PolicyValueEvaluator { evaluate(state: SearchState, legalActions: SearchAction[]): MaybePromise<{ value: number; priors: number[] }> } export class HeuristicPolicyValueEvaluator implements PolicyValueEvaluator { evaluate(state: SearchState, legalActions: SearchAction[]): { value: number; priors: number[] } { return { value: clamp(evaluateState(state), -1, 1), priors: normalizePriors(legalActions.map(action => Math.max(0.001, actionPrior(state, action)))), } } } export async function runPuctSearch( context: PuctContext, options: PuctOptions, temperature: number, rng: SeededRandom, evaluator: PolicyValueEvaluator = new HeuristicPolicyValueEvaluator(), ): Promise { const table = options.enableTranspositionTable === false ? undefined : options.transpositionTable const generation = options.generation ?? 1 const root = createNode(rootState(context), table, generation) await expand(root, evaluator, options, rng) for (let i = 0; i < options.simulations; i++) { await runSimulation(root, evaluator, options, rng, table, generation) } writeNodeToTranspositionTable(root, table, generation) const selected = selectRootEdge(root.edges, temperature, rng) const rootVisits = root.edges.reduce((sum, edge) => sum + edge.visits, 0) const rootValue = rootVisits > 0 ? root.edges.reduce((sum, edge) => sum + edge.totalValue, 0) / rootVisits : root.value return { selectedMove: selected.action.kind === 'place' ? { x: selected.action.x, y: selected.action.y } : undefined, decision: selected.action.kind === 'special' ? selected.action.decision : undefined, policyTarget: policyFromEdges(root.edges), mctsValue: clamp(rootValue, -1, 1), rootActionStats: rootStatsFromEdges(root.edges), metadata: metadataFromState(root.state), } } function createNode(state: SearchState, table?: PuctTranspositionTable, generation = 1): SearchNode { const ttKey = canonicalStateKey(state) const entry = table?.get(ttKey.key, generation) const node: SearchNode = { state, expanded: false, value: state.terminalValue ?? entry?.value ?? 0, edges: [], ttKey } if (entry && table) hydrateNodeFromTranspositionEntry(node, entry, table.reuse) return node } function hydrateNodeFromTranspositionEntry( node: SearchNode, entry: PuctTranspositionEntry, reuse: 'priors' | 'stats', ): void { if (node.state.terminalValue !== undefined || entry.actions.length === 0 || !node.ttKey) return const inverse = inverseTransform(node.ttKey.transform) node.value = entry.value node.expanded = true node.edges = entry.actions.map(action => ({ action: transformAction(action.action, inverse, node.state.boardSize), prior: action.prior, visits: reuse === 'stats' ? action.visits : 0, totalValue: reuse === 'stats' ? action.totalValue : 0, totalSquaredValue: reuse === 'stats' ? action.totalSquaredValue ?? 0 : 0, })) } function writeNodeToTranspositionTable( node: SearchNode, table: PuctTranspositionTable | undefined, generation: number, ): void { if (!table || !node.expanded || !node.ttKey) return const visits = node.edges.reduce((sum, edge) => sum + edge.visits, 0) const totalValue = node.edges.reduce((sum, edge) => sum + edge.totalValue, 0) const totalSquaredValue = node.edges.reduce((sum, edge) => sum + edge.totalSquaredValue, 0) table.set({ key: node.ttKey.key, generation, value: node.value, visits, totalValue, totalSquaredValue, actions: node.edges.map(edge => ({ action: transformAction(edge.action, node.ttKey?.transform ?? 0, node.state.boardSize), prior: edge.prior, visits: edge.visits, totalValue: edge.totalValue, totalSquaredValue: edge.totalSquaredValue, })), }) } function canonicalStateKey(state: SearchState): PuctStateKey { const canonical = canonicalBitBoard(state.bitBoard) return { key: [ canonical.key, state.phase, state.actorSeat, state.rootActorSeat, state.moveNumber, state.stoneColor ?? '-', state.playerToMove ?? '-', state.seatColors ? state.seatColors.join(':') : '-', state.rules.noOverlines ? 'no-overlines' : 'overlines', ].join('|'), transform: canonical.transform, } } export function canonicalBitBoardKey(board: BitBoard): string { return canonicalBitBoard(board).key } function canonicalBitBoard(board: BitBoard): { key: string; transform: BoardTransform } { let bestKey = '' let bestTransform: BoardTransform = 0 for (let transform = 0 as BoardTransform; transform < 8; transform = (transform + 1) as BoardTransform) { const transformed = BitBoard.empty(board.boardSize) for (let y = 0; y < board.boardSize; y++) { for (let x = 0; x < board.boardSize; x++) { const player = board.playerAt(x, y) if (player === null) continue const coord = transformCoord({ x, y }, transform, board.boardSize) transformed.place(coord.x, coord.y, player) } } const key = transformed.key() if (bestKey === '' || key < bestKey) { bestKey = key bestTransform = transform } } return { key: bestKey, transform: bestTransform } } function transformAction(action: SearchAction, transform: BoardTransform, boardSize: number): SearchAction { if (action.kind === 'special') return action const coord = transformCoord(action, transform, boardSize) return { kind: 'place', x: coord.x, y: coord.y } } function transformCoord(coord: { x: number; y: number }, transform: BoardTransform, boardSize: number): { x: number; y: number } { const max = boardSize - 1 if (transform === 0) return coord if (transform === 1) return { x: max - coord.y, y: coord.x } if (transform === 2) return { x: max - coord.x, y: max - coord.y } if (transform === 3) return { x: coord.y, y: max - coord.x } if (transform === 4) return { x: max - coord.x, y: coord.y } if (transform === 5) return { x: coord.x, y: max - coord.y } if (transform === 6) return { x: coord.y, y: coord.x } return { x: max - coord.y, y: max - coord.x } } function inverseTransform(transform: BoardTransform): BoardTransform { if (transform === 1) return 3 if (transform === 3) return 1 return transform } function rootState(context: PuctContext): SearchState { const board = new Map(Object.entries(context.board)) const bitBoard = BitBoard.fromBoard(board, context.boardSize) if (context.phase === 'play') { return { board, bitBoard, phase: 'play', actorSeat: context.currentPlayer, rootActorSeat: context.currentPlayer, playerToMove: context.currentPlayer, seatColors: [0, 1], moveNumber: context.moveNumber, rules: context.rules, boardSize: context.boardSize, playerSymbols: context.playerSymbols, } } if (context.phase === 'swap2_phase1') { return baseOpeningState(board, context, 'swap2_phase1', 1) } if (context.phase === 'swap2_phase2') { return baseOpeningState(board, context, 'swap2_phase2', 0) } const actorSeat: PlayerColor = context.moveNumber <= 3 ? 0 : 1 return { ...baseOpeningState(board, context, 'swap2_phase0', actorSeat), stoneColor: context.currentPlayer, } } function baseOpeningState( board: Map, context: PuctContext, phase: SearchPhase, actorSeat: PlayerColor, ): SearchState { return { board, bitBoard: BitBoard.fromBoard(board, context.boardSize), phase, actorSeat, rootActorSeat: actorSeat, moveNumber: context.moveNumber, rules: context.rules, boardSize: context.boardSize, playerSymbols: context.playerSymbols, } } async function runSimulation( root: SearchNode, evaluator: PolicyValueEvaluator, options: PuctOptions, rng: SeededRandom, table?: PuctTranspositionTable, generation = 1, ): Promise { let node = root const path: SearchEdge[] = [] const pathNodes: SearchNode[] = [root] while (node.expanded && node.edges.length > 0 && node.state.terminalValue === undefined) { const edge = selectPuctEdge(node, options, rng) if (!edge.child) edge.child = createNode(nextState(node.state, edge.action), table, generation) path.push(edge) node = edge.child pathNodes.push(node) } const value = node.state.terminalValue ?? await expand(node, evaluator, options, rng) for (const edge of path) { edge.visits++ edge.totalValue += value edge.totalSquaredValue += value * value } for (const pathNode of pathNodes) writeNodeToTranspositionTable(pathNode, table, generation) } async function expand( node: SearchNode, evaluator: PolicyValueEvaluator, options: PuctOptions, rng: SeededRandom, ): Promise { if (node.expanded) return node.value if (node.state.terminalValue !== undefined) { node.value = node.state.terminalValue node.expanded = true return node.value } const actions = legalActions(node.state, options) const evaluation = await evaluator.evaluate(node.state, actions) const priors = normalizePriors(evaluation.priors.length === actions.length ? evaluation.priors : []) node.edges = actions.map((action, index) => ({ action, prior: priors[index] ?? 1 / Math.max(1, actions.length), visits: 0, totalValue: 0, totalSquaredValue: 0, })) if (node.edges.length > 1) { const jitter = rng.next() * 0.000001 node.edges[0].prior += jitter node.edges = normalizeEdgePriors(node.edges) } node.value = clamp(evaluation.value, -1, 1) node.expanded = true return node.value } function selectPuctEdge(node: SearchNode, options: PuctOptions, rng: SeededRandom): SearchEdge { const parentVisits = Math.max(1, node.edges.reduce((sum, edge) => sum + edge.visits, 0)) let bestScore = -Infinity let best: SearchEdge[] = [] for (const edge of node.edges) { const q = edge.visits > 0 ? edge.totalValue / edge.visits : 0 const actorQ = isRootActorTurn(node.state) ? q : -q const u = options.cPuct * edge.prior * Math.sqrt(parentVisits) / (1 + edge.visits) const score = actorQ + u if (score > bestScore) { bestScore = score best = [edge] } else if (score === bestScore) { best.push(edge) } } return rng.pick(best) } function selectRootEdge(edges: SearchEdge[], temperature: number, rng: SeededRandom): SearchEdge { if (edges.length === 0) { throw new Error('PUCT search has no legal root actions') } if (temperature <= 0.05) { return edges.reduce((best, edge) => edge.visits > best.visits ? edge : best, edges[0]) } return edges[weightedIndex(edges.map(edge => Math.pow(Math.max(edge.visits, 1), 1 / temperature)), rng)] } function legalActions(state: SearchState, options: PuctOptions): SearchAction[] { if (state.phase === 'swap2_phase1') { return [ { kind: 'special', decision: 'offer' }, { kind: 'special', decision: 'player1' }, { kind: 'special', decision: 'player2' }, ] } if (state.phase === 'swap2_phase2') { return [ { kind: 'special', decision: 'player1' }, { kind: 'special', decision: 'player2' }, ] } return generateCandidates(state.bitBoard, options.candidateRadius) .map(move => ({ kind: 'place' as const, x: move.x, y: move.y })) .sort((a, b) => centerPrior(b, state.boardSize) - centerPrior(a, state.boardSize) || a.y - b.y || a.x - b.x) .slice(0, options.maxCandidates) } function nextState(state: SearchState, action: SearchAction): SearchState { if (action.kind === 'special') return nextSpecialState(state, action.decision) const stoneColor = placementColor(state) const board = withMove(state.board, action, stoneColor, state.playerSymbols) const bitBoard = state.bitBoard.withMove(action.x, action.y, stoneColor) const terminal = terminalValueForPlacement(state, bitBoard, action, stoneColor) const next = nextPlacementState(state, board, bitBoard) return { ...next, terminalValue: terminal ?? next.terminalValue } } function nextSpecialState( state: SearchState, decision: Swap2Phase1Decision | Swap2ColorDecision, ): SearchState { if (state.phase === 'swap2_phase1') { if (decision === 'offer') { return { ...state, phase: 'offer_place_1', actorSeat: 1, stoneColor: 1, } } return { ...state, phase: 'play', actorSeat: 1, playerToMove: 1, seatColors: decision === 'player1' ? [1, 0] : [0, 1], } } return { ...state, phase: 'play', actorSeat: 1, playerToMove: 1, seatColors: decision === 'player1' ? [0, 1] : [1, 0], } } function nextPlacementState(state: SearchState, board: Map, bitBoard: BitBoard): SearchState { if (state.phase === 'play') { const nextPlayer = otherPlayer(state.playerToMove ?? 0) return { ...state, board, bitBoard, actorSeat: nextPlayer, playerToMove: nextPlayer, moveNumber: state.moveNumber + 1, } } if (state.phase === 'offer_place_1') { return { ...state, board, bitBoard, phase: 'offer_place_2', actorSeat: 1, stoneColor: 0, moveNumber: state.moveNumber + 1, } } if (state.phase === 'offer_place_2') { return { ...state, board, bitBoard, phase: 'swap2_phase2', actorSeat: 0, stoneColor: undefined, moveNumber: state.moveNumber + 1, } } if (state.phase === 'swap2_phase0' && state.moveNumber <= 2) { return { ...state, board, bitBoard, actorSeat: 0, stoneColor: state.moveNumber === 1 ? 1 : 0, moveNumber: state.moveNumber + 1, } } if (state.phase === 'swap2_phase0' && state.moveNumber === 3) { return { ...state, board, bitBoard, phase: 'swap2_phase1', actorSeat: 1, stoneColor: undefined, moveNumber: state.moveNumber + 1, } } if (state.phase === 'swap2_phase0' && state.moveNumber === 4) { return { ...state, board, bitBoard, phase: 'offer_place_2', actorSeat: 1, stoneColor: 0, moveNumber: state.moveNumber + 1, } } return { ...state, board, bitBoard, phase: 'swap2_phase2', actorSeat: 0, stoneColor: undefined, moveNumber: state.moveNumber + 1, } } function placementColor(state: SearchState): PlayerColor { if (state.phase === 'play') return state.playerToMove ?? state.actorSeat return state.stoneColor ?? state.actorSeat } function terminalValueForPlacement( state: SearchState, bitBoard: BitBoard, move: { x: number; y: number }, stoneColor: PlayerColor, ): number | null { if (bitBoardCheckWin(bitBoard, move.x, move.y, stoneColor, state.rules.noOverlines)) { return valueForWinner(state, stoneColor) } return bitBoard.occupiedCount >= state.boardSize * state.boardSize ? 0 : null } function valueForWinner(state: SearchState, winner: PlayerColor): number { const rootColor = rootActorColor(state) if (rootColor === undefined) return winner === state.rootActorSeat ? 1 : -1 return winner === rootColor ? 1 : -1 } function evaluateState(state: SearchState): number { const rootColor = rootActorColor(state) ?? fallbackRootColor(state) const features = encodeFeaturePlanes({ board: state.board, boardSize: state.boardSize, rules: state.rules, currentPlayer: rootColor, phase: state.phase, }) const cells = state.boardSize * state.boardSize let ownThreats = 0 let opponentThreats = 0 let overlineTraps = 0 for (let i = 0; i < cells; i++) { ownThreats += features[CH_CURRENT_THREATS * cells + i] opponentThreats += features[CH_OPPONENT_THREATS * cells + i] overlineTraps += features[CH_OVERLINE_TRAPS * cells + i] } const lineScore = linePotential(state.board, rootColor, state) - linePotential(state.board, otherPlayer(rootColor), state) * 1.05 const tempoScore = earlyTempoScore(state, rootColor) return Math.tanh((lineScore + tempoScore + ownThreats * 35_000 - opponentThreats * 45_000 - overlineTraps * 20_000) / 100_000) } function rootActorColor(state: SearchState): PlayerColor | undefined { return state.seatColors?.[state.rootActorSeat] } export function valueSignForRootActor(state: SearchState, valueActor: PlayerColor): 1 | -1 { const rootColor = rootActorColor(state) ?? fallbackRootColor(state) return valueActor === rootColor ? 1 : -1 } function isRootActorTurn(state: SearchState): boolean { const rootColor = rootActorColor(state) if (rootColor !== undefined && state.playerToMove !== undefined) return state.playerToMove === rootColor return state.actorSeat === state.rootActorSeat } function metadataFromState(state: SearchState): SearchStateMetadata { return { actorSeat: state.actorSeat, ...(rootActorColor(state) !== undefined ? { actorColor: rootActorColor(state) } : {}), ...(state.stoneColor !== undefined ? { stoneColor: state.stoneColor } : {}), ...(state.playerToMove !== undefined ? { playerToMove: state.playerToMove } : {}), } } function fallbackRootColor(state: SearchState): PlayerColor { if (state.stoneColor !== undefined) return state.stoneColor return state.rootActorSeat } function earlyTempoScore(state: SearchState, rootColor: PlayerColor): number { if (state.phase !== 'play' || state.playerToMove === undefined || state.moveNumber > 6) return 0 const gain = bestImmediateMoveGain(state, state.playerToMove) * 2.5 return state.playerToMove === rootColor ? gain : -gain } function actionPrior(state: SearchState, action: SearchAction): number { if (action.kind === 'special') { const scores = specialActionScores(state) if (action.decision === 'offer') return scores.offer ?? 1 if (action.decision === 'player1') return scores.player1 ?? 1 return scores.player2 ?? 1 } const color = placementColor(state) const next = withMove(state.board, action, color, state.playerSymbols) const ownWin = checkWin(next, action.x, action.y, String(color), state.rules.noOverlines).won if (ownWin) return 1_000_000 const opponent = otherPlayer(color) const blockBoard = withMove(state.board, action, opponent, state.playerSymbols) const blocksWin = checkWin(blockBoard, action.x, action.y, String(opponent), state.rules.noOverlines).won if (blocksWin) return 900_000 return Math.max(1, linePotential(next, color, state) - linePotential(state.board, color, state) + centerPrior(action, state.boardSize)) } function specialActionScores(state: SearchState): Partial> { if (state.phase === 'swap2_phase1') { const player1 = swap2DecisionUtility(state, 'player1') const player2 = swap2DecisionUtility(state, 'player2') return positiveSpecialScores({ player1, player2, offer: Math.min(player1, player2) + Math.abs(player1 - player2) * 0.35, }) } return positiveSpecialScores({ player1: swap2DecisionUtility(state, 'player1'), player2: swap2DecisionUtility(state, 'player2'), }) } function swap2DecisionUtility( state: SearchState, decision: Swap2Phase1Decision | Swap2ColorDecision, ): number { const next = nextSpecialState(state, decision) const actorColor = rootActorColor(next) ?? fallbackRootColor(next) const opponent = otherPlayer(actorColor) const actorLineScore = linePotential(state.board, actorColor, state) const opponentLineScore = linePotential(state.board, opponent, state) const nextMover = next.playerToMove const nextMoveGain = nextMover === undefined ? 0 : bestImmediateMoveGain(state, nextMover) const tempoScore = nextMover === actorColor ? nextMoveGain * 2.5 : -nextMoveGain * 2.5 const threatScore = maxThreatDanger(state, actorColor) - maxThreatDanger(state, opponent) * 1.1 const mobilityScore = mobilityPotential(state, actorColor) - mobilityPotential(state, opponent) * 1.05 return ( actorLineScore - opponentLineScore * 1.05 + tempoScore + threatScore * SWAP2_THREAT_WEIGHT + mobilityScore * SWAP2_MOBILITY_WEIGHT ) } function bestImmediateMoveGain(state: SearchState, color: PlayerColor): number { let best = 0 for (const move of generateCandidates(state.bitBoard, 2).slice(0, 24)) { const next = withMove(state.board, move, color, state.playerSymbols) if (checkWin(next, move.x, move.y, String(color), state.rules.noOverlines).won) return 1_000_000 best = Math.max(best, linePotential(next, color, state) - linePotential(state.board, color, state) + centerPrior(move, state.boardSize)) } return best } function maxThreatDanger(state: SearchState, color: PlayerColor): number { let danger = 0 for (const move of generateCandidates(state.bitBoard, 2).slice(0, 24)) { const next = withMove(state.board, move, color, state.playerSymbols) if (checkWin(next, move.x, move.y, String(color), state.rules.noOverlines).won) return 1_000_000 danger = Math.max(danger, linePotential(next, color, state) - linePotential(state.board, color, state)) } return danger } function mobilityPotential(state: SearchState, color: PlayerColor): number { let mobility = 0 for (const move of generateCandidates(state.bitBoard, 2).slice(0, 24)) { const next = withMove(state.board, move, color, state.playerSymbols) const gain = linePotential(next, color, state) - linePotential(state.board, color, state) if (gain >= 20_000) mobility += 6 else if (gain >= 1_000) mobility += 2 else if (gain > 0) mobility += 0.5 } return mobility } function positiveSpecialScores(scores: Record): Record { const values = Object.values(scores) as number[] const min = Math.min(...values) const shifted = {} as Record for (const [key, value] of Object.entries(scores) as [T, number][]) { shifted[key] = Math.max(1, value - min + 1) } return shifted } function centerPrior(move: { x: number; y: number }, boardSize: number): number { const center = (boardSize - 1) / 2 return Math.max(1, boardSize - Math.abs(move.x - center) - Math.abs(move.y - center)) } function policyFromEdges(edges: SearchEdge[]): PolicyTarget { const placements = edges.filter(edge => edge.action.kind === 'place') if (placements.length > 0) { const total = placements.reduce((sum, edge) => sum + edge.visits, 0) return { placements: placements.map(edge => { const action = edge.action as Extract return { x: action.x, y: action.y, probability: total > 0 ? edge.visits / total : 1 / placements.length, } }), } } const total = edges.reduce((sum, edge) => sum + edge.visits, 0) const specialActions: NonNullable = {} for (const edge of edges) { if (edge.action.kind !== 'special') continue const probability = total > 0 ? edge.visits / total : 1 / edges.length if (edge.action.decision === 'offer') specialActions.offerTwo = probability if (edge.action.decision === 'player1') specialActions.choosePlayer1 = probability if (edge.action.decision === 'player2') specialActions.choosePlayer2 = probability } return { placements: [], specialActions } } function rootStatsFromEdges(edges: SearchEdge[]): PuctRootActionStats[] { return edges.map(edge => { const meanValue = edge.visits > 0 ? edge.totalValue / edge.visits : 0 const variance = edge.visits > 0 ? Math.max(0, edge.totalSquaredValue / edge.visits - meanValue * meanValue) : 0 return { action: edge.action, visits: edge.visits, totalValue: edge.totalValue, totalSquaredValue: edge.totalSquaredValue, meanValue, variance, standardError: edge.visits > 0 ? Math.sqrt(variance) / Math.sqrt(edge.visits) : 0, } }) } function generateCandidates(board: BitBoard, radius: number): { x: number; y: number }[] { if (board.occupiedCount === 0) { const boardSize = board.boardSize const center = Math.floor(boardSize / 2) return [{ x: center, y: center }] } const candidates = new Map() for (let y = 0; y < board.boardSize; y++) { for (let x = 0; x < board.boardSize; x++) { if (!board.has(x, y)) continue for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { const nx = x + dx const ny = y + dy const nKey = serializeCoord(nx, ny) if (!isBoardCoordInBounds(nx, ny, board.boardSize) || board.has(nx, ny)) continue candidates.set(nKey, { x: nx, y: ny }) } } } } if (candidates.size === 0) { for (let y = 0; y < board.boardSize; y++) { for (let x = 0; x < board.boardSize; x++) { const key = serializeCoord(x, y) if (!board.has(x, y)) candidates.set(key, { x, y }) } } } return [...candidates.values()].sort((a, b) => a.y - b.y || a.x - b.x) } function withMove( board: Map, move: { x: number; y: number }, player: PlayerColor, symbols: [string, string], ): Map { const next = new Map(board) next.set(serializeCoord(move.x, move.y), { playerId: String(player), symbol: symbols[player], timestamp: 0, }) return next } function linePotential(board: Map, player: PlayerColor, state: Pick): number { const directions: readonly [number, number][] = [[1, 0], [0, 1], [1, 1], [1, -1]] let score = 0 const playerId = String(player) const center = (state.boardSize - 1) / 2 for (const [key, cell] of board) { if (cell.playerId !== playerId) continue const { x, y } = deserializeCoord(key) score += Math.max(0, state.boardSize - Math.abs(x - center) - Math.abs(y - center)) * 4 for (const [dx, dy] of directions) { let count = 1 let openEnds = 0 let cx = x + dx let cy = y + dy while (board.get(serializeCoord(cx, cy))?.playerId === playerId) { count++ cx += dx cy += dy } if (isBoardCoordInBounds(cx, cy, state.boardSize) && !board.has(serializeCoord(cx, cy))) openEnds++ cx = x - dx cy = y - dy while (board.get(serializeCoord(cx, cy))?.playerId === playerId) { count++ cx -= dx cy -= dy } if (isBoardCoordInBounds(cx, cy, state.boardSize) && !board.has(serializeCoord(cx, cy))) openEnds++ if (state.rules.noOverlines && count > 5) continue score += Math.pow(10, Math.min(count, 5)) * (openEnds + 1) } } return score } function normalizePriors(values: number[]): number[] { if (values.length === 0) return [] const clean = values.map(value => Number.isFinite(value) && value > 0 ? value : 0) const total = clean.reduce((sum, value) => sum + value, 0) if (total <= 0) return values.map(() => 1 / values.length) return clean.map(value => value / total) } function normalizeEdgePriors(edges: SearchEdge[]): SearchEdge[] { const priors = normalizePriors(edges.map(edge => edge.prior)) return edges.map((edge, index) => ({ ...edge, prior: priors[index] })) } function otherPlayer(player: PlayerColor): PlayerColor { return player === 0 ? 1 : 0 } function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)) }