Skip to content

Commit 9e44776

Browse files
Add Tic-tac-toe game command and tests
1 parent c545682 commit 9e44776

4 files changed

Lines changed: 466 additions & 0 deletions

File tree

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ func Execute() error {
1717
func init() {
1818
rootCmd.AddCommand(whoamiCmd)
1919
rootCmd.AddCommand(cointossCmd)
20+
rootCmd.AddCommand(tictactoeCmd)
2021
}

cmd/tictactoe.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/chrisreddington/gh-game/internal/tictactoe"
8+
userPrompt "github.com/cli/go-gh/v2/pkg/prompter"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
var tictactoeCmd = &cobra.Command{
13+
Use: "tictactoe",
14+
Short: "Play Tic-tac-toe",
15+
Long: `Start a game of Tic-tac-toe where X and O take turns to play.`,
16+
Run: func(cmd *cobra.Command, args []string) {
17+
game := tictactoe.NewGame()
18+
prompter := userPrompt.New(os.Stdin, os.Stdout, os.Stderr)
19+
20+
for {
21+
fmt.Println(game)
22+
fmt.Printf("Player %s's turn\n", game.CurrentPlayer)
23+
24+
row, col, err := tictactoe.GetPlayerMove(prompter, game)
25+
if err != nil {
26+
fmt.Printf("Error getting move: %v\n", err)
27+
return
28+
}
29+
30+
if err := game.MakeMove(row, col); err != nil {
31+
fmt.Printf("Invalid move: %v\n", err)
32+
continue
33+
}
34+
35+
if winner := game.GetWinner(); winner != "" {
36+
fmt.Println(game)
37+
fmt.Printf("Player %s wins!\n", winner)
38+
return
39+
}
40+
41+
if game.IsBoardFull() {
42+
fmt.Println(game)
43+
fmt.Println("It's a draw!")
44+
return
45+
}
46+
}
47+
},
48+
}

internal/tictactoe/tictactoe.go

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

0 commit comments

Comments
 (0)