|
| 1 | +// Package tictactoe implements a classic Tic-tac-toe game where two players |
| 2 | +// take turns marking spaces on a 3x3 grid. Players alternate placing X's and O's |
| 3 | +// on the board until either one player wins by placing three marks in a row, |
| 4 | +// column, or diagonal, or the game ends in a draw when the board is full. |
| 5 | +package tictactoe |
| 6 | + |
| 7 | +import ( |
| 8 | + "errors" |
| 9 | + "fmt" |
| 10 | +) |
| 11 | + |
| 12 | +// Prompter defines an interface for getting user input |
| 13 | +type Prompter interface { |
| 14 | + Select(prompt, defaultValue string, options []string) (int, error) |
| 15 | +} |
| 16 | + |
| 17 | +// GameInterface defines the methods needed for GetPlayerMove |
| 18 | +type GameInterface interface { |
| 19 | + GetAvailablePositions() []string |
| 20 | +} |
| 21 | + |
| 22 | +// Board represents a 3x3 Tic-tac-toe game board. |
| 23 | +// Empty squares are represented by empty strings, |
| 24 | +// and played squares contain either "X" or "O". |
| 25 | +type Board [3][3]string |
| 26 | + |
| 27 | +// Game represents the current state of a Tic-tac-toe game. |
| 28 | +type Game struct { |
| 29 | + board Board // The game board storing player moves ("X" or "O", or empty for unplayed) |
| 30 | + CurrentPlayer string // CurrentPlayer indicates whose turn it is ("X" or "O") |
| 31 | +} |
| 32 | + |
| 33 | +// NewGame creates and initializes a new Tic-tac-toe game with an empty board. |
| 34 | +// Setting as X always plays first. |
| 35 | +func NewGame() *Game { |
| 36 | + return &Game{ |
| 37 | + board: Board{}, |
| 38 | + CurrentPlayer: "X", |
| 39 | + } |
| 40 | +} |
| 41 | + |
| 42 | +// MakeMove attempts to place the current player's mark at the specified position. |
| 43 | +// The position is specified using zero-based indices for row and column. |
| 44 | +// Returns an error if: |
| 45 | +// - The position is out of bounds (not between 0 and 2) |
| 46 | +// - The position is already occupied by a player's mark |
| 47 | +func (g *Game) MakeMove(rowIndex, columnIndex int) error { |
| 48 | + if rowIndex < 0 || rowIndex > 2 || columnIndex < 0 || columnIndex > 2 { |
| 49 | + return errors.New("invalid position: must be between 0 and 2") |
| 50 | + } |
| 51 | + if g.board[rowIndex][columnIndex] != "" { |
| 52 | + return errors.New("position already taken") |
| 53 | + } |
| 54 | + g.board[rowIndex][columnIndex] = g.CurrentPlayer |
| 55 | + g.CurrentPlayer = switchPlayer(g.CurrentPlayer) |
| 56 | + return nil |
| 57 | +} |
| 58 | + |
| 59 | +// GetWinner checks if there is a winner by examining all possible winning combinations: |
| 60 | +// - Three rows |
| 61 | +// - Three columns |
| 62 | +// - Two diagonals |
| 63 | +// Returns the winning player's mark ("X" or "O"), or an empty string if there's no winner. |
| 64 | +func (g *Game) GetWinner() string { |
| 65 | + // Check rows for a winner |
| 66 | + for rowIndex := 0; rowIndex < 3; rowIndex++ { |
| 67 | + if g.board[rowIndex][0] != "" && |
| 68 | + g.board[rowIndex][0] == g.board[rowIndex][1] && |
| 69 | + g.board[rowIndex][1] == g.board[rowIndex][2] { |
| 70 | + return g.board[rowIndex][0] |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | + // Check columns for a winner |
| 75 | + for columnIndex := 0; columnIndex < 3; columnIndex++ { |
| 76 | + if g.board[0][columnIndex] != "" && |
| 77 | + g.board[0][columnIndex] == g.board[1][columnIndex] && |
| 78 | + g.board[1][columnIndex] == g.board[2][columnIndex] { |
| 79 | + return g.board[0][columnIndex] |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + // Check main diagonal (top-left to bottom-right) |
| 84 | + if g.board[0][0] != "" && |
| 85 | + g.board[0][0] == g.board[1][1] && |
| 86 | + g.board[1][1] == g.board[2][2] { |
| 87 | + return g.board[0][0] |
| 88 | + } |
| 89 | + |
| 90 | + // Check secondary diagonal (top-right to bottom-left) |
| 91 | + if g.board[0][2] != "" && |
| 92 | + g.board[0][2] == g.board[1][1] && |
| 93 | + g.board[1][1] == g.board[2][0] { |
| 94 | + return g.board[0][2] |
| 95 | + } |
| 96 | + |
| 97 | + return "" |
| 98 | +} |
| 99 | + |
| 100 | +// IsBoardFull determines if all positions on the board have been played. |
| 101 | +// Returns true if no empty positions remain, false otherwise. |
| 102 | +func (g *Game) IsBoardFull() bool { |
| 103 | + |
| 104 | + for rowIndex := 0; rowIndex < 3; rowIndex++ { |
| 105 | + for columnIndex := 0; columnIndex < 3; columnIndex++ { |
| 106 | + if g.board[rowIndex][columnIndex] == "" { |
| 107 | + return false |
| 108 | + } |
| 109 | + } |
| 110 | + } |
| 111 | + return true |
| 112 | +} |
| 113 | + |
| 114 | +// String returns a formatted string representation of the current board state. |
| 115 | +// Empty squares are shown as numbers 1-9 for position selection, making it |
| 116 | +// easier for players to choose their moves. Played squares show the player's mark. |
| 117 | +// The board is displayed with column separators (|) and row separators (-). |
| 118 | +func (g *Game) String() string { |
| 119 | + result := "\n" |
| 120 | + position := 1 |
| 121 | + for rowIndex := 0; rowIndex < 3; rowIndex++ { |
| 122 | + for columnIndex := 0; columnIndex < 3; columnIndex++ { |
| 123 | + // Add leading space for first column |
| 124 | + if columnIndex == 0 { |
| 125 | + result += " " |
| 126 | + } |
| 127 | + |
| 128 | + // Show position number for empty squares, otherwise show the player's mark |
| 129 | + if g.board[rowIndex][columnIndex] == "" { |
| 130 | + result += fmt.Sprintf("%d", position) |
| 131 | + } else { |
| 132 | + result += g.board[rowIndex][columnIndex] |
| 133 | + } |
| 134 | + |
| 135 | + // Add column separators except for the last column |
| 136 | + if columnIndex < 2 { |
| 137 | + result += " | " |
| 138 | + } |
| 139 | + position++ |
| 140 | + } |
| 141 | + result += "\n" |
| 142 | + |
| 143 | + // Add row separators except for the last row |
| 144 | + if rowIndex < 2 { |
| 145 | + result += "---+---+---\n" |
| 146 | + } |
| 147 | + } |
| 148 | + return result |
| 149 | +} |
| 150 | + |
| 151 | +// switchPlayer determines the next player's turn. |
| 152 | +// Following traditional Tic-tac-toe rules, players alternate between X and O. |
| 153 | +func switchPlayer(currentPlayer string) string { |
| 154 | + if currentPlayer == "X" { |
| 155 | + return "O" |
| 156 | + } |
| 157 | + return "X" |
| 158 | +} |
| 159 | + |
| 160 | +// positionToRowCol converts a one-based position (1-9) to zero-based row and column indices. |
| 161 | +// The board positions are mapped as follows: |
| 162 | +// 1 2 3 |
| 163 | +// 4 5 6 |
| 164 | +// 7 8 9 |
| 165 | +// Returns (-1, -1) for invalid positions (0 or >9) |
| 166 | +func positionToRowCol(position int) (rowIndex, columnIndex int) { |
| 167 | + if position <= 0 || position > 9 { |
| 168 | + return -1, -1 |
| 169 | + } |
| 170 | + position-- // Convert to 0-based index |
| 171 | + return position / 3, position % 3 |
| 172 | +} |
| 173 | + |
| 174 | +// GetAvailablePositions returns a slice of strings representing unoccupied positions. |
| 175 | +// The positions are numbered 1-9 (one-based) to match the display format. |
| 176 | +func (g *Game) GetAvailablePositions() []string { |
| 177 | + var availablePositions []string |
| 178 | + for position := 1; position <= 9; position++ { |
| 179 | + rowIndex, columnIndex := positionToRowCol(position) |
| 180 | + if g.board[rowIndex][columnIndex] == "" { |
| 181 | + availablePositions = append(availablePositions, fmt.Sprintf("%d", position)) |
| 182 | + } |
| 183 | + } |
| 184 | + return availablePositions |
| 185 | +} |
| 186 | + |
| 187 | +// GetPlayerMove prompts the user to select a valid move and returns the chosen |
| 188 | +// row and column indices. It uses the provided Prompter interface to get user input. |
| 189 | +// Returns an error if: |
| 190 | +// - No valid moves are available (board is full) |
| 191 | +// - User input is invalid |
| 192 | +// - Selected position is invalid |
| 193 | +func GetPlayerMove(prompter Prompter, game GameInterface) (rowIndex, columnIndex int, err error) { |
| 194 | + availablePositions := game.GetAvailablePositions() |
| 195 | + if len(availablePositions) == 0 { |
| 196 | + return -1, -1, errors.New("no available moves") |
| 197 | + } |
| 198 | + |
| 199 | + posIndex, err := prompter.Select("Select position (1-9):", "1", availablePositions) |
| 200 | + if err != nil { |
| 201 | + return -1, -1, err |
| 202 | + } |
| 203 | + |
| 204 | + if posIndex < 0 || posIndex >= len(availablePositions) { |
| 205 | + return -1, -1, fmt.Errorf("invalid position selection: %d", posIndex) |
| 206 | + } |
| 207 | + |
| 208 | + var position int |
| 209 | + _, err = fmt.Sscanf(availablePositions[posIndex], "%d", &position) |
| 210 | + if err != nil { |
| 211 | + return -1, -1, fmt.Errorf("invalid position value: %v", err) |
| 212 | + } |
| 213 | + |
| 214 | + rowIndex, columnIndex = positionToRowCol(position) |
| 215 | + if rowIndex < 0 || columnIndex < 0 { |
| 216 | + return -1, -1, fmt.Errorf("invalid position value: %d", position) |
| 217 | + } |
| 218 | + |
| 219 | + return rowIndex, columnIndex, nil |
| 220 | +} |
0 commit comments