Files
gomoku/server/database/store.go
T
tiennm99 acf08669dd feat(database): new domain model for PVP/PVE/spectator with thread-safe store
- Split monolithic database.go/model.go/gomoku.go into focused files:
  player.go, room.go, store.go, errors.go, events.go, cleanup.go
- NewRoom embeds game.Board directly; carries blackPlayerId, whitePlayerId,
  currentTurn, moveHistory, spectators, owner, type, difficulty from caro domain
- Store singleton with sync.RWMutex: RegisterPlayer, CreatePvpRoom, CreatePveRoom,
  JoinNewRoom, LeaveNewRoom, WatchNewRoom, UnwatchNewRoom, BroadcastToNewRoom
- PVE rooms randomly assign human to Black or White; AI takes opposite side
- legacy.go shim keeps server/state/*.go and state/game/gomoku.go compiling
  without modification until phase-06 rewrites them
- consts/const.go: add RoomTypePvp/Pve, Status*, RoomStatus*, Difficulty* enums
2026-04-11 13:30:58 +07:00

351 lines
8.0 KiB
Go

package database
import (
"math/rand"
"sort"
"sync"
"time"
"github.com/tiennm99/gomoku/server/game"
)
// store is the package-level in-memory data store for the new domain model.
// It is separate from the legacy hashmaps in legacy.go which back the old state machine.
//
// Lock discipline:
// - Acquire store.mu only to read/write the maps and counters.
// - Release store.mu before calling any per-room method or player callback.
// - Per-room mutations use room.Lock() / room.RLock() independently.
var store = &roomStore{
players: make(map[int64]*Player),
rooms: make(map[int64]*NewRoom),
nextPlayerID: 1,
nextRoomID: 1,
}
type roomStore struct {
mu sync.RWMutex
players map[int64]*Player
rooms map[int64]*NewRoom
nextPlayerID int64
nextRoomID int64
}
// --- Player operations ---
// RegisterPlayer assigns a unique ID and registers the player in the store.
func RegisterPlayer(nickname string) *Player {
store.mu.Lock()
id := store.nextPlayerID
store.nextPlayerID++
p := &Player{
ID: id,
Name: nickname,
}
store.players[id] = p
store.mu.Unlock()
return p
}
// GetPlayer returns the player with the given ID, or (nil, false) if not found.
func GetNewPlayer(id int64) (*Player, bool) {
store.mu.RLock()
p, ok := store.players[id]
store.mu.RUnlock()
return p, ok
}
// RemovePlayer unregisters the player and removes them from any room they are in.
func RemovePlayer(id int64) {
store.mu.Lock()
p, ok := store.players[id]
if ok {
delete(store.players, id)
}
store.mu.Unlock()
if ok && p.RoomID != 0 {
LeaveNewRoom(p)
}
}
// --- Room creation ---
// CreatePvpRoom creates a PVP room owned by the given player.
func CreatePvpRoom(owner *Player) (*NewRoom, error) {
store.mu.Lock()
id := store.nextRoomID
store.nextRoomID++
r := &NewRoom{
ID: id,
OwnerID: owner.ID,
OwnerNickname: owner.Name,
RoomType: RoomTypePvp,
Status: RoomStatusWaiting,
Board: game.NewBoard(),
BlackPlayerID: 0,
WhitePlayerID: 0,
CurrentTurn: game.Black,
Players: make(map[int64]*Player),
Spectators: make(map[int64]*Player),
MoveHistory: nil,
CreatedAt: time.Now(),
LastActive: time.Now(),
}
store.rooms[id] = r
store.mu.Unlock()
return r, nil
}
// CreatePveRoom creates a PVE room. difficulty must be 1, 2, or 3.
// The human player is randomly assigned Black or White; the AI takes the other side.
func CreatePveRoom(owner *Player, difficulty int) (*NewRoom, error) {
if difficulty < 1 || difficulty > 3 {
return nil, ErrInvalidDifficulty
}
seed := rand.Int63()
var (
humanBlack bool
blackPlayerID int64
whitePlayerID int64
aiPiece game.Piece
)
// Random coin flip: even seed → human is Black.
if seed%2 == 0 {
humanBlack = true
blackPlayerID = owner.ID
whitePlayerID = -1
aiPiece = game.White
} else {
humanBlack = false
blackPlayerID = -1
whitePlayerID = owner.ID
aiPiece = game.Black
}
_ = humanBlack // used implicitly via blackPlayerID/whitePlayerID
aiSeed := rand.Int63()
ai := game.NewAI(aiPiece, aiSeed)
store.mu.Lock()
id := store.nextRoomID
store.nextRoomID++
r := &NewRoom{
ID: id,
OwnerID: owner.ID,
OwnerNickname: owner.Name,
RoomType: RoomTypePve,
Status: RoomStatusWaiting,
Board: game.NewBoard(),
BlackPlayerID: blackPlayerID,
WhitePlayerID: whitePlayerID,
CurrentTurn: game.Black,
Players: make(map[int64]*Player),
Spectators: make(map[int64]*Player),
MoveHistory: nil,
Difficulty: difficulty,
AI: ai,
CreatedAt: time.Now(),
LastActive: time.Now(),
}
store.rooms[id] = r
store.mu.Unlock()
return r, nil
}
// --- Room lookups ---
// GetNewRoom returns the room with the given ID, or (nil, false) if not found.
func GetNewRoom(id int64) (*NewRoom, bool) {
store.mu.RLock()
r, ok := store.rooms[id]
store.mu.RUnlock()
return r, ok
}
// GetAllRooms returns a snapshot slice of all rooms sorted by ID ascending.
// The slice is a copy; callers must not mutate rooms through it without locking.
func GetAllRooms() []*NewRoom {
store.mu.RLock()
list := make([]*NewRoom, 0, len(store.rooms))
for _, r := range store.rooms {
list = append(list, r)
}
store.mu.RUnlock()
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
return list
}
// deleteNewRoom removes a room from the store. Caller must hold store.mu.Lock().
func deleteNewRoom(id int64) {
delete(store.rooms, id)
}
// --- Room membership ---
// JoinNewRoom adds a player to a room as a human participant.
// Returns ErrRoomNotFound, ErrRoomFull, or ErrRoomPlaying on failure.
// On success, sets player.RoomID.
func JoinNewRoom(roomID int64, player *Player) error {
store.mu.RLock()
r, ok := store.rooms[roomID]
store.mu.RUnlock()
if !ok {
return ErrRoomNotFound
}
r.Lock()
defer r.Unlock()
if r.Status == RoomStatusPlaying {
return ErrRoomPlaying
}
maxPlayers := 2
if r.RoomType == RoomTypePve {
maxPlayers = 1
}
if len(r.Players) >= maxPlayers {
return ErrRoomFull
}
r.Players[player.ID] = player
r.LastActive = time.Now()
player.RoomID = roomID
player.Role = RolePlayer
if r.OwnerID == player.ID {
player.Role = RoleOwner
}
return nil
}
// LeaveNewRoom removes a player from their current room (Players or Spectators).
// If the room becomes empty it is deleted from the store.
// Broadcasts nothing — callers handle messaging.
func LeaveNewRoom(player *Player) {
if player.RoomID == 0 {
return
}
store.mu.RLock()
r, ok := store.rooms[player.RoomID]
store.mu.RUnlock()
if !ok {
player.RoomID = 0
return
}
r.Lock()
wasPlayer := false
if _, found := r.Players[player.ID]; found {
delete(r.Players, player.ID)
wasPlayer = true
}
if _, found := r.Spectators[player.ID]; found {
delete(r.Spectators, player.ID)
}
// Owner reassignment: if owner left and room still has players, pick another.
if wasPlayer && r.OwnerID == player.ID && len(r.Players) > 0 {
for newOwnerID, newOwner := range r.Players {
r.OwnerID = newOwnerID
r.OwnerNickname = newOwner.Name
newOwner.Role = RoleOwner
break
}
}
empty := len(r.Players) == 0 && len(r.Spectators) == 0
roomID := r.ID
r.Unlock()
player.RoomID = 0
player.Role = ""
if empty {
store.mu.Lock()
deleteNewRoom(roomID)
store.mu.Unlock()
}
}
// WatchNewRoom adds a player to a room's Spectators list.
// Returns ErrRoomNotFound if the room does not exist.
func WatchNewRoom(roomID int64, player *Player) error {
store.mu.RLock()
r, ok := store.rooms[roomID]
store.mu.RUnlock()
if !ok {
return ErrRoomNotFound
}
r.Lock()
r.Spectators[player.ID] = player
r.LastActive = time.Now()
r.Unlock()
player.RoomID = roomID
player.Role = RoleSpectator
return nil
}
// UnwatchNewRoom removes a spectator from their room.
func UnwatchNewRoom(player *Player) {
if player.RoomID == 0 {
return
}
store.mu.RLock()
r, ok := store.rooms[player.RoomID]
store.mu.RUnlock()
if !ok {
player.RoomID = 0
return
}
r.Lock()
delete(r.Spectators, player.ID)
empty := len(r.Players) == 0 && len(r.Spectators) == 0
roomID := r.ID
r.Unlock()
player.RoomID = 0
player.Role = ""
if empty {
store.mu.Lock()
deleteNewRoom(roomID)
store.mu.Unlock()
}
}
// BroadcastToNewRoom calls each player's sendFn in Players + Spectators,
// skipping any IDs listed in excludePlayerIDs.
// Safe to call without holding any lock — acquires RLock internally.
// If sendFn is nil (phase-05 not yet wired) the call is a no-op for that player.
func BroadcastToNewRoom(room *NewRoom, msg string, excludePlayerIDs ...int64) {
exclude := make(map[int64]bool, len(excludePlayerIDs))
for _, id := range excludePlayerIDs {
exclude[id] = true
}
room.RLock()
targets := make([]*Player, 0, len(room.Players)+len(room.Spectators))
for id, p := range room.Players {
if !exclude[id] {
targets = append(targets, p)
}
}
for id, p := range room.Spectators {
if !exclude[id] {
targets = append(targets, p)
}
}
room.RUnlock()
// Send outside the lock to avoid holding room lock while doing I/O.
for _, p := range targets {
_ = p.WriteString(msg)
}
}