I've implemented a simple class for managing a Tic-Tac-Toe game's logic and state in Typescript. This code is intended to be used by a larger program; it doesn't implement any aspects of a UI. I've designed the class to present an immutable API, although it's mutable internally; I'm not using ImmutableJS or other, similar libraries. I'd appreciate review on any aspect of the code, though one thing I'd like to do is figure out a way to condense the setup code in the tests.
TicTacToeGame.ts
export type Player = "PlayerX" | "PlayerO";
export type MoveResult = "Victory" | "WaitingForMove" | "SquareFilled" | "GameOver";
export type CellNumber = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
type CellState = "X" | "O" | "EMPTY";
export class TicTacToeGame {
private _currentPlayer: Player;
private _winningPlayer?: Player;
private board: CellState[];
constructor () {
this.board = new Array<CellState>(9);
this.board.fill("EMPTY");
this._currentPlayer = "PlayerX";
this._winningPlayer = undefined;
}
public get currentPlayer(): Player {
return this._currentPlayer;
}
public get winningPlayer(): Player | undefined {
return this._winningPlayer;
}
public makeMove(cellNum: CellNumber): [MoveResult, TicTacToeGame] {
const updatedGame = new TicTacToeGame();
updatedGame._currentPlayer = this.currentPlayer;
updatedGame._winningPlayer = this.winningPlayer;
updatedGame.board = this.board.map(element => element);
if (updatedGame.board[cellNum] !== "EMPTY") {
return ["SquareFilled", updatedGame];
}
if (updatedGame.winningPlayer) {
return ["GameOver", updatedGame];
}
if (updatedGame.currentPlayer === "PlayerX") {
updatedGame.board[cellNum] = "X";
} else {
updatedGame.board[cellNum] = "O";
}
updatedGame.checkForVictory();
if(updatedGame.winningPlayer) {
return ["Victory", updatedGame];
}
if (updatedGame.currentPlayer === "PlayerX") {
updatedGame._currentPlayer = "PlayerO";
} else {
updatedGame._currentPlayer = "PlayerX";
}
return ["WaitingForMove", updatedGame];
}
private checkForVictory (): void {
const lines = {
"row1": [this.board[0], this.board[1], this.board[2]],
"row2": [this.board[3], this.board[4], this.board[5]],
"row3": [this.board[6], this.board[7], this.board[8]],
"col1": [this.board[0], this.board[3], this.board[6]],
"col2": [this.board[1], this.board[4], this.board[7]],
"col3": [this.board[2], this.board[5], this.board[8]],
"diag1": [this.board[0], this.board[5], this.board[8]],
"diag2": [this.board[3], this.board[5], this.board[7]]
}
for (const line in lines) {
if ((lines[line] as CellState[]).every(cell => cell === "X")) {
this._winningPlayer = "PlayerX";
return;
} else if ((lines[line] as CellState[]).every(cell => cell === "O")) {
this._winningPlayer = "PlayerO";
return;
}
}
this._winningPlayer = undefined; // no winning player detected
return;
}
}
TicTacToeGame.test.ts
import { TicTacToeGame } from "./TicTacToeGame";
describe ("Tic Tac Toe game class", () => {
it("Recognizes player X's victory correctly", () => {
const game0 = new TicTacToeGame();
// [1] needed to get just updated game; ignore result
const game1 = game0.makeMove(0)[1]; // X moves
const game2 = game1.makeMove(6)[1]; // O moves
const game3 = game2.makeMove(1)[1]; // X moves
const game4 = game3.makeMove(7)[1]; // O moves
const [result, game5] = game4.makeMove(2); // X moves, completing top row
expect(result).toBe("Victory");
expect(game5.winningPlayer).toBe("PlayerX");
});
it("Recognizes player O's victory correctly", () => {
const game0 = new TicTacToeGame();
const game1 = game0.makeMove(0)[1]; // X moves
const game2 = game1.makeMove(6)[1]; // O moves
const game3 = game2.makeMove(1)[1]; // X moves
const game4 = game3.makeMove(7)[1]; // O moves
const game5 = game4.makeMove(5)[1]; // X moves
const [result, game6] = game5.makeMove(8); // O moves, completing bottom row
expect(result).toBe("Victory");
expect(game6.winningPlayer).toBe("PlayerO");
});
it ("Returns an error when attempting to fill an already-filled square", () => {
const game0 = new TicTacToeGame();
const game1 = game0.makeMove(0)[1]; // get updated game, ignore result
const result = game1.makeMove(0)[0]; // get result, ignore updated game
expect(result).toBe("SquareFilled");
});
it ("Returns an error when making a move in a completed game", () => {
const game0 = new TicTacToeGame();
const game1 = game0.makeMove(0)[1]; // X moves
const game2 = game1.makeMove(6)[1]; // O moves
const game3 = game2.makeMove(1)[1]; // X moves
const game4 = game3.makeMove(7)[1]; // O moves
const game5 = game4.makeMove(2)[1]; // X moves, completing top row
const result = game5.makeMove(3)[0];
expect(result).toBe("GameOver");
});
it("Recognizes when it's X's turn", () => {
const game0 = new TicTacToeGame();
const game1 = game0.makeMove(0)[1]; // X moves
const [result, game2] = game1.makeMove(1); // O moves
expect(result).toBe("WaitingForMove");
expect(game2.currentPlayer).toBe("PlayerX");
});
it("Recognizes when it's O's turn", () => {
const game0 = new TicTacToeGame();
const [result, game1] = game0.makeMove(0); // X moves
expect(result).toBe("WaitingForMove");
expect(game1.currentPlayer).toBe("PlayerO");
});
});