hive-game-core
Version:
Various stuff implementing the game and rules of Hive, the strategy game
387 lines (364 loc) • 15.2 kB
JavaScript
const _ = require('lodash')
const shortid = require('shortid')
const { Move, MoveValidationError } = require('./Move')
class Board {
constructor(opts={}) {
this._id = shortid.generate()
this._cells = {}
this._opts = opts
this._logFn = opts.logFn
if (opts.state) {
this.state = opts.state
}
}
get clone() {
const newBoard = new Board()
newBoard._cells = JSON.parse(JSON.stringify(this._cells))
return newBoard
}
get id() { return this._id }
get piecesPlaced() {
const placed = { white: [], black: [] }
for (const coords in this._cells) {
if (!this._cells.hasOwnProperty(coords)) { continue }
const cell = this._cells[coords]
cell.forEach((tile, i) => {
const coords3 = [...coords.split(',').map(x => parseInt(x, 10)), i]
if (tile.white) { placed.white.push({ piece: tile.piece, coords3 }) }
else { placed.black.push({ piece: tile.piece, coords3 }) }
})
}
placed.white = _.sortBy(placed.white, t => t.piece)
placed.black = _.sortBy(placed.black, t => t.piece)
return placed
}
get state() {
return JSON.parse(JSON.stringify(this._cells))
}
set state(s) {
this._cells = JSON.parse(JSON.stringify(s))
}
get _occupiedAndAdjacentCoord2s() {
const occupiedCoordKeys = _(this._cells).keys().filter(k => this._getCell(k.split(',')).length > 0).value()
const occupiedAndAdjacent = (_(occupiedCoordKeys)
.map(k => k.split(',').map(v => parseInt(v, 10)))
.map(c => this.getAdjacentCells(c, true))
.flatten()
.map(c => c.coords2.join(','))
.uniq()
.sortBy()
.map(k => k.split(',').map(v => parseInt(v, 10)))
.value())
return occupiedAndAdjacent
}
applyMove(move, turn) {
if (!turn) { throw new Error('turn argument required for applyMove') }
if (_.isString(move)) { move = new Move(move) }
const { origin } = this.validateMove(move, turn)
// Apply move
if (move.isPlacement) {
const white = ((turn - 1) % 2) === 0
this._addToTopOfCell(move.placementCoords, { white, piece: move.piece })
} else {
this._moveTopToTopOfCell(origin, move.movementTargetCoords)
}
return move
}
getAdjacentCells(coords2, richWithCoords=false) {
return [
[ coords2[0] + 1, coords2[1] ],
[ coords2[0], coords2[1] + 1 ],
[ coords2[0] - 1, coords2[1] + 1 ],
[ coords2[0] - 1, coords2[1] ],
[ coords2[0], coords2[1] - 1 ],
[ coords2[0] + 1, coords2[1] - 1 ],
].map(adjCoords2 => (richWithCoords ? {
coords2: adjCoords2,
cell: this._getCell(adjCoords2)
} : this._getCell(adjCoords2)))
}
getHopPathsFromOrigin(coords2) {
return _([ [1, 0], [0, 1], [-1, 1], [-1, 0], [0, -1], [1, -1] ]).map(hopVector => {
let current = [coords2[0] + hopVector[0], coords2[1] + hopVector[1]]
let numCleared = 0
while (this._getCell(current).length > 0) {
current = [current[0] + hopVector[0], current[1] + hopVector[1]]
numCleared++
}
if (numCleared === 0) { return null }
return [coords2, current]
}).reject(p => p === null).value()
}
getPathsAroundHive(origin, dest, stopTryingCb=null, allowClimbing=false, _currPath=null) {
if (_currPath === null) { _currPath = [origin] }
if (stopTryingCb && stopTryingCb(_currPath)) { return null }
const adjacentCells = this.getAdjacentCells(origin, true)
// Trawl through the adjacents for something valid
const validNextSteps =
_(adjacentCells)
// Filter out non-empty cells
.reject(({cell}) => (!allowClimbing && cell.length > 0))
// Filter out cells with no adjacent occupied cells (away from hive) apart from path start
.reject(({coords2}) => {
return _.filter(this.getAdjacentCells(coords2, true), c => {
const adjCoords = c.coords2
const { cell } = c
return ((cell.length === 0) || (adjCoords[0] === _currPath[0][0] && adjCoords[1] === _currPath[0][1]))
}).length === 6
})
// Filter out cells already in the path
.reject(({coords2}) => !!_.find(_currPath, c => (c[0] === coords2[0] && c[1] === coords2[1])))
// Reject cells with no occupied shared neighbours (hive break)
.reject(({coords2}) => {
const nextZ = this._getCell(coords2).length
if (allowClimbing && nextZ > 0) { return false }
const sharedNeighbours = this._getSharedNeighbours(_currPath[_currPath.length - 1], coords2)
return _(sharedNeighbours).filter(n => this._getCell(n).length > 0).value().length === 0
})
// Reject cells with two occupied shared neighbours with at least the same Z value as origin
.reject(({coords2}) => {
const originZ = this._getCell(origin).length - 1
const sharedNeighbours = this._getSharedNeighbours(_currPath[_currPath.length - 1], coords2)
return _(sharedNeighbours).filter(n => this._getCell(n).length > originZ).value().length === 2
})
.value()
if (validNextSteps.length === 0) {
// No good paths this way
return []
}
// If dest is in valid next steps, we're done this path
if (_.filter(validNextSteps, ({coords2}) => (coords2[0] === dest[0] && coords2[1] === dest[1])).length > 0) {
return [[origin, dest]]
}
const validPaths = []
for (const {coords2} of validNextSteps) {
// If we're looking at more than one potential future, or if this is the first recursion level, we'll need
// to clone this board to play a hypothetical future game path
const nextStepBoard = (validPaths.length === 1 && _currPath.length > 1) ? this : this.clone
const newCurrentPath = JSON.parse(JSON.stringify(_currPath))
newCurrentPath.push(coords2)
nextStepBoard._moveTopToTopOfCell(_currPath[_currPath.length - 1], coords2)
// Recurse, but on the _new_ board, to represent the up-to-date state in-move
const pathFinishers = nextStepBoard.getPathsAroundHive(coords2, dest, stopTryingCb, allowClimbing, newCurrentPath)
if (pathFinishers === null) { continue }
for (const pathFinish of pathFinishers) {
validPaths.push([ origin, ...pathFinish ])
}
}
return validPaths
}
getQueen(white) {
const piecesPlaced = this.piecesPlaced[white ? 'white' : 'black']
return _.find(piecesPlaced, p => p.piece === 'Q') || null
}
getValidPlacementLocations(turn) {
if (turn === 1) { return [ [0,0] ] }
if (turn === 2) { return [ [1,0] ] }
const white = ((turn - 1) % 2) === 0
return _(
this.piecesPlaced[white ? 'white' : 'black'])
// Get the unique coordinates of all player pieces
.map(p => _.slice(p.coords3, 0, 2))
.uniqBy(coords2 => coords2.join('|'))
// Get the cells adjacent to all player pieces
.map(coords2 => this.getAdjacentCells(coords2, true))
.flatten()
// Get only the empty cells adjacent to all player pieces
.filter(({ cell }) => !cell.length)
.uniqBy(({ coords2 }) => coords2.join('|'))
// Get the adjacent cells to those empty spaces
.map(({ coords2 }) => ({ coords2, adjacents: this.getAdjacentCells(coords2, true) }))
// Filter for the ones which have no adjacent opponent pieces
.filter(({ adjacents }) => _(
adjacents)
// Get top tile or null of all tiles in set
.map(t => (t.cell.length === 0 ? null : t.cell[t.cell.length - 1]))
// Filter out nulls
.reject(p => p === null)
// Get opponent's ones only
.filter(p => p.white !== white)
.value().length === 0
)
// Get coords of all such empty spots
.map(({ coords2 }) => coords2)
// Sort them (mostly for testing purposes)
.sortBy(coords2 => coords2.join('|'))
// Voila
.value()
}
getValidMovementTargets(originCoords3, turn) {
const cell = this._getCell(originCoords3)
if (originCoords3[2] < cell.length - 1) { return [] }
const piece = cell[originCoords3[2]].piece
const validTargets = []
for (const occupiedOrAdjacentCoord2 of this._occupiedAndAdjacentCoord2s) {
const topOfCellCoords3 = [...occupiedOrAdjacentCoord2, this._getCell(occupiedOrAdjacentCoord2).length]
const moveString = `${piece}[${originCoords3.join(',')}]>${topOfCellCoords3}`
try {
this.validateMove(moveString, turn)
validTargets.push(topOfCellCoords3)
} catch(e) {}
}
return validTargets
}
isAdjacentToPiece(coords2, piece, white) {
// Is coords2 adjacent to a cell topped by the given piece?
const occupiedAdjacent = _.filter(this.getAdjacentCells(coords2), c => c.length > 0)
const toppers = _.map(occupiedAdjacent, _.last)
return !!_.find(toppers, t => (t.piece === piece && t.white === white))
}
validateMove(move, turn) {
if (_.isString(move)) { move = new Move(move) }
const white = ((turn - 1) % 2) === 0
const piecesPlacedByCurrentPlayer = this.piecesPlaced[white ? 'white' : 'black']
const piecesPlayedPiecesOnly = piecesPlacedByCurrentPlayer.map(t => t.piece)
if (move.isPlacement) {
// Validate move
if (turn <= 2 && move.piece === 'Q') {
throw new MoveValidationError('NO_QUEEN_FIRST')
}
if (turn === 1 && (move.placementCoords[0] !== 0 || move.placementCoords[1] !== 0)) {
throw new MoveValidationError('FIRST_MOVE_ORIGIN_REQUIRED')
}
if (turn === 2 && (move.placementCoords[0] !== 1 || move.placementCoords[1] !== 0)) {
throw new MoveValidationError('SECOND_MOVE_PRIMARY_REQUIRED')
}
if ((turn === 7 || turn === 8) && !_.includes(piecesPlayedPiecesOnly, 'Q') && move.piece !== 'Q') {
throw new MoveValidationError('QUEEN_REQUIRED_IN_FIRST_FOUR')
}
if (this._getCell(move.placementCoords).length) {
throw new MoveValidationError('NO_PLACEMENT_ON_OTHER_PIECES')
}
if (turn > 2) {
for (const cell of this.getAdjacentCells(move.placementCoords)) {
if (cell.length === 0) { continue }
if (cell[cell.length - 1].white !== white) {
throw new MoveValidationError('NO_PLACEMENT_NEXT_TO_OPPONENT')
}
}
}
return {}
}
if (move.isMovement) {
// Validate move
if (!_.includes(piecesPlayedPiecesOnly, 'Q')) {
throw new MoveValidationError('NO_MOVE_BEFORE_QUEEN')
}
let origin = null
if (move.hasImplicitOrigin) {
let samePlayed = 0
for (const alreadyPlayed of piecesPlacedByCurrentPlayer) {
if (alreadyPlayed.piece === move.piece) {
samePlayed++
origin = alreadyPlayed.coords3
if (samePlayed > 1) {
throw new MoveValidationError('AMBIGUOUS_ORIGIN_PIECE')
}
}
}
if (samePlayed === 0) {
throw new MoveValidationError('NO_SUCH_PIECE')
}
}
if (move.hasExplicitOrigin) {
origin = move.movementOriginCoords
const cell = this._getCell(move.movementOriginCoords)
if (cell.length <= move.movementOriginCoords[2] ||
cell[move.movementOriginCoords[2]].piece !== move.piece) {
throw new MoveValidationError('NO_SUCH_PIECE')
}
}
if (this._getCell(origin).length > (origin[2] + 1)) {
throw new MoveValidationError('PIECE_TRAPPED_UNDER_ANOTHER')
}
if (move.movementTargetCoords[2] < this._getCell(move.movementTargetCoords).length) {
throw new MoveValidationError('MOVEMENT_TARGET_OCCUPIED')
}
if (move.movementTargetCoords[2] > this._getCell(move.movementTargetCoords).length) {
throw new MoveValidationError('MOVEMENT_TARGET_ABOVE_HIVE')
}
// TODO piece-specific movement rules
// Disallow climbing for non-climbers
if (_.includes(['Q', 'A', 'S', 'G'], move.piece)) {
if (move.movementTargetCoords[2] > 0) {
throw new MoveValidationError('CANNOT_CLIMB')
}
}
let isSlider = false
if (_.includes(['Q', 'A', 'S', 'B'], move.piece)) {
isSlider = true
}
// Compare intended around-hive move against valid paths for sliders
if (isSlider) {
const allowClimbing = (move.piece === 'B')
let paths = this.getPathsAroundHive(origin, move.movementTargetCoords, path => {
if (_.includes(['Q', 'B'], move.piece) && path.length > 2) { return true }
if (move.piece === 'S' && path.length > 4) { return true }
return false
}, allowClimbing)
if (_.includes(['Q', 'B'], move.piece)) {
paths = _.filter(paths, p => p.length === 2)
}
if (move.piece === 'S') {
paths = _.filter(paths, p => p.length === 4)
}
if (paths.length === 0) {
throw new MoveValidationError('NO_VALID_PATH')
}
}
// Validate hop paths for hoppers
if (move.piece === 'G') {
const paths = this.getHopPathsFromOrigin(origin)
const landingSites = _.map(paths, _.last)
if (!_.find(landingSites, c => (c[0] === move.movementTargetCoords[0] && c[1] === move.movementTargetCoords[1]))) {
throw new MoveValidationError('NO_VALID_PATH')
}
}
return { origin }
}
}
_addToTopOfCell(coords2, tile) {
const coordsKey = this._getCellCoordsKey(coords2)
if (!this._cells[coordsKey]) { this._cells[coordsKey] = [] }
this._cells[coordsKey].push(tile)
}
_cellsAreAdjacent(coords2A, coords2B) {
const adjCoords = _(this.getAdjacentCells(coords2A, true)).map(c => c.coords2).value()
const match = _.find(adjCoords, c => (c[0] === coords2B[0] && c[1] === coords2B[1]))
return !!match
}
_moveTopToTopOfCell(origin, dest) {
// Apply move - pop piece from its origin...
const origin2 = [ origin[0], origin[1] ]
const originCellKey = this._getCellCoordsKey(origin)
const originCell = this._cells[originCellKey]
const piece = JSON.parse(JSON.stringify(originCell[originCell.length - 1])).piece
const white = JSON.parse(JSON.stringify(originCell[originCell.length - 1])).white
originCell.splice(originCell.length - 1, 1)
// And add it at the target
const target2 = [ dest[0], dest[1] ]
const cell = this._getCell(dest)
cell.push({ piece, white })
this._cells[this._getCellCoordsKey(target2)] = cell
}
_getCell(coords) {
const coords2 = [coords[0], coords[1]]
const coordsKey = this._getCellCoordsKey(coords2)
return this._cells[coordsKey] || []
}
_getCellCoordsKey(coords2) {
return `${coords2[0]},${coords2[1]}`
}
_getSharedNeighbours(coordsA, coordsB) {
const adjA = this.getAdjacentCells(coordsA, true)
const adjB = this.getAdjacentCells(coordsB, true)
return _(adjA).filter(adjCellA => {
return _.find(adjB, adjCellB => (adjCellA.coords2[0] === adjCellB.coords2[0] && adjCellA.coords2[1] === adjCellB.coords2[1]))
}).map(c => c.coords2).value()
}
_log(s) {
if (!this._logFn) { return }
this._logFn(`Hive [Board:${this.id}] : ${s}`)
}
}
module.exports = { Board }