Skip to content

Commit 199dfd0

Browse files
Merge pull request #4 from chrisreddington/tictactoe
Add Tic-tac-toe game command and tests
2 parents c545682 + ef7f278 commit 199dfd0

4 files changed

Lines changed: 889 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: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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

Comments
 (0)