Skip to content

Commit

Permalink
fix(core): player-ai should avoid losing
Browse files Browse the repository at this point in the history
  • Loading branch information
kenrick95 committed Apr 21, 2023
1 parent 4ab0154 commit 1c4941c
Show file tree
Hide file tree
Showing 6 changed files with 266 additions and 51 deletions.
8 changes: 4 additions & 4 deletions core/src/__testHelpers/test-game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { BoardBase } from '../board'
import { Player } from '../player'

export class TestGame extends GameBase {
afterMoveResolve: null | (() => void) = null
afterMovePromise: null | Promise<void> = null
afterMoveResolve: null | ((action: number) => void) = null
afterMovePromise: null | Promise<number> = null

constructor(players: Array<Player>, board: BoardBase) {
super(players, board)
Expand All @@ -17,9 +17,9 @@ export class TestGame extends GameBase {
beforeMoveApplied() {
// no-op
}
afterMove() {
afterMove(action: number) {
if (this.afterMoveResolve) {
this.afterMoveResolve()
this.afterMoveResolve(action)
}
this.renewAfterMovePromise()
}
Expand Down
119 changes: 119 additions & 0 deletions core/src/__tests__/__snapshots__/humanVsAi.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`PlayerHuman vs PlayerAi > Issue #3 1`] = `
[
[
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
1,
0,
0,
0,
],
[
0,
0,
0,
1,
0,
1,
0,
],
[
0,
0,
0,
2,
1,
2,
1,
],
[
0,
0,
0,
1,
2,
2,
2,
],
[
0,
0,
0,
1,
2,
2,
1,
],
]
`;

exports[`PlayerHuman vs PlayerAi > Issue #3 2`] = `
[
[
0,
0,
0,
0,
0,
0,
0,
],
[
0,
0,
0,
1,
0,
0,
0,
],
[
0,
0,
0,
1,
0,
1,
0,
],
[
0,
0,
0,
2,
1,
2,
1,
],
[
0,
0,
0,
1,
2,
2,
2,
],
[
0,
0,
2,
1,
2,
2,
1,
],
]
`;
94 changes: 94 additions & 0 deletions core/src/__tests__/humanVsAi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { TestGame } from '../__testHelpers/test-game'
import { BoardPiece, BoardBase } from '../board'
import { TestPlayer } from '../__testHelpers/test-player'
import { clone } from '../utils'
import { describe, test, expect } from 'vitest'
import { PlayerAi } from '../player'

describe('PlayerHuman vs PlayerAi', () => {
const testPlayer = new TestPlayer(BoardPiece.PLAYER_1)
const aiPlayer = new PlayerAi(BoardPiece.PLAYER_2)
const players = [testPlayer, aiPlayer]
test('Issue #3', async () => {
const board = new BoardBase()
const game = new TestGame(players, board)

/*
Next move is Player 2 (AI)
Current board:
0 0 0 0 0 0 0
0 0 0 1 0 0 0
0 0 0 1 0 1 0
0 0 0 2 1 2 1
0 0 0 1 2 2 2
0 0 0 1 2 2 1
Should choose column 2 to block immediate Player 1 from winning
*/
board.map = [
[
BoardPiece.EMPTY,
BoardPiece.EMPTY,
BoardPiece.EMPTY,
BoardPiece.EMPTY,
BoardPiece.EMPTY,
BoardPiece.EMPTY,
BoardPiece.EMPTY,
],
[
BoardPiece.EMPTY,
BoardPiece.EMPTY,
BoardPiece.EMPTY,
BoardPiece.PLAYER_1,
BoardPiece.EMPTY,
BoardPiece.EMPTY,
BoardPiece.EMPTY,
],
[
BoardPiece.EMPTY,
BoardPiece.EMPTY,
BoardPiece.EMPTY,
BoardPiece.PLAYER_1,
BoardPiece.EMPTY,
BoardPiece.PLAYER_1,
BoardPiece.EMPTY,
],
[
BoardPiece.EMPTY,
BoardPiece.EMPTY,
BoardPiece.EMPTY,
BoardPiece.PLAYER_2,
BoardPiece.PLAYER_1,
BoardPiece.PLAYER_2,
BoardPiece.PLAYER_1,
],
[
BoardPiece.EMPTY,
BoardPiece.EMPTY,
BoardPiece.EMPTY,
BoardPiece.PLAYER_1,
BoardPiece.PLAYER_2,
BoardPiece.PLAYER_2,
BoardPiece.PLAYER_2,
],
[
BoardPiece.EMPTY,
BoardPiece.EMPTY,
BoardPiece.EMPTY,
BoardPiece.PLAYER_1,
BoardPiece.PLAYER_2,
BoardPiece.PLAYER_2,
BoardPiece.PLAYER_1,
],
]

expect(board.map).toMatchSnapshot()
game.currentPlayerId = 1
game.start()
const chosenAction = await game.afterMovePromise
expect(chosenAction).toBe(2)
expect(board.map).toMatchSnapshot()
})
})
87 changes: 44 additions & 43 deletions core/src/player/player-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,17 +100,14 @@ export class PlayerAi extends Player {
): number {
const isWon = winnerBoardPiece === this.boardPiece
const isLost = winnerBoardPiece === this.enemyBoardPiece

// value is slightly higher than BIG_NEGATIVE_NUMBER & lower than BIG_POSITIVE_NUMBER
// so that minState(...) and maxState(...) could "catch"" this value and AI take this move
// This is just my hypothesis, I haven't tested without it yet.
// My point is that this AI implementation is basically a heuristic function :P
returnValue -= depth * depth
if (isWon) {
returnValue = BIG_POSITIVE_NUMBER - 100
// Prefer to win in closer steps
returnValue = BIG_POSITIVE_NUMBER - 100 - depth * depth
} else if (isLost) {
returnValue = BIG_NEGATIVE_NUMBER + 100
// Prefer to lose in more steps
returnValue = BIG_NEGATIVE_NUMBER + 100 + depth * depth
}
returnValue -= depth * depth
return returnValue
}
private getMove(
Expand All @@ -128,11 +125,12 @@ export class PlayerAi extends Player {

if (depth >= PlayerAi.MAX_DEPTH || isWon || isLost) {
return {
value: this.transformValues(
stateValue.chain * this.ownBoardPieceValue,
stateValue.winnerBoardPiece,
depth
),
value:
this.transformValues(
stateValue.chain,
stateValue.winnerBoardPiece,
depth
) * this.ownBoardPieceValue,
move: -1, // leaf node
}
}
Expand All @@ -159,24 +157,25 @@ export class PlayerAi extends Player {
this.boardPiece,
column
)
if (actionSuccessful) {
const { value: nextValue } = this.getMove(nextState, depth, alpha, beta)
if (nextValue > value) {
value = nextValue
moveQueue = [column]
} else if (nextValue === value) {
moveQueue.push(column)
}
if (!actionSuccessful) {
continue
}
const { value: nextValue } = this.getMove(nextState, depth, alpha, beta)
if (nextValue > value) {
value = nextValue
moveQueue = [column]
} else if (nextValue === value) {
moveQueue.push(column)
}

// alpha-beta pruning
if (value > beta) {
return {
value: value,
move: choose(moveQueue),
}
// alpha-beta pruning
if (value > beta) {
return {
value: value,
move: choose(moveQueue),
}
alpha = Math.max(alpha, value)
}
alpha = Math.max(alpha, value)
}

return {
Expand All @@ -201,25 +200,27 @@ export class PlayerAi extends Player {
this.enemyBoardPiece,
column
)
if (actionSuccessful) {
const { value: nextValue } = this.getMove(nextState, depth, alpha, beta)
if (nextValue < value) {
value = nextValue
moveQueue = [column]
} else if (nextValue === value) {
moveQueue.push(column)
}
if (!actionSuccessful) {
continue
}
const { value: nextValue } = this.getMove(nextState, depth, alpha, beta)
if (nextValue < value) {
value = nextValue
moveQueue = [column]
} else if (nextValue === value) {
moveQueue.push(column)
}

// alpha-beta pruning
if (value < alpha) {
return {
value: value,
move: choose(moveQueue),
}
// alpha-beta pruning
if (value < alpha) {
return {
value: value,
move: choose(moveQueue),
}
beta = Math.min(beta, value)
}
beta = Math.min(beta, value)
}

return {
value: value,
move: choose(moveQueue),
Expand Down
1 change: 1 addition & 0 deletions core/src/player/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BoardBase, BoardPiece } from '../board'

export abstract class Player {
boardPiece: BoardPiece
/** @return {number} column number (0-index) */
abstract getAction(board: BoardBase): Promise<number>
constructor(boardPiece: BoardPiece) {
this.boardPiece = boardPiece
Expand Down
8 changes: 4 additions & 4 deletions core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ export function getRandomColumnNumber(): number {
return Math.floor(Math.random() * BoardBase.COLUMNS)
}

export function choose(choice: Array<any>): any {
export function choose<T>(choice: Array<T>): T {
return choice[Math.floor(Math.random() * choice.length)]
}

export function clone(array: Array<Array<any>>): Array<Array<any>> {
const arr: Array<Array<any>> = []
export function clone<T>(array: Array<Array<T>>): Array<Array<T>> {
const arr: Array<Array<T>> = []

for (let i: number = 0; i < array.length; i++) arr[i] = array[i].slice()

Expand All @@ -57,7 +57,7 @@ export function getMockPlayerAction(
success: boolean
map: Array<Array<number>>
} {
const clonedMap: Array<Array<any>> = clone(map)
const clonedMap: Array<Array<number>> = clone(map)

if (
clonedMap[0][column] !== BoardPiece.EMPTY ||
Expand Down

0 comments on commit 1c4941c

Please sign in to comment.