426 lines
11 KiB
Go
426 lines
11 KiB
Go
package game
|
|
|
|
import (
|
|
"math"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
|
|
"chessstars/internal/auth"
|
|
"chessstars/internal/engine"
|
|
"chessstars/internal/httpx"
|
|
)
|
|
|
|
const (
|
|
botRating = 1200
|
|
eloK = 32
|
|
)
|
|
|
|
func eloExpected(a, b int) float64 {
|
|
return 1.0 / (1.0 + math.Pow(10, float64(b-a)/400.0))
|
|
}
|
|
|
|
func eloNewRating(rating int, expected, actual float64) int {
|
|
return rating + int(math.Round(eloK*(actual-expected)))
|
|
}
|
|
|
|
func (h *Handlers) scoreGame(entry *Entry) {
|
|
if entry.Scored {
|
|
return
|
|
}
|
|
status := entry.Engine.Status()
|
|
if status == engine.StatusOngoing {
|
|
return
|
|
}
|
|
entry.Scored = true
|
|
|
|
var whiteActual, blackActual float64
|
|
switch status {
|
|
case engine.StatusWhiteWins:
|
|
whiteActual, blackActual = 1, 0
|
|
case engine.StatusBlackWins:
|
|
whiteActual, blackActual = 0, 1
|
|
default: // stalemate / draw
|
|
whiteActual, blackActual = 0.5, 0.5
|
|
}
|
|
|
|
whiteUser, errW := h.authStore.GetUserByID(entry.WhiteID)
|
|
var blackUser *auth.User
|
|
var errB error
|
|
if !entry.VsBot {
|
|
blackUser, errB = h.authStore.GetUserByID(entry.BlackID)
|
|
}
|
|
|
|
blackRating := botRating
|
|
if blackUser != nil {
|
|
blackRating = blackUser.Rating
|
|
}
|
|
if errW == nil {
|
|
newRating := eloNewRating(whiteUser.Rating, eloExpected(whiteUser.Rating, blackRating), whiteActual)
|
|
_ = h.authStore.RecordResult(entry.WhiteID, newRating, outcomeFor(whiteActual))
|
|
}
|
|
|
|
if !entry.VsBot && errB == nil {
|
|
newRating := eloNewRating(blackUser.Rating, eloExpected(blackUser.Rating, whiteUser.Rating), blackActual)
|
|
_ = h.authStore.RecordResult(entry.BlackID, newRating, outcomeFor(blackActual))
|
|
}
|
|
}
|
|
|
|
func outcomeFor(actual float64) string {
|
|
switch actual {
|
|
case 1:
|
|
return auth.OutcomeWin
|
|
case 0:
|
|
return auth.OutcomeLoss
|
|
default:
|
|
return auth.OutcomeDraw
|
|
}
|
|
}
|
|
|
|
type Handlers struct {
|
|
manager *Manager
|
|
authStore *auth.Store
|
|
}
|
|
|
|
func NewHandlers(m *Manager, authStore *auth.Store) *Handlers {
|
|
return &Handlers{manager: m, authStore: authStore}
|
|
}
|
|
|
|
type wireMove struct {
|
|
From uint8 `json:"from"`
|
|
To uint8 `json:"to"`
|
|
Piece uint8 `json:"piece"`
|
|
Flags uint8 `json:"flags"`
|
|
Captured uint8 `json:"captured"`
|
|
Promo uint8 `json:"promo"`
|
|
Reserved0 uint8 `json:"reserved0"`
|
|
Reserved1 uint8 `json:"reserved1"`
|
|
}
|
|
|
|
func toEngineMove(w wireMove) engine.Move {
|
|
return engine.Move{
|
|
From: w.From, To: w.To, Piece: w.Piece, Flags: w.Flags,
|
|
Captured: w.Captured, Promo: w.Promo, Reserved0: w.Reserved0, Reserved1: w.Reserved1,
|
|
}
|
|
}
|
|
|
|
func fromEngineMove(m engine.Move) wireMove {
|
|
return wireMove{
|
|
From: m.From, To: m.To, Piece: m.Piece, Flags: m.Flags,
|
|
Captured: m.Captured, Promo: m.Promo, Reserved0: m.Reserved0, Reserved1: m.Reserved1,
|
|
}
|
|
}
|
|
|
|
func (h *Handlers) Create(w http.ResponseWriter, r *http.Request) {
|
|
u, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
httpx.Error(w, http.StatusUnauthorized, "not logged in")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Opponent string `json:"opponent"`
|
|
Variant int `json:"variant"`
|
|
StartFEN string `json:"start_fen"`
|
|
RuleFlags uint32 `json:"rule_flags"`
|
|
BotDepth int `json:"bot_depth"`
|
|
}
|
|
if err := httpx.ReadJSON(r, &req); err != nil {
|
|
httpx.Error(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
if req.BotDepth <= 0 {
|
|
req.BotDepth = 3
|
|
}
|
|
|
|
var blackID int64
|
|
var blackName string
|
|
vsBot := req.Opponent == "bot"
|
|
|
|
if !vsBot && req.Opponent != "" {
|
|
opponent, err := h.authStore.GetUserByUsername(req.Opponent)
|
|
if err != nil {
|
|
httpx.Error(w, http.StatusNotFound, "no such user")
|
|
return
|
|
}
|
|
if opponent.ID == u.ID {
|
|
httpx.Error(w, http.StatusBadRequest, "can't challenge yourself")
|
|
return
|
|
}
|
|
blackID = opponent.ID
|
|
blackName = opponent.Username
|
|
}
|
|
|
|
entry, err := h.manager.Create(
|
|
engine.Variant(req.Variant), req.StartFEN, engine.RuleFlags(req.RuleFlags),
|
|
u.ID, u.Username, blackID, blackName, vsBot, req.BotDepth,
|
|
)
|
|
if err != nil {
|
|
httpx.Error(w, http.StatusInternalServerError, "could not create game")
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "id": entry.ID})
|
|
}
|
|
|
|
func (h *Handlers) Join(w http.ResponseWriter, r *http.Request) {
|
|
u, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
httpx.Error(w, http.StatusUnauthorized, "not logged in")
|
|
return
|
|
}
|
|
entry, err := h.manager.Join(r.PathValue("id"), u.ID, u.Username)
|
|
if err != nil {
|
|
switch err {
|
|
case ErrNotFound:
|
|
httpx.Error(w, http.StatusNotFound, "no such game")
|
|
case ErrAlreadyJoined:
|
|
httpx.Error(w, http.StatusConflict, "game already has an opponent")
|
|
case ErrSelfJoin:
|
|
httpx.Error(w, http.StatusBadRequest, "can't join your own game")
|
|
default:
|
|
httpx.Error(w, http.StatusInternalServerError, "could not join")
|
|
}
|
|
return
|
|
}
|
|
entry.Lock()
|
|
defer entry.Unlock()
|
|
httpx.WriteJSON(w, http.StatusOK, h.state(entry))
|
|
}
|
|
|
|
type gameSummary struct {
|
|
ID string `json:"id"`
|
|
White string `json:"white"`
|
|
Black string `json:"black"`
|
|
VsBot bool `json:"vs_bot"`
|
|
Status int `json:"status"`
|
|
Turn int `json:"turn"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
func (h *Handlers) summary(entry *Entry) gameSummary {
|
|
entry.Lock()
|
|
defer entry.Unlock()
|
|
black := entry.BlackName
|
|
if entry.VsBot {
|
|
black = "bot"
|
|
} else if black == "" {
|
|
black = ""
|
|
}
|
|
return gameSummary{
|
|
ID: entry.ID, White: entry.WhiteName, Black: black, VsBot: entry.VsBot,
|
|
Status: entry.Engine.Status(), Turn: entry.Engine.Turn(),
|
|
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
}
|
|
}
|
|
|
|
func (h *Handlers) ListOpen(w http.ResponseWriter, r *http.Request) {
|
|
entries := h.manager.ListOpen()
|
|
list := make([]gameSummary, 0, len(entries))
|
|
for _, e := range entries {
|
|
list = append(list, h.summary(e))
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "games": list})
|
|
}
|
|
|
|
func (h *Handlers) ListAll(w http.ResponseWriter, r *http.Request) {
|
|
entries := h.manager.ListAll()
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
entries[i].Lock()
|
|
ti := entries[i].CreatedAt
|
|
entries[i].Unlock()
|
|
entries[j].Lock()
|
|
tj := entries[j].CreatedAt
|
|
entries[j].Unlock()
|
|
return ti.After(tj)
|
|
})
|
|
if len(entries) > 100 {
|
|
entries = entries[:100]
|
|
}
|
|
list := make([]gameSummary, 0, len(entries))
|
|
for _, e := range entries {
|
|
list = append(list, h.summary(e))
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "games": list})
|
|
}
|
|
|
|
func (h *Handlers) ListMine(w http.ResponseWriter, r *http.Request) {
|
|
u, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
httpx.Error(w, http.StatusUnauthorized, "not logged in")
|
|
return
|
|
}
|
|
entries := h.manager.ListForUser(u.ID)
|
|
list := make([]gameSummary, 0, len(entries))
|
|
for _, e := range entries {
|
|
list = append(list, h.summary(e))
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "games": list})
|
|
}
|
|
|
|
type gameState struct {
|
|
ID string `json:"id"`
|
|
Board [64]byte `json:"board"`
|
|
Turn int `json:"turn"`
|
|
Status int `json:"status"`
|
|
InCheck bool `json:"in_check"`
|
|
History int32 `json:"history_count"`
|
|
White string `json:"white"`
|
|
Black string `json:"black"`
|
|
VsBot bool `json:"vs_bot"`
|
|
WhiteID int64 `json:"white_id"`
|
|
BlackID int64 `json:"black_id"`
|
|
}
|
|
|
|
func (h *Handlers) state(entry *Entry) gameState {
|
|
return gameState{
|
|
ID: entry.ID, Board: entry.Engine.Board(), Turn: entry.Engine.Turn(),
|
|
Status: entry.Engine.Status(), InCheck: entry.Engine.InCheck(), History: entry.Engine.HistoryCount(),
|
|
White: entry.WhiteName, Black: entry.BlackName, VsBot: entry.VsBot,
|
|
WhiteID: entry.WhiteID, BlackID: entry.BlackID,
|
|
}
|
|
}
|
|
|
|
func (h *Handlers) Get(w http.ResponseWriter, r *http.Request) {
|
|
entry, err := h.manager.Get(r.PathValue("id"))
|
|
if err != nil {
|
|
httpx.Error(w, http.StatusNotFound, "no such game")
|
|
return
|
|
}
|
|
entry.Lock()
|
|
defer entry.Unlock()
|
|
httpx.WriteJSON(w, http.StatusOK, h.state(entry))
|
|
}
|
|
|
|
func (h *Handlers) Move(w http.ResponseWriter, r *http.Request) {
|
|
u, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
httpx.Error(w, http.StatusUnauthorized, "not logged in")
|
|
return
|
|
}
|
|
entry, err := h.manager.Get(r.PathValue("id"))
|
|
if err != nil {
|
|
httpx.Error(w, http.StatusNotFound, "no such game")
|
|
return
|
|
}
|
|
|
|
var wm wireMove
|
|
if err := httpx.ReadJSON(r, &wm); err != nil {
|
|
httpx.Error(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
|
|
entry.Lock()
|
|
defer entry.Unlock()
|
|
|
|
if entry.Open() {
|
|
httpx.Error(w, http.StatusBadRequest, "waiting for an opponent to join")
|
|
return
|
|
}
|
|
|
|
turn := entry.Engine.Turn()
|
|
turnUserID := entry.WhiteID
|
|
if turn == engine.ColorBlack {
|
|
turnUserID = entry.BlackID
|
|
}
|
|
if turnUserID != u.ID {
|
|
httpx.Error(w, http.StatusForbidden, "not your turn")
|
|
return
|
|
}
|
|
|
|
ok = entry.Engine.AddMove(toEngineMove(wm))
|
|
if !ok {
|
|
httpx.Error(w, http.StatusBadRequest, "illegal move")
|
|
return
|
|
}
|
|
|
|
if entry.VsBot && entry.Engine.Status() == engine.StatusOngoing {
|
|
bot := entry.Engine.BotMove(entry.BotDepth)
|
|
entry.Engine.AddMove(bot)
|
|
}
|
|
|
|
h.scoreGame(entry)
|
|
|
|
httpx.WriteJSON(w, http.StatusOK, h.state(entry))
|
|
}
|
|
|
|
func (h *Handlers) Legal(w http.ResponseWriter, r *http.Request) {
|
|
entry, err := h.manager.Get(r.PathValue("id"))
|
|
if err != nil {
|
|
httpx.Error(w, http.StatusNotFound, "no such game")
|
|
return
|
|
}
|
|
var wm wireMove
|
|
if err := httpx.ReadJSON(r, &wm); err != nil {
|
|
httpx.Error(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
|
|
entry.Lock()
|
|
defer entry.Unlock()
|
|
legal := entry.Engine.IsLegal(toEngineMove(wm))
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"legal": legal})
|
|
}
|
|
|
|
func (h *Handlers) LegalFrom(w http.ResponseWriter, r *http.Request) {
|
|
entry, err := h.manager.Get(r.PathValue("id"))
|
|
if err != nil {
|
|
httpx.Error(w, http.StatusNotFound, "no such game")
|
|
return
|
|
}
|
|
from, err := strconv.Atoi(r.URL.Query().Get("from"))
|
|
if err != nil || from < 0 || from > 63 {
|
|
httpx.Error(w, http.StatusBadRequest, "bad from square")
|
|
return
|
|
}
|
|
|
|
entry.Lock()
|
|
defer entry.Unlock()
|
|
|
|
targets := make([]int, 0, 8)
|
|
for to := 0; to < 64; to++ {
|
|
if to == from {
|
|
continue
|
|
}
|
|
if entry.Engine.IsLegal(engine.Move{From: uint8(from), To: uint8(to), Promo: engine.PieceQueen}) {
|
|
targets = append(targets, to)
|
|
}
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"targets": targets})
|
|
}
|
|
|
|
func (h *Handlers) Moves(w http.ResponseWriter, r *http.Request) {
|
|
entry, err := h.manager.Get(r.PathValue("id"))
|
|
if err != nil {
|
|
httpx.Error(w, http.StatusNotFound, "no such game")
|
|
return
|
|
}
|
|
moves, err := GenerateSAN(entry)
|
|
if err != nil {
|
|
httpx.Error(w, http.StatusInternalServerError, "could not generate notation")
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"moves": moves})
|
|
}
|
|
|
|
func (h *Handlers) HistoryAt(w http.ResponseWriter, r *http.Request) {
|
|
entry, err := h.manager.Get(r.PathValue("id"))
|
|
if err != nil {
|
|
httpx.Error(w, http.StatusNotFound, "no such game")
|
|
return
|
|
}
|
|
idx, err := strconv.ParseInt(r.PathValue("index"), 10, 32)
|
|
if err != nil {
|
|
httpx.Error(w, http.StatusBadRequest, "bad index")
|
|
return
|
|
}
|
|
|
|
entry.Lock()
|
|
defer entry.Unlock()
|
|
mv, ok := entry.Engine.GetMoveAt(int32(idx))
|
|
if !ok {
|
|
httpx.Error(w, http.StatusNotFound, "no such move")
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, fromEngineMove(mv))
|
|
}
|