From 85351c49e191021f121f0209812e1d29aead1462 Mon Sep 17 00:00:00 2001 From: Kenrick Date: Fri, 21 Apr 2023 20:18:37 +0800 Subject: [PATCH] fix(core): AI logic (#43) * fix(core): player-ai should avoid losing * chore: remove year from license --- LICENSE | 2 +- core/src/__testHelpers/test-game.ts | 8 +- .../__tests__/__snapshots__/humanVsAi.ts.snap | 119 ++++++++++++++++++ core/src/__tests__/humanVsAi.ts | 93 ++++++++++++++ core/src/player/player-ai.ts | 87 ++++++------- core/src/player/player.ts | 1 + core/src/utils.ts | 8 +- 7 files changed, 266 insertions(+), 52 deletions(-) create mode 100644 core/src/__tests__/__snapshots__/humanVsAi.ts.snap create mode 100644 core/src/__tests__/humanVsAi.ts diff --git a/LICENSE b/LICENSE index bf434a1..a23f70a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2019 Kenrick +Copyright (c) Kenrick Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/core/src/__testHelpers/test-game.ts b/core/src/__testHelpers/test-game.ts index a0d8aff..6cc0142 100644 --- a/core/src/__testHelpers/test-game.ts +++ b/core/src/__testHelpers/test-game.ts @@ -3,8 +3,8 @@ import { BoardBase } from '../board' import { Player } from '../player' export class TestGame extends GameBase { - afterMoveResolve: null | (() => void) = null - afterMovePromise: null | Promise = null + afterMoveResolve: null | ((action: number) => void) = null + afterMovePromise: null | Promise = null constructor(players: Array, board: BoardBase) { super(players, board) @@ -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() } diff --git a/core/src/__tests__/__snapshots__/humanVsAi.ts.snap b/core/src/__tests__/__snapshots__/humanVsAi.ts.snap new file mode 100644 index 0000000..77ecd92 --- /dev/null +++ b/core/src/__tests__/__snapshots__/humanVsAi.ts.snap @@ -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, + ], +] +`; diff --git a/core/src/__tests__/humanVsAi.ts b/core/src/__tests__/humanVsAi.ts new file mode 100644 index 0000000..57769b3 --- /dev/null +++ b/core/src/__tests__/humanVsAi.ts @@ -0,0 +1,93 @@ +import { TestGame } from '../__testHelpers/test-game' +import { BoardPiece, BoardBase } from '../board' +import { TestPlayer } from '../__testHelpers/test-player' +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() + }) +}) diff --git a/core/src/player/player-ai.ts b/core/src/player/player-ai.ts index 3899d09..f67fab5 100644 --- a/core/src/player/player-ai.ts +++ b/core/src/player/player-ai.ts @@ -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( @@ -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 } } @@ -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 { @@ -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), diff --git a/core/src/player/player.ts b/core/src/player/player.ts index 956bdb9..9344e05 100644 --- a/core/src/player/player.ts +++ b/core/src/player/player.ts @@ -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 constructor(boardPiece: BoardPiece) { this.boardPiece = boardPiece diff --git a/core/src/utils.ts b/core/src/utils.ts index ad7ee78..ecf54fb 100644 --- a/core/src/utils.ts +++ b/core/src/utils.ts @@ -37,12 +37,12 @@ export function getRandomColumnNumber(): number { return Math.floor(Math.random() * BoardBase.COLUMNS) } -export function choose(choice: Array): any { +export function choose(choice: Array): T { return choice[Math.floor(Math.random() * choice.length)] } -export function clone(array: Array>): Array> { - const arr: Array> = [] +export function clone(array: Array>): Array> { + const arr: Array> = [] for (let i: number = 0; i < array.length; i++) arr[i] = array[i].slice() @@ -57,7 +57,7 @@ export function getMockPlayerAction( success: boolean map: Array> } { - const clonedMap: Array> = clone(map) + const clonedMap: Array> = clone(map) if ( clonedMap[0][column] !== BoardPiece.EMPTY ||