Skip to content

Commit 3419c60

Browse files
authored
Merge pull request #516 from uwcsc/Issue495
Connect4 Bot AI
2 parents 237d536 + 204e96f commit 3419c60

File tree

2 files changed

+212
-23
lines changed

2 files changed

+212
-23
lines changed

src/components/games/connectFour.ts

+201-16
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { SapphireMessageResponse, SapphireSentMessageType } from '../../codeyCom
1010
import { openDB } from '../db';
1111
import { CodeyUserError } from '../../codeyUserError';
1212
import { getEmojiByName } from '../emojis';
13-
import { getRandomIntFrom1 } from '../../utils/num';
1413

1514
const CONNECT_FOUR_COLUMN_COUNT = 7;
1615
const CONNECT_FOUR_ROW_COUNT = 6;
@@ -228,14 +227,13 @@ export class ConnectFourGame {
228227
left_pointer_y++;
229228
}
230229
while (
231-
right_pointer_x + 1 < CONNECT_FOUR_COLUMN_COUNT - 1 &&
230+
right_pointer_x + 1 < CONNECT_FOUR_COLUMN_COUNT && // used to be an extra -1, fixed bug
232231
right_pointer_y - 1 >= 0 &&
233232
state.columns[right_pointer_x + 1].tokens[right_pointer_y - 1] == sign
234233
) {
235234
right_pointer_x++;
236235
right_pointer_y--;
237236
}
238-
239237
if (right_pointer_x - left_pointer_x + 1 >= 4) {
240238
return true;
241239
}
@@ -253,6 +251,8 @@ export class ConnectFourGame {
253251
// newly placed token
254252
const horizontalIndex = columnIndex;
255253
const verticalIndex = state.columns[columnIndex].fill - 1;
254+
//console.log(horizontalIndex);
255+
//console.log(verticalIndex);
256256
const column = state.columns[columnIndex];
257257
const sign: ConnectFourGameSign = state.columns[columnIndex].tokens[verticalIndex];
258258

@@ -265,11 +265,11 @@ export class ConnectFourGame {
265265
state.winType = ConnectFourWinType.Horizontal;
266266
state.status = this.determineWinner(sign);
267267
} else if (this.checkForDiagonalLBRTWin(state, horizontalIndex, verticalIndex, sign)) {
268-
// Check for diagonal win (left top right bottom)
268+
// Check for diagonal win (left bottom right top)
269269
state.winType = ConnectFourWinType.DiagonalLBRT;
270270
state.status = this.determineWinner(sign);
271271
} else if (this.checkForDiagonalLTRBWin(state, horizontalIndex, verticalIndex, sign)) {
272-
// Check for diagonal win (left bottom right top)
272+
// Check for diagonal win (left top right bottom)
273273
state.winType = ConnectFourWinType.DiagonalLTRB;
274274
state.status = this.determineWinner(sign);
275275
} else if (state.columns.every((column) => column.fill === 6)) {
@@ -318,13 +318,13 @@ export class ConnectFourGame {
318318
return (
319319
'**' +
320320
getStateAsString(this.state) +
321-
`\n${this.state.player1Username} has won with a ${this.parseWin(this.state)} connect 4!**`
321+
`\n<@${this.state.player1Id}> has won with a ${this.parseWin(this.state)} connect 4!**`
322322
);
323323
case ConnectFourGameStatus.Player2Win:
324324
return (
325325
'**' +
326326
getStateAsString(this.state) +
327-
`\n${this.state.player2Username} has won with a ${this.parseWin(this.state)} connect 4**`
327+
`\n${this.state.player2Username} has won with a ${this.parseWin(this.state)} connect 4!**`
328328
);
329329
case ConnectFourGameStatus.Draw:
330330
return `The match ended in a draw!`;
@@ -341,7 +341,7 @@ export class ConnectFourGame {
341341
.setTitle('Connect4')
342342
.setDescription(
343343
`
344-
${this.state.player1Username} vs. ${this.state.player2Username}
344+
<@${this.state.player1Id}> vs. ${this.state.player2Username}
345345
`,
346346
)
347347
.addFields([
@@ -350,7 +350,7 @@ ${this.state.player1Username} vs. ${this.state.player2Username}
350350
value: `
351351
${this.getStatusAsString()}
352352
353-
${this.state.player1Username}: ${getEmojiFromSign(this.state.player1Sign)}
353+
<@${this.state.player1Id}>: ${getEmojiFromSign(this.state.player1Sign)}
354354
${this.state.player2Username}: ${getEmojiFromSign(this.state.player2Sign)}
355355
`,
356356
},
@@ -373,13 +373,13 @@ ${this.state.player2Username}: ${getEmojiFromSign(this.state.player2Sign)}
373373
.setCustomId(`connect4-4-${this.id}`)
374374
.setLabel('4')
375375
.setStyle(ButtonStyle.Secondary),
376+
);
377+
378+
const row2 = new ActionRowBuilder<ButtonBuilder>().addComponents(
376379
new ButtonBuilder()
377380
.setCustomId(`connect4-5-${this.id}`)
378381
.setLabel('5')
379382
.setStyle(ButtonStyle.Secondary),
380-
);
381-
382-
const row2 = new ActionRowBuilder<ButtonBuilder>().addComponents(
383383
new ButtonBuilder()
384384
.setCustomId(`connect4-6-${this.id}`)
385385
.setLabel('6')
@@ -395,6 +395,195 @@ ${this.state.player2Username}: ${getEmojiFromSign(this.state.player2Sign)}
395395
components: this.state.status === ConnectFourGameStatus.Pending ? [row1, row2] : [],
396396
};
397397
}
398+
399+
// ConnectFourGameState
400+
// Waiting = 0,
401+
// Pending = 1,
402+
// Draw = 2,
403+
// Player1Win = 3,
404+
// Player2Win = 4,
405+
// Player1TimeOut = 5,
406+
// Player2TimeOut = 6,
407+
// Unknown = 7,
408+
private updateState = (
409+
state: ConnectFourGameState,
410+
columnNumber: number,
411+
turn: ConnectFourGameSign,
412+
): ConnectFourGameState => {
413+
const fill: number = state.columns[columnNumber].fill;
414+
if (turn === ConnectFourGameSign.Player2) {
415+
state.columns[columnNumber].tokens[fill] = ConnectFourGameSign.Player2;
416+
} else {
417+
state.columns[columnNumber].tokens[fill] = ConnectFourGameSign.Player1;
418+
}
419+
state.columns[columnNumber].fill = state.columns[columnNumber].fill + 1;
420+
return state;
421+
};
422+
423+
// returns number of possible wins remaining
424+
private possibleWins = (
425+
state: ConnectFourGameState,
426+
opponentSign: ConnectFourGameSign,
427+
): number => {
428+
let possibleWins = 0;
429+
// check vertical
430+
for (let i = 0; i < CONNECT_FOUR_COLUMN_COUNT; i++) {
431+
for (let j = 3; j < CONNECT_FOUR_ROW_COUNT; j++) {
432+
if (
433+
state.columns[i].tokens[j] !== opponentSign &&
434+
state.columns[i].tokens[j - 1] !== opponentSign &&
435+
state.columns[i].tokens[j - 2] !== opponentSign &&
436+
state.columns[i].tokens[j - 3] !== opponentSign
437+
) {
438+
possibleWins = possibleWins + 1;
439+
}
440+
}
441+
}
442+
// check horizonal
443+
for (let j = 0; j < CONNECT_FOUR_ROW_COUNT; j++) {
444+
for (let i = 3; i < CONNECT_FOUR_COLUMN_COUNT; i++) {
445+
if (
446+
state.columns[i].tokens[j] !== opponentSign &&
447+
state.columns[i - 1].tokens[j] !== opponentSign &&
448+
state.columns[i - 2].tokens[j] !== opponentSign &&
449+
state.columns[i - 3].tokens[j] !== opponentSign
450+
) {
451+
possibleWins = possibleWins + 1;
452+
}
453+
}
454+
}
455+
// check diagonal up
456+
for (let i = 0; i <= 3; i++) {
457+
for (let j = 0; j <= 2; j++) {
458+
if (
459+
state.columns[i].tokens[j] !== opponentSign &&
460+
state.columns[i + 1].tokens[j + 1] !== opponentSign &&
461+
state.columns[i + 2].tokens[j + 2] !== opponentSign &&
462+
state.columns[i + 3].tokens[j + 3] !== opponentSign
463+
) {
464+
possibleWins = possibleWins + 1;
465+
}
466+
}
467+
}
468+
469+
//check diagonal down
470+
for (let i = 3; i < CONNECT_FOUR_COLUMN_COUNT; i++) {
471+
for (let j = 0; j <= 2; j++) {
472+
if (
473+
state.columns[i].tokens[j] !== opponentSign &&
474+
state.columns[i - 1].tokens[j + 1] !== opponentSign &&
475+
state.columns[i - 2].tokens[j + 2] !== opponentSign &&
476+
state.columns[i - 3].tokens[j + 3] !== opponentSign
477+
) {
478+
possibleWins = possibleWins + 1;
479+
}
480+
}
481+
}
482+
return possibleWins;
483+
};
484+
485+
// takes a ConnectFourGameState and evaluates it according to heuristic function
486+
private evaluate = (state: ConnectFourGameState): number => {
487+
const codeyPoints: number = this.possibleWins(state, ConnectFourGameSign.Player1); // 3 represents Codeybot sign
488+
const opponentPoints: number = this.possibleWins(state, ConnectFourGameSign.Player2); // 2 represents player1 sign
489+
return codeyPoints - opponentPoints;
490+
};
491+
492+
// from perspective of Codeybot, +infinity means Codeybot win, -infinity means Player1 wins
493+
// returns the best possible score that can be achieved, given that Player1 plays optimally
494+
private miniMax = (
495+
state: ConnectFourGameState,
496+
depth: number,
497+
turn: ConnectFourGameSign,
498+
): number => {
499+
if (state.status === ConnectFourGameStatus.Draw) {
500+
return 0;
501+
}
502+
if (state.status === ConnectFourGameStatus.Player1Win) {
503+
return -Infinity;
504+
}
505+
if (state.status === ConnectFourGameStatus.Player2Win) {
506+
return Infinity;
507+
}
508+
if (depth === 0) {
509+
return this.evaluate(state); //heuristic function to evaluate state of game
510+
}
511+
512+
// if it is Codeybot's turn, we want to find move that maximizes score
513+
const column_choices: number[] = [0, 1, 2, 3, 4, 5, 6];
514+
if (turn === ConnectFourGameSign.Player2) {
515+
let value = -Infinity;
516+
for (const column_choice of column_choices) {
517+
if (state.columns[column_choice].fill < CONNECT_FOUR_ROW_COUNT) {
518+
const newState: ConnectFourGameState = JSON.parse(JSON.stringify(state)); // create a deep copy
519+
this.updateState(newState, column_choice, turn);
520+
this.setStatus(newState, column_choice); // setStatus assumes newState has already been updated with chip
521+
value = Math.max(value, this.miniMax(newState, depth - 1, ConnectFourGameSign.Player1));
522+
}
523+
}
524+
return value;
525+
} else {
526+
//it is Player 1's turn, so we want to find minimum score (this assumes Player 1 plays optimally)
527+
let value = Infinity;
528+
for (const column_choice of column_choices) {
529+
// if selected column is not already full, recurse down the branch
530+
if (state.columns[column_choice].fill < CONNECT_FOUR_ROW_COUNT) {
531+
const newState: ConnectFourGameState = JSON.parse(JSON.stringify(state));
532+
this.updateState(newState, column_choice, turn);
533+
this.setStatus(newState, column_choice);
534+
value = Math.min(value, this.miniMax(newState, depth - 1, ConnectFourGameSign.Player2));
535+
}
536+
}
537+
return value;
538+
}
539+
};
540+
541+
private findBestColumn = (state: ConnectFourGameState): number => {
542+
const column_scores: number[] = [
543+
-Infinity,
544+
-Infinity,
545+
-Infinity,
546+
-Infinity,
547+
-Infinity,
548+
-Infinity,
549+
-Infinity,
550+
];
551+
for (let i = 0; i < 7; i++) {
552+
if (state.columns[i].fill < CONNECT_FOUR_ROW_COUNT) {
553+
const newState: ConnectFourGameState = JSON.parse(JSON.stringify(state)); // make a deep copy of game state
554+
this.updateState(newState, i, ConnectFourGameSign.Player2);
555+
this.setStatus(newState, i);
556+
// need to check if there is more than one possible guaranteed win
557+
// in that case, if there exists an immediate win, we want the bot to choose that column
558+
if (newState.status === ConnectFourGameStatus.Player2Win) {
559+
return i;
560+
}
561+
column_scores[i] = this.miniMax(newState, 4, ConnectFourGameSign.Player1);
562+
}
563+
}
564+
let value = -Infinity;
565+
let best_column = -1;
566+
for (let i = 0; i < 7; i++) {
567+
if (column_scores[i] > value) {
568+
value = column_scores[i];
569+
best_column = i;
570+
}
571+
}
572+
// if best_column = -1, then that means all posible moves lead to certain loss
573+
if (best_column === -1) {
574+
for (let i = 0; i < 7; i++) {
575+
if (state.columns[i].fill < CONNECT_FOUR_ROW_COUNT) {
576+
return i;
577+
}
578+
}
579+
}
580+
return best_column;
581+
};
582+
583+
// takes in ConnectFourGameState, returns best column for Codeybot to play in
584+
public getBestMove = (state: ConnectFourGameState): number => {
585+
return this.findBestColumn(state);
586+
};
398587
}
399588

400589
export enum ConnectFourGameStatus {
@@ -467,10 +656,6 @@ export const getStateAsString = (state: ConnectFourGameState): string => {
467656
return result;
468657
};
469658

470-
export const getCodeyConnectFourSign = (): ConnectFourGameSign => {
471-
return getRandomIntFrom1(7);
472-
};
473-
474659
export const updateColumn = (column: ConnectFourColumn, sign: ConnectFourGameSign): boolean => {
475660
const fill: number = column.fill;
476661
if (fill < CONNECT_FOUR_ROW_COUNT) {

src/interaction-handlers/games/connectFour.ts

+11-7
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { Option } from '@sapphire/result';
33
import { ButtonInteraction } from 'discord.js';
44
import { getEmojiByName } from '../../components/emojis';
55
import {
6-
getCodeyConnectFourSign,
76
ConnectFourGameStatus,
87
ConnectFourGameSign,
98
connectFourGameTracker,
@@ -60,18 +59,23 @@ export class ConnectFourHandler extends InteractionHandler {
6059
} else {
6160
await interaction.deferUpdate();
6261
const status = await game.setStatus(game.state, result.sign - 1);
63-
if (status == ConnectFourGameStatus.Pending) {
62+
if (status === ConnectFourGameStatus.Pending) {
6463
if (!game.state.player2Id) {
65-
let codeySign = getCodeyConnectFourSign();
66-
while (!updateColumn(game.state.columns[codeySign - 1], game.state.player2Sign)) {
67-
codeySign = getCodeyConnectFourSign();
68-
}
69-
game.setStatus(game.state, codeySign - 1);
64+
const bestMove = game.getBestMove(game.state);
65+
updateColumn(game.state.columns[bestMove], game.state.player2Sign);
66+
game.setStatus(game.state, bestMove);
7067
}
7168
}
7269
}
7370
updateMessageEmbed(game.gameMessage, game.getGameResponse());
7471
});
72+
7573
connectFourGameTracker.endGame(result.gameId);
7674
}
7775
}
76+
77+
//Debug function to enable program to allow program to pause
78+
// async function sleep(ms: number): Promise<void> {
79+
// return new Promise(
80+
// (resolve) => setTimeout(resolve, ms));
81+
// }

0 commit comments

Comments
 (0)