Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: CI

on:
push:
branches: ["**"]
pull_request:

permissions:
contents: read

jobs:
test-and-lint:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25.x"
cache: true

- name: Show Go environment
run: go env

- name: Download dependencies
run: go mod download

- name: Check formatting (gofmt)
run: |
test -z "$(gofmt -l .)"

- name: Static analysis (golangci-lint)
uses: golangci/golangci-lint-action@v6
with:
version: latest

- name: Run tests with coverage
run: TESTING=1 go test ./... -count=1 -cover

- name: Run race detector (develop only)
if: github.ref == 'refs/heads/develop'
run: TESTING=1 go test ./... -race -count=1

- name: CI summary
run: echo "CI completed successfully"
19 changes: 19 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
run:
timeout: 3m
tests: true

linters:
enable:
- govet
- staticcheck
- errcheck
- ineffassign
- unused

linters-settings:
errcheck:
check-type-assertions: true
check-blank: true

issues:
exclude-use-default: false
27 changes: 27 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- id: check-yaml

- repo: local
hooks:
- id: gofmt
name: gofmt
entry: gofmt -w
language: system
files: \.go$

- id: govet
name: go vet
entry: go vet ./...
language: system
pass_filenames: false

- id: gotest
name: go test
entry: python -c "import os, subprocess; os.environ['TESTING']='1'; subprocess.check_call(['go','test','./...','-count=1'])"
language: system
pass_filenames: false
14 changes: 7 additions & 7 deletions ai_models/ai.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package ai_models
import "GoTicTacToe/game"
type AIModel interface {
NextMove(board *game.Board, me *game.Player, players [2]*game.Player) (x, y int)
}
package ai_models

import "GoTicTacToe/game"

type AIModel interface {
NextMove(board *game.Board, me *game.Player, players [2]*game.Player) (x, y int)
}
130 changes: 65 additions & 65 deletions ai_models/minimax.go
Original file line number Diff line number Diff line change
@@ -1,65 +1,65 @@
package ai_models
import "GoTicTacToe/game"
type MinimaxAI struct{}
func (MinimaxAI) NextMove(board *game.Board, me *game.Player, players [2]*game.Player) (int, int) {
bestScore := -9999
bestMove := game.Move{X: -1, Y: -1}
for _, mv := range board.AvailableMoves() {
clone := board.Clone()
clone.Play(me, mv.X, mv.Y)
score := minimax(clone, me, players, false)
if score > bestScore {
bestScore = score
bestMove = mv
}
}
return bestMove.X, bestMove.Y
}
func minimax(board *game.Board, me *game.Player, players [2]*game.Player, maximizing bool) int {
winner := board.CheckWin()
if winner == me {
return +1
}
if winner != nil && winner != me {
return -1
}
if board.CheckDraw() {
return 0
}
if maximizing {
best := -9999
for _, mv := range board.AvailableMoves() {
clone := board.Clone()
clone.Play(me, mv.X, mv.Y)
score := minimax(clone, me, players, false)
if score > best {
best = score
}
}
return best
}
// minimizing (opponent turn)
opp := me.Opponent(players)
best := 9999
for _, mv := range board.AvailableMoves() {
clone := board.Clone()
clone.Play(opp, mv.X, mv.Y)
score := minimax(clone, me, players, true)
if score < best {
best = score
}
}
return best
}
package ai_models

import "GoTicTacToe/game"

type MinimaxAI struct{}

func (MinimaxAI) NextMove(board *game.Board, me *game.Player, players [2]*game.Player) (int, int) {
bestScore := -9999
bestMove := game.Move{X: -1, Y: -1}

for _, mv := range board.AvailableMoves() {
clone := board.Clone()
clone.Play(me, mv.X, mv.Y)

score := minimax(clone, me, players, false)

if score > bestScore {
bestScore = score
bestMove = mv
}
}

return bestMove.X, bestMove.Y
}

