import { BOARD_SIZE, deserializeCoord, isBoardCoordInBounds, serializeCoord } from '../board.ts' import { checkWin } from '../checkWin.ts' import type { Cell, GameRules } from '../types.ts' import { SeededRandom, weightedIndex } from '../random.ts' import type { MctsTarget, PlayerColor, PolicyTarget, Swap2ColorDecision, Swap2Phase, Swap2Phase1Decision, } from '../dataset/types.ts' import { MinimaxAlphaBetaBot } from './MinimaxAlphaBetaBot.ts' import { runPuctSearch } from './PuctSearch.ts' import type { PolicyValueEvaluator, PuctRootActionStats } from './PuctSearch.ts' import type { PuctTranspositionTable } from './PuctTranspositionTable.ts' export interface BotMove { x: number y: number } export interface DatasetBotContext { board: Record currentPlayer: PlayerColor playerSymbols: [string, string] rules: GameRules boardSize: number phase: Swap2Phase moveNumber: number } export interface BotMoveDecision { move: BotMove mctsTarget?: MctsTarget } export interface DatasetBot { id: string params: Record selectMove(context: DatasetBotContext, rng: SeededRandom): Promise chooseSwap2Phase1(context: DatasetBotContext, rng: SeededRandom): Promise<{ decision: Swap2Phase1Decision; mctsTarget?: MctsTarget }> chooseSwap2Phase2(context: DatasetBotContext, rng: SeededRandom): Promise<{ decision: Swap2ColorDecision; mctsTarget?: MctsTarget }> } export interface DatasetBotOptions { puctTranspositionTable?: PuctTranspositionTable puctGeneration?: number modelEvaluator?: PolicyValueEvaluator } interface MctsOptions { simulations: number rolloutDepth: number exploration: number candidateRadius: number maxCandidates?: number earlyTemperature: number lateTemperature: number } const PLAYER_SYMBOLS: [string, string] = ['X', 'O'] const BOT_IDS = [ 'random', 'greedy', 'minimax_weak', 'minimax_balanced', 'minimax_strong', 'mcts_weak', 'mcts_balanced', 'mcts_strong', 'mcts_model_guided', ] as const const THREAT_WIN = 1_000_000 const THREAT_OPEN_FOUR = 120_000 const THREAT_CLOSED_FOUR = 80_000 const THREAT_BROKEN_FOUR = 70_000 const THREAT_OPEN_THREE = 20_000 const SWAP2_OFFER_VALUE_MARGIN = 0.08 const SWAP2_OFFER_UNCERTAIN_MARGIN = 0.16 const SWAP2_OFFER_STANDARD_ERROR = 0.1 const SWAP2_OFFER_MIN_VISITS = 4 const SWAP2_UTILITY_SCALE = 10_000 const SWAP2_MOBILITY_WEIGHT = 350 const SWAP2_THREAT_WEIGHT = 0.75 const SWAP2_NEUTRAL_OPENING_LOOKAHEAD = 32 const SWAP2_NEUTRAL_OPENING_POLICY_SIZE = 8 const SELF_PLAY_BOT_IDS = [ 'minimax_balanced', 'minimax_strong', 'mcts_balanced', 'mcts_strong', ] as const export type DatasetBotId = typeof BOT_IDS[number] export function getDatasetBotIds(): DatasetBotId[] { return [...BOT_IDS] } export function getSelfPlayBotIds(): DatasetBotId[] { return [...SELF_PLAY_BOT_IDS] } export function createDatasetBot(id: DatasetBotId, options: DatasetBotOptions = {}): DatasetBot { if (id === 'random') return new RandomBot() if (id === 'greedy') return new GreedyBot() if (id === 'minimax_weak') return new MinimaxDatasetBot(id, { maxDepth: 1, timeBudgetMs: 80, candidateRadius: 1, panicPly: 1 }) if (id === 'minimax_balanced') return new MinimaxDatasetBot(id, { maxDepth: 2, timeBudgetMs: 180, candidateRadius: 2, panicPly: 1 }) if (id === 'minimax_strong') return new MinimaxDatasetBot(id, { maxDepth: 3, timeBudgetMs: 320, candidateRadius: 2, panicPly: 2 }) if (id === 'mcts_weak') return new MctsDatasetBot(id, { simulations: 48, rolloutDepth: 12, exploration: 1.4, candidateRadius: 1, earlyTemperature: 1, lateTemperature: 0.15 }, options) if (id === 'mcts_balanced') return new MctsDatasetBot(id, { simulations: 96, rolloutDepth: 18, exploration: 1.2, candidateRadius: 2, earlyTemperature: 0.75, lateTemperature: 0.08 }, options) if (id === 'mcts_strong') return new MctsDatasetBot(id, { simulations: 192, rolloutDepth: 24, exploration: 1.05, candidateRadius: 2, earlyTemperature: 0.6, lateTemperature: 0.03 }, options) return new MctsDatasetBot(id, { simulations: 96, rolloutDepth: 0, exploration: 1.2, candidateRadius: 2, maxCandidates: 24, earlyTemperature: 0.75, lateTemperature: 0.08 }, options) } function minimaxAttitude(id: DatasetBotId): string { if (id === 'minimax_weak') return 'shallow_tactical' if (id === 'minimax_strong') return 'deep_defensive' return 'balanced_tactical' } function mctsAttitude(id: DatasetBotId): string { if (id === 'mcts_weak') return 'exploratory_rollout' if (id === 'mcts_strong') return 'low_noise_search' if (id === 'mcts_model_guided') return 'model_guided_search' return 'balanced_search' } class RandomBot implements DatasetBot { readonly id = 'random' readonly params = { strategy: 'random', attitude: 'exploratory', noise: 1, aggressionWeight: 0.4, defensiveBias: 0.35 } async selectMove(context: DatasetBotContext, rng: SeededRandom): Promise { return { move: rng.pick(generateCandidates(toMap(context.board), context.boardSize, 2)) } } async chooseSwap2Phase1(_context: DatasetBotContext, rng: SeededRandom): Promise<{ decision: Swap2Phase1Decision }> { return { decision: rng.pick(['offer', 'player1', 'player2'] as const) } } async chooseSwap2Phase2(_context: DatasetBotContext, rng: SeededRandom): Promise<{ decision: Swap2ColorDecision }> { return { decision: rng.pick(['player1', 'player2'] as const) } } } class GreedyBot implements DatasetBot { readonly id = 'greedy' readonly params = { strategy: 'greedy', attitude: 'aggressive', noise: 0.35, aggressionWeight: 0.8, defensiveBias: 0.45 } async selectMove(context: DatasetBotContext, rng: SeededRandom): Promise { const board = toMap(context.board) const candidates = generateCandidates(board, context.boardSize, 2) let best = candidates[0] let bestScore = -Infinity for (const move of candidates) { const score = scoreMove(board, move, context.currentPlayer, context) if (score > bestScore || (score === bestScore && rng.next() < 0.35)) { best = move bestScore = score } } return { move: best } } async chooseSwap2Phase1(context: DatasetBotContext, rng: SeededRandom): Promise<{ decision: Swap2Phase1Decision }> { return { decision: choosePhase1Heuristic(toMap(context.board), context, rng, 0.12) } } async chooseSwap2Phase2(context: DatasetBotContext, rng: SeededRandom): Promise<{ decision: Swap2ColorDecision }> { return { decision: choosePhase2Heuristic(toMap(context.board), context, rng, 0.1) } } } class MinimaxDatasetBot implements DatasetBot { readonly id: DatasetBotId readonly params: Record private readonly bot: MinimaxAlphaBetaBot constructor(id: DatasetBotId, options: { maxDepth: number; timeBudgetMs: number; candidateRadius: number; panicPly?: number }) { this.id = id this.params = { strategy: 'minimax', attitude: minimaxAttitude(id), aggressionWeight: 0.65, defensiveBias: 0.85, ...options } this.bot = new MinimaxAlphaBetaBot({ ...options, enableThreatSpaceSearch: false }) } async selectMove(context: DatasetBotContext, rng: SeededRandom): Promise { if (context.phase === 'swap2_phase0') { const board = toMap(context.board) const candidates = generateCandidates(board, context.boardSize, 2) const ranked = rankNeutralOpeningMovesNoLookahead(board, candidates, context) const selected = chooseNeutralOpeningMove(ranked, rng) return { move: selected.move } } return { move: this.bot.selectMove({ board: context.board, currentPlayer: context.currentPlayer, playerSymbols: context.playerSymbols, rules: context.rules, boardSize: context.boardSize, }), } } async chooseSwap2Phase1(context: DatasetBotContext, rng: SeededRandom): Promise<{ decision: Swap2Phase1Decision }> { return { decision: choosePhase1Heuristic(toMap(context.board), context, rng, 0.06) } } async chooseSwap2Phase2(context: DatasetBotContext, rng: SeededRandom): Promise<{ decision: Swap2ColorDecision }> { return { decision: choosePhase2Heuristic(toMap(context.board), context, rng, 0.04) } } } class MctsDatasetBot implements DatasetBot { readonly id: DatasetBotId readonly params: Record private readonly options: MctsOptions private readonly botOptions: DatasetBotOptions constructor(id: DatasetBotId, options: MctsOptions, botOptions: DatasetBotOptions) { this.id = id this.options = options this.botOptions = botOptions this.params = { strategy: 'mcts', attitude: mctsAttitude(id), aggressionWeight: 0.6, defensiveBias: 0.75, ...options } } async selectMove(context: DatasetBotContext, rng: SeededRandom): Promise { const board = toMap(context.board) const candidates = generateCandidates(board, context.boardSize, this.options.candidateRadius) const temperature = this.temperatureForMove(context.moveNumber) if (context.phase === 'swap2_phase0') { return neutralSwap2OpeningDecision(this.id, this.params, context, board, candidates, temperature, rng) } const ownWins = immediateWinningMoves(board, candidates, context.currentPlayer, context) if (ownWins.length > 0) { return tacticalMctsDecision(this.id, this.params, context, ownWins[0], temperature, 1) } const opponentWins = immediateWinningMoves(board, candidates, otherPlayer(context.currentPlayer), context) if (opponentWins.length > 0) { return tacticalMctsDecision(this.id, this.params, context, opponentWins[0], temperature, opponentWins.length === 1 ? 0 : -1) } const defense = findDefensiveThreatMove(board, candidates, context.currentPlayer, context, rng) if (defense) { return tacticalMctsDecision(this.id, this.params, context, defense, temperature, 0) } const search = await runPuctSearch(context, this.puctOptions(), temperature, rng, this.botOptions.modelEvaluator) const move = search.selectedMove ?? candidates[0] return { move, mctsTarget: { phase: context.phase, moveNumber: context.moveNumber, actorBotId: this.id, actorBotParams: this.params, player: context.currentPlayer, ...search.metadata, selectedMove: move, policyTarget: search.policyTarget, mctsValue: search.mctsValue, temperature, }, } } async chooseSwap2Phase1(context: DatasetBotContext, rng: SeededRandom): Promise<{ decision: Swap2Phase1Decision; mctsTarget: MctsTarget }> { const temperature = this.temperatureForMove(context.moveNumber) const board = toMap(context.board) const uncertaintySearch = await runPuctSearch(context, this.puctOptions(), Math.min(temperature, 0.35), rng, this.botOptions.modelEvaluator) const decision = choosePhase1MctsDecision(board, context, uncertaintySearch.rootActionStats) return { decision, mctsTarget: { phase: 'swap2_phase1', actorBotId: this.id, actorBotParams: this.params, player: context.currentPlayer, ...uncertaintySearch.metadata, decision, policyTarget: uncertaintySearch.policyTarget, mctsValue: uncertaintySearch.mctsValue, temperature, }, } } async chooseSwap2Phase2(context: DatasetBotContext, rng: SeededRandom): Promise<{ decision: Swap2ColorDecision; mctsTarget: MctsTarget }> { const temperature = this.temperatureForMove(context.moveNumber) const search = await runPuctSearch(context, this.puctOptions(), Math.min(temperature, 0.25), rng, this.botOptions.modelEvaluator) const decision = search.decision as Swap2ColorDecision | undefined ?? choosePhase2MctsDecision(toMap(context.board), context, rng) return { decision, mctsTarget: { phase: 'swap2_phase2', actorBotId: this.id, actorBotParams: this.params, player: context.currentPlayer, ...search.metadata, decision, policyTarget: search.policyTarget, mctsValue: search.mctsValue, temperature, }, } } private temperatureForMove(moveNumber: number): number { return moveNumber < 24 ? this.options.earlyTemperature : this.options.lateTemperature } private puctOptions(): { simulations: number cPuct: number candidateRadius: number maxCandidates: number transpositionTable?: PuctTranspositionTable generation: number } { return { simulations: this.options.simulations, cPuct: this.options.exploration, candidateRadius: this.options.candidateRadius, maxCandidates: this.options.maxCandidates ?? 24, transpositionTable: this.botOptions.puctTranspositionTable, generation: this.botOptions.puctGeneration ?? 1, } } } export interface Swap2UncertaintyOfferOptions { valueMargin: number uncertainMargin: number standardError: number minVisits: number } export const DEFAULT_SWAP2_UNCERTAINTY_OFFER: Swap2UncertaintyOfferOptions = { valueMargin: SWAP2_OFFER_VALUE_MARGIN, uncertainMargin: SWAP2_OFFER_UNCERTAIN_MARGIN, standardError: SWAP2_OFFER_STANDARD_ERROR, minVisits: SWAP2_OFFER_MIN_VISITS, } export function shouldOfferSwap2FromUncertainty( stats: PuctRootActionStats[], options: Swap2UncertaintyOfferOptions = DEFAULT_SWAP2_UNCERTAINTY_OFFER, ): boolean { const player1 = stats.find(stat => stat.action.kind === 'special' && stat.action.decision === 'player1') const player2 = stats.find(stat => stat.action.kind === 'special' && stat.action.decision === 'player2') if (!player1 || !player2) return false if (player1.visits < options.minVisits || player2.visits < options.minVisits) return false const margin = Math.abs(player1.meanValue - player2.meanValue) if (margin <= options.valueMargin) return true const uncertainty = Math.max(player1.standardError, player2.standardError) return margin <= options.uncertainMargin && uncertainty >= options.standardError } function neutralSwap2OpeningDecision( botId: string, botParams: Record, context: DatasetBotContext, board: Map, candidates: BotMove[], temperature: number, rng: SeededRandom, ): BotMoveDecision { const ranked = rankNeutralOpeningMoves(board, candidates, context) const selected = chooseNeutralOpeningMove(ranked, rng) return { move: selected.move, mctsTarget: { phase: context.phase, moveNumber: context.moveNumber, actorBotId: botId, actorBotParams: { ...botParams, openingObjective: 'swap2_neutral' }, player: context.currentPlayer, ...targetMetadataFromContext(context), selectedMove: selected.move, policyTarget: neutralOpeningPolicy(ranked, context.boardSize), mctsValue: 0, temperature, }, } } export function rankNeutralOpeningMovesNoLookahead( board: Map, candidates: BotMove[], context: DatasetBotContext, ): { move: BotMove; imbalance: number }[] { if (board.size === 0) { const center = Math.floor(context.boardSize / 2) return [{ move: { x: center, y: center }, imbalance: 0 }] } return candidates .map(move => { const nextBoard = withMove(board, move, context.currentPlayer) const utilities = rawSwap2ColorUtilities(nextBoard, context) const imbalance = Math.abs(normalizeSwap2Utility(utilities.player1 - utilities.player2)) return { move, imbalance } }) .sort((a, b) => a.imbalance - b.imbalance || centerDistance(a.move, context.boardSize) - centerDistance(b.move, context.boardSize) || a.move.y - b.move.y || a.move.x - b.move.x) } export function rankNeutralOpeningMoves( board: Map, candidates: BotMove[], context: DatasetBotContext, ): { move: BotMove; imbalance: number }[] { if (board.size === 0) { const center = Math.floor(context.boardSize / 2) return [{ move: { x: center, y: center }, imbalance: 0 }] } return candidates .map(move => ({ move, imbalance: neutralOpeningImbalance(withMove(board, move, context.currentPlayer), context), })) .sort((a, b) => a.imbalance - b.imbalance || centerDistance(a.move, context.boardSize) - centerDistance(b.move, context.boardSize) || a.move.y - b.move.y || a.move.x - b.move.x) } function neutralOpeningImbalance(board: Map, context: DatasetBotContext): number { if (board.size >= 3) { const utilities = rawSwap2ColorUtilities(board, context) return Math.abs(normalizeSwap2Utility(utilities.player1 - utilities.player2)) } const nextColor: PlayerColor = board.size === 1 ? 1 : 0 const nextCandidates = generateCandidates(board, context.boardSize, Math.max(2, context.boardSize > 15 ? 2 : 1)) .slice(0, SWAP2_NEUTRAL_OPENING_LOOKAHEAD) if (nextCandidates.length === 0) return 1 return Math.min(...nextCandidates.map(move => neutralOpeningImbalance(withMove(board, move, nextColor), context))) } export function chooseNeutralOpeningMove( ranked: { move: BotMove; imbalance: number }[], rng: SeededRandom, ): { move: BotMove; imbalance: number } { const pool = ranked.slice(0, Math.min(SWAP2_NEUTRAL_OPENING_POLICY_SIZE, ranked.length)) const min = Math.min(...pool.map(entry => entry.imbalance)) const weights = pool.map(entry => Math.exp(-(entry.imbalance - min) / 0.05)) return pool[weightedIndex(weights, rng)] ?? ranked[0] } function neutralOpeningPolicy(ranked: { move: BotMove; imbalance: number }[], boardSize: number): PolicyTarget { const pool = ranked.slice(0, Math.min(SWAP2_NEUTRAL_OPENING_POLICY_SIZE, ranked.length)) const min = Math.min(...pool.map(entry => entry.imbalance)) const weights = pool.map(entry => Math.exp(-(entry.imbalance - min) / 0.05)) const total = weights.reduce((sum, weight) => sum + weight, 0) return { placements: pool.map((entry, index) => ({ x: entry.move.x, y: entry.move.y, probability: total > 0 ? weights[index] / total : 1 / Math.max(1, pool.length), })).filter(placement => isBoardCoordInBounds(placement.x, placement.y, boardSize)), } } function centerDistance(move: BotMove, boardSize: number): number { const center = (boardSize - 1) / 2 return Math.abs(move.x - center) + Math.abs(move.y - center) } function toMap(board: Record): Map { return new Map(Object.entries(board)) } function otherPlayer(player: PlayerColor): PlayerColor { return player === 0 ? 1 : 0 } export function generateCandidates(board: Map, boardSize: number, radius: number): BotMove[] { if (board.size === 0) { const center = Math.floor(boardSize / 2) return [{ x: center, y: center }] } const candidates = new Map() for (const key of board.keys()) { const { x, y } = deserializeCoord(key) 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, boardSize) || board.has(nKey)) continue candidates.set(nKey, { x: nx, y: ny }) } } } if (candidates.size === 0) { for (let y = 0; y < boardSize; y++) { for (let x = 0; x < boardSize; x++) { const key = serializeCoord(x, y) if (!board.has(key)) candidates.set(key, { x, y }) } } } return [...candidates.values()].sort((a, b) => a.y - b.y || a.x - b.x) } function withMove(board: Map, move: BotMove, player: PlayerColor): Map { const next = new Map(board) next.set(serializeCoord(move.x, move.y), { playerId: String(player), symbol: PLAYER_SYMBOLS[player], timestamp: 0, }) return next } function immediateWinningMoves( board: Map, candidates: BotMove[], player: PlayerColor, context: DatasetBotContext, ): BotMove[] { return candidates.filter(move => { const next = withMove(board, move, player) return checkWin(next, move.x, move.y, String(player), context.rules.noOverlines).won }) } function tacticalMctsDecision( botId: DatasetBotId, botParams: Record, context: DatasetBotContext, move: BotMove, temperature: number, value: number, ): BotMoveDecision { return { move, mctsTarget: { phase: context.phase, moveNumber: context.moveNumber, actorBotId: botId, actorBotParams: botParams, player: context.currentPlayer, ...targetMetadataFromContext(context), selectedMove: move, policyTarget: { placements: [{ ...move, probability: 1 }] }, mctsValue: value, temperature, }, } } function targetMetadataFromContext(context: DatasetBotContext): Pick { if (context.phase === 'play') { return { actorSeat: context.currentPlayer, actorColor: context.currentPlayer, stoneColor: context.currentPlayer, playerToMove: context.currentPlayer, } } if (context.phase === 'swap2_phase1') return { actorSeat: 1 } if (context.phase === 'swap2_phase2') return { actorSeat: 0 } return { actorSeat: context.moveNumber <= 3 ? 0 : 1, stoneColor: context.currentPlayer, } } function findDefensiveThreatMove( board: Map, candidates: BotMove[], player: PlayerColor, context: DatasetBotContext, rng: SeededRandom, ): BotMove | null { const opponent = otherPlayer(player) const currentDanger = maxThreatDanger(board, opponent, context) if (currentDanger < THREAT_OPEN_THREE) return null let best: BotMove | null = null let bestDanger = Infinity let bestOwnScore = -Infinity for (const move of candidates) { const next = withMove(board, move, player) const danger = maxThreatDanger(next, opponent, context) const ownScore = scoreMove(next, move, player, context) if ( danger < bestDanger || (danger === bestDanger && ownScore > bestOwnScore) || (danger === bestDanger && ownScore === bestOwnScore && rng.next() < 0.25) ) { best = move bestDanger = danger bestOwnScore = ownScore } } return best && bestDanger < currentDanger ? best : null } export function maxThreatDanger(board: Map, player: PlayerColor, context: DatasetBotContext): number { let danger = 0 for (const move of generateCandidates(board, context.boardSize, 2)) { danger = Math.max(danger, threatDangerForMove(board, move, player, context)) if (danger >= THREAT_WIN) return danger } return danger } function threatDangerForMove( board: Map, move: BotMove, player: PlayerColor, context: DatasetBotContext, ): number { const next = withMove(board, move, player) if (checkWin(next, move.x, move.y, String(player), context.rules.noOverlines).won) return THREAT_WIN let danger = 0 for (const [dx, dy] of [[1, 0], [0, 1], [1, 1], [1, -1]] as const) { const line = threatLineStats(board, move, player, dx, dy, context.boardSize) if (line.count === 4 && line.openEnds === 2) danger = Math.max(danger, THREAT_OPEN_FOUR) else if (line.count === 4 && line.openEnds === 1) danger = Math.max(danger, THREAT_CLOSED_FOUR) else if (isBrokenFourThreat(board, move, player, dx, dy, context.boardSize)) danger = Math.max(danger, THREAT_BROKEN_FOUR) else if (line.count === 3 && line.openEnds === 2) danger = Math.max(danger, THREAT_OPEN_THREE) } return danger } function threatLineStats( board: Map, move: BotMove, player: PlayerColor, dx: number, dy: number, boardSize: number, ): { count: number; openEnds: number } { const playerId = String(player) let count = 1 let openEnds = 0 let cx = move.x + dx let cy = move.y + dy while (board.get(serializeCoord(cx, cy))?.playerId === playerId) { count++ cx += dx cy += dy } if (isBoardCoordInBounds(cx, cy, boardSize) && !board.has(serializeCoord(cx, cy))) openEnds++ cx = move.x - dx cy = move.y - dy while (board.get(serializeCoord(cx, cy))?.playerId === playerId) { count++ cx -= dx cy -= dy } if (isBoardCoordInBounds(cx, cy, boardSize) && !board.has(serializeCoord(cx, cy))) openEnds++ return { count, openEnds } } function isBrokenFourThreat( board: Map, move: BotMove, player: PlayerColor, dx: number, dy: number, boardSize: number, ): boolean { const playerId = String(player) for (let start = -4; start <= 0; start++) { let own = 0 let empty = 0 let blocked = false for (let i = 0; i < 5; i++) { const x = move.x + dx * (start + i) const y = move.y + dy * (start + i) if (!isBoardCoordInBounds(x, y, boardSize)) { blocked = true break } const isMove = x === move.x && y === move.y const cell = isMove ? { playerId } as Cell : board.get(serializeCoord(x, y)) if (!cell) empty++ else if (cell.playerId === playerId) own++ else blocked = true } if (!blocked && own === 4 && empty === 1) return true } return false } function scoreMove(board: Map, move: BotMove, player: PlayerColor, context: DatasetBotContext): number { const next = withMove(board, move, player) if (checkWin(next, move.x, move.y, String(player), context.rules.noOverlines).won) return 1_000_000 if (checkWin(withMove(board, move, otherPlayer(player)), move.x, move.y, String(otherPlayer(player)), context.rules.noOverlines).won) return 900_000 return evaluateBoard(next, player, context) } function evaluateBoard(board: Map, player: PlayerColor, context: DatasetBotContext): number { const opponent = otherPlayer(player) return linePotential(board, player, context) - linePotential(board, opponent, context) * 1.05 } function linePotential(board: Map, player: PlayerColor, context: DatasetBotContext): number { const directions: readonly [number, number][] = [[1, 0], [0, 1], [1, 1], [1, -1]] let score = 0 const playerId = String(player) const center = (context.boardSize - 1) / 2 for (const [key, cell] of board) { if (cell.playerId !== playerId) continue const { x, y } = deserializeCoord(key) score += Math.max(0, context.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, context.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, context.boardSize) && !board.has(serializeCoord(cx, cy))) openEnds++ if (context.rules.noOverlines && count > 5) continue score += Math.pow(10, Math.min(count, 5)) * (openEnds + 1) } } return score } function phase1Scores(board: Map, context: DatasetBotContext): Record { const { player1, player2 } = rawSwap2ColorUtilities(board, context) return positiveSpecialScores({ player1, player2, offer: Math.min(player1, player2) + Math.abs(player1 - player2) * 0.35, }) } function phase2Scores(board: Map, context: DatasetBotContext): Record { const { player1, player2 } = rawSwap2ColorUtilities(board, context) return positiveSpecialScores({ player1, player2, }) } function choosePhase1MctsDecision( board: Map, context: DatasetBotContext, rootStats: PuctRootActionStats[], ): Swap2Phase1Decision { const utilities = rawSwap2ColorUtilities(board, context) const delta = utilities.player1 - utilities.player2 const margin = Math.abs(normalizeSwap2Utility(delta)) if (margin <= SWAP2_OFFER_VALUE_MARGIN) return 'offer' const searchDecision = choosePhase1FromRootStats(rootStats) if (searchDecision) return searchDecision if (margin <= SWAP2_OFFER_UNCERTAIN_MARGIN && shouldOfferSwap2FromUncertainty(rootStats)) return 'offer' return delta > 0 ? 'player1' : 'player2' } function choosePhase1FromRootStats(rootStats: PuctRootActionStats[]): Swap2Phase1Decision | null { const stats = rootStats.filter((stat): stat is PuctRootActionStats & { action: { kind: 'special'; decision: Swap2Phase1Decision } } => ( stat.action.kind === 'special' && (stat.action.decision === 'offer' || stat.action.decision === 'player1' || stat.action.decision === 'player2') )) const totalVisits = stats.reduce((sum, stat) => sum + stat.visits, 0) if (stats.length < 3 || totalVisits <= 0) return null const best = stats.reduce((current, stat) => stat.visits > current.visits ? stat : current, stats[0]) return best.action.decision } function choosePhase2MctsDecision( board: Map, context: DatasetBotContext, rng: SeededRandom, ): Swap2ColorDecision { const utilities = rawSwap2ColorUtilities(board, context) if (utilities.player1 === utilities.player2) return rng.pick(['player1', 'player2'] as const) return utilities.player1 > utilities.player2 ? 'player1' : 'player2' } function rawSwap2ColorUtilities( board: Map, context: DatasetBotContext, ): Record { return { player1: swap2DecisionUtility(board, context, 0), player2: swap2DecisionUtility(board, context, 1), } } function normalizeSwap2Utility(value: number): number { return Math.tanh(value / SWAP2_UTILITY_SCALE) } function swap2DecisionUtility(board: Map, context: DatasetBotContext, actorColor: PlayerColor): number { const opponent = otherPlayer(actorColor) const nextMoveGain = bestImmediateMoveGain(board, 1, context) const tempoScore = actorColor === 1 ? nextMoveGain * 2.5 : -nextMoveGain * 2.5 const threatScore = maxThreatDanger(board, actorColor, context) - maxThreatDanger(board, opponent, context) * 1.1 const mobilityScore = mobilityPotential(board, actorColor, context) - mobilityPotential(board, opponent, context) * 1.05 return ( linePotential(board, actorColor, context) - linePotential(board, opponent, context) * 1.05 + tempoScore + threatScore * SWAP2_THREAT_WEIGHT + mobilityScore * SWAP2_MOBILITY_WEIGHT ) } function bestImmediateMoveGain(board: Map, color: PlayerColor, context: DatasetBotContext): number { let best = 0 for (const move of generateCandidates(board, context.boardSize, 2).slice(0, 24)) { const next = withMove(board, move, color) if (checkWin(next, move.x, move.y, String(color), context.rules.noOverlines).won) return 1_000_000 best = Math.max(best, linePotential(next, color, context) - linePotential(board, color, context)) } return best } function mobilityPotential(board: Map, color: PlayerColor, context: DatasetBotContext): number { let mobility = 0 for (const move of generateCandidates(board, context.boardSize, 2).slice(0, 24)) { const next = withMove(board, move, color) const gain = linePotential(next, color, context) - linePotential(board, color, context) if (gain >= THREAT_OPEN_THREE) 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 } export function choosePhase1Heuristic( board: Map, context: DatasetBotContext, rng: SeededRandom, noise: number, ): Swap2Phase1Decision { const scores = phase1Scores(board, context) const policy = scoreSpecialPolicy(scores, 0.35 + noise) const weights = [ policy.specialActions?.offerTwo ?? 0, policy.specialActions?.choosePlayer1 ?? 0, policy.specialActions?.choosePlayer2 ?? 0, ] return (['offer', 'player1', 'player2'] as const)[weightedIndex(weights, rng)] } export function choosePhase2Heuristic( board: Map, context: DatasetBotContext, rng: SeededRandom, noise: number, ): Swap2ColorDecision { const scores = phase2Scores(board, context) const policy = scoreSpecialPolicy(scores, 0.2 + noise) const weights = [policy.specialActions?.choosePlayer1 ?? 0, policy.specialActions?.choosePlayer2 ?? 0] return (['player1', 'player2'] as const)[weightedIndex(weights, rng)] } function chooseTopSpecial( scores: Partial>, rng: SeededRandom, ): T { let bestScore = -Infinity let best: T[] = [] for (const [decision, score] of Object.entries(scores) as [T, number][]) { if (score > bestScore) { bestScore = score best = [decision] } else if (score === bestScore) { best.push(decision) } } return rng.pick(best) } function specialDecisionValue( scores: Partial>, decision: T, ): number { const values = Object.values(scores).filter((value): value is number => typeof value === 'number') const min = Math.min(...values) const max = Math.max(...values) if (max === min) return 0 return ((scores[decision] ?? min) - min) / (max - min) * 2 - 1 } function scoreSpecialPolicy( scores: Partial>, temperature: number, ): PolicyTarget { const entries: [Swap2Phase1Decision, number][] = [] if (scores.player1 !== undefined) entries.push(['player1', scores.player1]) if (scores.player2 !== undefined) entries.push(['player2', scores.player2]) if (scores.offer !== undefined) entries.push(['offer', scores.offer]) const max = Math.max(...entries.map(([, score]) => score)) const temp = Math.max(temperature, 0.01) const weights = entries.map(([, score]) => Math.exp((score - max) / Math.max(1, Math.abs(max), 1000) / temp)) const total = weights.reduce((sum, weight) => sum + weight, 0) const specialActions: NonNullable = {} for (let i = 0; i < entries.length; i++) { const probability = weights[i] / total if (entries[i][0] === 'player1') specialActions.choosePlayer1 = probability if (entries[i][0] === 'player2') specialActions.choosePlayer2 = probability if (entries[i][0] === 'offer') specialActions.offerTwo = probability } return { placements: [], specialActions } } export function boardSizeFromRules(rules: GameRules): number { return rules.boardSize ?? BOARD_SIZE }