refactor: rename game package to connect4, drop Game prefix from types
All checks were successful
CI / Deploy / test (pull_request) Successful in 16s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped

Rename game/ -> connect4/ to avoid c4/game stutter. Drop redundant
Game prefix from exported types (GameStore -> Store, GameInstance ->
Instance, GameStatus -> Status). Rename NATS subjects from game.{id}
to connect4.{id}. URL routes unchanged.
This commit is contained in:
Ryan Hamamura
2026-03-02 20:31:00 -10:00
parent f71acfc73e
commit 38eb9ee398
14 changed files with 125 additions and 127 deletions

98
connect4/logic.go Normal file
View File

@@ -0,0 +1,98 @@
// Package connect4 implements Connect 4 game logic, state management, and persistence.
package connect4
// DropPiece attempts to drop a piece in the given column.
// Returns (row placed, success).
func (g *Game) DropPiece(col, playerColor int) (int, bool) {
if col < 0 || col > 6 {
return -1, false
}
if g.CurrentTurn != playerColor {
return -1, false
}
if g.Status != StatusInProgress {
return -1, false
}
// Find lowest empty row in column
for row := 5; row >= 0; row-- {
if g.Board[row][col] == 0 {
g.Board[row][col] = playerColor
return row, true
}
}
return -1, false // Column full
}
// CheckWin checks if the last move at (row, col) created a win.
func (g *Game) CheckWin(row, col int) bool {
color := g.Board[row][col]
directions := [][2]int{
{0, 1}, // Horizontal
{1, 0}, // Vertical
{1, 1}, // Diagonal down-right
{1, -1}, // Diagonal down-left
}
for _, dir := range directions {
cells := g.countLine(row, col, dir[0], dir[1], color)
if len(cells) >= 4 {
g.Status = StatusWon
g.WinningCells = cells[:4]
return true
}
}
return false
}
func (g *Game) countLine(row, col, dr, dc, color int) [][2]int {
cells := [][2]int{{row, col}}
// Count in positive direction
for r, c := row+dr, col+dc; r >= 0 && r < 6 && c >= 0 && c < 7; r, c = r+dr, c+dc {
if g.Board[r][c] != color {
break
}
cells = append(cells, [2]int{r, c})
}
// Count in negative direction
for r, c := row-dr, col-dc; r >= 0 && r < 6 && c >= 0 && c < 7; r, c = r-dr, c-dc {
if g.Board[r][c] != color {
break
}
cells = append(cells, [2]int{r, c})
}
return cells
}
// CheckDraw checks if board is full with no winner.
func (g *Game) CheckDraw() bool {
for col := 0; col < 7; col++ {
if g.Board[0][col] == 0 {
return false
}
}
g.Status = StatusDraw
return true
}
// SwitchTurn alternates the current turn.
func (g *Game) SwitchTurn() {
if g.CurrentTurn == 1 {
g.CurrentTurn = 2
} else {
g.CurrentTurn = 1
}
}
// IsWinningCell checks if a cell is part of the winning line.
func (g *Game) IsWinningCell(row, col int) bool {
for _, cell := range g.WinningCells {
if cell[0] == row && cell[1] == col {
return true
}
}
return false
}

126
connect4/persist.go Normal file
View File

