Add user authentication and game persistence with SQLite
- User registration/login with bcrypt password hashing - SQLite database with goose migrations and sqlc-generated queries - Games and players persisted to database, resumable after restart - Guest play still supported alongside authenticated users - Auth UI components (login/register forms, auth header, guest banner)
This commit is contained in:
@@ -16,9 +16,17 @@ type PlayerSession struct {
|
||||
Sync Syncable
|
||||
}
|
||||
|
||||
type Persister interface {
|
||||
SaveGame(g *Game) error
|
||||
LoadGame(id string) (*Game, error)
|
||||
SaveGamePlayer(gameID string, player *Player, slot int) error
|
||||
LoadGamePlayers(gameID string) ([]*Player, error)
|
||||
}
|
||||
|
||||
type GameStore struct {
|
||||
games map[string]*GameInstance
|
||||
gamesMu sync.RWMutex
|
||||
games map[string]*GameInstance
|
||||
gamesMu sync.RWMutex
|
||||
persister Persister
|
||||
}
|
||||
|
||||
func NewGameStore() *GameStore {
|
||||
@@ -27,21 +35,68 @@ func NewGameStore() *GameStore {
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *GameStore) SetPersister(p Persister) {
|
||||
gs.persister = p
|
||||
}
|
||||
|
||||
func (gs *GameStore) Create() *GameInstance {
|
||||
id := GenerateID(4)
|
||||
gi := NewGameInstance(id)
|
||||
gi.persister = gs.persister
|
||||
gs.gamesMu.Lock()
|
||||
gs.games[id] = gi
|
||||
gs.gamesMu.Unlock()
|
||||
|
||||
if gs.persister != nil {
|
||||
gs.persister.SaveGame(gi.game)
|
||||
}
|
||||
|
||||
go gi.run()
|
||||
return gi
|
||||
}
|
||||
|
||||
func (gs *GameStore) Get(id string) (*GameInstance, bool) {
|
||||
gs.gamesMu.RLock()
|
||||
defer gs.gamesMu.RUnlock()
|
||||
gi, ok := gs.games[id]
|
||||
return gi, ok
|
||||
gs.gamesMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
return gi, true
|
||||
}
|
||||
|
||||
// Try to load from database
|
||||
if gs.persister == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
game, err := gs.persister.LoadGame(id)
|
||||
if err != nil || game == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
players, _ := gs.persister.LoadGamePlayers(id)
|
||||
for _, p := range players {
|
||||
if p.Color == 1 {
|
||||
game.Players[0] = p
|
||||
} else if p.Color == 2 {
|
||||
game.Players[1] = p
|
||||
}
|
||||
}
|
||||
|
||||
gi = &GameInstance{
|
||||
game: game,
|
||||
players: make(map[PlayerID]Syncable),
|
||||
leave: make(chan PlayerID, 5),
|
||||
done: make(chan struct{}),
|
||||
persister: gs.persister,
|
||||
}
|
||||
|
||||
gs.gamesMu.Lock()
|
||||
gs.games[id] = gi
|
||||
gs.gamesMu.Unlock()
|
||||
|
||||
go gi.run()
|
||||
return gi, true
|
||||
}
|
||||
|
||||
func GenerateID(size int) string {
|
||||
@@ -58,6 +113,7 @@ type GameInstance struct {
|
||||
leave chan PlayerID
|
||||
done chan struct{}
|
||||
dirty bool
|
||||
persister Persister
|
||||
}
|
||||
|
||||
func NewGameInstance(id string) *GameInstance {
|
||||
@@ -79,14 +135,17 @@ func (gi *GameInstance) Join(ps *PlayerSession) bool {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
var slot int
|
||||
// Assign player to an open slot
|
||||
if gi.game.Players[0] == nil {
|
||||
ps.Player.Color = 1 // Red
|
||||
gi.game.Players[0] = ps.Player
|
||||
slot = 0
|
||||
} else if gi.game.Players[1] == nil {
|
||||
ps.Player.Color = 2 // Yellow
|
||||
gi.game.Players[1] = ps.Player
|
||||
gi.game.Status = StatusInProgress
|
||||
slot = 1
|
||||
} else {
|
||||
return false // Game is full
|
||||
}
|
||||
@@ -95,6 +154,11 @@ func (gi *GameInstance) Join(ps *PlayerSession) bool {
|
||||
gi.players[ps.Player.ID] = ps.Sync
|
||||
gi.playersMu.Unlock()
|
||||
|
||||
if gi.persister != nil {
|
||||
gi.persister.SaveGamePlayer(gi.game.ID, ps.Player, slot)
|
||||
gi.persister.SaveGame(gi.game)
|
||||
}
|
||||
|
||||
gi.dirty = true
|
||||
return true
|
||||
}
|
||||
@@ -138,6 +202,10 @@ func (gi *GameInstance) DropPiece(col int, playerColor int) bool {
|
||||
gi.game.SwitchTurn()
|
||||
}
|
||||
|
||||
if gi.persister != nil {
|
||||
gi.persister.SaveGame(gi.game)
|
||||
}
|
||||
|
||||
gi.dirty = true
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package game
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type PlayerID string
|
||||
|
||||
type Player struct {
|
||||
ID PlayerID
|
||||
UserID *string // UUID for authenticated users, nil for guests
|
||||
Nickname string
|
||||
Color int // 1 = Red, 2 = Yellow
|
||||
}
|
||||
@@ -35,3 +38,27 @@ func NewGame(id string) *Game {
|
||||
Status: StatusWaitingForPlayer,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user