Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(core): AI logic #43

Merged
merged 2 commits into from
Apr 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -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
Expand Down
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,
],
]
`;
93 changes: 93 additions & 0 deletions core/src/__tests__/humanVsAi.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
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
Loading