@@ -0,0 +1,126 @@
package connect4
import (
"context"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/player"
"github.com/rs/zerolog/log"
)
func (gi *Instance) save() error {
err := saveGame(gi.queries, gi.game)
if err != nil {
log.Error().Err(err).Str("game_id", gi.game.ID).Msg("failed to save game")
}
return err
}
func (gi *Instance) savePlayer(p *Player, slot int) error {
err := saveGamePlayer(gi.queries, gi.game.ID, p, slot)
if err != nil {
log.Error().Err(err).Str("game_id", gi.game.ID).Int("slot", slot).Msg("failed to save game player")
}
return err
}
// saveGame persists the game state via upsert.
func saveGame(queries *repository.Queries, g *Game) error {
var winnerUserID *string
if g.Winner != nil && g.Winner.UserID != nil {
winnerUserID = g.Winner.UserID
}
var winningCells *string
if wc := g.WinningCellsToJSON(); wc != "" {
winningCells = &wc
}
return queries.UpsertGame(context.Background(), repository.UpsertGameParams{
ID: g.ID,
Board: g.BoardToJSON(),
CurrentTurn: int64(g.CurrentTurn),
Status: int64(g.Status),
WinnerUserID: winnerUserID,
WinningCells: winningCells,
RematchGameID: g.RematchGameID,
})
}
func saveGamePlayer(queries *repository.Queries, gameID string, p *Player, slot int) error {
var userID, guestPlayerID *string
if p.UserID != nil {
userID = p.UserID
} else {
id := string(p.ID)
guestPlayerID = &id
}
return queries.CreateGamePlayer(context.Background(), repository.CreateGamePlayerParams{
GameID: gameID,
UserID: userID,
GuestPlayerID: guestPlayerID,
Nickname: p.Nickname,
Color: int64(p.Color),
Slot: int64(slot),
})
}
func loadGame(queries *repository.Queries, id string) (*Game, error) {
row, err := queries.GetGame(context.Background(), id)
if err != nil {
return nil, err
}
return gameFromRow(row)
}
func loadGamePlayers(queries *repository.Queries, id string) ([]*Player, error) {
rows, err := queries.GetGamePlayers(context.Background(), id)
if err != nil {
return nil, err
}
return playersFromRows(rows), nil
}
func gameFromRow(row *repository.Game) (*Game, error) {
g := &Game{
ID: row.ID,
CurrentTurn: int(row.CurrentTurn),
Status: Status(row.Status),
}
if err := g.BoardFromJSON(row.Board); err != nil {
return nil, err
}
if row.WinningCells != nil {
_ = g.WinningCellsFromJSON(*row.WinningCells)
}
if row.RematchGameID != nil {
g.RematchGameID = row.RematchGameID
}
return g, nil
}
func playersFromRows(rows []*repository.GamePlayer) []*Player {
players := make([]*Player, 0, len(rows))
for _, row := range rows {
p := &Player{
Nickname: row.Nickname,
Color: int(row.Color),
}
if row.UserID != nil {
p.UserID = row.UserID
p.ID = player.ID(*row.UserID)
} else if row.GuestPlayerID != nil {
p.ID = player.ID(*row.GuestPlayerID)
}
players = append(players, p)
}
return players
}

225
connect4/store.go Normal file
View File

