import { deserializeCoord, isBoardCoordInBounds, serializeCoord } from '../board.ts' import type { Cell, GameRules } from '../types.ts' import { ZobristHasher } from './Zobrist.ts' export const FEATURE_CHANNELS = 13 export const PLAY_ONLY_FEATURE_CHANNELS = 7 export const CH_CURRENT_STONES = 0 export const CH_OPPONENT_STONES = 1 export const CH_LAST_MOVE = 2 export const CH_PHASE0 = 3 export const CH_PHASE1 = 4 export const CH_PHASE2 = 5 export const CH_NORMAL = 6 export const CH_CURRENT_PLAYER = 7 export const CH_OPENING_STONES = 8 export const CH_OFFERED_STONES = 9 export const CH_CURRENT_THREATS = 10 export const CH_OPPONENT_THREATS = 11 export const CH_OVERLINE_TRAPS = 12 const PLAY_ONLY_CH_CURRENT_STONES = 0 const PLAY_ONLY_CH_OPPONENT_STONES = 1 const PLAY_ONLY_CH_LAST_MOVE = 2 const PLAY_ONLY_CH_CURRENT_PLAYER = 3 const PLAY_ONLY_CH_CURRENT_THREATS = 4 const PLAY_ONLY_CH_OPPONENT_THREATS = 5 const PLAY_ONLY_CH_OVERLINE_TRAPS = 6 const DIRECTIONS: readonly [number, number][] = [[1, 0], [0, 1], [1, 1], [1, -1]] export const OPEN_THREE_THREAT_LEVEL = 0.65 export type FeaturePhase = 'swap2_phase0' | 'swap2_phase1' | 'swap2_phase2' | 'play' | 'offer_place_1' | 'offer_place_2' export interface FeatureCoord { x: number y: number } export interface FeatureEncoderInput { board: Map | Record boardSize: number rules: GameRules currentPlayer: 0 | 1 phase: FeaturePhase lastMove?: FeatureCoord openingStones?: FeatureCoord[] offeredStones?: FeatureCoord[] } export interface ThreatMaps { current: Float32Array opponent: Float32Array overline: Float32Array } export class ThreatMapState { readonly boardSize: number readonly hash: bigint readonly maps: ThreatMaps private constructor(boardSize: number, hash: bigint, maps: ThreatMaps) { this.boardSize = boardSize this.hash = hash this.maps = maps } static fromBoard(input: FeatureEncoderInput, hasher = new ZobristHasher(input.boardSize)): ThreatMapState { const board = toMap(input.board) return new ThreatMapState( input.boardSize, hasher.hash(board, input.currentPlayer), computeThreatMaps(board, input.boardSize, input.rules, input.currentPlayer), ) } applyMove(input: FeatureEncoderInput, move: FeatureCoord, player: 0 | 1, hasher = new ZobristHasher(input.boardSize)): ThreatMapState { const board = toMap(input.board) const boardArray = boardToArray(board, input.boardSize) const nextMaps = { current: new Float32Array(this.maps.current), opponent: new Float32Array(this.maps.opponent), overline: new Float32Array(this.maps.overline), } const affected = affectedCells(move, input.boardSize) for (const cell of affected) { updateThreatCell(nextMaps, boardArray, cell.x, cell.y, input.boardSize, input.rules, input.currentPlayer) } const hash = hasher.toggleStone(this.hash, move.x, move.y, player) return new ThreatMapState(input.boardSize, hash, nextMaps) } } export function encodeFeaturePlanes(input: FeatureEncoderInput, threatMaps?: ThreatMaps): Float32Array { const board = toMap(input.board) const size = input.boardSize const planes = new Float32Array(FEATURE_CHANNELS * size * size) const opponent = otherPlayer(input.currentPlayer) const maps = threatMaps ?? computeThreatMaps(board, size, input.rules, input.currentPlayer) for (const [key, cell] of board) { const { x, y } = deserializeCoord(key) if (!isBoardCoordInBounds(x, y, size)) continue const player = Number(cell.playerId) if (player === input.currentPlayer) setPlane(planes, size, CH_CURRENT_STONES, x, y, 1) if (player === opponent) setPlane(planes, size, CH_OPPONENT_STONES, x, y, 1) } if (input.lastMove) setPlane(planes, size, CH_LAST_MOVE, input.lastMove.x, input.lastMove.y, 1) fillPhasePlane(planes, size, input.phase) fillPlane(planes, size, CH_CURRENT_PLAYER, input.currentPlayer) for (const stone of input.openingStones ?? []) setPlane(planes, size, CH_OPENING_STONES, stone.x, stone.y, 1) for (const stone of input.offeredStones ?? []) setPlane(planes, size, CH_OFFERED_STONES, stone.x, stone.y, 1) copyMap(planes, size, CH_CURRENT_THREATS, maps.current) copyMap(planes, size, CH_OPPONENT_THREATS, maps.opponent) copyMap(planes, size, CH_OVERLINE_TRAPS, maps.overline) return planes } export function encodePlayOnlyFeaturePlanes(input: FeatureEncoderInput, threatMaps?: ThreatMaps): Float32Array { const board = toMap(input.board) const size = input.boardSize const planes = new Float32Array(PLAY_ONLY_FEATURE_CHANNELS * size * size) const opponent = otherPlayer(input.currentPlayer) const maps = threatMaps ?? computeThreatMaps(board, size, input.rules, input.currentPlayer) for (const [key, cell] of board) { const { x, y } = deserializeCoord(key) if (!isBoardCoordInBounds(x, y, size)) continue const player = Number(cell.playerId) if (player === input.currentPlayer) setPlane(planes, size, PLAY_ONLY_CH_CURRENT_STONES, x, y, 1) if (player === opponent) setPlane(planes, size, PLAY_ONLY_CH_OPPONENT_STONES, x, y, 1) } if (input.lastMove) setPlane(planes, size, PLAY_ONLY_CH_LAST_MOVE, input.lastMove.x, input.lastMove.y, 1) fillPlane(planes, size, PLAY_ONLY_CH_CURRENT_PLAYER, input.currentPlayer) copyMap(planes, size, PLAY_ONLY_CH_CURRENT_THREATS, maps.current) copyMap(planes, size, PLAY_ONLY_CH_OPPONENT_THREATS, maps.opponent) copyMap(planes, size, PLAY_ONLY_CH_OVERLINE_TRAPS, maps.overline) return planes } export function computeThreatMaps( board: Map, boardSize: number, rules: GameRules, currentPlayer: 0 | 1, ): ThreatMaps { const boardArray = boardToArray(board, boardSize) const current = new Float32Array(boardSize * boardSize) const opponent = new Float32Array(boardSize * boardSize) const overline = new Float32Array(boardSize * boardSize) for (let y = 0; y < boardSize; y++) { for (let x = 0; x < boardSize; x++) { updateThreatCell({ current, opponent, overline }, boardArray, x, y, boardSize, rules, currentPlayer) } } return { current, opponent, overline } } function updateThreatCell( maps: ThreatMaps, board: Uint8Array, x: number, y: number, boardSize: number, rules: GameRules, currentPlayer: 0 | 1, ): void { const index = y * boardSize + x if (!isBoardCoordInBounds(x, y, boardSize) || board[index] !== 0) { maps.current[index] = 0 maps.opponent[index] = 0 maps.overline[index] = 0 return } const currentOverline = rules.noOverlines && createsOverline(board, x, y, currentPlayer, boardSize) maps.current[index] = currentOverline ? 0 : threatScore(board, x, y, currentPlayer, boardSize, rules) maps.opponent[index] = threatScore(board, x, y, otherPlayer(currentPlayer), boardSize, rules) maps.overline[index] = currentOverline ? 1 : 0 } function threatScore(board: Uint8Array, x: number, y: number, player: 0 | 1, boardSize: number, rules: GameRules): number { let best = 0 for (const [dx, dy] of DIRECTIONS) { const line = scanLine(board, x, y, player, dx, dy, boardSize) if (line.count >= 5 && !(rules.noOverlines && line.count > 5)) best = Math.max(best, 1) else if (line.count === 4 && line.openEnds >= 1) best = Math.max(best, 0.85) else if (brokenFour(board, x, y, player, dx, dy, boardSize)) best = Math.max(best, 0.75) else if (line.count === 3 && line.openEnds === 2) best = Math.max(best, OPEN_THREE_THREAT_LEVEL) else if (line.count === 3 && line.openEnds === 1) best = Math.max(best, 0.25) } return best } function brokenFour(board: Uint8Array, x: number, y: number, player: 0 | 1, dx: number, dy: number, boardSize: number): boolean { const playerCell = player + 1 for (let start = -4; start <= 0; start++) { let own = 0 let empty = 0 let blocked = false for (let i = 0; i < 5; i++) { const cx = x + dx * (start + i) const cy = y + dy * (start + i) if (!isBoardCoordInBounds(cx, cy, boardSize)) { blocked = true break } const isPlacedCell = cx === x && cy === y const cell = isPlacedCell ? playerCell : board[cy * boardSize + cx] if (cell === 0) empty++ else if (cell === playerCell) own++ else blocked = true } if (!blocked && own === 4 && empty === 1) return true } return false } function createsOverline(board: Uint8Array, x: number, y: number, player: 0 | 1, boardSize: number): boolean { return DIRECTIONS.some(([dx, dy]) => scanLine(board, x, y, player, dx, dy, boardSize).count > 5) } function scanLine( board: Uint8Array, x: number, y: number, player: 0 | 1, dx: number, dy: number, boardSize: number, ): { count: number; openEnds: number } { const playerCell = player + 1 let count = 1 let openEnds = 0 let cx = x + dx let cy = y + dy while (isBoardCoordInBounds(cx, cy, boardSize) && board[cy * boardSize + cx] === playerCell) { count++ cx += dx cy += dy } if (isBoardCoordInBounds(cx, cy, boardSize) && board[cy * boardSize + cx] === 0) openEnds++ cx = x - dx cy = y - dy while (isBoardCoordInBounds(cx, cy, boardSize) && board[cy * boardSize + cx] === playerCell) { count++ cx -= dx cy -= dy } if (isBoardCoordInBounds(cx, cy, boardSize) && board[cy * boardSize + cx] === 0) openEnds++ return { count, openEnds } } function boardToArray(board: Map, boardSize: number): Uint8Array { const array = new Uint8Array(boardSize * boardSize) for (const [key, cell] of board) { const { x, y } = deserializeCoord(key) if (!isBoardCoordInBounds(x, y, boardSize)) continue const player = Number(cell.playerId) if (player === 0 || player === 1) array[y * boardSize + x] = player + 1 } return array } function affectedCells(move: FeatureCoord, boardSize: number): FeatureCoord[] { const cells = new Map() for (const [dx, dy] of DIRECTIONS) { for (let offset = -5; offset <= 5; offset++) { const x = move.x + dx * offset const y = move.y + dy * offset if (!isBoardCoordInBounds(x, y, boardSize)) continue cells.set(serializeCoord(x, y), { x, y }) } } cells.set(serializeCoord(move.x, move.y), move) return [...cells.values()] } function fillPhasePlane(planes: Float32Array, boardSize: number, phase: FeaturePhase): void { if (phase === 'swap2_phase0') fillPlane(planes, boardSize, CH_PHASE0, 1) else if (phase === 'swap2_phase1') fillPlane(planes, boardSize, CH_PHASE1, 1) else if (phase === 'swap2_phase2') fillPlane(planes, boardSize, CH_PHASE2, 1) else fillPlane(planes, boardSize, CH_NORMAL, 1) } function fillPlane(planes: Float32Array, boardSize: number, channel: number, value: number): void { const offset = channel * boardSize * boardSize planes.fill(value, offset, offset + boardSize * boardSize) } function copyMap(planes: Float32Array, boardSize: number, channel: number, map: Float32Array): void { planes.set(map, channel * boardSize * boardSize) } function setPlane(planes: Float32Array, boardSize: number, channel: number, x: number, y: number, value: number): void { if (!isBoardCoordInBounds(x, y, boardSize)) return planes[channel * boardSize * boardSize + y * boardSize + x] = value } function toMap(board: Map | Record): Map { return board instanceof Map ? board : new Map(Object.entries(board)) } function otherPlayer(player: 0 | 1): 0 | 1 { return player === 0 ? 1 : 0 }