func minimax(board *game.Board, me *game.Player, players [2]*game.Player, maximizing bool) int {
winner := board.CheckWin()
if winner == me {
return +1
}
if winner != nil && winner != me {
return -1
}
if board.CheckDraw() {
return 0
}

if maximizing {
best := -9999
for _, mv := range board.AvailableMoves() {
clone := board.Clone()
clone.Play(me, mv.X, mv.Y)
score := minimax(clone, me, players, false)
if score > best {
best = score
}
}
return best
}

// minimizing (opponent turn)
opp := me.Opponent(players)
best := 9999

for _, mv := range board.AvailableMoves() {
clone := board.Clone()
clone.Play(opp, mv.X, mv.Y)
score := minimax(clone, me, players, true)
if score < best {
best = score
}
}

return best
}
56 changes: 56 additions & 0 deletions ai_models/minimax_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package ai_models

import (
"GoTicTacToe/game"
"testing"
)

/*
FuzzMinimaxDoesNotCrash performs fuzz testing on the MinimaxAI algorithm.

The goal of this test is NOT to verify the quality of the moves,
but to ensure that the Minimax implementation:
- never panics
- always terminates
- behaves safely for a range of board sizes

The fuzzing engine generates random board sizes within a constrained range
to explore unexpected execution paths.
*/
func FuzzMinimaxDoesNotCrash(f *testing.F) {

// Seed corpus: start fuzzing with a valid board size
f.Add(3)

// Fuzz function executed with randomly generated inputs
f.Fuzz(func(t *testing.T, size int) {

// Ignore sizes that would produce invalid or unsupported boards
// This keeps the fuzzing focused on realistic game scenarios
if size < 3 || size > 5 {
return
}

// Create a board with the fuzz-generated size
b := game.NewBoard(size, size)

// Create AI player and opponent
p1 := &game.Player{Name: "AI"}
p2 := &game.Player{Name: "Human"}

// Players array required by the AI interface
players := [2]*game.Player{p1, p2}

// Instantiate the Minimax AI model
ai := MinimaxAI{}

// Ask the AI to compute a move
x, y := ai.NextMove(b, p1, players)

// The AI should never return absurd values or crash
// Even in edge cases, coordinates must remain within safe bounds
if x < -1 || y < -1 {
t.Fatalf("invalid move")
}
})
}
40 changes: 40 additions & 0 deletions ai_models/minimax_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package ai_models

import (
"GoTicTacToe/game"
"testing"
)

/*
TestMinimaxReturnsValidMove verifies that the MinimaxAI model
always returns a valid board coordinate on an empty board.

This test does NOT attempt to validate the optimality of the move
(minimax correctness), which would require complex scenario-based tests.
Instead, it ensures that:
- the algorithm terminates
- no panic occurs
- the returned move is within board bounds
*/
func TestMinimaxReturnsValidMove(t *testing.T) {
// Create a standard empty 3x3 board
b := game.NewBoard(3, 3)

// Create players: one controlled by the AI, one opponent
p1 := &game.Player{Name: "AI"}
p2 := &game.Player{Name: "Human"}

// Players array required by the AI interface
players := [2]*game.Player{p1, p2}

// Instantiate the Minimax AI model
ai := MinimaxAI{}

// Ask the AI to compute the next move
x, y := ai.NextMove(b, p1, players)

// The returned move must be inside the board boundaries
if x < 0 || x >= 3 || y < 0 || y >= 3 {
t.Errorf("invalid move returned: %d,%d", x, y)
}
}
36 changes: 18 additions & 18 deletions ai_models/random.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
package ai_models
import (
"GoTicTacToe/game"
"math/rand"
)
type RandomAI struct{}
func (RandomAI) NextMove(board *game.Board, me *game.Player, players [2]*game.Player) (int, int) {
moves := board.AvailableMoves()
if len(moves) == 0 {
return -1, -1
}
m := moves[rand.Intn(len(moves))]
return m.X, m.Y
}
package ai_models

import (
"GoTicTacToe/game"
"math/rand"
)

type RandomAI struct{}

func (RandomAI) NextMove(board *game.Board, me *game.Player, players [2]*game.Player) (int, int) {
moves := board.AvailableMoves()
if len(moves) == 0 {
return -1, -1
}

m := moves[rand.Intn(len(moves))]
return m.X, m.Y
}
Loading