@@ -0,0 +1,225 @@
package connect4
import (
"context"
"sync"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/player"
)
type PlayerSession struct {
Player *Player
}
type Store struct {
games map[string]*Instance
gamesMu sync.RWMutex
queries *repository.Queries
notifyFunc func(gameID string)
}
func NewStore(queries *repository.Queries) *Store {
return &Store{
games: make(map[string]*Instance),
queries: queries,
}
}
func (s *Store) SetNotifyFunc(f func(gameID string)) {
s.notifyFunc = f
}
func (s *Store) makeNotify(gameID string) func() {
return func() {
if s.notifyFunc != nil {
s.notifyFunc(gameID)
}
}
}
func (s *Store) Create() *Instance {
id := player.GenerateID(4)
gi := NewInstance(id)
gi.queries = s.queries
gi.notify = s.makeNotify(id)
s.gamesMu.Lock()
s.games[id] = gi
s.gamesMu.Unlock()
if s.queries != nil {
gi.save() //nolint:errcheck
}
return gi
}
func (s *Store) Get(id string) (*Instance, bool) {
s.gamesMu.RLock()
gi, ok := s.games[id]
s.gamesMu.RUnlock()
if ok {
return gi, true
}
if s.queries == nil {
return nil, false
}
g, err := loadGame(s.queries, id)
if err != nil || g == nil {
return nil, false
}
players, _ := loadGamePlayers(s.queries, id)
for _, p := range players {
switch p.Color {
case 1:
g.Players[0] = p
case 2:
g.Players[1] = p
}
}
gi = &Instance{
game: g,
queries: s.queries,
notify: s.makeNotify(id),
}
s.gamesMu.Lock()
s.games[id] = gi
s.gamesMu.Unlock()
return gi, true
}
func (s *Store) Delete(id string) error {
s.gamesMu.Lock()
delete(s.games, id)
s.gamesMu.Unlock()
if s.queries != nil {
return s.queries.DeleteGame(context.Background(), id)
}
return nil
}
type Instance struct {
game *Game
gameMu sync.RWMutex
notify func()
queries *repository.Queries
}
func NewInstance(id string) *Instance {
return &Instance{
game: NewGame(id),
notify: func() {},
}
}
func (gi *Instance) ID() string {
gi.gameMu.RLock()
defer gi.gameMu.RUnlock()
return gi.game.ID
}
func (gi *Instance) Join(ps *PlayerSession) bool {
gi.gameMu.Lock()
defer gi.gameMu.Unlock()
var slot int
if gi.game.Players[0] == nil {
ps.Player.Color = 1
gi.game.Players[0] = ps.Player
slot = 0
} else if gi.game.Players[1] == nil {
ps.Player.Color = 2
gi.game.Players[1] = ps.Player
gi.game.Status = StatusInProgress
slot = 1
} else {
return false
}
if gi.queries != nil {
gi.savePlayer(ps.Player, slot) //nolint:errcheck
gi.save() //nolint:errcheck
}
gi.notify()
return true
}
func (gi *Instance) GetGame() *Game {
gi.gameMu.RLock()
defer gi.gameMu.RUnlock()
return gi.game
}
func (gi *Instance) GetPlayerColor(pid player.ID) int {
gi.gameMu.RLock()
defer gi.gameMu.RUnlock()
for _, p := range gi.game.Players {
if p != nil && p.ID == pid {
return p.Color
}
}
return 0
}
func (gi *Instance) CreateRematch(s *Store) *Instance {
gi.gameMu.Lock()
defer gi.gameMu.Unlock()
if !gi.game.IsFinished() || gi.game.RematchGameID != nil {
return nil
}
newGI := s.Create()
newID := newGI.ID()
gi.game.RematchGameID = &newID
if gi.queries != nil {
if err := gi.save(); err != nil {
s.Delete(newID) //nolint:errcheck
gi.game.RematchGameID = nil
return nil
}
}
gi.notify()
return newGI
}
func (gi *Instance) DropPiece(col int, playerColor int) bool {
gi.gameMu.Lock()
defer gi.gameMu.Unlock()
row, ok := gi.game.DropPiece(col, playerColor)
if !ok {
return false
}
if gi.game.CheckWin(row, col) {
for _, p := range gi.game.Players {
if p != nil && p.Color == playerColor {
gi.game.Winner = p
break
}
}
} else if gi.game.CheckDraw() {
// Status already set by CheckDraw
} else {
gi.game.SwitchTurn()
}
if gi.queries != nil {
gi.save() //nolint:errcheck
}
gi.notify()
return true
}

71
connect4/types.go Normal file
View File

@@ -0,0 +1,71 @@
package connect4
import (
"encoding/json"
"github.com/ryanhamamura/c4/player"
)
type Player struct {
ID player.ID
UserID *string // UUID for authenticated users, nil for guests
Nickname string
Color int // 1 = Red, 2 = Yellow
}
type Status int
const (
StatusWaitingForPlayer Status = iota
StatusInProgress
StatusWon
StatusDraw
)
type Game struct {
ID string
Board [6][7]int // 6 rows, 7 columns; 0=empty, 1=red, 2=yellow
Players [2]*Player // Index 0 = creator (Red), Index 1 = joiner (Yellow)
CurrentTurn int // 1 or 2 (matches player color)
Status Status
Winner *Player
WinningCells [][2]int // Coordinates of winning 4 cells for highlighting
RematchGameID *string // ID of the rematch game, if one was created
}
func NewGame(id string) *Game {
return &Game{
ID: id,
Board: [6][7]int{},
CurrentTurn: 1, // Red goes first
Status: StatusWaitingForPlayer,
}
}
func (g *Game) IsFinished() bool {
return g.Status == StatusWon || g.Status == StatusDraw
}
func (g *Game) BoardToJSON() string {
data, _ := json.Marshal(g.Board)
return string(data)
}
func (g *Game) BoardFromJSON(data string) error {
return json.Unmarshal([]byte(data), &g.Board)
}
func (g *Game) WinningCellsToJSON() string {
if g.WinningCells == nil {
return ""
}
data, _ := json.Marshal(g.WinningCells)
return string(data)
}
func (g *Game) WinningCellsFromJSON(data string) error {
if data == "" {
return nil
}
return json.Unmarshal([]byte(data), &g.WinningCells)
}