Created
October 20, 2024 20:06
-
-
Save maksverver/e2de5d9b1f3cd32dbef9766f9dff1e7f to your computer and use it in GitHub Desktop.
StackAndCoquer new AI players
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* \file MinimaxCPU.js | |
* | |
* \section LICENSE | |
* | |
* Copyright (C) 2024 Maks Verver | |
* | |
* This file is part of StackAndConquer. | |
* | |
* StackAndConquer is free software: you can redistribute it and/or modify | |
* it under the terms of the GNU General Public License as published by | |
* the Free Software Foundation, either version 3 of the License, or | |
* (at your option) any later version. | |
* | |
* StackAndConquer is distributed in the hope that it will be useful, | |
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
* GNU General Public License for more details. | |
* | |
* You should have received a copy of the GNU General Public License | |
* along with StackAndConquer. If not, see <https://www.gnu.org/licenses/>. | |
* | |
* \section DESCRIPTION | |
* | |
* CPU opponent using the Minimax algorithm. | |
* | |
* This file was auto-generated from multiple source files. The original source | |
* code is available here: https://github.com/maksverver/stackandconquer-ai/ | |
*/ | |
'use strict'; | |
// src/minimax.js | |
var SEARCH_DEPTH = 4; | |
function findBestMoves(unusedCfg, state, moves) { | |
function search(depthLeft, alpha, beta) { | |
if (depthLeft === 0) { | |
return state.evaluate(); | |
} | |
var moves2 = state.generateMoves(); | |
if (moves2.length === 0) { | |
var value2 = state.evaluate(); | |
if (value2 > 0) value2 += depthLeft; | |
if (value2 < 0) value2 -= depthLeft; | |
return value2; | |
} | |
var bestValue2 = -Infinity; | |
for (var i2 = 0; i2 < moves2.length; ++i2) { | |
var move2 = moves2[i2]; | |
var undoState2 = state.doMove(move2); | |
var value2 = -search(depthLeft - 1, -beta, -alpha); | |
state.undoMove(move2, undoState2); | |
if (value2 > bestValue2) { | |
bestValue2 = value2; | |
if (value2 > alpha) alpha = value2; | |
if (value2 >= beta) break; | |
} | |
} | |
return bestValue2; | |
} | |
var bestMoves = []; | |
var bestValue = -Infinity; | |
for (var i = 0; i < moves.length; ++i) { | |
var move = moves[i]; | |
var undoState = state.doMove(move); | |
var value = -search(SEARCH_DEPTH - 1, -Infinity, -bestValue + 1); | |
state.undoMove(move, undoState); | |
if (value > bestValue) { | |
bestValue = value; | |
bestMoves.length = 0; | |
} | |
if (value === bestValue) { | |
bestMoves.push(move); | |
} | |
} | |
return [bestMoves, bestValue]; | |
} | |
// src/util.js | |
var log = typeof game === "object" ? game.log : console.log; | |
function createConfig(rows, cols, inputFields, outside, padding, paddingSize, winningHeight, playerCount) { | |
var paddedRows = rows + 2 * paddingSize; | |
var paddedCols = cols + 2 * paddingSize; | |
if (inputFields.length !== paddedRows * paddedCols) { | |
throw new Error("Invalid length of input fields"); | |
} | |
var apiToFieldIndex = []; | |
var fieldIndexToApi = []; | |
var fieldCount = 0; | |
for (var r1 = 0; r1 < paddedRows; ++r1) { | |
for (var c1 = 0; c1 < paddedCols; ++c1) { | |
var i = paddedCols * r1 + c1; | |
if (inputFields[i] === outside || inputFields[i] === padding) { | |
apiToFieldIndex.push(-1); | |
} else { | |
apiToFieldIndex.push(fieldCount++); | |
fieldIndexToApi.push(i); | |
} | |
} | |
} | |
if (fieldCount > 30) { | |
throw new Error("Too many fields (maximum supported: 30)"); | |
} | |
var moves = []; | |
var DR = [-1, -1, -1, 0, 0, 1, 1, 1]; | |
var DC = [-1, 0, 1, -1, 1, -1, 0, 1]; | |
for (var r2 = 0; r2 < paddedRows; ++r2) { | |
for (var c2 = 0; c2 < paddedCols; ++c2) { | |
var j = paddedCols * r2 + c2; | |
if (inputFields[j] === outside || inputFields[j] === padding) continue; | |
var dst = moves.length; | |
moves.push([]); | |
for (var height = 0; height < winningHeight; ++height) { | |
moves[dst].push([]); | |
} | |
for (var dir = 0; dir < 8; ++dir) { | |
var dr = DR[dir], dc = DC[dir]; | |
var mask = 0; | |
for (var height = 1; height < winningHeight; ++height) { | |
var r1 = r2 - dr * height; | |
var c1 = c2 - dc * height; | |
if (r1 < 0 || r1 >= paddedRows || c1 < 0 || c1 >= paddedCols) break; | |
var i = paddedCols * r1 + c1; | |
if (inputFields[i] === outside || inputFields[i] === padding) break; | |
var src = apiToFieldIndex[i]; | |
moves[dst][height].push([src, mask]); | |
mask |= 1 << src; | |
} | |
} | |
} | |
} | |
return Object.freeze({ | |
apiToFieldIndex: Object.freeze(apiToFieldIndex), | |
fieldIndexToApi: Object.freeze(fieldIndexToApi), | |
fieldCount: fieldCount, | |
moves: Object.freeze(moves), | |
winningHeight: winningHeight, | |
playerCount: playerCount, | |
// These are only used for move parsing/formatting and debug printing: | |
rows: rows, | |
cols: cols, | |
paddingSize: paddingSize | |
}); | |
} | |
function fieldIndexToRowCol(cfg2, index) { | |
var i = cfg2.fieldIndexToApi[index]; | |
var pad = cfg2.paddingSize; | |
var rowStride = pad * 2 + cfg2.cols; | |
var col = i % rowStride; | |
var row = (i - col) / rowStride; | |
return [row - pad, col - pad]; | |
} | |
function rowColToFieldIndex(cfg2, row, col) { | |
var pad = cfg2.paddingSize; | |
var rowStride = pad * 2 + cfg2.cols; | |
var res = cfg2.apiToFieldIndex[rowStride * (row + pad) + (col + pad)]; | |
return res == null ? -1 : res; | |
} | |
function arrayOfValues(len, value) { | |
var res = []; | |
for (var i = 0; i < len; ++i) res.push(value); | |
return res; | |
} | |
function arrayEquals(a, b) { | |
if (a.length !== b.length) return false; | |
for (var i = 0; i < a.length; ++i) if (a[i] !== b[i]) return false; | |
return true; | |
} | |
function indexOfMove(moves, move) { | |
for (var i = 0; i < moves.length; ++i) { | |
if (arrayEquals(move, moves[i])) return i; | |
} | |
return -1; | |
} | |
function randomChoice(array) { | |
return array[Math.floor(Math.random() * array.length)]; | |
} | |
// src/formatting.js | |
function formatRow(row) { | |
return String(row + 1); | |
} | |
function formatCol(col) { | |
return String.fromCharCode("a".charCodeAt(0) + col); | |
} | |
function formatField(cfg2, field) { | |
var coords = fieldIndexToRowCol(cfg2, field); | |
return formatCol(coords[1]) + formatRow(coords[0]); | |
} | |
function formatMove(cfg2, move) { | |
if (move.length === 0) return "pass"; | |
var src = move[0]; | |
var cnt = move[1]; | |
var dst = move[2]; | |
var res = ""; | |
if (cnt !== 1) res += String(cnt); | |
if (src !== -1) res += formatField(cfg2, src); | |
res += formatField(cfg2, dst); | |
return res; | |
} | |
function formatMoves(cfg2, moves) { | |
var res = ""; | |
for (var i = 0; i < moves.length; ++i) { | |
if (i > 0) res += " "; | |
res += formatMove(cfg2, moves[i]); | |
} | |
return res; | |
} | |
// src/State.js | |
var PASS = Object.freeze([]); | |
function cloneArray(arr) { | |
return arr.slice(); | |
} | |
function cloneFields(arr) { | |
return arr.map(cloneArray); | |
} | |
function State(cfg2, fields, nextPlayer, lastMove, piecesLeft, scoresLeft, occupied) { | |
function getNextPlayer() { | |
return nextPlayer; | |
} | |
function getWinner() { | |
return scoresLeft.indexOf(0); | |
} | |
function doMoveInternal(move) { | |
var removed = null; | |
if (move.length !== 0) { | |
var src = move[0]; | |
var cnt = move[1]; | |
var dst = move[2]; | |
var dstField = fields[dst]; | |
if (src === -1) { | |
--piecesLeft[nextPlayer]; | |
dstField.push(nextPlayer); | |
occupied ^= 1 << dst; | |
} else { | |
var srcField = fields[src]; | |
dstField.push.apply(dstField, srcField.splice(srcField.length - cnt)); | |
if (srcField.length === 0) { | |
occupied ^= 1 << src; | |
} | |
if (dstField.length >= cfg2.winningHeight) { | |
removed = dstField.splice(0); | |
var winner = removed[removed.length - 1]; | |
scoresLeft[winner] -= 1; | |
occupied ^= 1 << dst; | |
for (var i = 0; i < removed.length; ++i) ++piecesLeft[removed[i]]; | |
} | |
} | |
++nextPlayer; | |
if (nextPlayer === cfg2.playerCount) nextPlayer = 0; | |
lastMove = move; | |
return removed; | |
} | |
} | |
function doMove(move) { | |
return [lastMove, doMoveInternal(move)]; | |
} | |
function undoMove(move, undoState) { | |
if (nextPlayer === 0) nextPlayer = cfg2.playerCount; | |
--nextPlayer; | |
lastMove = undoState[0]; | |
if (move.length !== 0) { | |
var src = move[0]; | |
var cnt = move[1]; | |
var dst = move[2]; | |
var dstField = fields[dst]; | |
if (src === -1) { | |
++piecesLeft[nextPlayer]; | |
dstField.pop(); | |
occupied ^= 1 << dst; | |
} else { | |
var srcField = fields[src]; | |
var removed = undoState[1]; | |
if (removed != null) { | |
for (var i = 0; i < removed.length; ++i) --piecesLeft[removed[i]]; | |
var winner = removed[removed.length - 1]; | |
scoresLeft[winner] += 1; | |
dstField.push.apply(dstField, removed); | |
occupied ^= 1 << dst; | |
} | |
if (srcField.length === 0) { | |
occupied ^= 1 << src; | |
} | |
srcField.push.apply(srcField, dstField.splice(dstField.length - cnt)); | |
} | |
} | |
} | |
function evaluate() { | |
var winner = getWinner(); | |
if (winner !== -1) return winner === nextPlayer ? 1e9 : -1e9; | |
var score = 1e4 * (scoresLeft[1 - nextPlayer] - scoresLeft[nextPlayer]); | |
for (var dst = 0; dst < fields.length; ++dst) { | |
var dstField = fields[dst]; | |
var dstHeight = dstField.length; | |
if (dstHeight > 0) { | |
var options = cfg2.moves[dst][dstHeight]; | |
for (var i = 0; i < options.length; ++i) { | |
var src = options[i][0]; | |
var srcField = fields[src]; | |
var srcHeight = srcField.length; | |
if (srcHeight + dstHeight >= cfg2.winningHeight && (occupied & options[i][1]) === 0) { | |
if (srcField[srcHeight - 1] === nextPlayer) { | |
score += 1e3; | |
} else { | |
score -= 100; | |
} | |
} | |
} | |
if (dstField[dstHeight - 1] === nextPlayer) { | |
score += 10 * dstHeight; | |
} else { | |
score -= 10 * dstHeight; | |
} | |
for (var i = 0; i < dstHeight; ++i) { | |
if (dstField[i] === nextPlayer) { | |
score += 1 + i; | |
} else { | |
score -= 1 + i; | |
} | |
} | |
} | |
} | |
return score; | |
} | |
function generateMoves() { | |
if (getWinner() !== -1) return []; | |
var moveTemplates = cfg2.moves; | |
var moves = []; | |
var lastSrc = -1, lastCnt = 0, lastDst = -1; | |
if (lastMove != null && lastMove.length != 0) { | |
lastSrc = lastMove[0]; | |
lastCnt = lastMove[1]; | |
lastDst = lastMove[2]; | |
} | |
for (var dst = 0; dst < fields.length; ++dst) { | |
var dstHeight = fields[dst].length; | |
if (dstHeight === 0) { | |
if (piecesLeft[nextPlayer]) { | |
moves.push([-1, 1, dst]); | |
} | |
} else { | |
var options = moveTemplates[dst][dstHeight]; | |
for (var i = 0; i < options.length; ++i) { | |
var src = options[i][0]; | |
var srcHeight = fields[src].length; | |
if (srcHeight !== 0 && (occupied & options[i][1]) === 0) { | |
for (var cnt = 1; cnt <= srcHeight; ++cnt) { | |
if (src == lastDst && cnt === lastCnt && dst == lastSrc) continue; | |
moves.push([src, cnt, dst]); | |
} | |
} | |
} | |
} | |
} | |
if (moves.length === 0) moves.push(PASS); | |
return moves; | |
} | |
function getMoveWinner(move) { | |
if (move.length !== 0 && fields[move[2]].length + move[1] >= cfg2.winningHeight) { | |
var srcField = fields[move[0]]; | |
return srcField[srcField.length - 1]; | |
} | |
return -1; | |
} | |
function triageMoves(moves) { | |
var winningMoves = []; | |
var neutralMoves = []; | |
var losingMoves = []; | |
for (var i = 0; i < moves.length; ++i) { | |
var move = moves[i]; | |
var winner = getMoveWinner(move); | |
if (winner === -1) { | |
neutralMoves.push(move); | |
} else if (winner === nextPlayer) { | |
winningMoves.push(move); | |
} else { | |
losingMoves.push(move); | |
} | |
} | |
return [winningMoves, neutralMoves, losingMoves]; | |
} | |
function playRandomMove() { | |
var triagedMoves = triageMoves(generateMoves()); | |
for (var i = 0; i < triagedMoves.length; ++i) { | |
var moves = triagedMoves[i]; | |
if (moves.length > 0) { | |
doMove(randomChoice(moves)); | |
return; | |
} | |
} | |
throw new Error("No moves available!"); | |
} | |
function randomPlayout(maxSteps) { | |
for (var step = 0; step < maxSteps; ++step) { | |
if (getWinner() !== -1) return step; | |
playRandomMove(); | |
} | |
return maxSteps; | |
} | |
function debugPrint() { | |
log("Scores left: " + scoresLeft); | |
log("Pieces left: " + piecesLeft); | |
log("Player " + (nextPlayer + 1) + " to move."); | |
for (var r = 0; r < cfg2.rows; ++r) { | |
var line = formatRow(r) + " "; | |
for (var c = 0; c < cfg2.cols; ++c) { | |
var src = rowColToFieldIndex(cfg2, r, c); | |
var part = ""; | |
if (src === -1) { | |
part = "#"; | |
} else if (fields[src].length === 0) { | |
part = "."; | |
} else { | |
for (var i = 0; i < fields[src].length; ++i) { | |
part += String(fields[src][i] + 1); | |
} | |
} | |
while (part.length < cfg2.winningHeight) part += " "; | |
line += " " + part; | |
if (src !== -1 && (occupied & 1 << src) !== 0 != (fields[src].length !== 0)) { | |
log("INTERNAL ERROR: occupied does not match fields at " + src); | |
} | |
} | |
log(line); | |
} | |
var line = " "; | |
for (var c = 0; c < cfg2.cols; ++c) { | |
var part = formatCol(c); | |
while (part.length < cfg2.winningHeight) part += " "; | |
line += " " + part; | |
} | |
log(line); | |
log("last move: " + (lastMove ? formatMove(cfg2, lastMove) : "none")); | |
var moves = generateMoves(); | |
log(moves.length + " possible moves: " + formatMoves(cfg2, moves)); | |
} | |
function toJson() { | |
return { | |
fields: fields, | |
nextPlayer: nextPlayer, | |
lastMove: lastMove, | |
scoresLeft: scoresLeft, | |
piecesLeft: piecesLeft | |
}; | |
} | |
function clone(winningScore) { | |
return State( | |
cfg2, | |
cloneFields(fields), | |
nextPlayer, | |
lastMove, | |
cloneArray(piecesLeft), | |
winningScore == null ? cloneArray(scoresLeft) : arrayOfValues(cfg2.playerCount, winningScore), | |
occupied | |
); | |
} | |
return { | |
getNextPlayer: getNextPlayer, | |
getWinner: getWinner, | |
generateMoves: generateMoves, | |
doMove: doMove, | |
undoMove: undoMove, | |
evaluate: evaluate, | |
triageMoves: triageMoves, | |
randomPlayout: randomPlayout, | |
debugPrint: debugPrint, | |
toJson: toJson, | |
clone: clone | |
}; | |
} | |
function createStateFromJson(cfg2, inputJson) { | |
var fields = inputJson.fields; | |
var occupied = 0; | |
for (var i = 0; i < fields.length; ++i) { | |
if (fields[i].length > 0) occupied |= 1 << i; | |
} | |
return State(cfg2, fields, inputJson.nextPlayer, inputJson.lastMove, inputJson.piecesLeft, inputJson.scoresLeft, occupied); | |
} | |
// src/cpu-player.js | |
function convertMoveFromApi(cfg2, apiMove) { | |
if (apiMove == null) return null; | |
if (apiMove.length === 0) return apiMove; | |
var src = apiMove[0]; | |
var cnt = apiMove[1]; | |
var dst = apiMove[2]; | |
if (src !== -1) src = cfg2.apiToFieldIndex[src]; | |
dst = cfg2.apiToFieldIndex[dst]; | |
return [src, cnt, dst]; | |
} | |
function initCpuWrapper(jsonBoard) { | |
return createConfig( | |
game.getBoardDimensionY(), | |
game.getBoardDimensionX(), | |
JSON.parse(jsonBoard), | |
game.getOutside(), | |
game.getPadding(), | |
game.getHeightToWin(), | |
// padding size | |
game.getHeightToWin(), | |
game.getNumOfPlayers() | |
); | |
} | |
function callCpuWrapper(findBestMoves2, cfg2, jsonBoard) { | |
var board = JSON.parse(jsonBoard); | |
var nextPlayer = game.getID() - 1; | |
if (!Number.isInteger(nextPlayer) || nextPlayer < 0 || nextPlayer >= cfg2.playerCount) { | |
throw new Error("Invalid player ID"); | |
} | |
var apiMoves = JSON.parse(game.getLegalMoves()); | |
if (apiMoves.length === 0) throw new Error("No moves available"); | |
if (apiMoves.length === 1) return apiMoves[0]; | |
var moves = apiMoves.map(convertMoveFromApi.bind(null, cfg2)); | |
var fields = []; | |
for (var i = 0; i < cfg2.fieldCount; ++i) { | |
var field = []; | |
var string = board[cfg2.fieldIndexToApi[i]]; | |
for (var j = 0; j < string.length; ++j) { | |
var piece = string.charCodeAt(j) - "1".charCodeAt(0); | |
if (piece < 0 || piece >= cfg2.playerCount) throw new Error("Invalid piece"); | |
field.push(piece); | |
} | |
fields.push(field); | |
} | |
if (fields.length !== cfg2.fieldCount) throw new Error("Invalid number of fields"); | |
var state = createStateFromJson(cfg2, { | |
fields: fields, | |
nextPlayer: nextPlayer, | |
piecesLeft: game.getNumberOfStones(), | |
scoresLeft: game.getTowersNeededToWin(), | |
lastMove: convertMoveFromApi(game.getLastMove()) | |
}); | |
var result = findBestMoves2(cfg2, state, moves); | |
var bestMoves = result[0]; | |
var bestValue = result[1]; | |
var bestMove = randomChoice(bestMoves); | |
log(moves.length + " moves, " + bestMoves.length + " best with value " + bestValue + ": " + formatMoves(cfg2, bestMoves) + "; selected " + formatMove(cfg2, bestMove) + " at random"); | |
var moveIndex = indexOfMove(moves, bestMove); | |
if (moveIndex < 0) throw new Error("Best move not found somehow?!"); | |
return apiMoves[moveIndex]; | |
} | |
// src/MinimaxCPU.js | |
var cfg = null; | |
function initCPU(jsonBoard) { | |
if (game.getNumOfPlayers() !== 2) { | |
throw new Error("Unsupported number of players!"); | |
} | |
cfg = initCpuWrapper(jsonBoard); | |
} | |
function callCPU(jsonBoard) { | |
if (cfg == null) throw new Error("initCPU() has not been called!"); | |
return callCpuWrapper(findBestMoves, cfg, jsonBoard); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* \file MonteCarloCPU.js | |
* | |
* \section LICENSE | |
* | |
* Copyright (C) 2024 Maks Verver | |
* | |
* This file is part of StackAndConquer. | |
* | |
* StackAndConquer is free software: you can redistribute it and/or modify | |
* it under the terms of the GNU General Public License as published by | |
* the Free Software Foundation, either version 3 of the License, or | |
* (at your option) any later version. | |
* | |
* StackAndConquer is distributed in the hope that it will be useful, | |
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
* GNU General Public License for more details. | |
* | |
* You should have received a copy of the GNU General Public License | |
* along with StackAndConquer. If not, see <https://www.gnu.org/licenses/>. | |
* | |
* \section DESCRIPTION | |
* | |
* CPU opponent using Monte Carlo simulations. | |
* | |
* This file was auto-generated from multiple source files. The original source | |
* code is available here: https://github.com/maksverver/stackandconquer-ai/ | |
*/ | |
'use strict'; | |
// src/util.js | |
var log = typeof game === "object" ? game.log : console.log; | |
function createConfig(rows, cols, inputFields, outside, padding, paddingSize, winningHeight, playerCount) { | |
var paddedRows = rows + 2 * paddingSize; | |
var paddedCols = cols + 2 * paddingSize; | |
if (inputFields.length !== paddedRows * paddedCols) { | |
throw new Error("Invalid length of input fields"); | |
} | |
var apiToFieldIndex = []; | |
var fieldIndexToApi = []; | |
var fieldCount = 0; | |
for (var r1 = 0; r1 < paddedRows; ++r1) { | |
for (var c1 = 0; c1 < paddedCols; ++c1) { | |
var i = paddedCols * r1 + c1; | |
if (inputFields[i] === outside || inputFields[i] === padding) { | |
apiToFieldIndex.push(-1); | |
} else { | |
apiToFieldIndex.push(fieldCount++); | |
fieldIndexToApi.push(i); | |
} | |
} | |
} | |
if (fieldCount > 30) { | |
throw new Error("Too many fields (maximum supported: 30)"); | |
} | |
var moves = []; | |
var DR = [-1, -1, -1, 0, 0, 1, 1, 1]; | |
var DC = [-1, 0, 1, -1, 1, -1, 0, 1]; | |
for (var r2 = 0; r2 < paddedRows; ++r2) { | |
for (var c2 = 0; c2 < paddedCols; ++c2) { | |
var j = paddedCols * r2 + c2; | |
if (inputFields[j] === outside || inputFields[j] === padding) continue; | |
var dst = moves.length; | |
moves.push([]); | |
for (var height = 0; height < winningHeight; ++height) { | |
moves[dst].push([]); | |
} | |
for (var dir = 0; dir < 8; ++dir) { | |
var dr = DR[dir], dc = DC[dir]; | |
var mask = 0; | |
for (var height = 1; height < winningHeight; ++height) { | |
var r1 = r2 - dr * height; | |
var c1 = c2 - dc * height; | |
if (r1 < 0 || r1 >= paddedRows || c1 < 0 || c1 >= paddedCols) break; | |
var i = paddedCols * r1 + c1; | |
if (inputFields[i] === outside || inputFields[i] === padding) break; | |
var src = apiToFieldIndex[i]; | |
moves[dst][height].push([src, mask]); | |
mask |= 1 << src; | |
} | |
} | |
} | |
} | |
return Object.freeze({ | |
apiToFieldIndex: Object.freeze(apiToFieldIndex), | |
fieldIndexToApi: Object.freeze(fieldIndexToApi), | |
fieldCount: fieldCount, | |
moves: Object.freeze(moves), | |
winningHeight: winningHeight, | |
playerCount: playerCount, | |
// These are only used for move parsing/formatting and debug printing: | |
rows: rows, | |
cols: cols, | |
paddingSize: paddingSize | |
}); | |
} | |
function fieldIndexToRowCol(cfg2, index) { | |
var i = cfg2.fieldIndexToApi[index]; | |
var pad = cfg2.paddingSize; | |
var rowStride = pad * 2 + cfg2.cols; | |
var col = i % rowStride; | |
var row = (i - col) / rowStride; | |
return [row - pad, col - pad]; | |
} | |
function rowColToFieldIndex(cfg2, row, col) { | |
var pad = cfg2.paddingSize; | |
var rowStride = pad * 2 + cfg2.cols; | |
var res = cfg2.apiToFieldIndex[rowStride * (row + pad) + (col + pad)]; | |
return res == null ? -1 : res; | |
} | |
function arrayOfValues(len, value) { | |
var res = []; | |
for (var i = 0; i < len; ++i) res.push(value); | |
return res; | |
} | |
function arrayEquals(a, b) { | |
if (a.length !== b.length) return false; | |
for (var i = 0; i < a.length; ++i) if (a[i] !== b[i]) return false; | |
return true; | |
} | |
function indexOfMove(moves, move) { | |
for (var i = 0; i < moves.length; ++i) { | |
if (arrayEquals(move, moves[i])) return i; | |
} | |
return -1; | |
} | |
function randomChoice(array) { | |
return array[Math.floor(Math.random() * array.length)]; | |
} | |
// src/montecarlo.js | |
var TOTAL_SIMULATIONS = 4e3; | |
var MIN_SIMULATIONS_PER_MOVE = 10; | |
var MAX_STEPS_TO_SIMULATE = 250; | |
function evaluateWithRandomPlayouts(player, initialState, simulations) { | |
var value = 0; | |
var errorPrinted = false; | |
for (var n = 0; n < simulations; ++n) { | |
var state = initialState.clone(1); | |
var steps = state.randomPlayout(MAX_STEPS_TO_SIMULATE); | |
if (steps >= MAX_STEPS_TO_SIMULATE && !errorPrinted) { | |
log("WARNING: maxSteps exceeded!"); | |
errorPrinted = true; | |
} | |
var winner = state.getWinner(); | |
value += winner === player ? 1 : winner === -1 ? 0.5 : 0; | |
} | |
return value / simulations; | |
} | |
function findBestMoves(cfg2, state, moves) { | |
var triagedMoves = state.triageMoves(moves); | |
if (triagedMoves[0].length > 0) { | |
return [triagedMoves[0], 1]; | |
} | |
var neutralMoves = triagedMoves[1]; | |
if (neutralMoves.length === 0) { | |
return [triagedMoves[2], 0]; | |
} | |
var player = state.getNextPlayer(); | |
var simulationsPerMove = Math.max(MIN_SIMULATIONS_PER_MOVE, Math.floor(TOTAL_SIMULATIONS / neutralMoves.length)); | |
var bestMoves = []; | |
var bestValue = 0; | |
for (var i = 0; i < neutralMoves.length; ++i) { | |
var move = neutralMoves[i]; | |
var undoState = state.doMove(move); | |
var value = evaluateWithRandomPlayouts(player, state, simulationsPerMove); | |
state.undoMove(move, undoState); | |
if (value > bestValue) { | |
bestValue = value; | |
bestMoves.length = 0; | |
} | |
if (value === bestValue) { | |
bestMoves.push(move); | |
} | |
} | |
return [bestMoves, bestValue]; | |
} | |
// src/formatting.js | |
function formatRow(row) { | |
return String(row + 1); | |
} | |
function formatCol(col) { | |
return String.fromCharCode("a".charCodeAt(0) + col); | |
} | |
function formatField(cfg2, field) { | |
var coords = fieldIndexToRowCol(cfg2, field); | |
return formatCol(coords[1]) + formatRow(coords[0]); | |
} | |
function formatMove(cfg2, move) { | |
if (move.length === 0) return "pass"; | |
var src = move[0]; | |
var cnt = move[1]; | |
var dst = move[2]; | |
var res = ""; | |
if (cnt !== 1) res += String(cnt); | |
if (src !== -1) res += formatField(cfg2, src); | |
res += formatField(cfg2, dst); | |
return res; | |
} | |
function formatMoves(cfg2, moves) { | |
var res = ""; | |
for (var i = 0; i < moves.length; ++i) { | |
if (i > 0) res += " "; | |
res += formatMove(cfg2, moves[i]); | |
} | |
return res; | |
} | |
// src/State.js | |
var PASS = Object.freeze([]); | |
function cloneArray(arr) { | |
return arr.slice(); | |
} | |
function cloneFields(arr) { | |
return arr.map(cloneArray); | |
} | |
function State(cfg2, fields, nextPlayer, lastMove, piecesLeft, scoresLeft, occupied) { | |
function getNextPlayer() { | |
return nextPlayer; | |
} | |
function getWinner() { | |
return scoresLeft.indexOf(0); | |
} | |
function doMoveInternal(move) { | |
var removed = null; | |
if (move.length !== 0) { | |
var src = move[0]; | |
var cnt = move[1]; | |
var dst = move[2]; | |
var dstField = fields[dst]; | |
if (src === -1) { | |
--piecesLeft[nextPlayer]; | |
dstField.push(nextPlayer); | |
occupied ^= 1 << dst; | |
} else { | |
var srcField = fields[src]; | |
dstField.push.apply(dstField, srcField.splice(srcField.length - cnt)); | |
if (srcField.length === 0) { | |
occupied ^= 1 << src; | |
} | |
if (dstField.length >= cfg2.winningHeight) { | |
removed = dstField.splice(0); | |
var winner = removed[removed.length - 1]; | |
scoresLeft[winner] -= 1; | |
occupied ^= 1 << dst; | |
for (var i = 0; i < removed.length; ++i) ++piecesLeft[removed[i]]; | |
} | |
} | |
++nextPlayer; | |
if (nextPlayer === cfg2.playerCount) nextPlayer = 0; | |
lastMove = move; | |
return removed; | |
} | |
} | |
function doMove(move) { | |
return [lastMove, doMoveInternal(move)]; | |
} | |
function undoMove(move, undoState) { | |
if (nextPlayer === 0) nextPlayer = cfg2.playerCount; | |
--nextPlayer; | |
lastMove = undoState[0]; | |
if (move.length !== 0) { | |
var src = move[0]; | |
var cnt = move[1]; | |
var dst = move[2]; | |
var dstField = fields[dst]; | |
if (src === -1) { | |
++piecesLeft[nextPlayer]; | |
dstField.pop(); | |
occupied ^= 1 << dst; | |
} else { | |
var srcField = fields[src]; | |
var removed = undoState[1]; | |
if (removed != null) { | |
for (var i = 0; i < removed.length; ++i) --piecesLeft[removed[i]]; | |
var winner = removed[removed.length - 1]; | |
scoresLeft[winner] += 1; | |
dstField.push.apply(dstField, removed); | |
occupied ^= 1 << dst; | |
} | |
if (srcField.length === 0) { | |
occupied ^= 1 << src; | |
} | |
srcField.push.apply(srcField, dstField.splice(dstField.length - cnt)); | |
} | |
} | |
} | |
function evaluate() { | |
var winner = getWinner(); | |
if (winner !== -1) return winner === nextPlayer ? 1e9 : -1e9; | |
var score = 1e4 * (scoresLeft[1 - nextPlayer] - scoresLeft[nextPlayer]); | |
for (var dst = 0; dst < fields.length; ++dst) { | |
var dstField = fields[dst]; | |
var dstHeight = dstField.length; | |
if (dstHeight > 0) { | |
var options = cfg2.moves[dst][dstHeight]; | |
for (var i = 0; i < options.length; ++i) { | |
var src = options[i][0]; | |
var srcField = fields[src]; | |
var srcHeight = srcField.length; | |
if (srcHeight + dstHeight >= cfg2.winningHeight && (occupied & options[i][1]) === 0) { | |
if (srcField[srcHeight - 1] === nextPlayer) { | |
score += 1e3; | |
} else { | |
score -= 100; | |
} | |
} | |
} | |
if (dstField[dstHeight - 1] === nextPlayer) { | |
score += 10 * dstHeight; | |
} else { | |
score -= 10 * dstHeight; | |
} | |
for (var i = 0; i < dstHeight; ++i) { | |
if (dstField[i] === nextPlayer) { | |
score += 1 + i; | |
} else { | |
score -= 1 + i; | |
} | |
} | |
} | |
} | |
return score; | |
} | |
function generateMoves() { | |
if (getWinner() !== -1) return []; | |
var moveTemplates = cfg2.moves; | |
var moves = []; | |
var lastSrc = -1, lastCnt = 0, lastDst = -1; | |
if (lastMove != null && lastMove.length != 0) { | |
lastSrc = lastMove[0]; | |
lastCnt = lastMove[1]; | |
lastDst = lastMove[2]; | |
} | |
for (var dst = 0; dst < fields.length; ++dst) { | |
var dstHeight = fields[dst].length; | |
if (dstHeight === 0) { | |
if (piecesLeft[nextPlayer]) { | |
moves.push([-1, 1, dst]); | |
} | |
} else { | |
var options = moveTemplates[dst][dstHeight]; | |
for (var i = 0; i < options.length; ++i) { | |
var src = options[i][0]; | |
var srcHeight = fields[src].length; | |
if (srcHeight !== 0 && (occupied & options[i][1]) === 0) { | |
for (var cnt = 1; cnt <= srcHeight; ++cnt) { | |
if (src == lastDst && cnt === lastCnt && dst == lastSrc) continue; | |
moves.push([src, cnt, dst]); | |
} | |
} | |
} | |
} | |
} | |
if (moves.length === 0) moves.push(PASS); | |
return moves; | |
} | |
function getMoveWinner(move) { | |
if (move.length !== 0 && fields[move[2]].length + move[1] >= cfg2.winningHeight) { | |
var srcField = fields[move[0]]; | |
return srcField[srcField.length - 1]; | |
} | |
return -1; | |
} | |
function triageMoves(moves) { | |
var winningMoves = []; | |
var neutralMoves = []; | |
var losingMoves = []; | |
for (var i = 0; i < moves.length; ++i) { | |
var move = moves[i]; | |
var winner = getMoveWinner(move); | |
if (winner === -1) { | |
neutralMoves.push(move); | |
} else if (winner === nextPlayer) { | |
winningMoves.push(move); | |
} else { | |
losingMoves.push(move); | |
} | |
} | |
return [winningMoves, neutralMoves, losingMoves]; | |
} | |
function playRandomMove() { | |
var triagedMoves = triageMoves(generateMoves()); | |
for (var i = 0; i < triagedMoves.length; ++i) { | |
var moves = triagedMoves[i]; | |
if (moves.length > 0) { | |
doMove(randomChoice(moves)); | |
return; | |
} | |
} | |
throw new Error("No moves available!"); | |
} | |
function randomPlayout(maxSteps) { | |
for (var step = 0; step < maxSteps; ++step) { | |
if (getWinner() !== -1) return step; | |
playRandomMove(); | |
} | |
return maxSteps; | |
} | |
function debugPrint() { | |
log("Scores left: " + scoresLeft); | |
log("Pieces left: " + piecesLeft); | |
log("Player " + (nextPlayer + 1) + " to move."); | |
for (var r = 0; r < cfg2.rows; ++r) { | |
var line = formatRow(r) + " "; | |
for (var c = 0; c < cfg2.cols; ++c) { | |
var src = rowColToFieldIndex(cfg2, r, c); | |
var part = ""; | |
if (src === -1) { | |
part = "#"; | |
} else if (fields[src].length === 0) { | |
part = "."; | |
} else { | |
for (var i = 0; i < fields[src].length; ++i) { | |
part += String(fields[src][i] + 1); | |
} | |
} | |
while (part.length < cfg2.winningHeight) part += " "; | |
line += " " + part; | |
if (src !== -1 && (occupied & 1 << src) !== 0 != (fields[src].length !== 0)) { | |
log("INTERNAL ERROR: occupied does not match fields at " + src); | |
} | |
} | |
log(line); | |
} | |
var line = " "; | |
for (var c = 0; c < cfg2.cols; ++c) { | |
var part = formatCol(c); | |
while (part.length < cfg2.winningHeight) part += " "; | |
line += " " + part; | |
} | |
log(line); | |
log("last move: " + (lastMove ? formatMove(cfg2, lastMove) : "none")); | |
var moves = generateMoves(); | |
log(moves.length + " possible moves: " + formatMoves(cfg2, moves)); | |
} | |
function toJson() { | |
return { | |
fields: fields, | |
nextPlayer: nextPlayer, | |
lastMove: lastMove, | |
scoresLeft: scoresLeft, | |
piecesLeft: piecesLeft | |
}; | |
} | |
function clone(winningScore) { | |
return State( | |
cfg2, | |
cloneFields(fields), | |
nextPlayer, | |
lastMove, | |
cloneArray(piecesLeft), | |
winningScore == null ? cloneArray(scoresLeft) : arrayOfValues(cfg2.playerCount, winningScore), | |
occupied | |
); | |
} | |
return { | |
getNextPlayer: getNextPlayer, | |
getWinner: getWinner, | |
generateMoves: generateMoves, | |
doMove: doMove, | |
undoMove: undoMove, | |
evaluate: evaluate, | |
triageMoves: triageMoves, | |
randomPlayout: randomPlayout, | |
debugPrint: debugPrint, | |
toJson: toJson, | |
clone: clone | |
}; | |
} | |
function createStateFromJson(cfg2, inputJson) { | |
var fields = inputJson.fields; | |
var occupied = 0; | |
for (var i = 0; i < fields.length; ++i) { | |
if (fields[i].length > 0) occupied |= 1 << i; | |
} | |
return State(cfg2, fields, inputJson.nextPlayer, inputJson.lastMove, inputJson.piecesLeft, inputJson.scoresLeft, occupied); | |
} | |
// src/cpu-player.js | |
function convertMoveFromApi(cfg2, apiMove) { | |
if (apiMove == null) return null; | |
if (apiMove.length === 0) return apiMove; | |
var src = apiMove[0]; | |
var cnt = apiMove[1]; | |
var dst = apiMove[2]; | |
if (src !== -1) src = cfg2.apiToFieldIndex[src]; | |
dst = cfg2.apiToFieldIndex[dst]; | |
return [src, cnt, dst]; | |
} | |
function initCpuWrapper(jsonBoard) { | |
return createConfig( | |
game.getBoardDimensionY(), | |
game.getBoardDimensionX(), | |
JSON.parse(jsonBoard), | |
game.getOutside(), | |
game.getPadding(), | |
game.getHeightToWin(), | |
// padding size | |
game.getHeightToWin(), | |
game.getNumOfPlayers() | |
); | |
} | |
function callCpuWrapper(findBestMoves2, cfg2, jsonBoard) { | |
var board = JSON.parse(jsonBoard); | |
var nextPlayer = game.getID() - 1; | |
if (!Number.isInteger(nextPlayer) || nextPlayer < 0 || nextPlayer >= cfg2.playerCount) { | |
throw new Error("Invalid player ID"); | |
} | |
var apiMoves = JSON.parse(game.getLegalMoves()); | |
if (apiMoves.length === 0) throw new Error("No moves available"); | |
if (apiMoves.length === 1) return apiMoves[0]; | |
var moves = apiMoves.map(convertMoveFromApi.bind(null, cfg2)); | |
var fields = []; | |
for (var i = 0; i < cfg2.fieldCount; ++i) { | |
var field = []; | |
var string = board[cfg2.fieldIndexToApi[i]]; | |
for (var j = 0; j < string.length; ++j) { | |
var piece = string.charCodeAt(j) - "1".charCodeAt(0); | |
if (piece < 0 || piece >= cfg2.playerCount) throw new Error("Invalid piece"); | |
field.push(piece); | |
} | |
fields.push(field); | |
} | |
if (fields.length !== cfg2.fieldCount) throw new Error("Invalid number of fields"); | |
var state = createStateFromJson(cfg2, { | |
fields: fields, | |
nextPlayer: nextPlayer, | |
piecesLeft: game.getNumberOfStones(), | |
scoresLeft: game.getTowersNeededToWin(), | |
lastMove: convertMoveFromApi(game.getLastMove()) | |
}); | |
var result = findBestMoves2(cfg2, state, moves); | |
var bestMoves = result[0]; | |
var bestValue = result[1]; | |
var bestMove = randomChoice(bestMoves); | |
log(moves.length + " moves, " + bestMoves.length + " best with value " + bestValue + ": " + formatMoves(cfg2, bestMoves) + "; selected " + formatMove(cfg2, bestMove) + " at random"); | |
var moveIndex = indexOfMove(moves, bestMove); | |
if (moveIndex < 0) throw new Error("Best move not found somehow?!"); | |
return apiMoves[moveIndex]; | |
} | |
// src/MonteCarloCPU.js | |
var cfg = null; | |
function initCPU(jsonBoard) { | |
cfg = initCpuWrapper(jsonBoard); | |
} | |
function callCPU(jsonBoard) { | |
if (cfg == null) throw new Error("initCPU() has not been called!"); | |
return callCpuWrapper(findBestMoves, cfg, jsonBoard); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment