caro5 / shared /bot /PuctSearch.ts
Pedro de Carvalho
Add bot
4d74f52
Raw
History Blame Contribute Delete
30.3 kB
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> = T | Promise<T>
export interface PuctContext {
board: Record<string, Cell>
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<string, Cell>
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<PuctSearchResult> {
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<string, Cell>,
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<void> {
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<number> {
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<string, Cell>, 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<Record<Swap2Phase1Decision, number>> {
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<T extends string>(scores: Record<T, number>): Record<T, number> {
const values = Object.values(scores) as number[]
const min = Math.min(...values)
const shifted = {} as Record<T, number>
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<SearchAction, { kind: 'place' }>
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<PolicyTarget['specialActions']> = {}
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<string, { x: number; y: number }>()
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<string, Cell>,
move: { x: number; y: number },
player: PlayerColor,
symbols: [string, string],
): Map<string, Cell> {
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<string, Cell>, player: PlayerColor, state: Pick<SearchState, 'boardSize' | 'rules'>): 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))
}