389 lines
9.9 KiB
Go
389 lines
9.9 KiB
Go
package game
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"chess/internal/server/chess"
|
|
"chess/internal/server/core"
|
|
)
|
|
|
|
type Snapshot struct {
|
|
FEN string `json:"fen"`
|
|
PreviousMove string `json:"previousMove"`
|
|
NextTurnColor core.Color `json:"nextTurnColor"`
|
|
PlayerType core.PlayerType `json:"playerType"`
|
|
PlayerID string `json:"playerId"` // ID of the player whose turn it is
|
|
}
|
|
|
|
// MoveResult tracks the outcome of a move
|
|
type MoveResult struct {
|
|
Move string `json:"move"`
|
|
PlayerColor core.Color `json:"playerColor"`
|
|
GameState core.State `json:"gameState"`
|
|
Score int `json:"score"`
|
|
Depth int `json:"depth"`
|
|
}
|
|
|
|
type Game struct {
|
|
snapshots []Snapshot
|
|
players map[core.Color]*core.Player
|
|
state core.State
|
|
termination core.Termination
|
|
lastResult *MoveResult
|
|
endTimeUTC *time.Time
|
|
lastActivity time.Time // last create, move, undo, reconfiguration, or state change
|
|
drawOffer core.Color
|
|
// offerPly[c] is the ply count when color c last offered a draw, or -1.
|
|
offerPly map[core.Color]int
|
|
}
|
|
|
|
// View is an immutable copy of the state needed by processors and transports.
|
|
// Service returns views instead of exposing mutable Game pointers outside its
|
|
// lock, preventing torn responses and concurrent move races.
|
|
type View struct {
|
|
FEN string
|
|
InitialFEN string
|
|
NextTurnColor core.Color
|
|
Moves []string
|
|
// FENs holds the position before the first move and after every move.
|
|
FENs []string
|
|
WhitePlayer *core.Player
|
|
BlackPlayer *core.Player
|
|
State core.State
|
|
Termination core.Termination
|
|
DrawOffer core.Color // color whose offer awaits an answer; 0 when none
|
|
OfferPly map[core.Color]int
|
|
LastResult *MoveResult
|
|
EndTimeUTC *time.Time
|
|
}
|
|
|
|
func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurnColor core.Color) *Game {
|
|
// Determine which player's turn it is initially
|
|
var initialPlayerID string
|
|
var initialPlayerType core.PlayerType
|
|
if startingTurnColor == core.ColorWhite {
|
|
initialPlayerID = whitePlayer.ID
|
|
initialPlayerType = whitePlayer.Type
|
|
} else {
|
|
initialPlayerID = blackPlayer.ID
|
|
initialPlayerType = blackPlayer.Type
|
|
}
|
|
whiteCopy := *whitePlayer
|
|
blackCopy := *blackPlayer
|
|
|
|
return &Game{
|
|
snapshots: []Snapshot{
|
|
{
|
|
FEN: initialFEN,
|
|
PreviousMove: "",
|
|
NextTurnColor: startingTurnColor,
|
|
PlayerType: initialPlayerType,
|
|
PlayerID: initialPlayerID,
|
|
},
|
|
},
|
|
players: map[core.Color]*core.Player{
|
|
core.ColorWhite: &whiteCopy,
|
|
core.ColorBlack: &blackCopy,
|
|
},
|
|
state: core.StateOngoing,
|
|
offerPly: map[core.Color]int{core.ColorWhite: -1, core.ColorBlack: -1},
|
|
}
|
|
}
|
|
|
|
func (g *Game) View() View {
|
|
view := View{
|
|
FEN: g.CurrentFEN(),
|
|
InitialFEN: g.InitialFEN(),
|
|
NextTurnColor: g.NextTurnColor(),
|
|
Moves: g.Moves(),
|
|
FENs: make([]string, len(g.snapshots)),
|
|
State: g.state,
|
|
Termination: g.termination,
|
|
DrawOffer: g.drawOffer,
|
|
OfferPly: map[core.Color]int{
|
|
core.ColorWhite: g.offerPly[core.ColorWhite],
|
|
core.ColorBlack: g.offerPly[core.ColorBlack],
|
|
},
|
|
}
|
|
for i, snapshot := range g.snapshots {
|
|
view.FENs[i] = snapshot.FEN
|
|
}
|
|
if player := g.players[core.ColorWhite]; player != nil {
|
|
copy := *player
|
|
view.WhitePlayer = ©
|
|
}
|
|
if player := g.players[core.ColorBlack]; player != nil {
|
|
copy := *player
|
|
view.BlackPlayer = ©
|
|
}
|
|
if g.lastResult != nil {
|
|
copy := *g.lastResult
|
|
view.LastResult = ©
|
|
}
|
|
if g.endTimeUTC != nil {
|
|
copy := *g.endTimeUTC
|
|
view.EndTimeUTC = ©
|
|
}
|
|
return view
|
|
}
|
|
|
|
func (v View) NextPlayer() *core.Player {
|
|
if v.NextTurnColor == core.ColorWhite {
|
|
return v.WhitePlayer
|
|
}
|
|
return v.BlackPlayer
|
|
}
|
|
|
|
func (v View) Player(color core.Color) *core.Player {
|
|
if color == core.ColorWhite {
|
|
return v.WhitePlayer
|
|
}
|
|
if color == core.ColorBlack {
|
|
return v.BlackPlayer
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (g *Game) SetLastResult(result *MoveResult) {
|
|
if result == nil {
|
|
g.lastResult = nil
|
|
return
|
|
}
|
|
copy := *result
|
|
g.lastResult = ©
|
|
}
|
|
|
|
// CurrentSnapshot returns the latest game snapshot
|
|
func (g *Game) CurrentSnapshot() Snapshot {
|
|
return g.snapshots[len(g.snapshots)-1]
|
|
}
|
|
|
|
// CurrentFEN returns the current position in FEN notation
|
|
func (g *Game) CurrentFEN() string {
|
|
return g.CurrentSnapshot().FEN
|
|
}
|
|
|
|
func (g *Game) NextTurnColor() core.Color {
|
|
return g.CurrentSnapshot().NextTurnColor
|
|
}
|
|
|
|
func (g *Game) NextPlayer() *core.Player {
|
|
return g.players[g.NextTurnColor()]
|
|
}
|
|
|
|
func (g *Game) GetPlayer(color core.Color) *core.Player {
|
|
return g.players[color]
|
|
}
|
|
|
|
// AddSnapshot appends a move. It answers a draw offer made by the other
|
|
// side: moving instead of accepting declines it. An offer from the mover
|
|
// stands.
|
|
func (g *Game) AddSnapshot(fen string, move string, nextTurnColor core.Color) {
|
|
if g.drawOffer == nextTurnColor {
|
|
g.drawOffer = 0
|
|
}
|
|
// Get the player ID for the next turn
|
|
nextPlayer := g.players[nextTurnColor]
|
|
g.snapshots = append(g.snapshots, Snapshot{
|
|
FEN: fen,
|
|
PreviousMove: move,
|
|
NextTurnColor: nextTurnColor,
|
|
PlayerType: nextPlayer.Type,
|
|
PlayerID: nextPlayer.ID,
|
|
})
|
|
}
|
|
|
|
func (g *Game) UpdatePlayers(whitePlayer, blackPlayer *core.Player) {
|
|
whiteCopy := *whitePlayer
|
|
blackCopy := *blackPlayer
|
|
g.players[core.ColorWhite] = &whiteCopy
|
|
g.players[core.ColorBlack] = &blackCopy
|
|
|
|
// Update current snapshot's PlayerID to reflect new player
|
|
if len(g.snapshots) > 0 {
|
|
currentSnap := &g.snapshots[len(g.snapshots)-1]
|
|
currentPlayer := g.players[currentSnap.NextTurnColor]
|
|
currentSnap.PlayerID = currentPlayer.ID
|
|
currentSnap.PlayerType = currentPlayer.Type
|
|
}
|
|
}
|
|
|
|
func (g *Game) UndoMoves(count int) error {
|
|
if count < 1 {
|
|
return fmt.Errorf("invalid undo count: %d", count)
|
|
}
|
|
|
|
availableMoves := len(g.snapshots) - 1
|
|
if availableMoves < count {
|
|
return fmt.Errorf("cannot undo %d moves: only %d moves available", count, availableMoves)
|
|
}
|
|
|
|
g.snapshots = g.snapshots[:len(g.snapshots)-count]
|
|
g.state = core.StateOngoing // Reset game state when undoing
|
|
g.termination = core.TermNone
|
|
g.lastResult = nil // Clear last result
|
|
g.endTimeUTC = nil
|
|
g.ClearDrawOffers()
|
|
return nil
|
|
}
|
|
|
|
// Plies returns the number of moves played.
|
|
func (g *Game) Plies() int {
|
|
return len(g.snapshots) - 1
|
|
}
|
|
|
|
// DrawOffer returns the color whose draw offer is pending, or 0.
|
|
func (g *Game) DrawOffer() core.Color {
|
|
return g.drawOffer
|
|
}
|
|
|
|
// OfferDraw records a pending offer from color and the ply it was made at.
|
|
func (g *Game) OfferDraw(color core.Color) {
|
|
g.drawOffer = color
|
|
g.offerPly[color] = g.Plies()
|
|
}
|
|
|
|
// LastOfferPly returns the ply count at color's last draw offer, or -1.
|
|
func (g *Game) LastOfferPly(color core.Color) int {
|
|
return g.offerPly[color]
|
|
}
|
|
|
|
// RecordDeclinedOffer counts an offer that was answered at once (by a
|
|
// computer) against its maker's one-offer-per-move allowance.
|
|
func (g *Game) RecordDeclinedOffer(color core.Color) {
|
|
g.offerPly[color] = g.Plies()
|
|
}
|
|
|
|
// DeclineDraw withdraws a pending offer.
|
|
func (g *Game) DeclineDraw() {
|
|
g.drawOffer = 0
|
|
}
|
|
|
|
// ClearDrawOffers forgets the pending offer and the per-move allowance, as
|
|
// after an undo or a player change.
|
|
func (g *Game) ClearDrawOffers() {
|
|
g.drawOffer = 0
|
|
g.offerPly[core.ColorWhite] = -1
|
|
g.offerPly[core.ColorBlack] = -1
|
|
}
|
|
|
|
// Termination returns how a finished game ended.
|
|
func (g *Game) Termination() core.Termination {
|
|
return g.termination
|
|
}
|
|
|
|
func (g *Game) Moves() []string {
|
|
moves := []string{}
|
|
for i := 1; i < len(g.snapshots); i++ {
|
|
if g.snapshots[i].PreviousMove != "" {
|
|
moves = append(moves, g.snapshots[i].PreviousMove)
|
|
}
|
|
}
|
|
return moves
|
|
}
|
|
|
|
func (g *Game) State() core.State {
|
|
return g.state
|
|
}
|
|
|
|
// SetOutcome sets the state and how it was reached; a non-terminal state
|
|
// clears the termination.
|
|
func (g *Game) SetOutcome(s core.State, termination core.Termination, at time.Time) {
|
|
g.SetStateAt(s, at)
|
|
if s.IsTerminal() {
|
|
g.termination = termination
|
|
g.drawOffer = 0
|
|
} else {
|
|
g.termination = core.TermNone
|
|
}
|
|
}
|
|
|
|
func (g *Game) SetStateAt(s core.State, at time.Time) {
|
|
g.Touch(at)
|
|
if s.IsTerminal() {
|
|
if !g.state.IsTerminal() || g.endTimeUTC == nil {
|
|
ended := at.UTC()
|
|
g.endTimeUTC = &ended
|
|
}
|
|
} else if g.state.IsTerminal() {
|
|
g.endTimeUTC = nil
|
|
}
|
|
g.state = s
|
|
}
|
|
|
|
// Touch records activity at the given time; earlier times are ignored.
|
|
func (g *Game) Touch(at time.Time) {
|
|
if at.After(g.lastActivity) {
|
|
g.lastActivity = at
|
|
}
|
|
}
|
|
|
|
// LastActivity returns the latest time passed to Touch.
|
|
func (g *Game) LastActivity() time.Time {
|
|
return g.lastActivity
|
|
}
|
|
|
|
// IsClaimed reports whether a registered user claimed either slot.
|
|
func (g *Game) IsClaimed() bool {
|
|
for _, player := range g.players {
|
|
if player != nil && player.ClaimedBy != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (g *Game) EndTimeUTC() *time.Time {
|
|
if g.endTimeUTC == nil {
|
|
return nil
|
|
}
|
|
copy := *g.endTimeUTC
|
|
return ©
|
|
}
|
|
|
|
func (g *Game) InitialFEN() string {
|
|
if len(g.snapshots) > 0 {
|
|
return g.snapshots[0].FEN
|
|
}
|
|
return chess.StartFEN
|
|
}
|
|
|
|
// ClaimSlot claims a player slot for a user
|
|
// Caller must hold the lock
|
|
func (g *Game) ClaimSlot(color core.Color, userID string) error {
|
|
player := g.players[color]
|
|
if player == nil {
|
|
return fmt.Errorf("invalid color")
|
|
}
|
|
|
|
if player.Type != core.PlayerHuman {
|
|
return fmt.Errorf("cannot claim computer slot")
|
|
}
|
|
|
|
if player.ClaimedBy != "" && player.ClaimedBy != userID {
|
|
return fmt.Errorf("slot already claimed by another user")
|
|
}
|
|
|
|
player.ClaimedBy = userID
|
|
return nil
|
|
}
|
|
|
|
// GetSlotOwner returns the userID that claimed the slot, empty if unclaimed
|
|
// Caller must hold the lock
|
|
func (g *Game) GetSlotOwner(color core.Color) string {
|
|
player := g.players[color]
|
|
if player == nil {
|
|
return ""
|
|
}
|
|
return player.ClaimedBy
|
|
}
|
|
|
|
// HasComputerPlayer returns true if at least one player is computer
|
|
func (g *Game) HasComputerPlayer() bool {
|
|
white := g.players[core.ColorWhite]
|
|
black := g.players[core.ColorBlack]
|
|
return (white != nil && white.Type == core.PlayerComputer) ||
|
|
(black != nil && black.Type == core.PlayerComputer)
|
|
}
|